diff --git a/crates/ruff/tests/cli/lint.rs b/crates/ruff/tests/cli/lint.rs index 25500ed346..9ad806b4a4 100644 --- a/crates/ruff/tests/cli/lint.rs +++ b/crates/ruff/tests/cli/lint.rs @@ -775,7 +775,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` + Unknown rule selector: `F481`. External rule selectors are not supported yet. For more information, try '--help'. "); diff --git a/crates/ruff_linter/src/checkers/noqa.rs b/crates/ruff_linter/src/checkers/noqa.rs index 7cf58a5def..ee87989bf2 100644 --- a/crates/ruff_linter/src/checkers/noqa.rs +++ b/crates/ruff_linter/src/checkers/noqa.rs @@ -5,6 +5,8 @@ use std::path::Path; use itertools::Itertools; use rustc_hash::FxHashSet; +use ruff_db::diagnostic::SecondaryCode; + use ruff_python_trivia::CommentRanges; use ruff_text_size::{Ranged, TextRange}; @@ -77,7 +79,7 @@ pub(crate) fn check_noqa( { let suppressed = match &directive_line.directive { Directive::All(_) => { - let Ok(rule) = Rule::from_code(code) else { + let Some(rule) = resolve_rule_for_noqa(code, settings) else { debug_assert!(false, "Invalid secondary code `{code}`"); continue; }; @@ -87,7 +89,7 @@ pub(crate) fn check_noqa( } Directive::Codes(directive) => { if directive.includes(code) { - let Ok(rule) = Rule::from_code(code) else { + let Some(rule) = resolve_rule_for_noqa(code, settings) else { debug_assert!(false, "Invalid secondary code `{code}`"); continue; }; @@ -258,3 +260,19 @@ pub(crate) fn check_noqa( ignored_diagnostics.sort_unstable(); ignored_diagnostics } + +fn resolve_rule_for_noqa(code: &SecondaryCode, settings: &LinterSettings) -> Option { + if let Ok(rule) = Rule::from_code(code.as_str()) { + return Some(rule); + } + + if settings + .external + .iter() + .any(|external| code.as_str().starts_with(external)) + { + return Some(Rule::ExternalLinter); + } + + None +} diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 172841dc7c..a143f11d9e 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1064,6 +1064,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Ruff, "102") => rules::ruff::rules::InvalidRuleCode, (Ruff, "200") => rules::ruff::rules::InvalidPyprojectToml, + (Ruff, "300") => rules::ruff::rules::ExternalLinter, #[cfg(any(feature = "test-rules", test))] (Ruff, "900") => rules::ruff::rules::StableTestRule, #[cfg(any(feature = "test-rules", test))] @@ -1091,7 +1092,6 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { #[cfg(any(feature = "test-rules", test))] (Ruff, "990") => rules::ruff::rules::PanicyTestRule, - // flake8-django (Flake8Django, "001") => rules::flake8_django::rules::DjangoNullableModelStringField, (Flake8Django, "003") => rules::flake8_django::rules::DjangoLocalsInRenderFunction, diff --git a/crates/ruff_linter/src/external/ast/definition.rs b/crates/ruff_linter/src/external/ast/definition.rs new file mode 100644 index 0000000000..26b4e6565c --- /dev/null +++ b/crates/ruff_linter/src/external/ast/definition.rs @@ -0,0 +1,26 @@ +use serde::Deserialize; + +use crate::external::ast::rule::ExternalAstRuleSpec; + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ExternalAstLinterFile { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + #[serde(rename = "rule")] + pub rules: Vec, +} + +#[allow(dead_code)] +pub fn _assert_specs_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} diff --git a/crates/ruff_linter/src/external/ast/loader.rs b/crates/ruff_linter/src/external/ast/loader.rs new file mode 100644 index 0000000000..8cbebc4880 --- /dev/null +++ b/crates/ruff_linter/src/external/ast/loader.rs @@ -0,0 +1,420 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::external::PyprojectExternalLinterEntry; +use crate::external::ast::definition::ExternalAstLinterFile; +use crate::external::ast::registry::ExternalLintRegistry; +use crate::external::ast::rule::{ + CallCalleeMatcher, ExternalAstLinter, ExternalAstRule, ExternalAstRuleSpec, ExternalRuleCode, + ExternalRuleCodeError, ExternalRuleScript, +}; +use crate::external::ast::target::{AstTarget, AstTargetSpec, ExprKind}; +use crate::external::error::ExternalLinterError; + +pub fn load_linter_into_registry( + registry: &mut ExternalLintRegistry, + id: &str, + entry: &PyprojectExternalLinterEntry, +) -> Result<(), ExternalLinterError> { + let linter = load_linter_from_entry(id, entry)?; + registry.insert_linter(linter) +} + +pub fn load_linter_from_entry( + id: &str, + entry: &PyprojectExternalLinterEntry, +) -> Result { + let definition = load_definition_file(&entry.toml_path)?; + build_linter(id, entry, &definition) +} + +fn load_definition_file(path: &Path) -> Result { + let contents = fs::read_to_string(path).map_err(|source| ExternalLinterError::Io { + path: path.to_path_buf(), + source, + })?; + toml::from_str(&contents).map_err(|source| ExternalLinterError::Parse { + path: path.to_path_buf(), + source, + }) +} + +fn build_linter( + id: &str, + entry: &PyprojectExternalLinterEntry, + linter_file: &ExternalAstLinterFile, +) -> Result { + if linter_file.rules.is_empty() { + return Err(ExternalLinterError::EmptyLinter { id: id.to_string() }); + } + + let resolved_dir = entry + .toml_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + + let mut codes = HashSet::new(); + let mut rules = Vec::with_capacity(linter_file.rules.len()); + + for rule_spec in &linter_file.rules { + let rule = build_rule(id, &resolved_dir, rule_spec)?; + + if !codes.insert(rule.code.as_str().to_string()) { + return Err(ExternalLinterError::DuplicateRule { + linter: id.to_string(), + code: rule.code.as_str().to_string(), + }); + } + + rules.push(rule); + } + + let linter = ExternalAstLinter::new( + id, + linter_file.name.clone().unwrap_or_else(|| id.to_string()), + linter_file.description.clone(), + entry.enabled && linter_file.enabled, + rules, + ); + + Ok(linter) +} + +fn build_rule( + linter_id: &str, + base_dir: &Path, + spec: &ExternalAstRuleSpec, +) -> Result { + let code = ExternalRuleCode::new(&spec.code).map_err(|error| match error { + ExternalRuleCodeError::Empty | ExternalRuleCodeError::InvalidCharacters(_) => { + ExternalLinterError::InvalidRuleCode { + linter: linter_id.to_string(), + code: spec.code.clone(), + } + } + })?; + + if spec.targets.is_empty() { + return Err(ExternalLinterError::MissingTargets { + linter: linter_id.to_string(), + rule: spec.name.clone(), + }); + } + + let mut resolved_targets = Vec::with_capacity(spec.targets.len()); + for target in &spec.targets { + let parsed = parse_target(target).map_err(|source| ExternalLinterError::UnknownTarget { + linter: linter_id.to_string(), + rule: spec.name.clone(), + target: target.raw().to_string(), + source, + })?; + resolved_targets.push(parsed); + } + + let script = resolve_script(linter_id, &spec.name, base_dir, &spec.script)?; + let call_callee = if let Some(pattern) = spec.call_callee_regex.as_ref() { + if !resolved_targets + .iter() + .any(|target| matches!(target, AstTarget::Expr(ExprKind::Call))) + { + return Err(ExternalLinterError::CallCalleeRegexWithoutCallTarget { + linter: linter_id.to_string(), + rule: spec.name.clone(), + }); + } + + Some(CallCalleeMatcher::new(pattern.clone()).map_err(|source| { + ExternalLinterError::InvalidCallCalleeRegex { + linter: linter_id.to_string(), + rule: spec.name.clone(), + pattern: pattern.clone(), + source, + } + })?) + } else { + None + }; + + Ok(ExternalAstRule::new( + code, + spec.name.clone(), + spec.summary.clone(), + resolved_targets, + script, + call_callee, + )) +} + +fn resolve_script( + linter_id: &str, + rule_name: &str, + base_dir: &Path, + script_path: &Path, +) -> Result { + let resolved = if script_path.is_absolute() { + script_path.to_path_buf() + } else { + base_dir.join(script_path) + }; + let contents = + fs::read_to_string(&resolved).map_err(|source| ExternalLinterError::ScriptIo { + linter: linter_id.to_string(), + rule: rule_name.to_string(), + path: resolved.clone(), + source, + })?; + if contents.trim().is_empty() { + return Err(ExternalLinterError::MissingScriptBody { + linter: linter_id.to_string(), + rule: rule_name.to_string(), + }); + } + Ok(ExternalRuleScript::file(resolved, contents)) +} + +fn parse_target( + spec: &AstTargetSpec, +) -> Result { + spec.parse() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::external::ast::target::{ExprKind, StmtKind}; + use anyhow::Result; + use std::path::Path; + use tempfile::tempdir; + + fn write(path: &Path, contents: &str) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, contents)?; + Ok(()) + } + + #[test] + fn load_linter_from_entry_resolves_relative_paths() -> Result<()> { + let temp = tempdir()?; + let linter_path = temp.path().join("linters/my_linter.toml"); + let script_path = temp.path().join("linters/rules/example.py"); + let call_script_path = temp.path().join("linters/rules/call.py"); + + write( + &script_path, + r#" +def check(): + # placeholder body + pass +"#, + )?; + + write( + &call_script_path, + r#" +def check(): + pass +"#, + )?; + + write( + &linter_path, + r#" +name = "Example External Linter" +description = "Demonstrates external AST configuration" + +[[rule]] +code = "EXT001" +name = "ExampleRule" +summary = "Flags demo targets" +targets = ["stmt:FunctionDef"] +script = "rules/example.py" + +[[rule]] +code = "EXT100" +name = "CallRule" +targets = ["expr:Call"] +call-callee-regex = "^logging\\." +script = "rules/call.py" +"#, + )?; + + let entry = PyprojectExternalLinterEntry { + toml_path: linter_path, + enabled: true, + }; + + let linter = load_linter_from_entry("example", &entry)?; + assert!(linter.enabled); + assert_eq!(linter.id.as_str(), "example"); + assert_eq!(linter.name.as_str(), "Example External Linter"); + assert_eq!( + linter.description.as_deref(), + Some("Demonstrates external AST configuration") + ); + assert_eq!(linter.rules.len(), 2); + + let example_rule = &linter.rules[0]; + assert_eq!(example_rule.code.as_str(), "EXT001"); + assert_eq!(example_rule.name.as_str(), "ExampleRule"); + assert_eq!(example_rule.summary.as_deref(), Some("Flags demo targets")); + assert_eq!(example_rule.targets.len(), 1); + assert_eq!( + example_rule.targets[0], + AstTarget::Stmt(StmtKind::FunctionDef) + ); + assert_eq!(example_rule.script.path(), script_path.as_path()); + assert!(example_rule.script.body().contains("placeholder body")); + + let call_rule = &linter.rules[1]; + assert_eq!(call_rule.code.as_str(), "EXT100"); + assert_eq!(call_rule.name.as_str(), "CallRule"); + assert_eq!(call_rule.targets[0], AstTarget::Expr(ExprKind::Call)); + let call_callee = call_rule + .call_callee() + .expect("expected call callee matcher to be present"); + assert_eq!(call_callee.pattern(), "^logging\\."); + assert!(call_callee.regex().is_match("logging.info")); + + Ok(()) + } + + #[test] + fn load_linter_rejects_call_regex_without_call_target() -> Result<()> { + let temp = tempdir()?; + let linter_path = temp.path().join("linters/invalid-call.toml"); + let script_path = temp.path().join("linters/rules/invalid.py"); + + write( + &script_path, + r#" +def check(): + pass +"#, + )?; + + write( + &linter_path, + r#" +[[rule]] +code = "EXT101" +name = "InvalidCallRule" +targets = ["expr:Name"] +call-callee-regex = "^logging\\." +script = "rules/invalid.py" +"#, + )?; + + let entry = PyprojectExternalLinterEntry { + toml_path: linter_path, + enabled: true, + }; + + let err = load_linter_from_entry("invalid-call", &entry).unwrap_err(); + let ExternalLinterError::CallCalleeRegexWithoutCallTarget { linter, rule } = err else { + panic!("expected call regex without target error"); + }; + assert_eq!(linter, "invalid-call"); + assert_eq!(rule, "InvalidCallRule"); + + Ok(()) + } + + #[test] + fn load_linter_rejects_invalid_call_regex() -> Result<()> { + let temp = tempdir()?; + let linter_path = temp.path().join("linters/bad-regex.toml"); + let script_path = temp.path().join("linters/rules/bad.py"); + + write( + &script_path, + r#" +def check(): + pass +"#, + )?; + + write( + &linter_path, + r#" +[[rule]] +code = "EXT102" +name = "BadRegexRule" +targets = ["expr:Call"] +call-callee-regex = "[" +script = "rules/bad.py" +"#, + )?; + + let entry = PyprojectExternalLinterEntry { + toml_path: linter_path, + enabled: true, + }; + + let err = load_linter_from_entry("bad-regex", &entry).unwrap_err(); + let ExternalLinterError::InvalidCallCalleeRegex { + linter, + rule, + pattern, + .. + } = err + else { + panic!("expected invalid call regex error"); + }; + assert_eq!(linter, "bad-regex"); + assert_eq!(rule, "BadRegexRule"); + assert_eq!(pattern, "["); + + Ok(()) + } + + #[test] + fn load_linter_into_registry_marks_disabled_linters() -> Result<()> { + let temp = tempdir()?; + let linter_path = temp.path().join("linters/disabled.toml"); + let script_path = temp.path().join("linters/rules/unused.py"); + + write( + &script_path, + r#" +def check(): + pass +"#, + )?; + + write( + &linter_path, + r#" +enabled = false + +[[rule]] +code = "EXT002" +name = "DisabledRule" +targets = ["stmt:Expr"] +script = "rules/unused.py" +"#, + )?; + + let entry = PyprojectExternalLinterEntry { + toml_path: linter_path, + enabled: true, + }; + + let mut registry = ExternalLintRegistry::new(); + load_linter_into_registry(&mut registry, "disabled", &entry)?; + + assert_eq!(registry.linters().len(), 1); + + let linter = ®istry.linters()[0]; + assert!(!linter.enabled); + + // Disabled linters should not be discoverable by rule code lookup. + assert!(registry.find_rule_by_code("EXT002").is_none()); + + Ok(()) + } +} diff --git a/crates/ruff_linter/src/external/ast/mod.rs b/crates/ruff_linter/src/external/ast/mod.rs new file mode 100644 index 0000000000..db244eb75d --- /dev/null +++ b/crates/ruff_linter/src/external/ast/mod.rs @@ -0,0 +1,6 @@ +pub mod definition; +pub mod loader; +pub mod registry; +pub mod rule; +pub mod runtime; +pub mod target; diff --git a/crates/ruff_linter/src/external/ast/registry.rs b/crates/ruff_linter/src/external/ast/registry.rs new file mode 100644 index 0000000000..ee0435f68c --- /dev/null +++ b/crates/ruff_linter/src/external/ast/registry.rs @@ -0,0 +1,163 @@ +use std::hash::Hasher; + +use crate::external::ast::rule::{CallCalleeMatcher, ExternalAstLinter, ExternalAstRule}; +use crate::external::ast::target::{AstTarget, ExprKind, StmtKind}; +use crate::external::error::ExternalLinterError; +use rustc_hash::FxHashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RuleLocator { + pub linter_index: usize, + pub rule_index: usize, +} + +impl RuleLocator { + pub const fn new(linter_index: usize, rule_index: usize) -> Self { + Self { + linter_index, + rule_index, + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct ExternalLintRegistry { + linters: Vec, + index_by_code: FxHashMap, + stmt_index: FxHashMap>, + expr_index: FxHashMap>, +} + +impl ExternalLintRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.linters.is_empty() + } + + pub fn linters(&self) -> &[ExternalAstLinter] { + &self.linters + } + + pub fn insert_linter(&mut self, linter: ExternalAstLinter) -> Result<(), ExternalLinterError> { + if self.linters.iter().any(|existing| existing.id == linter.id) { + return Err(ExternalLinterError::DuplicateLinter { id: linter.id }); + } + + let linter_index = self.linters.len(); + for (rule_index, rule) in linter.rules.iter().enumerate() { + let code = rule.code.as_str().to_string(); + if self.index_by_code.contains_key(&code) { + return Err(ExternalLinterError::DuplicateRule { + linter: linter.id.clone(), + code, + }); + } + self.index_by_code + .insert(code, RuleLocator::new(linter_index, rule_index)); + + if !linter.enabled { + continue; + } + + for target in &rule.targets { + match target { + AstTarget::Stmt(kind) => self + .stmt_index + .entry(*kind) + .or_default() + .push(RuleLocator::new(linter_index, rule_index)), + AstTarget::Expr(kind) => self + .expr_index + .entry(*kind) + .or_default() + .push(RuleLocator::new(linter_index, rule_index)), + } + } + } + self.linters.push(linter); + Ok(()) + } + + pub fn get_rule(&self, locator: RuleLocator) -> Option<&ExternalAstRule> { + self.linters + .get(locator.linter_index) + .and_then(|linter| linter.rules.get(locator.rule_index)) + } + + pub fn get_linter(&self, locator: RuleLocator) -> Option<&ExternalAstLinter> { + self.linters.get(locator.linter_index) + } + + pub fn find_rule_by_code( + &self, + code: &str, + ) -> Option<(RuleLocator, &ExternalAstRule, &ExternalAstLinter)> { + let locator = *self.index_by_code.get(code)?; + let linter = self.linters.get(locator.linter_index)?; + if !linter.enabled { + return None; + } + let rule = linter.rules.get(locator.rule_index)?; + Some((locator, rule, linter)) + } + + pub fn rules_for_stmt(&self, kind: StmtKind) -> impl Iterator + '_ { + self.stmt_index.get(&kind).into_iter().flatten().copied() + } + + pub fn rules_for_expr(&self, kind: ExprKind) -> impl Iterator + '_ { + self.expr_index.get(&kind).into_iter().flatten().copied() + } + + pub fn rule_entry( + &self, + locator: RuleLocator, + ) -> Option<(&ExternalAstRule, &ExternalAstLinter)> { + let linter = self.linters.get(locator.linter_index)?; + let rule = linter.rules.get(locator.rule_index)?; + Some((rule, linter)) + } +} + +impl ruff_cache::CacheKey for ExternalLintRegistry { + fn cache_key(&self, key: &mut ruff_cache::CacheKeyHasher) { + key.write_usize(self.linters.len()); + for linter in &self.linters { + linter.id.as_str().cache_key(key); + linter.enabled.cache_key(key); + linter.name.as_str().cache_key(key); + linter.description.as_deref().cache_key(key); + key.write_usize(linter.rules.len()); + for rule in &linter.rules { + rule.code.as_str().cache_key(key); + rule.name.as_str().cache_key(key); + rule.summary.as_deref().cache_key(key); + rule.call_callee() + .map(CallCalleeMatcher::pattern) + .cache_key(key); + key.write_usize(rule.targets.len()); + for target in &rule.targets { + match target { + AstTarget::Stmt(kind) => { + key.write_u8(0); + key.write_u16(*kind as u16); + } + AstTarget::Expr(kind) => { + key.write_u8(1); + key.write_u16(*kind as u16); + } + } + } + let path_str = rule.script.path().to_string_lossy(); + key.write_usize(path_str.len()); + key.write(path_str.as_bytes()); + let contents_str = rule.script.body(); + key.write_usize(contents_str.len()); + key.write(contents_str.as_bytes()); + } + } + } +} diff --git a/crates/ruff_linter/src/external/ast/rule.rs b/crates/ruff_linter/src/external/ast/rule.rs new file mode 100644 index 0000000000..45c7f9f711 --- /dev/null +++ b/crates/ruff_linter/src/external/ast/rule.rs @@ -0,0 +1,203 @@ +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use regex::Regex; +use ruff_db::diagnostic::SecondaryCode; +use serde::Deserialize; +use thiserror::Error; + +use crate::external::ast::target::{AstTarget, AstTargetSpec}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ExternalRuleCode(Box); + +impl ExternalRuleCode { + pub fn new>(code: S) -> Result { + let code_ref = code.as_ref(); + if code_ref.is_empty() { + return Err(ExternalRuleCodeError::Empty); + } + if !Self::matches_format(code_ref) { + return Err(ExternalRuleCodeError::InvalidCharacters( + code_ref.to_string(), + )); + } + Ok(Self(code_ref.into())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn to_secondary_code(&self) -> SecondaryCode { + SecondaryCode::new(self.as_str().to_string()) + } + + fn pattern() -> &'static Regex { + static PATTERN: OnceLock = OnceLock::new(); + PATTERN.get_or_init(|| Regex::new(r"^[A-Z]+[0-9]+$").expect("valid external rule regex")) + } + + pub(crate) fn matches_format(code: &str) -> bool { + Self::pattern().is_match(code) + } +} + +impl std::fmt::Display for ExternalRuleCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Error)] +pub enum ExternalRuleCodeError { + #[error("external rule codes must not be empty")] + Empty, + #[error("external rule codes must contain only uppercase ASCII letters and digits: `{0}`")] + InvalidCharacters(String), +} + +/// Fully resolved script content that can be handed to the runtime for compilation. +#[derive(Debug, Clone)] +pub struct ExternalRuleScript { + path: PathBuf, + contents: String, +} + +impl ExternalRuleScript { + pub fn file(path: PathBuf, contents: impl Into) -> Self { + Self { + path, + contents: contents.into(), + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn body(&self) -> &str { + &self.contents + } +} + +/// User-facing metadata describing an external AST rule before targets are resolved. +#[derive(Debug, Clone, Deserialize)] +pub struct ExternalAstRuleSpec { + pub code: String, + pub name: String, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub targets: Vec, + #[serde(default, rename = "call-callee-regex")] + pub call_callee_regex: Option, + pub script: PathBuf, +} + +/// A validated, ready-to-run external AST rule definition. +#[derive(Debug, Clone)] +pub struct ExternalAstRule { + pub code: ExternalRuleCode, + pub name: String, + pub summary: Option, + pub targets: Box<[AstTarget]>, + pub script: ExternalRuleScript, + pub call_callee: Option, +} + +impl ExternalAstRule { + #[allow(clippy::too_many_arguments)] + pub fn new( + code: ExternalRuleCode, + name: impl Into, + summary: Option>, + targets: Vec, + script: ExternalRuleScript, + call_callee: Option, + ) -> Self { + let targets = targets.into_boxed_slice(); + Self { + code, + name: name.into(), + summary: summary.map(Into::into), + targets, + script, + call_callee, + } + } + + pub fn call_callee(&self) -> Option<&CallCalleeMatcher> { + self.call_callee.as_ref() + } +} + +/// Metadata about a collection of external AST rules loaded from a user-defined linter file. +#[derive(Debug, Clone)] +pub struct ExternalAstLinter { + pub id: String, + pub name: String, + pub description: Option, + pub enabled: bool, + pub rules: Vec, +} + +impl ExternalAstLinter { + pub fn new( + id: impl Into, + name: impl Into, + description: Option>, + enabled: bool, + rules: Vec, + ) -> Self { + Self { + id: id.into(), + name: name.into(), + description: description.map(Into::into), + enabled, + rules, + } + } +} + +impl std::fmt::Display for ExternalAstLinter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "{}{}", + self.id, + if self.enabled { "" } else { " (disabled)" } + )?; + writeln!(f, " name: {}", self.name)?; + if let Some(description) = &self.description { + writeln!(f, " description: {description}")?; + } + writeln!(f, " rules:")?; + for rule in &self.rules { + writeln!(f, " - {} ({})", rule.code.as_str(), rule.name)?; + } + writeln!(f) + } +} + +#[derive(Debug, Clone)] +pub struct CallCalleeMatcher { + pattern: String, + regex: Regex, +} + +impl CallCalleeMatcher { + pub fn new(pattern: impl Into) -> Result { + let pattern = pattern.into(); + let regex = Regex::new(pattern.as_ref())?; + Ok(Self { pattern, regex }) + } + + pub fn pattern(&self) -> &str { + &self.pattern + } + + pub fn regex(&self) -> &Regex { + &self.regex + } +} diff --git a/crates/ruff_linter/src/external/ast/runtime.rs b/crates/ruff_linter/src/external/ast/runtime.rs new file mode 100644 index 0000000000..7d87ff8075 --- /dev/null +++ b/crates/ruff_linter/src/external/ast/runtime.rs @@ -0,0 +1,21 @@ +use std::sync::Arc; + +use crate::external::ast::registry::ExternalLintRegistry; + +/// Shareable handle to the external lint runtime state. +#[derive(Clone, Debug, Default)] +pub struct ExternalLintRuntimeHandle { + registry: Arc, +} + +impl ExternalLintRuntimeHandle { + pub fn new(registry: ExternalLintRegistry) -> Self { + Self { + registry: Arc::new(registry), + } + } + + pub fn registry(&self) -> &ExternalLintRegistry { + &self.registry + } +} diff --git a/crates/ruff_linter/src/external/ast/target.rs b/crates/ruff_linter/src/external/ast/target.rs new file mode 100644 index 0000000000..bc63412ff2 --- /dev/null +++ b/crates/ruff_linter/src/external/ast/target.rs @@ -0,0 +1,449 @@ +use std::fmt; +use std::str::FromStr; + +use ruff_python_ast::{Expr, Stmt}; +use serde::Deserialize; +use thiserror::Error; + +/// An AST node selector identifying which nodes a scripted rule should run against. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AstTarget { + Stmt(StmtKind), + Expr(ExprKind), +} + +impl AstTarget { + pub const fn kind(&self) -> AstNodeClass { + match self { + AstTarget::Stmt(..) => AstNodeClass::Stmt, + AstTarget::Expr(..) => AstNodeClass::Expr, + } + } + + pub const fn name(&self) -> &'static str { + match self { + AstTarget::Stmt(kind) => kind.as_str(), + AstTarget::Expr(kind) => kind.as_str(), + } + } +} + +impl fmt::Display for AstTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AstTarget::Stmt(kind) => write!(f, "stmt:{}", kind.as_str()), + AstTarget::Expr(kind) => write!(f, "expr:{}", kind.as_str()), + } + } +} + +impl FromStr for AstTarget { + type Err = AstTargetParseError; + + fn from_str(s: &str) -> Result { + parse_target(s) + } +} + +/// Convenience wrapper that enables parsing `AstTarget` values directly from configuration. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)] +#[serde(transparent)] +pub struct AstTargetSpec(String); + +impl AstTargetSpec { + pub fn parse(&self) -> Result { + self.0.as_str().parse() + } + + pub fn raw(&self) -> &str { + &self.0 + } +} + +/// Broad AST node classes supported by scripted rules. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AstNodeClass { + Stmt, + Expr, +} + +/// Statement kinds supported by scripted rules. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum StmtKind { + FunctionDef, + ClassDef, + Return, + Delete, + TypeAlias, + Assign, + AugAssign, + AnnAssign, + For, + While, + If, + With, + Match, + Raise, + Try, + Assert, + Import, + ImportFrom, + Global, + Nonlocal, + Expr, + Pass, + Break, + Continue, + IpyEscapeCommand, +} + +impl StmtKind { + pub const fn as_str(self) -> &'static str { + match self { + StmtKind::FunctionDef => "FunctionDef", + StmtKind::ClassDef => "ClassDef", + StmtKind::Return => "Return", + StmtKind::Delete => "Delete", + StmtKind::TypeAlias => "TypeAlias", + StmtKind::Assign => "Assign", + StmtKind::AugAssign => "AugAssign", + StmtKind::AnnAssign => "AnnAssign", + StmtKind::For => "For", + StmtKind::While => "While", + StmtKind::If => "If", + StmtKind::With => "With", + StmtKind::Match => "Match", + StmtKind::Raise => "Raise", + StmtKind::Try => "Try", + StmtKind::Assert => "Assert", + StmtKind::Import => "Import", + StmtKind::ImportFrom => "ImportFrom", + StmtKind::Global => "Global", + StmtKind::Nonlocal => "Nonlocal", + StmtKind::Expr => "Expr", + StmtKind::Pass => "Pass", + StmtKind::Break => "Break", + StmtKind::Continue => "Continue", + StmtKind::IpyEscapeCommand => "IpyEscapeCommand", + } + } + + pub fn matches(self, stmt: &Stmt) -> bool { + matches!( + (self, stmt), + (StmtKind::FunctionDef, Stmt::FunctionDef(_)) + | (StmtKind::ClassDef, Stmt::ClassDef(_)) + | (StmtKind::Return, Stmt::Return(_)) + | (StmtKind::Delete, Stmt::Delete(_)) + | (StmtKind::TypeAlias, Stmt::TypeAlias(_)) + | (StmtKind::Assign, Stmt::Assign(_)) + | (StmtKind::AugAssign, Stmt::AugAssign(_)) + | (StmtKind::AnnAssign, Stmt::AnnAssign(_)) + | (StmtKind::For, Stmt::For(_)) + | (StmtKind::While, Stmt::While(_)) + | (StmtKind::If, Stmt::If(_)) + | (StmtKind::With, Stmt::With(_)) + | (StmtKind::Match, Stmt::Match(_)) + | (StmtKind::Raise, Stmt::Raise(_)) + | (StmtKind::Try, Stmt::Try(_)) + | (StmtKind::Assert, Stmt::Assert(_)) + | (StmtKind::Import, Stmt::Import(_)) + | (StmtKind::ImportFrom, Stmt::ImportFrom(_)) + | (StmtKind::Global, Stmt::Global(_)) + | (StmtKind::Nonlocal, Stmt::Nonlocal(_)) + | (StmtKind::Expr, Stmt::Expr(_)) + | (StmtKind::Pass, Stmt::Pass(_)) + | (StmtKind::Break, Stmt::Break(_)) + | (StmtKind::Continue, Stmt::Continue(_)) + | (StmtKind::IpyEscapeCommand, Stmt::IpyEscapeCommand(_)) + ) + } +} + +impl fmt::Display for StmtKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From<&Stmt> for StmtKind { + fn from(value: &Stmt) -> Self { + match value { + Stmt::FunctionDef(_) => StmtKind::FunctionDef, + Stmt::ClassDef(_) => StmtKind::ClassDef, + Stmt::Return(_) => StmtKind::Return, + Stmt::Delete(_) => StmtKind::Delete, + Stmt::TypeAlias(_) => StmtKind::TypeAlias, + Stmt::Assign(_) => StmtKind::Assign, + Stmt::AugAssign(_) => StmtKind::AugAssign, + Stmt::AnnAssign(_) => StmtKind::AnnAssign, + Stmt::For(_) => StmtKind::For, + Stmt::While(_) => StmtKind::While, + Stmt::If(_) => StmtKind::If, + Stmt::With(_) => StmtKind::With, + Stmt::Match(_) => StmtKind::Match, + Stmt::Raise(_) => StmtKind::Raise, + Stmt::Try(_) => StmtKind::Try, + Stmt::Assert(_) => StmtKind::Assert, + Stmt::Import(_) => StmtKind::Import, + Stmt::ImportFrom(_) => StmtKind::ImportFrom, + Stmt::Global(_) => StmtKind::Global, + Stmt::Nonlocal(_) => StmtKind::Nonlocal, + Stmt::Expr(_) => StmtKind::Expr, + Stmt::Pass(_) => StmtKind::Pass, + Stmt::Break(_) => StmtKind::Break, + Stmt::Continue(_) => StmtKind::Continue, + Stmt::IpyEscapeCommand(_) => StmtKind::IpyEscapeCommand, + } + } +} + +/// Expression kinds supported by scripted rules. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum ExprKind { + Attribute, + Await, + BinOp, + BoolOp, + BooleanLiteral, + BytesLiteral, + Call, + Compare, + Dict, + DictComp, + EllipsisLiteral, + FString, + Generator, + If, + IpyEscapeCommand, + Lambda, + List, + ListComp, + Name, + Named, + NoneLiteral, + NumberLiteral, + Set, + SetComp, + Slice, + Starred, + StringLiteral, + Subscript, + Tuple, + UnaryOp, + Yield, + YieldFrom, +} + +impl ExprKind { + pub const fn as_str(self) -> &'static str { + match self { + ExprKind::Attribute => "Attribute", + ExprKind::Await => "Await", + ExprKind::BinOp => "BinOp", + ExprKind::BoolOp => "BoolOp", + ExprKind::BooleanLiteral => "BooleanLiteral", + ExprKind::BytesLiteral => "BytesLiteral", + ExprKind::Call => "Call", + ExprKind::Compare => "Compare", + ExprKind::Dict => "Dict", + ExprKind::DictComp => "DictComp", + ExprKind::EllipsisLiteral => "EllipsisLiteral", + ExprKind::FString => "FString", + ExprKind::Generator => "Generator", + ExprKind::If => "If", + ExprKind::IpyEscapeCommand => "IpyEscapeCommand", + ExprKind::Lambda => "Lambda", + ExprKind::List => "List", + ExprKind::ListComp => "ListComp", + ExprKind::Name => "Name", + ExprKind::Named => "Named", + ExprKind::NoneLiteral => "NoneLiteral", + ExprKind::NumberLiteral => "NumberLiteral", + ExprKind::Set => "Set", + ExprKind::SetComp => "SetComp", + ExprKind::Slice => "Slice", + ExprKind::Starred => "Starred", + ExprKind::StringLiteral => "StringLiteral", + ExprKind::Subscript => "Subscript", + ExprKind::Tuple => "Tuple", + ExprKind::UnaryOp => "UnaryOp", + ExprKind::Yield => "Yield", + ExprKind::YieldFrom => "YieldFrom", + } + } + + pub fn matches(self, expr: &Expr) -> bool { + match self { + ExprKind::Attribute => matches!(expr, Expr::Attribute(_)), + ExprKind::Await => matches!(expr, Expr::Await(_)), + ExprKind::BinOp => matches!(expr, Expr::BinOp(_)), + ExprKind::BoolOp => matches!(expr, Expr::BoolOp(_)), + ExprKind::BooleanLiteral => matches!(expr, Expr::BooleanLiteral(_)), + ExprKind::BytesLiteral => matches!(expr, Expr::BytesLiteral(_)), + ExprKind::Call => matches!(expr, Expr::Call(_)), + ExprKind::Compare => matches!(expr, Expr::Compare(_)), + ExprKind::Dict => matches!(expr, Expr::Dict(_)), + ExprKind::DictComp => matches!(expr, Expr::DictComp(_)), + ExprKind::EllipsisLiteral => matches!(expr, Expr::EllipsisLiteral(_)), + ExprKind::FString => matches!(expr, Expr::FString(_) | Expr::TString(_)), + ExprKind::Generator => matches!(expr, Expr::Generator(_)), + ExprKind::If => matches!(expr, Expr::If(_)), + ExprKind::IpyEscapeCommand => matches!(expr, Expr::IpyEscapeCommand(_)), + ExprKind::Lambda => matches!(expr, Expr::Lambda(_)), + ExprKind::List => matches!(expr, Expr::List(_)), + ExprKind::ListComp => matches!(expr, Expr::ListComp(_)), + ExprKind::Name => matches!(expr, Expr::Name(_)), + ExprKind::Named => matches!(expr, Expr::Named(_)), + ExprKind::NoneLiteral => matches!(expr, Expr::NoneLiteral(_)), + ExprKind::NumberLiteral => matches!(expr, Expr::NumberLiteral(_)), + ExprKind::Set => matches!(expr, Expr::Set(_)), + ExprKind::SetComp => matches!(expr, Expr::SetComp(_)), + ExprKind::Slice => matches!(expr, Expr::Slice(_)), + ExprKind::Starred => matches!(expr, Expr::Starred(_)), + ExprKind::StringLiteral => matches!(expr, Expr::StringLiteral(_)), + ExprKind::Subscript => matches!(expr, Expr::Subscript(_)), + ExprKind::Tuple => matches!(expr, Expr::Tuple(_)), + ExprKind::UnaryOp => matches!(expr, Expr::UnaryOp(_)), + ExprKind::Yield => matches!(expr, Expr::Yield(_)), + ExprKind::YieldFrom => matches!(expr, Expr::YieldFrom(_)), + } + } +} + +impl fmt::Display for ExprKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From<&Expr> for ExprKind { + fn from(value: &Expr) -> Self { + match value { + Expr::Attribute(_) => ExprKind::Attribute, + Expr::Await(_) => ExprKind::Await, + Expr::BinOp(_) => ExprKind::BinOp, + Expr::BoolOp(_) => ExprKind::BoolOp, + Expr::BooleanLiteral(_) => ExprKind::BooleanLiteral, + Expr::BytesLiteral(_) => ExprKind::BytesLiteral, + Expr::Call(_) => ExprKind::Call, + Expr::Compare(_) => ExprKind::Compare, + Expr::Dict(_) => ExprKind::Dict, + Expr::DictComp(_) => ExprKind::DictComp, + Expr::EllipsisLiteral(_) => ExprKind::EllipsisLiteral, + Expr::FString(_) => ExprKind::FString, + Expr::TString(_) => ExprKind::FString, + Expr::Generator(_) => ExprKind::Generator, + Expr::If(_) => ExprKind::If, + Expr::IpyEscapeCommand(_) => ExprKind::IpyEscapeCommand, + Expr::Lambda(_) => ExprKind::Lambda, + Expr::List(_) => ExprKind::List, + Expr::ListComp(_) => ExprKind::ListComp, + Expr::Name(_) => ExprKind::Name, + Expr::Named(_) => ExprKind::Named, + Expr::NoneLiteral(_) => ExprKind::NoneLiteral, + Expr::NumberLiteral(_) => ExprKind::NumberLiteral, + Expr::Set(_) => ExprKind::Set, + Expr::SetComp(_) => ExprKind::SetComp, + Expr::Slice(_) => ExprKind::Slice, + Expr::Starred(_) => ExprKind::Starred, + Expr::StringLiteral(_) => ExprKind::StringLiteral, + Expr::Subscript(_) => ExprKind::Subscript, + Expr::Tuple(_) => ExprKind::Tuple, + Expr::UnaryOp(_) => ExprKind::UnaryOp, + Expr::Yield(_) => ExprKind::Yield, + Expr::YieldFrom(_) => ExprKind::YieldFrom, + } + } +} + +#[derive(Debug, Error)] +pub enum AstTargetParseError { + #[error("expected `stmt:` or `expr:` target selector")] + MissingPrefix, + #[error("unknown statement selector `{0}`")] + UnknownStmtKind(String), + #[error("unknown expression selector `{0}`")] + UnknownExprKind(String), +} + +fn parse_target(raw: &str) -> Result { + let (prefix, name) = raw + .split_once(':') + .ok_or(AstTargetParseError::MissingPrefix)?; + match prefix { + "stmt" => Ok(AstTarget::Stmt(parse_stmt_kind(name)?)), + "expr" => Ok(AstTarget::Expr(parse_expr_kind(name)?)), + _ => Err(AstTargetParseError::MissingPrefix), + } +} + +fn parse_stmt_kind(name: &str) -> Result { + match name { + "FunctionDef" => Ok(StmtKind::FunctionDef), + "ClassDef" => Ok(StmtKind::ClassDef), + "Return" => Ok(StmtKind::Return), + "Delete" => Ok(StmtKind::Delete), + "TypeAlias" => Ok(StmtKind::TypeAlias), + "Assign" => Ok(StmtKind::Assign), + "AugAssign" => Ok(StmtKind::AugAssign), + "AnnAssign" => Ok(StmtKind::AnnAssign), + "For" => Ok(StmtKind::For), + "While" => Ok(StmtKind::While), + "If" => Ok(StmtKind::If), + "With" => Ok(StmtKind::With), + "Match" => Ok(StmtKind::Match), + "Raise" => Ok(StmtKind::Raise), + "Try" => Ok(StmtKind::Try), + "Assert" => Ok(StmtKind::Assert), + "Import" => Ok(StmtKind::Import), + "ImportFrom" => Ok(StmtKind::ImportFrom), + "Global" => Ok(StmtKind::Global), + "Nonlocal" => Ok(StmtKind::Nonlocal), + "Expr" => Ok(StmtKind::Expr), + "Pass" => Ok(StmtKind::Pass), + "Break" => Ok(StmtKind::Break), + "Continue" => Ok(StmtKind::Continue), + "IpyEscapeCommand" => Ok(StmtKind::IpyEscapeCommand), + other => Err(AstTargetParseError::UnknownStmtKind(other.to_string())), + } +} + +fn parse_expr_kind(name: &str) -> Result { + match name { + "Attribute" => Ok(ExprKind::Attribute), + "Await" => Ok(ExprKind::Await), + "BinOp" => Ok(ExprKind::BinOp), + "BoolOp" => Ok(ExprKind::BoolOp), + "BooleanLiteral" => Ok(ExprKind::BooleanLiteral), + "BytesLiteral" => Ok(ExprKind::BytesLiteral), + "Call" => Ok(ExprKind::Call), + "Compare" => Ok(ExprKind::Compare), + "Dict" => Ok(ExprKind::Dict), + "DictComp" => Ok(ExprKind::DictComp), + "EllipsisLiteral" => Ok(ExprKind::EllipsisLiteral), + "FString" => Ok(ExprKind::FString), + "Generator" => Ok(ExprKind::Generator), + "If" => Ok(ExprKind::If), + "IpyEscapeCommand" => Ok(ExprKind::IpyEscapeCommand), + "Lambda" => Ok(ExprKind::Lambda), + "List" => Ok(ExprKind::List), + "ListComp" => Ok(ExprKind::ListComp), + "Name" => Ok(ExprKind::Name), + "Named" => Ok(ExprKind::Named), + "NoneLiteral" => Ok(ExprKind::NoneLiteral), + "NumberLiteral" => Ok(ExprKind::NumberLiteral), + "Set" => Ok(ExprKind::Set), + "SetComp" => Ok(ExprKind::SetComp), + "Slice" => Ok(ExprKind::Slice), + "Starred" => Ok(ExprKind::Starred), + "StringLiteral" => Ok(ExprKind::StringLiteral), + "Subscript" => Ok(ExprKind::Subscript), + "Tuple" => Ok(ExprKind::Tuple), + "TString" => Ok(ExprKind::FString), + "UnaryOp" => Ok(ExprKind::UnaryOp), + "Yield" => Ok(ExprKind::Yield), + "YieldFrom" => Ok(ExprKind::YieldFrom), + other => Err(AstTargetParseError::UnknownExprKind(other.to_string())), + } +} diff --git a/crates/ruff_linter/src/external/error.rs b/crates/ruff_linter/src/external/error.rs new file mode 100644 index 0000000000..dd4ec23f5a --- /dev/null +++ b/crates/ruff_linter/src/external/error.rs @@ -0,0 +1,78 @@ +use std::path::PathBuf; + +use thiserror::Error; + +use crate::external::ast::target::AstTargetParseError; + +#[derive(Debug, Error)] +pub enum ExternalLinterError { + #[error("failed to read external linter definition `{path}`: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("failed to parse external linter definition `{path}`: {source}")] + Parse { + path: PathBuf, + #[source] + source: toml::de::Error, + }, + + #[error("invalid rule code `{code}` for external linter `{linter}`")] + InvalidRuleCode { linter: String, code: String }, + + #[error("unknown AST target `{target}` for external rule `{rule}` in linter `{linter}`")] + // Targets must expand to one of the supported StmtKind or ExprKind enums; anything else is rejected. + UnknownTarget { + linter: String, + rule: String, + target: String, + #[source] + source: AstTargetParseError, + }, + + #[error("duplicate rule code `{code}` in external linter `{linter}`")] + DuplicateRule { linter: String, code: String }, + + #[error("duplicate external linter identifier `{id}`")] + DuplicateLinter { id: String }, + + #[error("external linter `{id}` defines no rules")] + EmptyLinter { id: String }, + + #[error("external rule `{rule}` in linter `{linter}` must declare at least one AST target")] + MissingTargets { linter: String, rule: String }, + + #[error( + "failed to read script `{path}` for external rule `{rule}` in linter `{linter}`: {source}" + )] + ScriptIo { + linter: String, + rule: String, + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("no script body provided for external rule `{rule}` in linter `{linter}`")] + // Raised when we read a script file but it is empty or whitespace-only. + MissingScriptBody { linter: String, rule: String }, + + #[error( + "invalid `call-callee-regex` `{pattern}` for external rule `{rule}` in linter `{linter}`: {source}" + )] + InvalidCallCalleeRegex { + linter: String, + rule: String, + pattern: String, + #[source] + source: regex::Error, + }, + + #[error( + "external rule `{rule}` in linter `{linter}` declares `call-callee-regex` but does not target `expr:Call` nodes" + )] + CallCalleeRegexWithoutCallTarget { linter: String, rule: String }, +} diff --git a/crates/ruff_linter/src/external/mod.rs b/crates/ruff_linter/src/external/mod.rs new file mode 100644 index 0000000000..acccb64147 --- /dev/null +++ b/crates/ruff_linter/src/external/mod.rs @@ -0,0 +1,26 @@ +pub mod ast; +pub mod error; + +use serde::Deserialize; +use std::path::PathBuf; + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PyprojectExternalLinterEntry { + pub toml_path: PathBuf, + #[serde(default = "default_true")] + pub enabled: bool, +} + +pub use ast::definition::ExternalAstLinterFile; +pub use ast::loader::{load_linter_from_entry, load_linter_into_registry}; +pub use ast::registry::{ExternalLintRegistry, RuleLocator}; +pub use ast::rule::{ + ExternalAstLinter, ExternalAstRule, ExternalAstRuleSpec, ExternalRuleCode, ExternalRuleScript, +}; +pub use ast::runtime::ExternalLintRuntimeHandle; +pub use ast::target::{AstNodeClass, AstTarget, AstTargetSpec, ExprKind, StmtKind}; +pub use error::ExternalLinterError; diff --git a/crates/ruff_linter/src/lib.rs b/crates/ruff_linter/src/lib.rs index eaafd7a526..58d9248441 100644 --- a/crates/ruff_linter/src/lib.rs +++ b/crates/ruff_linter/src/lib.rs @@ -26,6 +26,7 @@ mod cst; pub mod directives; mod doc_lines; mod docstrings; +pub mod external; mod fix; pub mod fs; mod importer; diff --git a/crates/ruff_linter/src/rule_selector.rs b/crates/ruff_linter/src/rule_selector.rs index b3eee0d837..6203ca39f8 100644 --- a/crates/ruff_linter/src/rule_selector.rs +++ b/crates/ruff_linter/src/rule_selector.rs @@ -7,10 +7,15 @@ use strum_macros::EnumIter; use crate::codes::RuleIter; use crate::codes::{RuleCodePrefix, RuleGroup}; +use crate::external::ast::rule::ExternalRuleCode; use crate::registry::{Linter, Rule, RuleNamespace}; use crate::rule_redirects::get_redirect; use crate::settings::types::PreviewMode; +fn looks_like_external_rule_code(s: &str) -> bool { + ExternalRuleCode::matches_format(s) +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum RuleSelector { /// Select all rules (includes rules in preview if enabled) @@ -33,6 +38,8 @@ pub enum RuleSelector { prefix: RuleCodePrefix, redirected_from: Option<&'static str>, }, + /// Select an external rule code. + External { code: Box }, } impl From for RuleSelector { @@ -70,8 +77,16 @@ impl FromStr for RuleSelector { None => (s, None), }; - let (linter, code) = - Linter::parse_code(s).ok_or_else(|| ParseError::Unknown(s.to_string()))?; + let Some((linter, code)) = Linter::parse_code(s) else { + if looks_like_external_rule_code(s) { + if ExternalRuleCode::new(s).is_ok() { + return Ok(Self::External { code: s.into() }); + } + + return Err(ParseError::External(s.to_string())); + } + return Err(ParseError::Unknown(s.to_string())); + }; if code.is_empty() { return Ok(Self::Linter(linter)); @@ -119,10 +134,12 @@ 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.")] + External(String), } impl RuleSelector { - pub fn prefix_and_code(&self) -> (&'static str, &'static str) { + pub fn prefix_and_code(&self) -> (&str, &str) { match self { RuleSelector::All => ("", "ALL"), RuleSelector::C => ("", "C"), @@ -131,6 +148,7 @@ impl RuleSelector { (prefix.linter().common_prefix(), prefix.short_code()) } RuleSelector::Linter(l) => (l.common_prefix(), ""), + RuleSelector::External { code } => ("", code.as_ref()), } } } @@ -174,7 +192,14 @@ impl Visitor<'_> for SelectorVisitor { where E: de::Error, { - FromStr::from_str(v).map_err(de::Error::custom) + match FromStr::from_str(v) { + 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(err) => Err(de::Error::custom(err)), + } } } @@ -198,6 +223,9 @@ impl RuleSelector { RuleSelector::Prefix { prefix, .. } | RuleSelector::Rule { prefix, .. } => { RuleSelectorIter::Vec(prefix.clone().rules()) } + RuleSelector::External { .. } => { + RuleSelectorIter::Vec(vec![Rule::ExternalLinter].into_iter()) + } } } @@ -224,7 +252,7 @@ impl RuleSelector { /// Returns true if this selector is exact i.e. selects a single rule by code pub fn is_exact(&self) -> bool { - matches!(self, Self::Rule { .. }) + matches!(self, Self::Rule { .. } | Self::External { .. }) } } @@ -337,6 +365,7 @@ impl RuleSelector { RuleSelector::C => Specificity::LinterGroup, RuleSelector::Linter(..) => Specificity::Linter, RuleSelector::Rule { .. } => Specificity::Rule, + RuleSelector::External { .. } => Specificity::Rule, RuleSelector::Prefix { prefix, .. } => { let prefix: &'static str = prefix.short_code(); match prefix.len() { @@ -360,8 +389,16 @@ impl RuleSelector { "C" => Ok(Self::C), "T" => Ok(Self::T), _ => { - let (linter, code) = - Linter::parse_code(s).ok_or_else(|| ParseError::Unknown(s.to_string()))?; + let Some((linter, code)) = Linter::parse_code(s) else { + if looks_like_external_rule_code(s) { + if ExternalRuleCode::new(s).is_ok() { + return Ok(Self::External { code: s.into() }); + } + + return Err(ParseError::External(s.to_string())); + } + return Err(ParseError::Unknown(s.to_string())); + }; if code.is_empty() { return Ok(Self::Linter(linter)); @@ -415,7 +452,7 @@ pub mod clap_completion { RuleSelector, codes::RuleCodePrefix, registry::{Linter, RuleNamespace}, - rule_selector::is_single_rule_selector, + rule_selector::{ParseError, is_single_rule_selector}, }; #[derive(Clone)] @@ -442,20 +479,26 @@ pub mod clap_completion { .to_str() .ok_or_else(|| clap::Error::new(clap::error::ErrorKind::InvalidUtf8))?; - value.parse().map_err(|_| { - let mut error = - clap::Error::new(clap::error::ErrorKind::ValueValidation).with_cmd(cmd); - if let Some(arg) = arg { + 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."), + ), + ParseError::Unknown(_) => { + let mut error = + clap::Error::new(clap::error::ErrorKind::ValueValidation).with_cmd(cmd); + if let Some(arg) = arg { + error.insert( + clap::error::ContextKind::InvalidArg, + clap::error::ContextValue::String(arg.to_string()), + ); + } error.insert( - clap::error::ContextKind::InvalidArg, - clap::error::ContextValue::String(arg.to_string()), + clap::error::ContextKind::InvalidValue, + clap::error::ContextValue::String(value.to_string()), ); + error } - error.insert( - clap::error::ContextKind::InvalidValue, - clap::error::ContextValue::String(value.to_string()), - ); - error }) } diff --git a/crates/ruff_linter/src/rules/ruff/rules/external_ast.rs b/crates/ruff_linter/src/rules/ruff/rules/external_ast.rs new file mode 100644 index 0000000000..d46097c83b --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/rules/external_ast.rs @@ -0,0 +1,27 @@ +use ruff_macros::{CacheKey, ViolationMetadata, derive_message_formats}; + +/// Diagnostics surfaced by external AST linters +#[derive(Debug, Clone, PartialEq, Eq, CacheKey, ViolationMetadata)] +#[violation_metadata(stable_since = "v0.0.0")] +pub(crate) struct ExternalLinter { + pub rule_name: String, + pub message: String, +} + +impl ExternalLinter { + #[allow(dead_code)] + pub(crate) fn new(rule_name: impl Into, message: String) -> Self { + Self { + rule_name: rule_name.into(), + message, + } + } +} + +impl crate::Violation for ExternalLinter { + #[derive_message_formats] + fn message(&self) -> String { + let ExternalLinter { rule_name, message } = self; + format!("{rule_name}: {message}") + } +} diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index 9d5ade77f3..d1788a2a6e 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -9,6 +9,7 @@ pub(crate) use dataclass_enum::*; pub(crate) use decimal_from_float_literal::*; pub(crate) use default_factory_kwarg::*; pub(crate) use explicit_f_string_type_conversion::*; +pub(crate) use external_ast::*; pub(crate) use falsy_dict_get_fallback::*; pub(crate) use function_call_in_dataclass_default::*; pub(crate) use if_key_in_dict_del::*; @@ -73,6 +74,7 @@ mod dataclass_enum; mod decimal_from_float_literal; mod default_factory_kwarg; mod explicit_f_string_type_conversion; +mod external_ast; mod falsy_dict_get_fallback; mod function_call_in_dataclass_default; mod if_key_in_dict_del; diff --git a/crates/ruff_server/src/session/options.rs b/crates/ruff_server/src/session/options.rs index dba88c99ae..19c221aa1e 100644 --- a/crates/ruff_server/src/session/options.rs +++ b/crates/ruff_server/src/session/options.rs @@ -187,7 +187,7 @@ impl ClientOptions { for rule in rules { match RuleSelector::from_str(rule) { Ok(selector) => known.push(selector), - Err(ParseError::Unknown(_)) => unknown.push(rule), + Err(ParseError::Unknown(_) | ParseError::External(_)) => unknown.push(rule), } } if !unknown.is_empty() { diff --git a/ruff.schema.json b/ruff.schema.json index 5ef059e122..4a5aca8bb6 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -4052,6 +4052,9 @@ "RUF2", "RUF20", "RUF200", + "RUF3", + "RUF30", + "RUF300", "S", "S1", "S10",