diff --git a/README.md b/README.md index f4093bc..0af3367 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ slopcop path/to/file.py # Quiet mode (exit code only, for CI) slopcop --quiet src/ +# Skip specific rules from the CLI +slopcop --ignore no-print,no-assert src/ + +# Run only specific rules from the CLI +slopcop --select no-print --select no-assert src/ + # JSON output slopcop --format json src/ @@ -39,6 +45,8 @@ slopcop --warn-only src/ Exit codes: `0` = clean, `1` = violations found, `2` = fatal error. +CLI rule filters these semantics: `--select` replaces the default enabled set of all rules, `--ignore` removes rules from the enabled set afterward, and both flags override `[tool.slopcop]` config from `pyproject.toml`. Both flags accept comma-separated values and may be repeated. + ## Rules All rules are **enabled by default**. Disable per-project via `pyproject.toml`. diff --git a/src/main.rs b/src/main.rs index 961fb1f..23b6849 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::path::PathBuf; use std::process; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -20,6 +21,14 @@ struct Cli { #[arg(required = true)] paths: Vec, + /// Rule IDs to run. If provided, only these rules are enabled. + #[arg(long, value_delimiter = ',')] + select: Vec, + + /// Rule IDs to skip after selection/config has been resolved. + #[arg(long, value_delimiter = ',')] + ignore: Vec, + /// Suppress output; only set exit code #[arg(short, long)] quiet: bool, @@ -56,7 +65,7 @@ fn main() { } fn run(cli: &Cli) -> Result { - let config = config::discover_config( + let mut config = config::discover_config( &std::env::current_dir().unwrap_or_else(|_| { cli.paths .first() @@ -65,6 +74,8 @@ fn run(cli: &Cli) -> Result { }), ); + apply_cli_rule_filters(&mut config, &cli.select, &cli.ignore)?; + let files = collect_python_files(&cli.paths)?; if files.is_empty() { @@ -272,3 +283,83 @@ fn collect_python_files(paths: &[PathBuf]) -> Result> { Ok(files) } + +fn apply_cli_rule_filters( + config: &mut config::Config, + select: &[String], + ignore: &[String], +) -> Result<()> { + let known_rule_names = rules::all_rule_names(); + let known_rule_set: BTreeSet<&str> = known_rule_names.iter().copied().collect(); + + let invalid_rule_ids = select + .iter() + .chain(ignore.iter()) + .filter(|rule_id| !known_rule_set.contains(rule_id.as_str())) + .cloned() + .collect::>(); + + if !invalid_rule_ids.is_empty() { + let invalid_rule_ids = invalid_rule_ids.into_iter().collect::>().join(", "); + anyhow::bail!("unknown rule id(s): {invalid_rule_ids}"); + } + + if !select.is_empty() { + let selected_rule_ids: BTreeSet<&str> = select.iter().map(String::as_str).collect(); + config.exclude.extend( + known_rule_names + .into_iter() + .filter(|rule_id| !selected_rule_ids.contains(rule_id)) + .map(str::to_owned), + ); + } + + config.exclude.extend(ignore.iter().cloned()); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::apply_cli_rule_filters; + use slopcop::{Config, lint_source_with_config}; + + #[test] + fn select_runs_only_selected_rules() { + let mut config = Config::default(); + let source = "print(\"hi\")\nassert value"; + + apply_cli_rule_filters(&mut config, &["no-print".to_string()], &[]).unwrap(); + + let diagnostics = lint_source_with_config(source, "src/example.py", &config); + + assert!(diagnostics.iter().any(|diagnostic| diagnostic.rule_id == "no-print")); + assert!(!diagnostics.iter().any(|diagnostic| diagnostic.rule_id == "no-assert")); + } + + #[test] + fn ignore_skips_rule() { + let mut config = Config::default(); + let source = "print(\"hi\")"; + + apply_cli_rule_filters(&mut config, &[], &["no-print".to_string()]).unwrap(); + + let diagnostics = lint_source_with_config(source, "src/example.py", &config); + + assert!(!diagnostics.iter().any(|diagnostic| diagnostic.rule_id == "no-print")); + } + + #[test] + fn unknown_rule_ids_error() { + let mut config = Config::default(); + + let error = apply_cli_rule_filters( + &mut config, + &["no-print".to_string()], + &["not-a-rule".to_string()], + ) + .unwrap_err(); + + assert_eq!(error.to_string(), "unknown rule id(s): not-a-rule"); + } +} diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 5daab9b..edcb7b7 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -80,6 +80,11 @@ pub fn all_rules() -> Vec> { all_rules_with_config(&Config::default()) } +/// Return the full set of known rule IDs. +pub fn all_rule_names() -> Vec<&'static str> { + all_rules().into_iter().map(|rule| rule.name()).collect() +} + /// Return a map from rule_id → help text for all rules, with user overrides applied. pub fn help_texts(config: &Config) -> std::collections::HashMap<&'static str, String> { let rules = all_rules_with_config(config);