From 4adbf7798da3528323ebbf5e05fe5af067e43299 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Fri, 16 Jan 2026 09:23:26 +0100 Subject: [PATCH] [ty] Fix `--force-exclude` when excluding entire directories (#22595) --- crates/ty/tests/cli/file_selection.rs | 71 +++++++++++++++++++++++++++ crates/ty_project/src/glob.rs | 2 +- crates/ty_project/src/walk.rs | 37 ++++++++------ 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/crates/ty/tests/cli/file_selection.rs b/crates/ty/tests/cli/file_selection.rs index 05be57a118..52b974323f 100644 --- a/crates/ty/tests/cli/file_selection.rs +++ b/crates/ty/tests/cli/file_selection.rs @@ -770,6 +770,77 @@ fn explicit_path_overrides_exclude_force_exclude() -> anyhow::Result<()> { Ok(()) } +/// Test that `--force-exclude` respects exclude patterns even for explicitly passed files. +#[test] +fn force_exclude_directory_exclusion() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "src/main.py", + r#" + print(undefined_var) # error: unresolved-reference + "#, + ), + ( + "out/amd64/install/_setup_util.py", + r#" + base_path: str = "/path" + if base_path not in CMAKE_PREFIX_PATH: + CMAKE_PREFIX_PATH.insert(0, base_path) + "#, + ), + ( + "ty.toml", + r#" + [src] + exclude = ["out"] + "#, + ), + ])?; + + // Without --force-exclude, explicitly passed file overrides exclude. + assert_cmd_snapshot!(case.command().arg("out/amd64/install/_setup_util.py"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-reference]: Name `CMAKE_PREFIX_PATH` used when not defined + --> out/amd64/install/_setup_util.py:3:21 + | + 2 | base_path: str = "/path" + 3 | if base_path not in CMAKE_PREFIX_PATH: + | ^^^^^^^^^^^^^^^^^ + 4 | CMAKE_PREFIX_PATH.insert(0, base_path) + | + info: rule `unresolved-reference` is enabled by default + + error[unresolved-reference]: Name `CMAKE_PREFIX_PATH` used when not defined + --> out/amd64/install/_setup_util.py:4:5 + | + 2 | base_path: str = "/path" + 3 | if base_path not in CMAKE_PREFIX_PATH: + 4 | CMAKE_PREFIX_PATH.insert(0, base_path) + | ^^^^^^^^^^^^^^^^^ + | + info: rule `unresolved-reference` is enabled by default + + Found 2 diagnostics + + ----- stderr ----- + "#); + + // With --force-exclude, the exclude pattern is enforced even for explicit paths. + assert_cmd_snapshot!(case.command().arg("--force-exclude").arg("out/amd64/install/_setup_util.py"), @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + WARN No python files found under the given path(s) + "); + + Ok(()) +} + #[test] fn cli_and_configuration_exclude() -> anyhow::Result<()> { let case = CliTest::with_files([ diff --git a/crates/ty_project/src/glob.rs b/crates/ty_project/src/glob.rs index 7f421682c1..080a96db64 100644 --- a/crates/ty_project/src/glob.rs +++ b/crates/ty_project/src/glob.rs @@ -75,7 +75,7 @@ impl std::fmt::Display for IncludeExcludeFilter { } } -#[derive(Copy, Clone, Eq, PartialEq)] +#[derive(Copy, Clone, Eq, PartialEq, Debug)] pub(crate) enum GlobFilterCheckMode { /// The paths are checked top-to-bottom and inclusion is determined /// for each path during the traversal. diff --git a/crates/ty_project/src/walk.rs b/crates/ty_project/src/walk.rs index ee91914a97..1908bafcc8 100644 --- a/crates/ty_project/src/walk.rs +++ b/crates/ty_project/src/walk.rs @@ -21,6 +21,8 @@ pub(crate) struct ProjectFilesFilter<'a> { /// The resolved `src.include` and `src.exclude` filter. src_filter: &'a IncludeExcludeFilter, + + force_exclude: bool, } impl<'a> ProjectFilesFilter<'a> { @@ -28,9 +30,14 @@ impl<'a> ProjectFilesFilter<'a> { Self { included_paths: project.included_paths_or_root(db), src_filter: &project.settings(db).src().files, + force_exclude: project.force_exclude(db), } } + pub(crate) fn force_exclude(&self) -> bool { + self.force_exclude + } + fn match_included_paths( &self, path: &SystemPath, @@ -43,8 +50,8 @@ impl<'a> ProjectFilesFilter<'a> { .iter() .filter_map(|included_path| { if let Ok(relative_path) = path.strip_prefix(included_path) { - // Exact matches are always included - if relative_path.as_str().is_empty() { + // Exact matches are always included, unless forced to exclude + if relative_path.as_str().is_empty() && !self.force_exclude { Some(CheckPathMatch::Full) } else { Some(CheckPathMatch::Partial) @@ -115,8 +122,6 @@ pub(crate) struct ProjectFilesWalker<'a> { walker: WalkDirectoryBuilder, filter: ProjectFilesFilter<'a>, - - force_exclude: bool, } impl<'a> ProjectFilesWalker<'a> { @@ -164,11 +169,7 @@ impl<'a> ProjectFilesWalker<'a> { walker = walker.add(path); } - Some(Self { - walker, - filter, - force_exclude: db.project().force_exclude(db), - }) + Some(Self { walker, filter }) } /// Walks the project paths and collects the paths of all files that @@ -182,6 +183,7 @@ impl<'a> ProjectFilesWalker<'a> { let filter = &self.filter; let files = &files; let diagnostics = &diagnostics; + let force_exclude = filter.force_exclude(); Box::new(move |entry| { match entry { @@ -189,7 +191,7 @@ impl<'a> ProjectFilesWalker<'a> { // Skip excluded directories unless they were explicitly passed to the walker // (which is the case passed to `ty check `). if entry.file_type().is_directory() { - if entry.depth() > 0 || self.force_exclude { + if entry.depth() > 0 || force_exclude { let directory_included = filter .is_directory_included(entry.path(), GlobFilterCheckMode::TopDown); return match directory_included { @@ -213,9 +215,14 @@ impl<'a> ProjectFilesWalker<'a> { } else { // For all files, except the ones that were explicitly passed to the walker (CLI), // check if they're included in the project. - if entry.depth() > 0 || self.force_exclude { + if entry.depth() > 0 || force_exclude { + let match_mode = if entry.depth() == 0 && force_exclude { + GlobFilterCheckMode::Adhoc + } else { + GlobFilterCheckMode::TopDown + }; match filter - .is_file_included(entry.path(), GlobFilterCheckMode::TopDown) + .is_file_included(entry.path(), match_mode) { IncludeResult::Included { literal_match } => { // Ignore any non python files to avoid creating too many entries in `Files`. @@ -229,7 +236,7 @@ impl<'a> ProjectFilesWalker<'a> { if source_type.is_none() { - return WalkState::Continue; + return WalkState::Skip; } } IncludeResult::Excluded => { @@ -237,14 +244,14 @@ impl<'a> ProjectFilesWalker<'a> { "Ignoring file `{path}` because it is excluded by a default or `src.exclude` pattern.", path=entry.path() ); - return WalkState::Continue; + return WalkState::Skip; } IncludeResult::NotIncluded => { tracing::debug!( "Ignoring file `{path}` because it doesn't match any `src.include` pattern or path specified on the CLI.", path=entry.path() ); - return WalkState::Continue; + return WalkState::Skip; } } }