diff --git a/crates/ruff/tests/lint.rs b/crates/ruff/tests/lint.rs index a32c46aeed4c3d..d02b112f934dc3 100644 --- a/crates/ruff/tests/lint.rs +++ b/crates/ruff/tests/lint.rs @@ -1168,3 +1168,83 @@ def func(): Ok(()) } + +/// Per-file selects via ! negation in per-file-ignores +#[test] +fn negated_per_file_ignores() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + fs::write( + &ruff_toml, + r#" +[lint.per-file-ignores] +"!selected.py" = ["RUF"] +"#, + )?; + let selected = tempdir.path().join("selected.py"); + fs::write(selected, "")?; + let ignored = tempdir.path().join("ignored.py"); + fs::write(ignored, "")?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(STDIN_BASE_OPTIONS) + .arg("--config") + .arg(&ruff_toml) + .arg("--select") + .arg("RUF901") + .current_dir(&tempdir) + , @r###" + success: false + exit_code: 1 + ----- stdout ----- + selected.py:1:1: RUF901 [*] Hey this is a stable test rule with a safe fix. + Found 1 error. + [*] 1 fixable with the `--fix` option. + + ----- stderr ----- + "###); + Ok(()) +} + +#[test] +fn negated_per_file_ignores_absolute() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + fs::write( + &ruff_toml, + r#" +[lint.per-file-ignores] +"!src/**.py" = ["RUF"] +"#, + )?; + let src_dir = tempdir.path().join("src"); + fs::create_dir(&src_dir)?; + let selected = src_dir.join("selected.py"); + fs::write(selected, "")?; + let ignored = tempdir.path().join("ignored.py"); + fs::write(ignored, "")?; + + insta::with_settings!({filters => vec![ + // Replace windows paths + (r"\\", "/"), + ]}, { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(STDIN_BASE_OPTIONS) + .arg("--config") + .arg(&ruff_toml) + .arg("--select") + .arg("RUF901") + .current_dir(&tempdir) + , @r###" + success: false + exit_code: 1 + ----- stdout ----- + src/selected.py:1:1: RUF901 [*] Hey this is a stable test rule with a safe fix. + Found 1 error. + [*] 1 fixable with the `--fix` option. + + ----- stderr ----- + "###); + }); + Ok(()) +} diff --git a/crates/ruff_linter/src/fs.rs b/crates/ruff_linter/src/fs.rs index 2a617113fe2eee..aa230ab28788e2 100644 --- a/crates/ruff_linter/src/fs.rs +++ b/crates/ruff_linter/src/fs.rs @@ -9,24 +9,37 @@ use crate::registry::RuleSet; /// Create a set with codes matching the pattern/code pairs. pub(crate) fn ignores_from_path( path: &Path, - pattern_code_pairs: &[(GlobMatcher, GlobMatcher, RuleSet)], + pattern_code_pairs: &[(GlobMatcher, GlobMatcher, bool, RuleSet)], ) -> RuleSet { let file_name = path.file_name().expect("Unable to parse filename"); pattern_code_pairs .iter() - .filter_map(|(absolute, basename, rules)| { + .filter_map(|(absolute, basename, negated, rules)| { if basename.is_match(file_name) { - debug!( - "Adding per-file ignores for {:?} due to basename match on {:?}: {:?}", - path, - basename.glob().regex(), - rules - ); - Some(rules) + if *negated { None } else { + debug!( + "Adding per-file ignores for {:?} due to basename match on {:?}: {:?}", + path, + basename.glob().regex(), + rules + ); + Some(rules) + } } else if absolute.is_match(path) { + if *negated { None } else { + debug!( + "Adding per-file ignores for {:?} due to absolute match on {:?}: {:?}", + path, + absolute.glob().regex(), + rules + ); + Some(rules) + } + } else if *negated { debug!( - "Adding per-file ignores for {:?} due to absolute match on {:?}: {:?}", + "Adding per-file ignores for {:?} due to negated pattern matching neither {:?} nor {:?}: {:?}", path, + basename.glob().regex(), absolute.glob().regex(), rules ); diff --git a/crates/ruff_linter/src/settings/types.rs b/crates/ruff_linter/src/settings/types.rs index 99aec6740cab29..a06bb2ecaeb54d 100644 --- a/crates/ruff_linter/src/settings/types.rs +++ b/crates/ruff_linter/src/settings/types.rs @@ -296,13 +296,22 @@ impl CacheKey for FilePatternSet { pub struct PerFileIgnore { pub(crate) basename: String, pub(crate) absolute: PathBuf, + pub(crate) negated: bool, pub(crate) rules: RuleSet, } impl PerFileIgnore { - pub fn new(pattern: String, prefixes: &[RuleSelector], project_root: Option<&Path>) -> Self { + pub fn new( + mut pattern: String, + prefixes: &[RuleSelector], + project_root: Option<&Path>, + ) -> Self { // Rules in preview are included here even if preview mode is disabled; it's safe to ignore disabled rules let rules: RuleSet = prefixes.iter().flat_map(RuleSelector::all_rules).collect(); + let negated = pattern.starts_with('!'); + if negated { + pattern.drain(..1); + } let path = Path::new(&pattern); let absolute = match project_root { Some(project_root) => fs::normalize_path_to(path, project_root), @@ -312,6 +321,7 @@ impl PerFileIgnore { Self { basename: pattern, absolute, + negated, rules, } } @@ -593,7 +603,7 @@ pub type IdentifierPattern = glob::Pattern; #[derive(Debug, Clone, CacheKey, Default)] pub struct PerFileIgnores { // Ordered as (absolute path matcher, basename matcher, rules) - ignores: Vec<(GlobMatcher, GlobMatcher, RuleSet)>, + ignores: Vec<(GlobMatcher, GlobMatcher, bool, RuleSet)>, } impl PerFileIgnores { @@ -609,7 +619,12 @@ impl PerFileIgnores { // Construct basename matcher. let basename = Glob::new(&per_file_ignore.basename)?.compile_matcher(); - Ok((absolute, basename, per_file_ignore.rules)) + Ok(( + absolute, + basename, + per_file_ignore.negated, + per_file_ignore.rules, + )) }) .collect(); Ok(Self { ignores: ignores? }) @@ -622,10 +637,10 @@ impl Display for PerFileIgnores { write!(f, "{{}}")?; } else { writeln!(f, "{{")?; - for (absolute, basename, rules) in &self.ignores { + for (absolute, basename, negated, rules) in &self.ignores { writeln!( f, - "\t{{ absolute = {absolute:#?}, basename = {basename:#?}, rules = {rules} }}," + "\t{{ absolute = {absolute:#?}, basename = {basename:#?}, negated = {negated:#?}, rules = {rules} }}," )?; } write!(f, "}}")?; @@ -635,7 +650,7 @@ impl Display for PerFileIgnores { } impl Deref for PerFileIgnores { - type Target = Vec<(GlobMatcher, GlobMatcher, RuleSet)>; + type Target = Vec<(GlobMatcher, GlobMatcher, bool, RuleSet)>; fn deref(&self) -> &Self::Target { &self.ignores diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index fefffa3c1defb6..2553290464f3e0 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -905,7 +905,8 @@ pub struct LintCommonOptions { // Tables are required to go last. /// A list of mappings from file pattern to rule codes or prefixes to - /// exclude, when considering any matching files. + /// exclude, when considering any matching files. An initial '!' negates + /// the file pattern. #[option( default = "{}", value_type = "dict[str, list[RuleSelector]]", @@ -914,6 +915,8 @@ pub struct LintCommonOptions { # Ignore `E402` (import violations) in all `__init__.py` files, and in `path/to/file.py`. "__init__.py" = ["E402"] "path/to/file.py" = ["E402"] + # Ignore `D` rules everywhere except for the `src/` directory. + "!src/**.py" = ["F401"] "# )] pub per_file_ignores: Option>>, diff --git a/ruff.schema.json b/ruff.schema.json index bc00be8a327bbb..c057b616c8e724 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -554,7 +554,7 @@ ] }, "per-file-ignores": { - "description": "A list of mappings from file pattern to rule codes or prefixes to exclude, when considering any matching files.", + "description": "A list of mappings from file pattern to rule codes or prefixes to exclude, when considering any matching files. An initial '!' negates the file pattern.", "deprecated": true, "type": [ "object", @@ -2168,7 +2168,7 @@ ] }, "per-file-ignores": { - "description": "A list of mappings from file pattern to rule codes or prefixes to exclude, when considering any matching files.", + "description": "A list of mappings from file pattern to rule codes or prefixes to exclude, when considering any matching files. An initial '!' negates the file pattern.", "type": [ "object", "null"