diff --git a/crates/ruff/src/args.rs b/crates/ruff/src/args.rs index f1d38336f2..181e8dabda 100644 --- a/crates/ruff/src/args.rs +++ b/crates/ruff/src/args.rs @@ -28,7 +28,7 @@ use ruff_options_metadata::{OptionEntry, OptionsMetadata}; use ruff_python_ast as ast; use ruff_source_file::{LineIndex, OneIndexed, PositionEncoding}; use ruff_text_size::TextRange; -use ruff_workspace::configuration::{Configuration, RuleSelection}; +use ruff_workspace::configuration::{Configuration, ExternalRuleSelection, RuleSelection}; use ruff_workspace::options::{Options, PycodestyleOptions}; use ruff_workspace::resolver::ConfigurationTransformer; use rustc_hash::FxHashMap; @@ -462,6 +462,53 @@ pub struct CheckCommand { conflicts_with = "watch", )] pub show_settings: bool, + /// List configured external AST linters and exit. + #[arg( + long, + help_heading = "External linter options", + conflicts_with = "add_noqa" + )] + pub list_external_linters: bool, + /// Restrict linting to the given external linter IDs. + #[arg( + long = "select-external", + value_name = "LINTER", + action = clap::ArgAction::Append, + help_heading = "External linter options", + )] + pub select_external: Vec, + /// Enable additional external linter IDs or rule codes without replacing existing selections. + #[arg( + long = "extend-select-external", + value_name = "LINTER", + action = clap::ArgAction::Append, + help_heading = "External linter options", + )] + pub extend_select_external: Vec, + /// Disable the given external linter IDs or rule codes. + #[arg( + long = "ignore-external", + value_name = "LINTER", + action = clap::ArgAction::Append, + help_heading = "External linter options", + )] + pub ignore_external: Vec, + /// Disable additional external linter IDs or rule codes without replacing existing ignores. + #[arg( + long = "extend-ignore-external", + value_name = "LINTER", + action = clap::ArgAction::Append, + help_heading = "External linter options", + )] + pub extend_ignore_external: Vec, + /// Validate external linter definitions without running lint checks. + #[arg( + long = "verify-external-linters", + help_heading = "External linter options", + conflicts_with = "add_noqa", + conflicts_with = "list_external_linters" + )] + pub verify_external_linters: bool, } #[derive(Clone, Debug, clap::Parser)] @@ -730,6 +777,70 @@ impl CheckCommand { self, global_options: GlobalConfigArgs, ) -> anyhow::Result<(CheckArguments, ConfigArguments)> { + if let Some(invalid) = self + .select_external + .iter() + .find(|selector| is_builtin_rule_selector(selector)) + { + anyhow::bail!( + "Internal rule `{invalid}` cannot be enabled with `--select-external`; use `--select` instead." + ); + } + if let Some(selector) = self.select.as_ref().and_then(|selectors| { + selectors.iter().find_map(|selector| { + if let RuleSelector::External { code } = selector { + Some(code.as_ref().to_string()) + } else { + None + } + }) + }) { + anyhow::bail!( + "External rule `{selector}` cannot be enabled with `--select`; use `--select-external` instead." + ); + } + if let Some(selector) = self.extend_select.as_ref().and_then(|selectors| { + selectors.iter().find_map(|selector| { + if let RuleSelector::External { code } = selector { + Some(code.as_ref().to_string()) + } else { + None + } + }) + }) { + anyhow::bail!( + "External rule `{selector}` cannot be enabled with `--extend-select`; use `--extend-select-external` instead." + ); + } + if let Some(invalid) = self + .extend_select_external + .iter() + .find(|selector| is_builtin_rule_selector(selector)) + { + anyhow::bail!( + "Internal rule `{invalid}` cannot be enabled with `--extend-select-external`; use `--extend-select` instead." + ); + } + if let Some(invalid) = self + .ignore_external + .iter() + .chain(self.extend_ignore_external.iter()) + .find(|selector| is_builtin_rule_selector(selector)) + { + anyhow::bail!( + "Internal rule `{invalid}` cannot be disabled with `--ignore-external`; use `--ignore` instead." + ); + } + + let select_external_override = if self.select_external.is_empty() { + None + } else { + Some(self.select_external.clone()) + }; + let extend_select_external_override = self.extend_select_external.clone(); + let ignore_external_override = self.ignore_external.clone(); + let extend_ignore_external_override = self.extend_ignore_external.clone(); + let check_arguments = CheckArguments { add_noqa: self.add_noqa, diff: self.diff, @@ -741,6 +852,12 @@ impl CheckCommand { output_file: self.output_file, show_files: self.show_files, show_settings: self.show_settings, + list_external_linters: self.list_external_linters, + select_external: self.select_external, + extend_select_external: self.extend_select_external, + ignore_external: self.ignore_external, + extend_ignore_external: self.extend_ignore_external, + verify_external_linters: self.verify_external_linters, statistics: self.statistics, stdin_filename: self.stdin_filename, watch: self.watch, @@ -774,6 +891,10 @@ impl CheckCommand { output_format: self.output_format, show_fixes: resolve_bool_arg(self.show_fixes, self.no_show_fixes), extension: self.extension, + select_external: select_external_override, + extend_select_external: extend_select_external_override, + ignore_external: ignore_external_override, + extend_ignore_external: extend_ignore_external_override, ..ExplicitConfigOverrides::default() }; @@ -1072,6 +1193,12 @@ pub struct CheckArguments { pub output_file: Option, pub show_files: bool, pub show_settings: bool, + pub list_external_linters: bool, + pub select_external: Vec, + pub extend_select_external: Vec, + pub ignore_external: Vec, + pub extend_ignore_external: Vec, + pub verify_external_linters: bool, pub statistics: bool, pub stdin_filename: Option, pub watch: bool, @@ -1335,6 +1462,10 @@ struct ExplicitConfigOverrides { extension: Option>, detect_string_imports: Option, string_imports_min_dots: Option, + select_external: Option>, + extend_select_external: Vec, + ignore_external: Vec, + extend_ignore_external: Vec, } impl ConfigurationTransformer for ExplicitConfigOverrides { @@ -1425,11 +1556,33 @@ impl ConfigurationTransformer for ExplicitConfigOverrides { if let Some(string_imports_min_dots) = &self.string_imports_min_dots { config.analyze.string_imports_min_dots = Some(*string_imports_min_dots); } + if self.select_external.is_some() + || !self.extend_select_external.is_empty() + || !self.ignore_external.is_empty() + || !self.extend_ignore_external.is_empty() + { + config + .lint + .external_rule_selections + .push(ExternalRuleSelection { + select: self.select_external.clone(), + extend_select: self.extend_select_external.clone(), + ignore: self.ignore_external.clone(), + extend_ignore: self.extend_ignore_external.clone(), + }); + } config } } +fn is_builtin_rule_selector(selector: &str) -> bool { + matches!( + RuleSelector::from_str(selector), + Ok(RuleSelector::Linter(_) | RuleSelector::Prefix { .. } | RuleSelector::Rule { .. }) + ) +} + /// Convert a list of `PatternPrefixPair` structs to `PerFileIgnore`. pub fn collect_per_file_ignores(pairs: Vec) -> Vec { let mut per_file_ignores: FxHashMap> = FxHashMap::default(); diff --git a/crates/ruff/src/commands/check.rs b/crates/ruff/src/commands/check.rs index dcdd0f9b18..dac07af94d 100644 --- a/crates/ruff/src/commands/check.rs +++ b/crates/ruff/src/commands/check.rs @@ -21,6 +21,7 @@ use ruff_linter::settings::{LinterSettings, flags}; use ruff_linter::{IOError, Violation, fs, warn_user_once}; use ruff_source_file::SourceFileBuilder; use ruff_text_size::TextRange; +use ruff_workspace::Settings; use ruff_workspace::resolver::{ PyprojectConfig, ResolvedFile, match_exclusion, python_files_in_path, }; @@ -28,6 +29,7 @@ use ruff_workspace::resolver::{ use crate::args::ConfigArguments; use crate::cache::{Cache, PackageCacheMap, PackageCaches}; use crate::diagnostics::Diagnostics; +use crate::{apply_external_linter_selection_to_settings, compute_external_selection_state}; /// Run the linter over a collection of files. pub(crate) fn check( @@ -41,6 +43,20 @@ pub(crate) fn check( ) -> Result { // Collect all the Python files to check. let start = Instant::now(); + let apply_external_selection = |settings: &mut Settings| -> Result<()> { + let state = compute_external_selection_state( + &settings.linter.selected_external, + &settings.linter.ignored_external, + &[], + &[], + &[], + &[], + ); + settings.linter.selected_external = state.effective.iter().cloned().collect(); + settings.linter.ignored_external = state.ignored.iter().cloned().collect(); + apply_external_linter_selection_to_settings(settings, &state.effective, &state.ignored)?; + Ok(()) + }; let (paths, resolver) = python_files_in_path(files, pyproject_config, config_arguments)?; debug!("Identified files to lint in: {:?}", start.elapsed()); @@ -49,6 +65,17 @@ pub(crate) fn check( return Ok(Diagnostics::default()); } + let resolver = resolver.transform_settings(apply_external_selection)?; + let any_external_selection = resolver + .settings() + .any(|settings| !settings.linter.selected_external.is_empty()); + let any_external_registry = resolver + .settings() + .any(|settings| settings.linter.external_ast.is_some()); + if any_external_selection && !any_external_registry { + anyhow::bail!("No external AST linters are configured in this workspace."); + } + // Discover the package root for each Python file. let package_roots = resolver.package_roots( &paths diff --git a/crates/ruff/src/commands/format.rs b/crates/ruff/src/commands/format.rs index 0e245efa8c..57d34512da 100644 --- a/crates/ruff/src/commands/format.rs +++ b/crates/ruff/src/commands/format.rs @@ -596,7 +596,7 @@ impl<'a> FormatResults<'a> { .iter() .map(Diagnostic::from) .chain(self.to_diagnostics(&mut notebook_index)) - .sorted_unstable_by(Diagnostic::ruff_start_ordering) + .sorted_by(Diagnostic::ruff_start_ordering) .collect(); let context = EmitterContext::new(¬ebook_index); diff --git a/crates/ruff/src/external.rs b/crates/ruff/src/external.rs new file mode 100644 index 0000000000..b18cc63d2d --- /dev/null +++ b/crates/ruff/src/external.rs @@ -0,0 +1,293 @@ +use std::io::{self, Write}; + +use anyhow::Result; +use ruff_linter::external::ExternalLintRegistry; +use ruff_linter::external::ast::rule::{ExternalAstLinter, ExternalAstRule}; +use ruff_linter::registry::Rule; +use ruff_workspace::Settings; +use rustc_hash::FxHashSet; + +#[derive(Debug)] +pub(crate) struct ExternalSelectionState { + pub ignored: FxHashSet, + pub effective: FxHashSet, +} + +pub(crate) fn compute_external_selection_state( + base_selected: &[String], + base_ignored: &[String], + cli_select: &[String], + cli_extend_select: &[String], + cli_ignore: &[String], + cli_extend_ignore: &[String], +) -> ExternalSelectionState { + let mut selected: FxHashSet = if cli_select.is_empty() { + base_selected.iter().cloned().collect() + } else { + FxHashSet::default() + }; + selected.extend(cli_select.iter().cloned()); + selected.extend(cli_extend_select.iter().cloned()); + + let mut ignored: FxHashSet = base_ignored.iter().cloned().collect(); + ignored.extend(cli_ignore.iter().cloned()); + ignored.extend(cli_extend_ignore.iter().cloned()); + + let effective = selected + .iter() + .filter(|code| !ignored.contains(*code)) + .cloned() + .collect(); + + ExternalSelectionState { ignored, effective } +} + +pub(crate) fn apply_external_linter_selection_to_settings( + settings: &mut Settings, + selected: &FxHashSet, + ignored: &FxHashSet, +) -> Result { + let linter = &mut settings.linter; + + if selected.is_empty() { + if linter.rules.enabled(Rule::ExternalLinter) { + linter.rules.disable(Rule::ExternalLinter); + } + linter.selected_external.clear(); + linter.external_ast = None; + return Ok(true); + } + + if let Some(registry) = linter.external_ast.take() { + let selection = select_external_linters(®istry, selected, ignored); + if !selection.missing.is_empty() { + anyhow::bail!( + "Unknown external linter or rule selector(s): {}", + selection.missing.join(", ") + ); + } + + let mut filtered = ExternalLintRegistry::new(); + for matched in &selection.matches { + filtered.insert_linter(matched.clone_selected())?; + } + + linter.selected_external = selected.iter().cloned().collect(); + let codes: Vec = filtered + .iter_enabled_rules() + .map(|rule| rule.code.as_str().to_string()) + .collect(); + + if filtered.is_empty() { + linter.rules.disable(Rule::ExternalLinter); + linter.external_ast = None; + linter.selected_external.clear(); + } else { + linter.rules.enable(Rule::ExternalLinter, false); + linter.external_ast = Some(filtered); + let external_codes = &mut linter.external; + for code in codes { + if !external_codes.iter().any(|existing| existing == &code) { + external_codes.push(code); + } + } + } + + Ok(true) + } else { + linter.external_ast = None; + Ok(false) + } +} + +pub(crate) fn select_external_linters<'a>( + registry: &'a ExternalLintRegistry, + selected: &FxHashSet, + ignored: &FxHashSet, +) -> SelectedExternalLinters<'a> { + let mut matches = Vec::new(); + let mut missing = Vec::new(); + + let enabled_linters: Vec<&'a ExternalAstLinter> = registry + .linters() + .iter() + .filter(|linter| linter.enabled) + .collect(); + + if selected.is_empty() { + matches.extend( + enabled_linters + .iter() + .copied() + .map(SelectedExternalLinter::all_rules), + ); + return SelectedExternalLinters { matches, missing }; + } + + let mut satisfied: FxHashSet<&'a str> = FxHashSet::default(); + let mut available_linter_ids: FxHashSet<&'a str> = FxHashSet::default(); + + for linter in &enabled_linters { + available_linter_ids.insert(linter.id.as_str()); + } + + for linter in enabled_linters { + let selected_linter = selected.contains(linter.id.as_str()); + + if selected_linter && ignored.is_empty() { + matches.push(SelectedExternalLinter::all_rules(linter)); + satisfied.insert(linter.id.as_str()); + continue; + } + + let included: Vec<_> = linter + .rules + .iter() + .filter(|rule| !ignored.contains(rule.code.as_str())) + .collect(); + + if selected_linter { + if included.is_empty() { + missing.push(linter.id.clone()); + continue; + } + + satisfied.insert(linter.id.as_str()); + for rule in &included { + if selected.contains(rule.code.as_str()) { + satisfied.insert(rule.code.as_str()); + } + } + + matches.push(SelectedExternalLinter::subset(linter, included)); + continue; + } + + let matched_rules: Vec<_> = included + .iter() + .copied() + .filter(|rule| selected.contains(rule.code.as_str())) + .collect(); + + if matched_rules.is_empty() { + continue; + } + + for rule in &matched_rules { + satisfied.insert(rule.code.as_str()); + } + + matches.push(SelectedExternalLinter::subset(linter, matched_rules)); + } + + for selector in selected { + let selector = selector.as_str(); + if ignored.contains(selector) || satisfied.contains(selector) { + continue; + } + + if available_linter_ids.contains(selector) { + continue; + } + + if registry.find_rule_by_code(selector).is_some() { + continue; + } + + missing.push(selector.to_string()); + } + + SelectedExternalLinters { matches, missing } +} + +#[derive(Debug)] +pub(crate) struct SelectedExternalLinters<'a> { + pub matches: Vec>, + pub missing: Vec, +} + +pub(crate) fn print_external_linters( + registry: &ExternalLintRegistry, + linters: &[SelectedExternalLinter<'_>], + mut writer: impl Write, +) -> io::Result<()> { + match (registry.is_empty(), linters.is_empty()) { + (true, _) => writeln!(writer, "No external AST linters configured.")?, + (false, true) => writeln!(writer, "No matching external AST linters found.")?, + (false, false) => { + for selected in linters { + selected.print(&mut writer)?; + } + } + } + Ok(()) +} + +#[derive(Debug)] +pub(crate) struct SelectedExternalLinter<'a> { + linter: &'a ExternalAstLinter, + selection: SelectedRules<'a>, +} + +impl<'a> SelectedExternalLinter<'a> { + fn all_rules(linter: &'a ExternalAstLinter) -> Self { + Self { + linter, + selection: SelectedRules::All, + } + } + + fn subset(linter: &'a ExternalAstLinter, rules: Vec<&'a ExternalAstRule>) -> Self { + debug_assert!(!rules.is_empty()); + Self { + linter, + selection: SelectedRules::Subset(rules), + } + } + + fn clone_selected(&self) -> ExternalAstLinter { + match &self.selection { + SelectedRules::All => self.linter.clone(), + SelectedRules::Subset(rules) => ExternalAstLinter { + id: self.linter.id.clone(), + name: self.linter.name.clone(), + description: self.linter.description.clone(), + enabled: self.linter.enabled, + rules: rules.iter().map(|&rule| rule.clone()).collect(), + }, + } + } + + fn print(&self, writer: &mut impl Write) -> io::Result<()> { + match &self.selection { + SelectedRules::All => write!(writer, "{}", self.linter), + SelectedRules::Subset(rules) => { + writeln!( + writer, + "{}{}", + self.linter.id, + if self.linter.enabled { + "" + } else { + " (disabled)" + } + )?; + writeln!(writer, " name: {}", self.linter.name)?; + if let Some(description) = &self.linter.description { + writeln!(writer, " description: {description}")?; + } + writeln!(writer, " rules:")?; + for rule in rules { + writeln!(writer, " - {} ({})", rule.code.as_str(), rule.name)?; + } + writeln!(writer) + } + } + } +} + +#[derive(Debug)] +enum SelectedRules<'a> { + All, + Subset(Vec<&'a ExternalAstRule>), +} diff --git a/crates/ruff/src/lib.rs b/crates/ruff/src/lib.rs index 3ea0d94fad..7b4eede305 100644 --- a/crates/ruff/src/lib.rs +++ b/crates/ruff/src/lib.rs @@ -14,26 +14,35 @@ use notify::{RecursiveMode, Watcher, recommended_watcher}; use args::{GlobalConfigArgs, ServerCommand}; use ruff_db::diagnostic::{Diagnostic, Severity}; +use ruff_linter::external::ExternalLintRegistry; use ruff_linter::logging::{LogLevel, set_up_logging}; use ruff_linter::settings::flags::FixMode; use ruff_linter::settings::types::OutputFormat; use ruff_linter::{fs, warn_user, warn_user_once}; use ruff_workspace::Settings; +use ruff_workspace::resolver::PyprojectConfig; +use rustc_hash::FxHashSet; -use crate::args::{ - AnalyzeCommand, AnalyzeGraphCommand, Args, CheckCommand, Command, FormatCommand, +use crate::{ + args::{AnalyzeCommand, AnalyzeGraphCommand, Args, CheckCommand, Command, FormatCommand}, + printer::{Flags as PrinterFlags, Printer}, }; -use crate::printer::{Flags as PrinterFlags, Printer}; pub mod args; mod cache; mod commands; mod diagnostics; +mod external; mod printer; pub mod resolve; mod stdin; mod version; +pub(crate) use external::{ + apply_external_linter_selection_to_settings, compute_external_selection_state, + print_external_linters, select_external_linters, +}; + #[derive(Copy, Clone)] pub enum ExitStatus { /// Linting was successful and there were no linting errors. @@ -125,6 +134,14 @@ fn resolve_default_files(files: Vec, is_stdin: bool) -> Vec { } } +fn apply_external_linter_selection( + pyproject_config: &mut PyprojectConfig, + selected: &FxHashSet, + ignored: &FxHashSet, +) -> Result { + apply_external_linter_selection_to_settings(&mut pyproject_config.settings, selected, ignored) +} + pub fn run( Args { command, @@ -238,7 +255,7 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result = match cli.output_file { Some(path) if !cli.watch => { @@ -256,6 +273,48 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result Result Result ExternalLintRegistry { + let mut registry = ExternalLintRegistry::new(); + + let rules = vec![ + ExternalAstRule::new( + ExternalRuleCode::new("EXT001").unwrap(), + "FirstRule", + None::<&str>, + vec![AstTarget::Stmt(StmtKind::FunctionDef)], + ExternalRuleScript::file( + PathBuf::from("ext001.py"), + "def check_stmt(node, ctx):\n pass\n", + ), + None, + ), + ExternalAstRule::new( + ExternalRuleCode::new("EXT002").unwrap(), + "SecondRule", + None::<&str>, + vec![AstTarget::Stmt(StmtKind::FunctionDef)], + ExternalRuleScript::file( + PathBuf::from("ext002.py"), + "def check_stmt(node, ctx):\n pass\n", + ), + None, + ), + ]; + + let linter = ExternalAstLinter::new("demo", "Demo", None::<&str>, true, rules); + registry.insert_linter(linter).unwrap(); + registry + } + + #[test] + fn selecting_linter_respects_ignored_rule_codes() { + let registry = make_registry(); + + let mut settings = Settings::default(); + settings.linter.external_ast = Some(registry); + settings.linter.selected_external = vec!["demo".to_string()]; + settings.linter.ignored_external = vec!["EXT002".to_string()]; + + let selected: FxHashSet = + settings.linter.selected_external.iter().cloned().collect(); + let ignored: FxHashSet = settings.linter.ignored_external.iter().cloned().collect(); + apply_external_linter_selection_to_settings(&mut settings, &selected, &ignored).unwrap(); + + let filtered = settings + .linter + .external_ast + .expect("external registry should remain configured"); + assert!( + filtered.find_rule_by_code("EXT002").is_none(), + "ignored rule code should be excluded when selecting the entire linter" + ); + assert!( + filtered.find_rule_by_code("EXT001").is_some(), + "other rules should remain enabled" + ); + } +} + #[cfg(test)] mod test_file_change_detector { use std::path::PathBuf; diff --git a/crates/ruff/tests/cli/lint.rs b/crates/ruff/tests/cli/lint.rs index 9ad806b4a4..f5f3276934 100644 --- a/crates/ruff/tests/cli/lint.rs +++ b/crates/ruff/tests/cli/lint.rs @@ -21,6 +21,39 @@ impl CliTest { } } +fn write_demo_external_linter(test: &CliTest) -> Result { + test.write_file( + "lint/external/demo.toml", + r#" +name = "Demo External Linter" +description = "Shows how to configure external AST linters" + +[[rule]] +code = "EXT001" +name = "ExampleRule" +summary = "Provides illustrative coverage" +targets = ["stmt:FunctionDef"] +script = "rules/example.py" + +[[rule]] +code = "EXT002" +name = "AnotherRule" +summary = "Provides illustrative coverage" +targets = ["stmt:FunctionDef"] +script = "rules/example.py" +"#, + )?; + test.write_file( + "lint/external/rules/example.py", + r#" +def check_stmt(node, ctx): + if node["_kind"] == "FunctionDef": + ctx.report("external lint fired") +"#, + )?; + Ok(test.root().join("lint/external/demo.toml")) +} + #[test] fn top_level_options() -> Result<()> { let test = CliTest::new()?; @@ -168,6 +201,700 @@ inline-quotes = "single" Ok(()) } +#[test] +fn external_ast_linter_listing() -> Result<()> { + let test = CliTest::new()?; + test.write_file( + "lint/external/demo.toml", + r#" +name = "Demo External Linter" +description = "Shows how to configure external AST linters" + +[[rule]] +code = "EXT001" +name = "ExampleRule" +summary = "Provides illustrative coverage" +targets = ["stmt:FunctionDef"] +script = "rules/example.py" + +[[rule]] +code = "EXT002" +name = "AnotherRule" +summary = "Demonstrates code-based selection" +targets = ["stmt:FunctionDef"] +script = "rules/example.py" +"#, + )?; + test.write_file( + "lint/external/rules/example.py", + r#" +def check(): + # placeholder script body + pass +"#, + )?; + let linter_path = test.root().join("lint/external/demo.toml"); + let config = format!( + r#" +[lint.external-ast.demo] +path = "{}" +"#, + linter_path.display() + ); + test.write_file("ruff.toml", &config)?; + + let output = test + .command() + .args(["check", "--config", "ruff.toml", "--list-external-linters"]) + .output()?; + + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = std::str::from_utf8(&output.stdout)?; + assert!(stdout.contains("demo")); + assert!(stdout.contains("Demo External Linter")); + assert!(stdout.contains("EXT001")); + assert!(stdout.contains("ExampleRule")); + + Ok(()) +} + +#[test] +fn external_ast_linter_listing_filtered_by_code() -> Result<()> { + let test = CliTest::new()?; + test.write_file( + "lint/external/demo.toml", + r#" +name = "Demo External Linter" +description = "Shows how to configure external AST linters" + +[[rule]] +code = "EXT001" +name = "ExampleRule" +summary = "Provides illustrative coverage" +targets = ["stmt:FunctionDef"] +script = "rules/example.py" + +[[rule]] +code = "EXT002" +name = "AnotherRule" +summary = "Demonstrates code-based selection" +targets = ["stmt:FunctionDef"] +script = "rules/example.py" +"#, + )?; + test.write_file( + "lint/external/rules/example.py", + r#" +def check(): + # placeholder script body + pass +"#, + )?; + let linter_path = test.root().join("lint/external/demo.toml"); + let config = format!( + r#" +[lint.external-ast.demo] +path = "{}" +"#, + linter_path.display() + ); + test.write_file("ruff.toml", &config)?; + + let output = test + .command() + .args([ + "check", + "--config", + "ruff.toml", + "--list-external-linters", + "--select-external", + "EXT002", + ]) + .output()?; + + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = std::str::from_utf8(&output.stdout)?; + assert!(stdout.contains("EXT002")); + assert!(!stdout.contains("EXT001")); + + Ok(()) +} + +#[test] +fn external_ast_requires_explicit_selection() -> Result<()> { + let test = CliTest::new()?; + test.write_file( + "lint/external/demo.toml", + r#" +name = "Demo External Linter" +description = "Shows how to configure external AST linters" + +[[rule]] +code = "EXT001" +name = "ExampleRule" +summary = "Provides illustrative coverage" +targets = ["stmt:FunctionDef"] +script = "rules/example.py" +"#, + )?; + test.write_file( + "lint/external/rules/example.py", + r#" +def check(): + # placeholder script body + pass +"#, + )?; + let linter_path = test.root().join("lint/external/demo.toml"); + let config = format!( + r#" +[lint.external-ast.demo] +path = "{}" +"#, + linter_path.display() + ); + test.write_file("ruff.toml", &config)?; + + let output = test + .command() + .args(["check", "--config", "ruff.toml", "--show-settings"]) + .output()?; + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = std::str::from_utf8(&output.stdout)?; + assert!( + !stdout.contains("external-linter (RUF300)"), + "Expected external-linter to be disabled without an explicit selection" + ); + + let output = test + .command() + .args([ + "check", + "--config", + "ruff.toml", + "--select-external", + "EXT001", + "--show-settings", + ]) + .output()?; + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = std::str::from_utf8(&output.stdout)?; + assert!( + stdout.contains("external-linter (RUF300)"), + "Expected external-linter to be enabled when provided via --select-external" + ); + + let config_with_select = format!( + r#" +[lint] +select-external = ["EXT001"] + +[lint.external-ast.demo] +path = "{}" +"#, + linter_path.display() + ); + test.write_file("ruff.toml", &config_with_select)?; + let output = test + .command() + .args(["check", "--config", "ruff.toml", "--show-settings"]) + .output()?; + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = std::str::from_utf8(&output.stdout)?; + assert!( + stdout.contains("external-linter (RUF300)"), + "Expected external-linter to be enabled when configured via lint.select-external" + ); + + Ok(()) +} + +#[test] +fn external_ast_select_external_disabled_rule_errors() -> Result<()> { + let test = CliTest::new()?; + let linter_path = { + test.write_file( + "lint/external/disabled.toml", + r#" +enabled = false +name = "Disabled External Linter" + +[[rule]] +code = "EXTDIS001" +name = "DisabledRule" +summary = "Rule intentionally disabled" +targets = ["stmt:FunctionDef"] +script = "rules/disabled.py" +"#, + )?; + test.write_file( + "lint/external/rules/disabled.py", + r#" +def check_stmt(node, ctx): + ctx.report("should not fire") +"#, + )?; + test.root().join("lint/external/disabled.toml") + }; + let config = format!( + r#" +[lint.external-ast.disabled] +path = "{}" + +[lint] +select-external = ["EXTDIS001"] +"#, + linter_path.display() + ); + test.write_file("ruff.toml", &config)?; + test.write_file("example.py", "def foo():\n pass\n")?; + + let output = test + .command() + .args(["check", "--config", "ruff.toml", "example.py"]) + .output()?; + + assert!( + !output.status.success(), + "command unexpectedly succeeded: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = std::str::from_utf8(&output.stderr)?; + assert!( + stderr.contains("Unknown external linter or rule selector(s): EXTDIS001"), + "stderr missing warning about disabled external selection: {stderr}" + ); + + Ok(()) +} + +#[test] +fn external_ast_ignore_external_cli() -> Result<()> { + let test = CliTest::new()?; + let linter_path = write_demo_external_linter(&test)?; + let config = format!( + r#" +[lint] +select-external = ["EXT001"] + +[lint.external-ast.demo] +path = "{}" +"#, + linter_path.display() + ); + test.write_file("ruff.toml", &config)?; + test.write_file( + "example.py", + r#" +def demo(): + return 1 +"#, + )?; + + let output = test + .command() + .args([ + "check", + "--config", + "ruff.toml", + "--select", + "RUF300", + "--extend-select-external", + "EXT002", + "--ignore-external", + "EXT002", + "--list-external-linters", + "example.py", + ]) + .output()?; + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("EXT001"), "stdout missing EXT001: {stdout}"); + assert!( + !stdout.contains("EXT002"), + "stdout unexpectedly included EXT002: {stdout}" + ); + Ok(()) +} + +#[test] +fn external_ast_select_external_overrides_config() -> Result<()> { + let test = CliTest::new()?; + let linter_path = write_demo_external_linter(&test)?; + let config = format!( + r#" +[lint] +select-external = ["EXT001"] + +[lint.external-ast.demo] +path = "{}" +"#, + linter_path.display() + ); + test.write_file("ruff.toml", &config)?; + test.write_file("example.py", "def demo():\n return 1\n")?; + + let output = test + .command() + .args([ + "check", + "--config", + "ruff.toml", + "--select", + "RUF300", + "--select-external", + "EXT002", + "--list-external-linters", + "example.py", + ]) + .output()?; + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("EXT002"), "stdout missing EXT002: {stdout}"); + assert!( + !stdout.contains("EXT001"), + "stdout unexpectedly included EXT001: {stdout}" + ); + Ok(()) +} + +#[test] +fn external_ast_select_external_overrides_nested_config() -> Result<()> { + let test = CliTest::new()?; + let linter_path = write_demo_external_linter(&test)?; + let config = format!( + r#" +[lint] +select-external = ["EXT001", "EXT002"] + +[lint.external-ast.demo] +path = "{}" +"#, + linter_path.display() + ); + test.write_file("ruff.toml", &config)?; + test.write_file("nested/ruff.toml", r#"extend = "../ruff.toml""#)?; + test.write_file( + "nested/example.py", + r#" +def demo(): + return 1 +"#, + )?; + + let output = test + .command() + .args([ + "check", + "--select", + "RUF300", + "--select-external", + "EXT002", + "--list-external-linters", + "nested/example.py", + ]) + .output()?; + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("EXT002"), "stdout missing EXT002: {stdout}"); + assert!( + !stdout.contains("EXT001"), + "stdout unexpectedly included EXT001: {stdout}" + ); + + Ok(()) +} + +#[test] +fn select_external_cli_allows_nested_registry() -> Result<()> { + let test = CliTest::new()?; + let linter_path = write_demo_external_linter(&test)?; + + let nested_config = format!( + r#" +[lint.external-ast.demo] +path = "{}" +"#, + linter_path.display() + ); + test.write_file("nested/ruff.toml", &nested_config)?; + test.write_file("example.py", "def root():\n return 1\n")?; + test.write_file("nested/example.py", "def nested():\n return 1\n")?; + + let output = test + .command() + .args([ + "check", + "--select", + "RUF300", + "--select-external", + "EXT001", + "example.py", + "nested/example.py", + ]) + .output()?; + assert!( + output.status.success(), + "command failed unexpectedly: {}", + String::from_utf8_lossy(&output.stderr) + ); + + Ok(()) +} + +#[test] +fn select_external_cli_errors_when_no_registries() -> Result<()> { + let test = CliTest::new()?; + test.write_file("example.py", "def root():\n return 1\n")?; + + let output = test + .command() + .args(["check", "--select-external", "EXT001", "example.py"]) + .output()?; + assert!( + !output.status.success(), + "command unexpectedly succeeded: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("No external AST linters are configured in this workspace."), + "stderr missing missing-registry error: {stderr}" + ); + + Ok(()) +} + +#[test] +fn external_ast_ignore_external_config() -> Result<()> { + let test = CliTest::new()?; + let linter_path = write_demo_external_linter(&test)?; + let config = format!( + r#" +[lint] +select-external = ["EXT001"] +extend-select-external = ["EXT002"] +ignore-external = ["EXT002"] + +[lint.external-ast.demo] +path = "{}" +"#, + linter_path.display() + ); + test.write_file("ruff.toml", &config)?; + test.write_file( + "example.py", + r#" +def demo(): + return 1 +"#, + )?; + + let output = test + .command() + .args([ + "check", + "--config", + "ruff.toml", + "--select", + "RUF300", + "--list-external-linters", + "example.py", + ]) + .output()?; + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("EXT001"), "stdout missing EXT001: {stdout}"); + assert!( + !stdout.contains("EXT002"), + "stdout unexpectedly included EXT002: {stdout}" + ); + Ok(()) +} + +#[test] +fn external_ast_ignore_external_nested_config() -> Result<()> { + let test = CliTest::new()?; + let linter_path = write_demo_external_linter(&test)?; + let config = format!( + r#" +[lint] +select-external = ["EXT001", "EXT002"] +ignore-external = ["EXT002"] + +[lint.external-ast.demo] +path = "{}" +"#, + linter_path.display() + ); + test.write_file("ruff.toml", &config)?; + test.write_file( + "pkg/pyproject.toml", + r#" +[tool.ruff] +line-length = 88 +"#, + )?; + test.write_file( + "pkg/example.py", + r#" +def demo(): + return 1 +"#, + )?; + + let output = test + .command() + .args([ + "check", + "--config", + "ruff.toml", + "--select", + "RUF300", + "--list-external-linters", + "pkg/example.py", + ]) + .output()?; + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("EXT001"), + "stdout missing surviving EXT001 listing: {stdout}" + ); + assert!( + !stdout.contains("EXT002"), + "stdout unexpectedly included ignored EXT002 listing: {stdout}" + ); + + Ok(()) +} + +#[test] +fn select_rejects_external_rules() -> Result<()> { + let test = CliTest::new()?; + + let output = test + .command() + .args(["check", "--isolated", "--select", "EXT001"]) + .output()?; + assert!(!output.status.success(), "command unexpectedly succeeded"); + let stderr = std::str::from_utf8(&output.stderr)?; + assert!( + stderr.contains("cannot be enabled with `--select`"), + "expected failure when selecting external rule via --select" + ); + + Ok(()) +} + +#[test] +fn select_external_rejects_internal_rules() -> Result<()> { + let test = CliTest::new()?; + + let output = test + .command() + .args(["check", "--isolated", "--select-external", "F401"]) + .output()?; + assert!(!output.status.success(), "command unexpectedly succeeded"); + let stderr = std::str::from_utf8(&output.stderr)?; + assert!( + stderr.contains("cannot be enabled with `--select-external`"), + "expected failure when selecting internal rules via --select-external" + ); + + Ok(()) +} + +#[test] +fn config_select_rejects_external_rules() -> Result<()> { + let test = CliTest::new()?; + test.write_file( + "ruff.toml", + r#" +[lint] +select = ["EXT001"] +"#, + )?; + + let output = test + .command() + .args(["check", "--config", "ruff.toml"]) + .output()?; + assert!(!output.status.success(), "command unexpectedly succeeded"); + let stderr = std::str::from_utf8(&output.stderr)?; + assert!( + stderr.contains("lint.select-external"), + "expected parse failure when selecting external rule via configuration" + ); + + Ok(()) +} + +#[test] +fn config_select_external_rejects_internal_rules() -> Result<()> { + let test = CliTest::new()?; + test.write_file( + "ruff.toml", + r#" +[lint] +select-external = ["F401"] +"#, + )?; + + let output = test + .command() + .args(["check", "--config", "ruff.toml"]) + .output()?; + assert!(!output.status.success(), "command unexpectedly succeeded"); + let stderr = std::str::from_utf8(&output.stderr)?; + assert!( + stderr.contains("cannot be enabled via `lint.select-external`"), + "expected lint.select-external failure when selecting internal rule" + ); + + Ok(()) +} + #[test] fn exclude() -> Result<()> { let case = CliTest::new()?; @@ -775,7 +1502,7 @@ fn valid_toml_but_nonexistent_option_provided_via_config_argument() { Could not parse the supplied argument as a `ruff.toml` configuration option: - Unknown rule selector: `F481`. External rule selectors are not supported yet. + Unknown rule selector: `F481`. External rule selectors must be provided via `lint.select-external`. For more information, try '--help'. "); @@ -907,6 +1634,10 @@ fn value_given_to_table_key_is_not_inline_table_2() { - `lint.dummy-variable-rgx` - `lint.extend-ignore` - `lint.extend-select` + - `lint.select-external` + - `lint.extend-select-external` + - `lint.ignore-external` + - `lint.extend-ignore-external` - `lint.extend-fixable` - `lint.external` - `lint.fixable` diff --git a/crates/ruff_linter/src/external/ast/registry.rs b/crates/ruff_linter/src/external/ast/registry.rs index ee0435f68c..791e4f672d 100644 --- a/crates/ruff_linter/src/external/ast/registry.rs +++ b/crates/ruff_linter/src/external/ast/registry.rs @@ -120,6 +120,43 @@ impl ExternalLintRegistry { let rule = linter.rules.get(locator.rule_index)?; Some((rule, linter)) } + + pub fn iter_enabled_rules(&self) -> impl Iterator { + self.linters + .iter() + .filter(|linter| linter.enabled) + .flat_map(|linter| linter.rules.iter()) + } + + pub fn iter_enabled_linter_rules( + &self, + ) -> impl Iterator { + self.linters + .iter() + .filter(|linter| linter.enabled) + .flat_map(|linter| linter.rules.iter().map(move |rule| (linter, rule))) + } + + pub fn iter_enabled_rule_locators(&self) -> impl Iterator + '_ { + self.linters + .iter() + .enumerate() + .filter(|(_, linter)| linter.enabled) + .flat_map(|(linter_index, linter)| { + linter + .rules + .iter() + .enumerate() + .map(move |(rule_index, _)| RuleLocator::new(linter_index, rule_index)) + }) + } + + pub fn expect_entry(&self, locator: RuleLocator) -> (&ExternalAstLinter, &ExternalAstRule) { + let (rule, linter) = self + .rule_entry(locator) + .expect("rule locator does not reference a valid entry"); + (linter, rule) + } } impl ruff_cache::CacheKey for ExternalLintRegistry { diff --git a/crates/ruff_linter/src/external/error.rs b/crates/ruff_linter/src/external/error.rs index dd4ec23f5a..95b2ee5d36 100644 --- a/crates/ruff_linter/src/external/error.rs +++ b/crates/ruff_linter/src/external/error.rs @@ -75,4 +75,25 @@ pub enum ExternalLinterError { "external rule `{rule}` in linter `{linter}` declares `call-callee-regex` but does not target `expr:Call` nodes" )] CallCalleeRegexWithoutCallTarget { linter: String, rule: String }, + + #[error("{message}")] + ScriptCompile { message: String }, +} + +impl ExternalLinterError { + #[allow(dead_code)] + pub(crate) fn format_script_compile_message( + linter: &str, + rule: &str, + path: Option, + message: impl Into, + ) -> String { + let message = message.into(); + let location = path + .map(|p| format!(" at {}", p.display())) + .unwrap_or_default(); + format!( + "failed to compile script for external rule `{rule}` in linter `{linter}`{location}: {message}" + ) + } } diff --git a/crates/ruff_linter/src/rule_selector.rs b/crates/ruff_linter/src/rule_selector.rs index 6203ca39f8..ecb7d68197 100644 --- a/crates/ruff_linter/src/rule_selector.rs +++ b/crates/ruff_linter/src/rule_selector.rs @@ -67,6 +67,11 @@ impl FromStr for RuleSelector { fn from_str(s: &str) -> Result { // **Changes should be reflected in `parse_no_redirect` as well** + // External AST rules reserve the `EXT` prefix; short-circuit before we + // attempt to interpret the selector as a built-in linter code. + if s.starts_with("EXT") && ExternalRuleCode::new(s).is_ok() { + return Ok(Self::External { code: s.into() }); + } match s { "ALL" => Ok(Self::All), "C" => Ok(Self::C), @@ -134,7 +139,9 @@ pub enum ParseError { // TODO(martin): tell the user how to discover rule codes via the CLI once such a command is // implemented (but that should of course be done only in ruff and not here) Unknown(String), - #[error("External rule selector `{0}` is not supported yet.")] + #[error( + "External rule selector `{0}` must be provided via `--select-external` or `lint.select-external`." + )] External(String), } @@ -158,6 +165,9 @@ impl Serialize for RuleSelector { where S: serde::Serializer, { + if let RuleSelector::External { code } = self { + return serializer.serialize_str(code); + } let (prefix, code) = self.prefix_and_code(); serializer.serialize_str(&format!("{prefix}{code}")) } @@ -196,7 +206,7 @@ impl Visitor<'_> for SelectorVisitor { Ok(value) => Ok(value), Err(err @ ParseError::External(_)) => Err(de::Error::custom(err.to_string())), Err(err) if looks_like_external_rule_code(v) => Err(de::Error::custom(format!( - "{err}. External rule selectors are not supported yet." + "{err}. External rule selectors must be provided via `lint.select-external`." ))), Err(err) => Err(de::Error::custom(err)), } @@ -384,6 +394,11 @@ impl RuleSelector { /// Parse [`RuleSelector`] from a string; but do not follow redirects. pub fn parse_no_redirect(s: &str) -> Result { // **Changes should be reflected in `from_str` as well** + // External AST rules reserve the `EXT` prefix; short-circuit before we + // attempt to interpret the selector as a built-in linter code. + if s.starts_with("EXT") && ExternalRuleCode::new(s).is_ok() { + return Ok(Self::External { code: s.into() }); + } match s { "ALL" => Ok(Self::All), "C" => Ok(Self::C), @@ -482,7 +497,9 @@ pub mod clap_completion { value.parse().map_err(|err| match err { ParseError::External(code) => clap::Error::raw( clap::error::ErrorKind::ValueValidation, - format!("External rule selector `{code}` is not supported yet."), + format!( + "External rule selector `{code}` must be provided via `--select-external`." + ), ), ParseError::Unknown(_) => { let mut error = diff --git a/crates/ruff_linter/src/settings/mod.rs b/crates/ruff_linter/src/settings/mod.rs index b94e4edafb..d7180f3ec8 100644 --- a/crates/ruff_linter/src/settings/mod.rs +++ b/crates/ruff_linter/src/settings/mod.rs @@ -10,6 +10,7 @@ use std::sync::LazyLock; use types::CompiledPerFileTargetVersionList; use crate::codes::RuleCodePrefix; +use crate::external::ExternalLintRegistry; use ruff_macros::CacheKey; use ruff_python_ast::PythonVersion; @@ -243,6 +244,9 @@ pub struct LinterSettings { pub builtins: Vec, pub dummy_variable_rgx: Regex, pub external: Vec, + pub external_ast: Option, + pub selected_external: Vec, + pub ignored_external: Vec, pub ignore_init_module_imports: bool, pub logger_objects: Vec, pub namespace_packages: Vec, @@ -319,6 +323,9 @@ impl Display for LinterSettings { self.typing_extensions, ] } + if let Some(registry) = &self.external_ast { + writeln!(f, "linter.external-ast = {registry:#?}")?; + } writeln!(f, "\n# Linter Plugins")?; display_settings! { formatter = f, @@ -410,6 +417,9 @@ impl LinterSettings { dummy_variable_rgx: DUMMY_VARIABLE_RGX.clone(), external: vec![], + external_ast: None, + selected_external: Vec::new(), + ignored_external: Vec::new(), ignore_init_module_imports: true, logger_objects: vec![], namespace_packages: vec![], diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index 8ca5350d11..b0f79134cc 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -21,6 +21,9 @@ use strum::IntoEnumIterator; use ruff_cache::cache_dir; use ruff_formatter::IndentStyle; use ruff_graph::{AnalyzeSettings, Direction, StringImports}; +use ruff_linter::external::{ + ExternalLintRegistry, PyprojectExternalLinterEntry, load_linter_into_registry, +}; use ruff_linter::line_width::{IndentWidth, LineLength}; use ruff_linter::registry::{INCOMPATIBLE_CODES, Rule, RuleNamespace, RuleSet}; use ruff_linter::rule_selector::{PreviewOptions, Specificity}; @@ -69,6 +72,14 @@ pub struct RuleSelection { pub extend_fixable: Vec, } +#[derive(Clone, Debug, Default)] +pub struct ExternalRuleSelection { + pub select: Option>, + pub extend_select: Vec, + pub ignore: Vec, + pub extend_ignore: Vec, +} + #[derive(Debug, Eq, PartialEq, is_macro::Is)] pub enum RuleSelectorKind { /// Enables the selected rules @@ -112,6 +123,65 @@ impl RuleSelection { .map(|selector| (RuleSelectorKind::Modify, selector)), ) } + + pub fn selectors(&self) -> impl Iterator { + self.selectors_by_kind().map(|(_, selector)| selector) + } +} + +fn build_external_registry( + entries: &BTreeMap, +) -> Result> { + let mut registry = ExternalLintRegistry::new(); + for (id, entry) in entries { + if !entry.enabled { + continue; + } + let py_entry = PyprojectExternalLinterEntry { + toml_path: entry.path.clone(), + enabled: entry.enabled, + }; + load_linter_into_registry(&mut registry, id, &py_entry)?; + } + if registry.is_empty() { + Ok(None) + } else { + Ok(Some(registry)) + } +} + +fn resolve_external_rule_selections( + selections: &[ExternalRuleSelection], +) -> (Vec, Vec) { + let mut selected: FxHashSet = FxHashSet::default(); + let mut ignored: FxHashSet = FxHashSet::default(); + + for selection in selections { + if let Some(select) = &selection.select { + selected = select.iter().cloned().collect(); + } + for code in &selection.extend_select { + selected.insert(code.clone()); + } + for code in &selection.ignore { + ignored.insert(code.clone()); + } + for code in &selection.extend_ignore { + ignored.insert(code.clone()); + } + } + + let mut selected_vec: Vec = selected.into_iter().collect(); + let mut ignored_vec: Vec = ignored.into_iter().collect(); + selected_vec.sort_unstable(); + ignored_vec.sort_unstable(); + (selected_vec, ignored_vec) +} + +#[derive(Debug, Clone)] +pub struct ExternalLinterEntry { + pub path: PathBuf, + pub enabled: bool, } #[derive(Debug, Default, Clone)] @@ -239,7 +309,8 @@ impl Configuration { let line_length = self.line_length.unwrap_or_default(); - let rules = lint.as_rule_table(lint_preview)?; + #[allow(unused_mut)] + let mut rules = lint.as_rule_table(lint_preview)?; // LinterSettings validation let isort = lint @@ -257,6 +328,75 @@ impl Configuration { let future_annotations = lint.future_annotations.unwrap_or_default(); + let (configured_selected_external_vec, configured_ignored_external_vec) = + resolve_external_rule_selections(&lint.external_rule_selections); + + let mut external_codes = lint.external.unwrap_or_default(); + let mut seen_external_codes = external_codes.iter().cloned().collect::>(); + + let external_ast_registry = match lint.external_ast.as_ref() { + Some(entries) => build_external_registry(entries)?, + None => None, + }; + + if let Some(registry) = external_ast_registry.as_ref() { + for rule in registry.iter_enabled_rules() { + let code = rule.code.as_str().to_owned(); + if seen_external_codes.insert(code.clone()) { + external_codes.push(code); + } + } + } + for code in &configured_selected_external_vec { + if seen_external_codes.insert(code.clone()) { + external_codes.push(code.clone()); + } + } + + let available_external_codes = external_ast_registry.as_ref().map(|registry| { + registry + .iter_enabled_rules() + .map(|rule| rule.code.as_str().to_string()) + .collect::>() + }); + + let selectors = lint + .rule_selections + .iter() + .flat_map(RuleSelection::selectors) + .chain(lint.extend_safe_fixes.iter()) + .chain(lint.extend_unsafe_fixes.iter()); + + let mut missing_external = FxHashSet::default(); + for selector in selectors { + if let RuleSelector::External { code } = selector { + let is_known = available_external_codes + .as_ref() + .is_some_and(|available| available.contains(code.as_ref())); + if !is_known { + missing_external.insert(code.as_ref().to_string()); + } + } + } + + if !missing_external.is_empty() { + let mut missing: Vec<_> = missing_external.into_iter().collect(); + missing.sort_unstable(); + let formatted = missing + .into_iter() + .map(|code| format!("`{code}`")) + .collect::>(); + let message = if formatted.len() == 1 { + format!("Unknown rule selector: {}", formatted[0]) + } else { + format!("Unknown rule selectors: {}", formatted.join(", ")) + }; + return Err(anyhow!(message)); + } + if external_ast_registry.is_none() { + rules.disable(Rule::ExternalLinter); + } + Ok(Settings { cache_dir: self .cache_dir @@ -303,7 +443,10 @@ impl Configuration { dummy_variable_rgx: lint .dummy_variable_rgx .unwrap_or_else(|| DUMMY_VARIABLE_RGX.clone()), - external: lint.external.unwrap_or_default(), + external: external_codes, + external_ast: external_ast_registry, + selected_external: configured_selected_external_vec, + ignored_external: configured_ignored_external_vec, ignore_init_module_imports: lint.ignore_init_module_imports.unwrap_or(true), line_length, tab_size: self.indent_width.unwrap_or_default(), @@ -635,6 +778,7 @@ pub struct LintConfiguration { pub per_file_ignores: Option>, pub rule_selections: Vec, pub explicit_preview_rules: Option, + pub external_rule_selections: Vec, // Fix configuration pub extend_unsafe_fixes: Vec, @@ -644,6 +788,7 @@ pub struct LintConfiguration { pub allowed_confusables: Option>, pub dummy_variable_rgx: Option, pub external: Option>, + pub external_ast: Option>, pub ignore_init_module_imports: Option, pub logger_objects: Option>, pub task_tags: Option>, @@ -710,6 +855,118 @@ impl LintConfiguration { options.common.ignore_init_module_imports }; + for (label, selectors) in [ + ("lint.select", options.common.select.as_ref()), + ("lint.extend-select", options.common.extend_select.as_ref()), + ] { + if let Some(selectors) = selectors { + if let Some(code) = selectors.iter().find_map(|selector| { + if let RuleSelector::External { code } = selector { + Some(code.as_ref().to_string()) + } else { + None + } + }) { + return Err(anyhow::anyhow!( + "External rule `{code}` cannot be enabled via `{label}`; use `lint.select-external` instead." + )); + } + } + } + + if let Some(internal) = options.common.select_external.as_ref().and_then(|values| { + values.iter().find(|value| { + matches!( + RuleSelector::from_str(value), + Ok(RuleSelector::Linter(_) + | RuleSelector::Prefix { .. } + | RuleSelector::Rule { .. }) + ) + }) + }) { + return Err(anyhow::anyhow!( + "Internal rule `{internal}` cannot be enabled via `lint.select-external`; use `lint.select` instead." + )); + } + if let Some(internal) = options + .common + .extend_select_external + .as_ref() + .and_then(|values| { + values.iter().find(|value| { + matches!( + RuleSelector::from_str(value), + Ok(RuleSelector::Linter(_) + | RuleSelector::Prefix { .. } + | RuleSelector::Rule { .. }) + ) + }) + }) + { + return Err(anyhow::anyhow!( + "Internal rule `{internal}` cannot be enabled via `lint.extend-select-external`; use `lint.extend-select` instead." + )); + } + if let Some(internal) = options.common.ignore_external.as_ref().and_then(|values| { + values.iter().find(|value| { + matches!( + RuleSelector::from_str(value), + Ok(RuleSelector::Linter(_) + | RuleSelector::Prefix { .. } + | RuleSelector::Rule { .. }) + ) + }) + }) { + return Err(anyhow::anyhow!( + "Internal rule `{internal}` cannot be disabled via `lint.ignore-external`; use `lint.ignore` instead." + )); + } + if let Some(internal) = options + .common + .extend_ignore_external + .as_ref() + .and_then(|values| { + values.iter().find(|value| { + matches!( + RuleSelector::from_str(value), + Ok(RuleSelector::Linter(_) + | RuleSelector::Prefix { .. } + | RuleSelector::Rule { .. }) + ) + }) + }) + { + return Err(anyhow::anyhow!( + "Internal rule `{internal}` cannot be disabled via `lint.extend-ignore-external`; use `lint.extend-ignore` instead." + )); + } + + let external_ast_entries = options + .external_ast + .map(|entries| { + entries + .into_iter() + .map(|(id, entry)| { + let raw_path = entry + .path + .ok_or_else(|| anyhow!("external linter `{id}` must define `path`"))?; + let path = if raw_path.is_absolute() { + raw_path + } else { + project_root.join(raw_path) + }; + Ok(( + id, + ExternalLinterEntry { + path, + enabled: entry.enabled.unwrap_or(true), + }, + )) + }) + .collect::>>() + }) + .transpose()?; + Ok(LintConfiguration { exclude: options.exclude.map(|paths| { paths @@ -730,6 +987,12 @@ impl LintConfiguration { unfixable, extend_fixable: options.common.extend_fixable.unwrap_or_default(), }], + external_rule_selections: vec![ExternalRuleSelection { + select: options.common.select_external, + extend_select: options.common.extend_select_external.unwrap_or_default(), + ignore: options.common.ignore_external.unwrap_or_default(), + extend_ignore: options.common.extend_ignore_external.unwrap_or_default(), + }], extend_safe_fixes: options.common.extend_safe_fixes.unwrap_or_default(), extend_unsafe_fixes: options.common.extend_unsafe_fixes.unwrap_or_default(), allowed_confusables: options.common.allowed_confusables, @@ -752,6 +1015,7 @@ impl LintConfiguration { }) .unwrap_or_default(), external: options.common.external, + external_ast: external_ast_entries, ignore_init_module_imports, explicit_preview_rules: options.common.explicit_preview_rules, per_file_ignores: options.common.per_file_ignores.map(|per_file_ignores| { @@ -1133,6 +1397,8 @@ impl LintConfiguration { let mut extend_per_file_ignores = config.extend_per_file_ignores; extend_per_file_ignores.extend(self.extend_per_file_ignores); + let mut external_rule_selections = config.external_rule_selections; + external_rule_selections.extend(self.external_rule_selections); Self { exclude: self.exclude.or(config.exclude), @@ -1140,10 +1406,12 @@ impl LintConfiguration { rule_selections, extend_safe_fixes, extend_unsafe_fixes, + external_rule_selections, 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, external: self.external.or(config.external), + external_ast: self.external_ast.or(config.external_ast), ignore_init_module_imports: self .ignore_init_module_imports .or(config.ignore_init_module_imports), @@ -1408,6 +1676,8 @@ fn warn_about_deprecated_top_level_lint_options( pyupgrade, per_file_ignores, extend_per_file_ignores, + select_external, + .. } = top_level_options; let mut used_options = Vec::new(); @@ -1466,6 +1736,9 @@ fn warn_about_deprecated_top_level_lint_options( if select.is_some() { used_options.push("select"); } + if select_external.is_some() { + used_options.push("select-external"); + } if explicit_preview_rules.is_some() { used_options.push("explicit-preview-rules"); diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 708d6dcf0b..ce3656f5be 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -554,6 +554,8 @@ pub struct LintOptions { "# )] pub future_annotations: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_ast: Option>, } /// Newtype wrapper for [`LintCommonOptions`] that allows customizing the JSON schema and omitting the fields from the [`OptionsMetadata`]. @@ -569,6 +571,23 @@ impl OptionsMetadata for DeprecatedTopLevelLintOptions { } } +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, PartialEq, Eq, OptionsMetadata, CombineOptions, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct ExternalAstLinterOptions { + /// Path to the TOML file containing external lint rule definitions. + #[option( + default = "null", + value_type = "path", + example = r#"path = "lint/custom_rules.toml""# + )] + pub path: Option, + /// Whether this external linter should be considered during lint runs. + #[option(default = "true", value_type = "bool", example = "enabled = false")] + #[serde(default)] + pub enabled: Option, +} + #[cfg(feature = "schemars")] impl schemars::JsonSchema for DeprecatedTopLevelLintOptions { fn schema_name() -> std::borrow::Cow<'static, str> { @@ -666,6 +685,45 @@ pub struct LintCommonOptions { "# )] pub extend_select: Option>, + /// A list of external linter IDs or rule codes to enable. When omitted, external linters remain disabled. + #[option( + default = "[]", + value_type = "list[str]", + example = r#" + # Enable the `logging_interpolation` external linter by default. + select-external = ["logging_interpolation"] + "# + )] + pub select_external: Option>, + /// A list of external linter IDs or rule codes to enable, in addition to those specified by [`select-external`](#lint_select-external). + #[option( + default = "[]", + value_type = "list[str]", + example = r#" + # Enable the `logging_interpolation` external linter alongside those specified by `select-external`. + extend-select-external = ["logging_interpolation"] + "# + )] + pub extend_select_external: Option>, + /// A list of external linter IDs or rule codes to ignore. Prefixes are not supported. + #[option( + default = "[]", + value_type = "list[str]", + example = r#" + # Suppress the `logging_interpolation.dangerous` rule, even if the linter is otherwise enabled. + ignore-external = ["logging_interpolation.dangerous"] + "# + )] + pub ignore_external: Option>, + /// A list of external linter IDs or rule codes to ignore, in addition to those specified by [`ignore-external`](#lint_ignore-external). + #[option( + default = "[]", + value_type = "list[str]", + example = r#" + extend-ignore-external = ["logging_interpolation.dangerous"] + "# + )] + pub extend_ignore_external: Option>, /// A list of rule codes or prefixes to consider fixable, in addition to those /// specified by [`fixable`](#lint_fixable). @@ -3904,9 +3962,14 @@ pub struct LintOptionsWire { dummy_variable_rgx: Option, extend_ignore: Option>, extend_select: Option>, + select_external: Option>, + extend_select_external: Option>, + ignore_external: Option>, + extend_ignore_external: Option>, extend_fixable: Option>, extend_unfixable: Option>, external: Option>, + external_ast: Option>, fixable: Option>, ignore: Option>, extend_safe_fixes: Option>, @@ -3961,9 +4024,14 @@ impl From for LintOptions { dummy_variable_rgx, extend_ignore, extend_select, + select_external, + extend_select_external, + ignore_external, + extend_ignore_external, extend_fixable, extend_unfixable, external, + external_ast, fixable, ignore, extend_safe_fixes, @@ -4017,6 +4085,10 @@ impl From for LintOptions { dummy_variable_rgx, extend_ignore, extend_select, + select_external, + extend_select_external, + ignore_external, + extend_ignore_external, extend_fixable, extend_unfixable, external, @@ -4064,6 +4136,7 @@ impl From for LintOptions { ruff, preview, typing_extensions, + external_ast, future_annotations, } } diff --git a/crates/ruff_workspace/src/resolver.rs b/crates/ruff_workspace/src/resolver.rs index 1740cc184a..b184894548 100644 --- a/crates/ruff_workspace/src/resolver.rs +++ b/crates/ruff_workspace/src/resolver.rs @@ -257,6 +257,23 @@ impl<'a> Resolver<'a> { pub fn settings(&self) -> impl Iterator { std::iter::once(&self.pyproject_config.settings).chain(&self.settings) } + + /// Return a mutable iterator over resolved [`Settings`] excluding the base configuration. + pub fn settings_mut(&mut self) -> impl Iterator { + self.settings.iter_mut() + } + + /// Apply a transformation to each resolved [`Settings`] (excluding the base configuration) + /// and return the [`Resolver`] for further use. + pub fn transform_settings(mut self, mut f: F) -> Result + where + F: FnMut(&mut Settings) -> Result<()>, + { + for settings in &mut self.settings { + f(settings)?; + } + Ok(self) + } } /// A wrapper around `detect_package_root` to cache filesystem lookups. diff --git a/docs/configuration.md b/docs/configuration.md index 7a5f62fc60..bdd6d69ee0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -678,6 +678,22 @@ Miscellaneous: Exit with a non-zero status code if any files were modified via fix, even if no lint violations remain +External linter options: + --list-external-linters + List configured external AST linters and exit + --select-external + Restrict linting to the given external linter IDs + --extend-select-external + Enable additional external linter IDs or rule codes without replacing + existing selections + --ignore-external + Disable the given external linter IDs or rule codes + --extend-ignore-external + Disable additional external linter IDs or rule codes without + replacing existing ignores + --verify-external-linters + Validate external linter definitions without running lint checks + Log levels: -v, --verbose Enable verbose logging -q, --quiet Print diagnostics, but nothing else diff --git a/ruff.schema.json b/ruff.schema.json index 40ecf2e9ee..9b410df391 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -109,6 +109,17 @@ "$ref": "#/definitions/RuleSelector" } }, + "extend-ignore-external": { + "description": "A list of external linter IDs or rule codes to ignore, in addition to those specified by [`ignore-external`](#lint_ignore-external).", + "type": [ + "array", + "null" + ], + "deprecated": true, + "items": { + "type": "string" + } + }, "extend-include": { "description": "A list of file patterns to include when linting, in addition to those\nspecified by [`include`](#include).\n\nInclusion are based on globs, and should be single-path patterns, like\n`*.pyw`, to include any file with the `.pyw` extension.\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ @@ -155,6 +166,17 @@ "$ref": "#/definitions/RuleSelector" } }, + "extend-select-external": { + "description": "A list of external linter IDs or rule codes to enable, in addition to those specified by [`select-external`](#lint_select-external).", + "type": [ + "array", + "null" + ], + "deprecated": true, + "items": { + "type": "string" + } + }, "extend-unfixable": { "description": "A list of rule codes or prefixes to consider non-auto-fixable, in addition to those\nspecified by [`unfixable`](#lint_unfixable).", "type": [ @@ -446,6 +468,17 @@ "$ref": "#/definitions/RuleSelector" } }, + "ignore-external": { + "description": "A list of external linter IDs or rule codes to ignore. Prefixes are not supported.", + "type": [ + "array", + "null" + ], + "deprecated": true, + "items": { + "type": "string" + } + }, "ignore-init-module-imports": { "description": "Avoid automatically removing unused imports in `__init__.py` files. Such\nimports will still be flagged, but with a dedicated message suggesting\nthat the import is either added to the module's `__all__` symbol, or\nre-exported with a redundant alias (e.g., `import os as os`).\n\nThis option is enabled by default, but you can opt-in to removal of imports\nvia an unsafe fix.", "type": [ @@ -684,6 +717,17 @@ "$ref": "#/definitions/RuleSelector" } }, + "select-external": { + "description": "A list of external linter IDs or rule codes to enable. When omitted, external linters remain disabled.", + "type": [ + "array", + "null" + ], + "deprecated": true, + "items": { + "type": "string" + } + }, "show-fixes": { "description": "Whether to show an enumeration of all fixed lint violations\n(overridden by the `--show-fixes` command-line flag).", "type": [ @@ -900,6 +944,27 @@ } ] }, + "ExternalAstLinterOptions": { + "type": "object", + "properties": { + "enabled": { + "description": "Whether this external linter should be considered during lint runs.", + "type": [ + "boolean", + "null" + ], + "default": null + }, + "path": { + "description": "Path to the TOML file containing external lint rule definitions.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "Flake8AnnotationsOptions": { "description": "Options for the `flake8-annotations` plugin.", "type": "object", @@ -2009,6 +2074,16 @@ "$ref": "#/definitions/RuleSelector" } }, + "extend-ignore-external": { + "description": "A list of external linter IDs or rule codes to ignore, in addition to those specified by [`ignore-external`](#lint_ignore-external).", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "extend-per-file-ignores": { "description": "A list of mappings from file pattern to rule codes or prefixes to\nexclude, in addition to any rules excluded by [`per-file-ignores`](#lint_per-file-ignores).", "type": [ @@ -2042,6 +2117,16 @@ "$ref": "#/definitions/RuleSelector" } }, + "extend-select-external": { + "description": "A list of external linter IDs or rule codes to enable, in addition to those specified by [`select-external`](#lint_select-external).", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "extend-unfixable": { "description": "A list of rule codes or prefixes to consider non-auto-fixable, in addition to those\nspecified by [`unfixable`](#lint_unfixable).", "type": [ @@ -2073,6 +2158,15 @@ "type": "string" } }, + "external-ast": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/ExternalAstLinterOptions" + } + }, "fixable": { "description": "A list of rule codes or prefixes to consider fixable. By default,\nall rules are considered fixable.", "type": [ @@ -2287,6 +2381,16 @@ "$ref": "#/definitions/RuleSelector" } }, + "ignore-external": { + "description": "A list of external linter IDs or rule codes to ignore. Prefixes are not supported.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "ignore-init-module-imports": { "description": "Avoid automatically removing unused imports in `__init__.py` files. Such\nimports will still be flagged, but with a dedicated message suggesting\nthat the import is either added to the module's `__all__` symbol, or\nre-exported with a redundant alias (e.g., `import os as os`).\n\nThis option is enabled by default, but you can opt-in to removal of imports\nvia an unsafe fix.", "type": [ @@ -2445,6 +2549,16 @@ "$ref": "#/definitions/RuleSelector" } }, + "select-external": { + "description": "A list of external linter IDs or rule codes to enable. When omitted, external linters remain disabled.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "task-tags": { "description": "A list of task tags to recognize (e.g., \"TODO\", \"FIXME\", \"XXX\").\n\nComments starting with these tags will be ignored by commented-out code\ndetection (`ERA`), and skipped by line-length rules (`E501`) if\n[`ignore-overlong-task-comments`](#lint_pycodestyle_ignore-overlong-task-comments) is set to `true`.", "type": [