Skip to content

Commit 85bd961

Browse files
ngromannroman-openaiMichaReiser
authored
[ty] resolve file symlinks in src walk (astral-sh#19674)
Co-authored-by: Nathaniel Roman <[email protected]> Co-authored-by: Micha Reiser <[email protected]>
1 parent d379116 commit 85bd961

File tree

2 files changed

+264
-18
lines changed

2 files changed

+264
-18
lines changed

crates/ty/tests/cli/file_selection.rs

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,3 +724,231 @@ fn invalid_exclude_pattern() -> anyhow::Result<()> {
724724

725725
Ok(())
726726
}
727+
728+
/// Test that ty works correctly with Bazel's symlinked file structure
729+
#[test]
730+
#[cfg(unix)]
731+
fn bazel_symlinked_files() -> anyhow::Result<()> {
732+
let case = CliTest::with_files([
733+
// Original source files in the project
734+
(
735+
"main.py",
736+
r#"
737+
import library
738+
739+
result = library.process_data()
740+
print(undefined_var) # error: unresolved-reference
741+
"#,
742+
),
743+
(
744+
"library.py",
745+
r#"
746+
def process_data():
747+
return missing_value # error: unresolved-reference
748+
"#,
749+
),
750+
// Another source file that won't be symlinked
751+
(
752+
"other.py",
753+
r#"
754+
print(other_undefined) # error: unresolved-reference
755+
"#,
756+
),
757+
])?;
758+
759+
// Create Bazel-style symlinks pointing to the actual source files
760+
// Bazel typically creates symlinks in bazel-out/k8-fastbuild/bin/ that point to actual sources
761+
std::fs::create_dir_all(case.project_dir.join("bazel-out/k8-fastbuild/bin"))?;
762+
763+
// Use absolute paths to ensure the symlinks work correctly
764+
case.write_symlink(
765+
case.project_dir.join("main.py"),
766+
"bazel-out/k8-fastbuild/bin/main.py",
767+
)?;
768+
case.write_symlink(
769+
case.project_dir.join("library.py"),
770+
"bazel-out/k8-fastbuild/bin/library.py",
771+
)?;
772+
773+
// Change to the bazel-out directory and run ty from there
774+
// The symlinks should be followed and errors should be found
775+
assert_cmd_snapshot!(case.command().current_dir(case.project_dir.join("bazel-out/k8-fastbuild/bin")), @r"
776+
success: false
777+
exit_code: 1
778+
----- stdout -----
779+
error[unresolved-reference]: Name `missing_value` used when not defined
780+
--> library.py:3:12
781+
|
782+
2 | def process_data():
783+
3 | return missing_value # error: unresolved-reference
784+
| ^^^^^^^^^^^^^
785+
|
786+
info: rule `unresolved-reference` is enabled by default
787+
788+
error[unresolved-reference]: Name `undefined_var` used when not defined
789+
--> main.py:5:7
790+
|
791+
4 | result = library.process_data()
792+
5 | print(undefined_var) # error: unresolved-reference
793+
| ^^^^^^^^^^^^^
794+
|
795+
info: rule `unresolved-reference` is enabled by default
796+
797+
Found 2 diagnostics
798+
799+
----- stderr -----
800+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
801+
");
802+
803+
// Test that when checking a specific symlinked file from the bazel-out directory, it works correctly
804+
assert_cmd_snapshot!(case.command().current_dir(case.project_dir.join("bazel-out/k8-fastbuild/bin")).arg("main.py"), @r"
805+
success: false
806+
exit_code: 1
807+
----- stdout -----
808+
error[unresolved-reference]: Name `undefined_var` used when not defined
809+
--> main.py:5:7
810+
|
811+
4 | result = library.process_data()
812+
5 | print(undefined_var) # error: unresolved-reference
813+
| ^^^^^^^^^^^^^
814+
|
815+
info: rule `unresolved-reference` is enabled by default
816+
817+
Found 1 diagnostic
818+
819+
----- stderr -----
820+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
821+
");
822+
823+
Ok(())
824+
}
825+
826+
/// Test that exclude patterns match on symlink source names, not target names
827+
#[test]
828+
#[cfg(unix)]
829+
fn exclude_symlink_source_not_target() -> anyhow::Result<()> {
830+
let case = CliTest::with_files([
831+
// Target files with generic names
832+
(
833+
"src/module.py",
834+
r#"
835+
def process():
836+
return undefined_var # error: unresolved-reference
837+
"#,
838+
),
839+
(
840+
"src/utils.py",
841+
r#"
842+
def helper():
843+
return missing_value # error: unresolved-reference
844+
"#,
845+
),
846+
(
847+
"regular.py",
848+
r#"
849+
print(regular_undefined) # error: unresolved-reference
850+
"#,
851+
),
852+
])?;
853+
854+
// Create symlinks with names that differ from their targets
855+
// This simulates build systems that rename files during symlinking
856+
case.write_symlink("src/module.py", "generated_module.py")?;
857+
case.write_symlink("src/utils.py", "generated_utils.py")?;
858+
859+
// Exclude pattern should match on the symlink name (generated_*), not the target name
860+
assert_cmd_snapshot!(case.command().arg("--exclude").arg("generated_*.py"), @r"
861+
success: false
862+
exit_code: 1
863+
----- stdout -----
864+
error[unresolved-reference]: Name `regular_undefined` used when not defined
865+
--> regular.py:2:7
866+
|
867+
2 | print(regular_undefined) # error: unresolved-reference
868+
| ^^^^^^^^^^^^^^^^^
869+
|
870+
info: rule `unresolved-reference` is enabled by default
871+
872+
error[unresolved-reference]: Name `undefined_var` used when not defined
873+
--> src/module.py:3:12
874+
|
875+
2 | def process():
876+
3 | return undefined_var # error: unresolved-reference
877+
| ^^^^^^^^^^^^^
878+
|
879+
info: rule `unresolved-reference` is enabled by default
880+
881+
error[unresolved-reference]: Name `missing_value` used when not defined
882+
--> src/utils.py:3:12
883+
|
884+
2 | def helper():
885+
3 | return missing_value # error: unresolved-reference
886+
| ^^^^^^^^^^^^^
887+
|
888+
info: rule `unresolved-reference` is enabled by default
889+
890+
Found 3 diagnostics
891+
892+
----- stderr -----
893+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
894+
");
895+
896+
// Exclude pattern on target path should not affect symlinks with different names
897+
assert_cmd_snapshot!(case.command().arg("--exclude").arg("src/*.py"), @r"
898+
success: false
899+
exit_code: 1
900+
----- stdout -----
901+
error[unresolved-reference]: Name `undefined_var` used when not defined
902+
--> generated_module.py:3:12
903+
|
904+
2 | def process():
905+
3 | return undefined_var # error: unresolved-reference
906+
| ^^^^^^^^^^^^^
907+
|
908+
info: rule `unresolved-reference` is enabled by default
909+
910+
error[unresolved-reference]: Name `missing_value` used when not defined
911+
--> generated_utils.py:3:12
912+
|
913+
2 | def helper():
914+
3 | return missing_value # error: unresolved-reference
915+
| ^^^^^^^^^^^^^
916+
|
917+
info: rule `unresolved-reference` is enabled by default
918+
919+
error[unresolved-reference]: Name `regular_undefined` used when not defined
920+
--> regular.py:2:7
921+
|
922+
2 | print(regular_undefined) # error: unresolved-reference
923+
| ^^^^^^^^^^^^^^^^^
924+
|
925+
info: rule `unresolved-reference` is enabled by default
926+
927+
Found 3 diagnostics
928+
929+
----- stderr -----
930+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
931+
");
932+
933+
// Test that explicitly passing a symlink always checks it, even if excluded
934+
assert_cmd_snapshot!(case.command().arg("--exclude").arg("generated_*.py").arg("generated_module.py"), @r"
935+
success: false
936+
exit_code: 1
937+
----- stdout -----
938+
error[unresolved-reference]: Name `undefined_var` used when not defined
939+
--> generated_module.py:3:12
940+
|
941+
2 | def process():
942+
3 | return undefined_var # error: unresolved-reference
943+
| ^^^^^^^^^^^^^
944+
|
945+
info: rule `unresolved-reference` is enabled by default
946+
947+
Found 1 diagnostic
948+
949+
----- stderr -----
950+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
951+
");
952+
953+
Ok(())
954+
}

crates/ty_project/src/walk.rs

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -173,21 +173,30 @@ impl<'a> ProjectFilesWalker<'a> {
173173
Ok(entry) => {
174174
// Skip excluded directories unless they were explicitly passed to the walker
175175
// (which is the case passed to `ty check <paths>`).
176-
if entry.file_type().is_directory() && entry.depth() > 0 {
177-
return match self.filter.is_directory_included(entry.path(), GlobFilterCheckMode::TopDown) {
178-
IncludeResult::Included => WalkState::Continue,
179-
IncludeResult::Excluded => {
180-
tracing::debug!("Skipping directory '{path}' because it is excluded by a default or `src.exclude` pattern", path=entry.path());
181-
WalkState::Skip
182-
},
183-
IncludeResult::NotIncluded => {
184-
tracing::debug!("Skipping directory `{path}` because it doesn't match any `src.include` pattern or path specified on the CLI", path=entry.path());
185-
WalkState::Skip
186-
},
187-
};
188-
}
189-
190-
if entry.file_type().is_file() {
176+
if entry.file_type().is_directory() {
177+
if entry.depth() > 0 {
178+
let directory_included = self
179+
.filter
180+
.is_directory_included(entry.path(), GlobFilterCheckMode::TopDown);
181+
return match directory_included {
182+
IncludeResult::Included => WalkState::Continue,
183+
IncludeResult::Excluded => {
184+
tracing::debug!(
185+
"Skipping directory '{path}' because it is excluded by a default or `src.exclude` pattern",
186+
path=entry.path()
187+
);
188+
WalkState::Skip
189+
},
190+
IncludeResult::NotIncluded => {
191+
tracing::debug!(
192+
"Skipping directory `{path}` because it doesn't match any `src.include` pattern or path specified on the CLI",
193+
path=entry.path()
194+
);
195+
WalkState::Skip
196+
},
197+
};
198+
}
199+
} else {
191200
// Ignore any non python files to avoid creating too many entries in `Files`.
192201
if entry
193202
.path()
@@ -201,14 +210,23 @@ impl<'a> ProjectFilesWalker<'a> {
201210
// For all files, except the ones that were explicitly passed to the walker (CLI),
202211
// check if they're included in the project.
203212
if entry.depth() > 0 {
204-
match self.filter.is_file_included(entry.path(), GlobFilterCheckMode::TopDown) {
213+
match self
214+
.filter
215+
.is_file_included(entry.path(), GlobFilterCheckMode::TopDown)
216+
{
205217
IncludeResult::Included => {},
206218
IncludeResult::Excluded => {
207-
tracing::debug!("Ignoring file `{path}` because it is excluded by a default or `src.exclude` pattern.", path=entry.path());
219+
tracing::debug!(
220+
"Ignoring file `{path}` because it is excluded by a default or `src.exclude` pattern.",
221+
path=entry.path()
222+
);
208223
return WalkState::Continue;
209224
},
210225
IncludeResult::NotIncluded => {
211-
tracing::debug!("Ignoring file `{path}` because it doesn't match any `src.include` pattern or path specified on the CLI.", path=entry.path());
226+
tracing::debug!(
227+
"Ignoring file `{path}` because it doesn't match any `src.include` pattern or path specified on the CLI.",
228+
path=entry.path()
229+
);
212230
return WalkState::Continue;
213231
},
214232
}

0 commit comments

Comments
 (0)