diff --git a/Cargo.lock b/Cargo.lock index 3f2f82896658d2..92f511cd00deef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2141,6 +2141,7 @@ name = "ruff_diagnostics" version = "0.0.0" dependencies = [ "anyhow", + "is-macro", "log", "ruff_text_size", "serde", diff --git a/crates/ruff_cli/tests/integration_test.rs b/crates/ruff_cli/tests/integration_test.rs index 28a34d7453c47c..d5ba48e3c4874f 100644 --- a/crates/ruff_cli/tests/integration_test.rs +++ b/crates/ruff_cli/tests/integration_test.rs @@ -1,6 +1,5 @@ #![cfg(not(target_family = "wasm"))] -#[cfg(unix)] use std::fs; #[cfg(unix)] use std::fs::Permissions; @@ -11,7 +10,6 @@ use std::path::Path; use std::process::Command; use std::str; -#[cfg(unix)] use anyhow::{Context, Result}; #[cfg(unix)] use clap::Parser; @@ -1105,3 +1103,81 @@ fn diff_shows_unsafe_fixes_with_opt_in() { "### ); } + +#[test] +fn check_extend_unsafe_fixes() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + fs::write( + &ruff_toml, + r#" +[lint] +extend-unsafe-fixes = ["UP034"] +"#, + )?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["check", "--config"]) + .arg(&ruff_toml) + .arg("-") + .args([ + "--output-format", + "text", + "--no-cache", + "--select", + "F601,UP034", + ]) + .pass_stdin("x = {'a': 1, 'a': 1}\nprint(('foo'))\n"), + @r###" + success: false + exit_code: 1 + ----- stdout ----- + -:1:14: F601 Dictionary key literal `'a'` repeated + -:2:7: UP034 Avoid extraneous parentheses + Found 2 errors. + 2 hidden fixes can be enabled with the `--unsafe-fixes` option. + + ----- stderr ----- + "###); + + Ok(()) +} + +#[test] +fn check_extend_safe_fixes() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + fs::write( + &ruff_toml, + r#" +[lint] +extend-safe-fixes = ["F601"] +"#, + )?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["check", "--config"]) + .arg(&ruff_toml) + .arg("-") + .args([ + "--output-format", + "text", + "--no-cache", + "--select", + "F601,UP034", + ]) + .pass_stdin("x = {'a': 1, 'a': 1}\nprint(('foo'))\n"), + @r###" + success: false + exit_code: 1 + ----- stdout ----- + -:1:14: F601 [*] Dictionary key literal `'a'` repeated + -:2:7: UP034 [*] Avoid extraneous parentheses + Found 2 errors. + [*] 2 fixable with the `--fix` option. + + ----- stderr ----- + "###); + + Ok(()) +} diff --git a/crates/ruff_diagnostics/Cargo.toml b/crates/ruff_diagnostics/Cargo.toml index 4d548f41b62923..9a2e22e2340fb5 100644 --- a/crates/ruff_diagnostics/Cargo.toml +++ b/crates/ruff_diagnostics/Cargo.toml @@ -17,4 +17,5 @@ ruff_text_size = { path = "../ruff_text_size" } anyhow = { workspace = true } log = { workspace = true } +is-macro = { workspace = true } serde = { workspace = true, optional = true, features = [] } diff --git a/crates/ruff_diagnostics/src/fix.rs b/crates/ruff_diagnostics/src/fix.rs index cbc6995b370987..27c900036d37f8 100644 --- a/crates/ruff_diagnostics/src/fix.rs +++ b/crates/ruff_diagnostics/src/fix.rs @@ -6,7 +6,7 @@ use ruff_text_size::{Ranged, TextSize}; use crate::edit::Edit; /// Indicates if a fix can be applied. -#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, is_macro::Is)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))] pub enum Applicability { @@ -138,4 +138,11 @@ impl Fix { pub fn applies(&self, applicability: Applicability) -> bool { self.applicability >= applicability } + + /// Create a new [`Fix`] with the given [`Applicability`]. + #[must_use] + pub fn with_applicability(mut self, applicability: Applicability) -> Self { + self.applicability = applicability; + self + } } diff --git a/crates/ruff_linter/src/linter.rs b/crates/ruff_linter/src/linter.rs index 9219bfa8ef7748..dbef3566e31357 100644 --- a/crates/ruff_linter/src/linter.rs +++ b/crates/ruff_linter/src/linter.rs @@ -8,7 +8,7 @@ use itertools::Itertools; use log::error; use rustc_hash::FxHashMap; -use ruff_diagnostics::Diagnostic; +use ruff_diagnostics::{Applicability, Diagnostic}; use ruff_python_ast::imports::ImportMap; use ruff_python_ast::PySourceType; use ruff_python_codegen::Stylist; @@ -260,6 +260,26 @@ pub fn check_path( } } + // Update fix applicability to account for overrides + if !settings.extend_safe_fixes.is_empty() || !settings.extend_unsafe_fixes.is_empty() { + for diagnostic in &mut diagnostics { + if let Some(fix) = &diagnostic.fix { + // Check unsafe before safe so if someone puts a rule in both we are conservative + if settings + .extend_unsafe_fixes + .contains(diagnostic.kind.rule()) + && fix.applicability().is_always() + { + diagnostic.set_fix(fix.clone().with_applicability(Applicability::Sometimes)); + } else if settings.extend_safe_fixes.contains(diagnostic.kind.rule()) + && fix.applicability().is_sometimes() + { + diagnostic.set_fix(fix.clone().with_applicability(Applicability::Always)); + } + } + } + } + LinterResult::new((diagnostics, imports), error) } diff --git a/crates/ruff_linter/src/settings/mod.rs b/crates/ruff_linter/src/settings/mod.rs index 881611cfc8d528..0d12ad2f764026 100644 --- a/crates/ruff_linter/src/settings/mod.rs +++ b/crates/ruff_linter/src/settings/mod.rs @@ -42,6 +42,8 @@ pub struct LinterSettings { pub rules: RuleTable, pub per_file_ignores: Vec<(GlobMatcher, GlobMatcher, RuleSet)>, + pub extend_unsafe_fixes: RuleSet, + pub extend_safe_fixes: RuleSet, pub target_version: PythonVersion, pub preview: PreviewMode, @@ -139,6 +141,8 @@ impl LinterSettings { namespace_packages: vec![], per_file_ignores: vec![], + extend_safe_fixes: RuleSet::empty(), + extend_unsafe_fixes: RuleSet::empty(), src: vec![path_dedot::CWD.clone()], // Needs duplicating diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index 9733bbf197bdfd..5b941712b4e3b4 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -181,6 +181,28 @@ impl Configuration { .chain(lint.extend_per_file_ignores) .collect(), )?, + + extend_safe_fixes: lint + .extend_safe_fixes + .iter() + .flat_map(|selector| { + selector.rules(&PreviewOptions { + mode: preview, + require_explicit: false, + }) + }) + .collect(), + extend_unsafe_fixes: lint + .extend_unsafe_fixes + .iter() + .flat_map(|selector| { + selector.rules(&PreviewOptions { + mode: preview, + require_explicit: false, + }) + }) + .collect(), + src: self.src.unwrap_or_else(|| vec![project_root.to_path_buf()]), explicit_preview_rules: lint.explicit_preview_rules.unwrap_or_default(), @@ -449,6 +471,10 @@ pub struct LintConfiguration { pub rule_selections: Vec, pub explicit_preview_rules: Option, + // Fix configuration + pub extend_unsafe_fixes: Vec, + pub extend_safe_fixes: Vec, + // Global lint settings pub allowed_confusables: Option>, pub dummy_variable_rgx: Option, @@ -506,6 +532,8 @@ impl LintConfiguration { .collect(), extend_fixable: options.extend_fixable.unwrap_or_default(), }], + extend_safe_fixes: options.extend_safe_fixes.unwrap_or_default(), + extend_unsafe_fixes: options.extend_unsafe_fixes.unwrap_or_default(), allowed_confusables: options.allowed_confusables, dummy_variable_rgx: options .dummy_variable_rgx @@ -809,6 +837,16 @@ impl LintConfiguration { .into_iter() .chain(self.rule_selections) .collect(), + extend_safe_fixes: config + .extend_safe_fixes + .into_iter() + .chain(self.extend_safe_fixes) + .collect(), + extend_unsafe_fixes: config + .extend_unsafe_fixes + .into_iter() + .chain(self.extend_unsafe_fixes) + .collect(), allowed_confusables: self.allowed_confusables.or(config.allowed_confusables), dummy_variable_rgx: self.dummy_variable_rgx.or(config.dummy_variable_rgx), extend_per_file_ignores: config diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 1a79575c52ae49..6917f1b1d3e726 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -524,6 +524,30 @@ pub struct LintOptions { )] pub ignore: Option>, + /// A list of rule codes or prefixes for which unsafe fixes should be considered + /// safe. + #[option( + default = "[]", + value_type = "list[RuleSelector]", + example = r#" + # Allow applying all unsafe fixes in the `E` rules and `F401` without the `--unsafe-fixes` flag + extend_safe_fixes = ["E", "F401"] + "# + )] + pub extend_safe_fixes: Option>, + + /// A list of rule codes or prefixes for which safe fixes should be considered + /// unsafe. + #[option( + default = "[]", + value_type = "list[RuleSelector]", + example = r#" + # Require the `--unsafe-fixes` flag when fixing the `E` rules and `F401` + extend_unsafe_fixes = ["E", "F401"] + "# + )] + pub extend_unsafe_fixes: Option>, + /// Avoid automatically removing unused imports in `__init__.py` files. Such /// imports will still be flagged, but with a dedicated message suggesting /// that the import is either added to the module's `__all__` symbol, or diff --git a/ruff.schema.json b/ruff.schema.json index de0bf5807ed9d4..0e599ce0365323 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -107,6 +107,16 @@ } } }, + "extend-safe-fixes": { + "description": "A list of rule codes or prefixes for which unsafe fixes should be considered safe.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/RuleSelector" + } + }, "extend-select": { "description": "A list of rule codes or prefixes to enable, in addition to those specified by `select`.", "type": [ @@ -117,6 +127,16 @@ "$ref": "#/definitions/RuleSelector" } }, + "extend-unsafe-fixes": { + "description": "A list of rule codes or prefixes for which safe fixes should be considered unsafe.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/RuleSelector" + } + }, "external": { "description": "A list of rule codes that are unsupported by Ruff, but should be preserved when (e.g.) validating `# noqa` directives. Useful for retaining `# noqa` directives that cover plugins not yet implemented by Ruff.", "type": [ @@ -1613,6 +1633,16 @@ } } }, + "extend-safe-fixes": { + "description": "A list of rule codes or prefixes for which unsafe fixes should be considered safe.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/RuleSelector" + } + }, "extend-select": { "description": "A list of rule codes or prefixes to enable, in addition to those specified by `select`.", "type": [ @@ -1623,6 +1653,16 @@ "$ref": "#/definitions/RuleSelector" } }, + "extend-unsafe-fixes": { + "description": "A list of rule codes or prefixes for which safe fixes should be considered unsafe.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/RuleSelector" + } + }, "external": { "description": "A list of rule codes that are unsupported by Ruff, but should be preserved when (e.g.) validating `# noqa` directives. Useful for retaining `# noqa` directives that cover plugins not yet implemented by Ruff.", "type": [