diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index b0577a6c2b333c..a8e7d0c9ce7c11 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -1,5 +1,40 @@ # Breaking Changes +## Unreleased + +`--explain`, `--clean` and `--generate-shell-completion` are now +implemented as subcommands: + + ruff . # still works and will always work + ruff check . # now also works + + ruff --explain E402 # still works + ruff explain E402 # now also works + + ruff --format json --explain E402 # no longer works + # the command has to come first: + ruff --explain E402 --format json # or using the new syntax: + ruff explain E402 --format json + +Please also note that: + +* the subcommands will now fail when invoked with unsupported arguments + instead of silently ignoring them, e.g. the following will now fail: + + ruff --explain E402 --respect-gitignore + + Since the `explain` command doesn't support `--respect-gitignore`. + +* The semantics of `ruff ` has changed when `` is a + subcommand, e.g. before `ruff explain` would look for a file or + directory `explain` in the current directory but now it just invokes + the explain command. Note that scripts invoking ruff should supply + `--` anyway before any positional arguments and the semantics of + `ruff -- ` have not changed. + +* `--explain` previously treated `--format grouped` just like `--format text` + (this is no longer supported, use `--format text` instead) + ## 0.0.226 ### `misplaced-comparison-constant` (`PLC2201`) was deprecated in favor of `SIM300` ([#1980](https://github.com/charliermarsh/ruff/pull/1980)) diff --git a/README.md b/README.md index 4bc92a74458633..10d187313db721 100644 --- a/README.md +++ b/README.md @@ -346,77 +346,24 @@ See `ruff --help` for more: ``` Ruff: An extremely fast Python linter. -Usage: ruff [OPTIONS] [FILES]... +Usage: ruff [OPTIONS] -Arguments: - [FILES]... +Commands: + check Run ruff on the given files or directories (this command is used by default and may be omitted) + explain Explain a rule + clean Clear any caches in the current directory or any subdirectories + help Print this message or the help of the given subcommand(s) Options: - --fix Attempt to automatically fix lint violations - --show-source Show violations with source code - --diff Avoid writing any fixed files back; instead, output a diff for each changed file to stdout - -w, --watch Run in watch mode by re-running whenever files change - --fix-only Fix any fixable lint violations, but don't report on leftover violations. Implies `--fix` - --format Output serialization format for violations [env: RUFF_FORMAT=] [possible values: text, json, junit, grouped, github, gitlab, pylint] - --config Path to the `pyproject.toml` or `ruff.toml` file to use for configuration - --add-noqa Enable automatic additions of `noqa` directives to failing lines - --show-files See the files Ruff will be run against with the current settings - --show-settings See the settings Ruff will use to lint a given Python file - -h, --help Print help - -V, --version Print version - -Rule selection: - --select - Comma-separated list of rule codes to enable (or ALL, to enable all rules) - --ignore - Comma-separated list of rule codes to disable - --extend-select - Like --select, but adds additional rule codes on top of the selected ones - --extend-ignore - Like --ignore, but adds additional rule codes on top of the ignored ones - --per-file-ignores - List of mappings from file pattern to code to exclude - --fixable - List of rule codes to treat as eligible for autofix. Only applicable when autofix itself is enabled (e.g., via `--fix`) - --unfixable - List of rule codes to treat as ineligible for autofix. Only applicable when autofix itself is enabled (e.g., via `--fix`) - -File selection: - --exclude List of paths, used to omit files and/or directories from analysis - --extend-exclude Like --exclude, but adds additional files and directories on top of those already excluded - --respect-gitignore Respect file exclusions via `.gitignore` and other standard ignore files - --force-exclude Enforce exclusions, even for paths passed to Ruff directly on the command-line - -Rule configuration: - --target-version - The minimum Python version that should be supported - --line-length - Set the line-length for length-associated rules and automatic formatting - --dummy-variable-rgx - Regular expression matching the name of dummy variables - -Miscellaneous: - -n, --no-cache - Disable cache reads - --isolated - Ignore all configuration files - --cache-dir - Path to the cache directory [env: RUFF_CACHE_DIR=] - --stdin-filename - The name of the file when passing it through stdin - -e, --exit-zero - Exit with status code "0", even upon detecting lint violations - --update-check - Enable or disable automatic update checks - -Subcommands: - --explain Explain a rule - --clean Clear any caches in the current directory or any subdirectories + -h, --help Print help + -V, --version Print version Log levels: -v, --verbose Enable verbose logging -q, --quiet Print lint violations, but nothing else -s, --silent Disable all logging (but still exit with status code "1" upon detecting lint violations) + +To get help about a specific command, see 'ruff help '. ``` diff --git a/ruff_cli/src/args.rs b/ruff_cli/src/args.rs index 49dc8cb2631cdb..d5d632655c416d 100644 --- a/ruff_cli/src/args.rs +++ b/ruff_cli/src/args.rs @@ -15,12 +15,44 @@ use rustc_hash::FxHashMap; #[command( author, name = "ruff", - about = "Ruff: An extremely fast Python linter." + about = "Ruff: An extremely fast Python linter.", + after_help = "To get help about a specific command, see 'ruff help '." )] #[command(version)] -#[allow(clippy::struct_excessive_bools)] pub struct Args { - #[arg(required_unless_present_any = ["clean", "explain", "generate_shell_completion"])] + #[command(subcommand)] + pub command: Command, + #[clap(flatten)] + pub log_level_args: LogLevelArgs, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, clap::Subcommand)] +pub enum Command { + /// Run ruff on the given files or directories (this command is used by + /// default and may be omitted) + Check(CheckArgs), + /// Explain a rule. + #[clap(alias = "--explain")] + Explain { + #[arg(value_parser=Rule::from_code)] + rule: &'static Rule, + + /// Output serialization format for violations. + #[arg(long, value_enum, env = "RUFF_FORMAT", default_value = "text")] + format: HelpFormat, + }, + /// Clear any caches in the current directory or any subdirectories. + #[clap(alias = "--clean")] + Clean, + /// Generate shell completion + #[clap(alias = "--generate-shell-completion", hide = true)] + GenerateShellCompletion { shell: clap_complete_command::Shell }, +} + +#[derive(Debug, clap::Args)] +#[allow(clippy::struct_excessive_bools, clippy::module_name_repetitions)] +pub struct CheckArgs { pub files: Vec, /// Attempt to automatically fix lint violations. #[arg(long, overrides_with("no_fix"))] @@ -183,9 +215,6 @@ pub struct Args { #[arg( long, // conflicts_with = "add_noqa", - conflicts_with = "clean", - conflicts_with = "explain", - conflicts_with = "generate_shell_completion", conflicts_with = "show_files", conflicts_with = "show_settings", // Unsupported default-command arguments. @@ -193,64 +222,11 @@ pub struct Args { conflicts_with = "watch", )] pub add_noqa: bool, - /// Explain a rule. - #[arg( - long, - value_parser=Rule::from_code, - help_heading="Subcommands", - // Fake subcommands. - conflicts_with = "add_noqa", - conflicts_with = "clean", - // conflicts_with = "explain", - conflicts_with = "generate_shell_completion", - conflicts_with = "show_files", - conflicts_with = "show_settings", - // Unsupported default-command arguments. - conflicts_with = "stdin_filename", - conflicts_with = "watch", - )] - pub explain: Option<&'static Rule>, - /// Clear any caches in the current directory or any subdirectories. - #[arg( - long, - help_heading="Subcommands", - // Fake subcommands. - conflicts_with = "add_noqa", - // conflicts_with = "clean", - conflicts_with = "explain", - conflicts_with = "generate_shell_completion", - conflicts_with = "show_files", - conflicts_with = "show_settings", - // Unsupported default-command arguments. - conflicts_with = "stdin_filename", - conflicts_with = "watch", - )] - pub clean: bool, - /// Generate shell completion - #[arg( - long, - hide = true, - value_name = "SHELL", - // Fake subcommands. - conflicts_with = "add_noqa", - conflicts_with = "clean", - conflicts_with = "explain", - // conflicts_with = "generate_shell_completion", - conflicts_with = "show_files", - conflicts_with = "show_settings", - // Unsupported default-command arguments. - conflicts_with = "stdin_filename", - conflicts_with = "watch", - )] - pub generate_shell_completion: Option, /// See the files Ruff will be run against with the current settings. #[arg( long, // Fake subcommands. conflicts_with = "add_noqa", - conflicts_with = "clean", - conflicts_with = "explain", - conflicts_with = "generate_shell_completion", // conflicts_with = "show_files", conflicts_with = "show_settings", // Unsupported default-command arguments. @@ -263,9 +239,6 @@ pub struct Args { long, // Fake subcommands. conflicts_with = "add_noqa", - conflicts_with = "clean", - conflicts_with = "explain", - conflicts_with = "generate_shell_completion", conflicts_with = "show_files", // conflicts_with = "show_settings", // Unsupported default-command arguments. @@ -273,22 +246,44 @@ pub struct Args { conflicts_with = "watch", )] pub show_settings: bool, - #[clap(flatten)] - pub log_level_args: LogLevelArgs, +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum HelpFormat { + Text, + Json, } #[allow(clippy::module_name_repetitions)] #[derive(Debug, clap::Args)] pub struct LogLevelArgs { /// Enable verbose logging. - #[arg(short, long, group = "verbosity", help_heading = "Log levels")] + #[arg( + short, + long, + global = true, + group = "verbosity", + help_heading = "Log levels" + )] pub verbose: bool, /// Print lint violations, but nothing else. - #[arg(short, long, group = "verbosity", help_heading = "Log levels")] + #[arg( + short, + long, + global = true, + group = "verbosity", + help_heading = "Log levels" + )] pub quiet: bool, /// Disable all logging (but still exit with status code "1" upon detecting /// lint violations). - #[arg(short, long, group = "verbosity", help_heading = "Log levels")] + #[arg( + short, + long, + global = true, + group = "verbosity", + help_heading = "Log levels" + )] pub silent: bool, } @@ -306,20 +301,17 @@ impl From<&LogLevelArgs> for LogLevel { } } -impl Args { +impl CheckArgs { /// Partition the CLI into command-line arguments and configuration /// overrides. pub fn partition(self) -> (Arguments, Overrides) { ( Arguments { add_noqa: self.add_noqa, - clean: self.clean, config: self.config, diff: self.diff, exit_zero: self.exit_zero, - explain: self.explain, files: self.files, - generate_shell_completion: self.generate_shell_completion, isolated: self.isolated, no_cache: self.no_cache, show_files: self.show_files, @@ -371,13 +363,10 @@ fn resolve_bool_arg(yes: bool, no: bool) -> Option { #[allow(clippy::struct_excessive_bools)] pub struct Arguments { pub add_noqa: bool, - pub clean: bool, pub config: Option, pub diff: bool, pub exit_zero: bool, - pub explain: Option<&'static Rule>, pub files: Vec, - pub generate_shell_completion: Option, pub isolated: bool, pub no_cache: bool, pub show_files: bool, diff --git a/ruff_cli/src/commands.rs b/ruff_cli/src/commands.rs index 811176b148eaa6..7cbff1e2774b2f 100644 --- a/ruff_cli/src/commands.rs +++ b/ruff_cli/src/commands.rs @@ -18,12 +18,11 @@ use ruff::message::{Location, Message}; use ruff::registry::{Linter, Rule, RuleNamespace}; use ruff::resolver::PyprojectDiscovery; use ruff::settings::flags; -use ruff::settings::types::SerializationFormat; use ruff::{fix, fs, packaging, resolver, warn_user_once, AutofixAvailability, IOError}; use serde::Serialize; use walkdir::WalkDir; -use crate::args::Overrides; +use crate::args::{HelpFormat, Overrides}; use crate::cache; use crate::diagnostics::{lint_path, lint_stdin, Diagnostics}; use crate::iterators::par_iter; @@ -269,10 +268,10 @@ struct Explanation<'a> { } /// Explain a `Rule` to the user. -pub fn explain(rule: &Rule, format: SerializationFormat) -> Result<()> { +pub fn explain(rule: &Rule, format: HelpFormat) -> Result<()> { let (linter, _) = Linter::parse_code(rule.code()).unwrap(); match format { - SerializationFormat::Text | SerializationFormat::Grouped => { + HelpFormat::Text => { println!("{}\n", rule.as_ref()); println!("Code: {} ({})\n", rule.code(), linter.name()); @@ -290,7 +289,7 @@ pub fn explain(rule: &Rule, format: SerializationFormat) -> Result<()> { println!("* {format}"); } } - SerializationFormat::Json => { + HelpFormat::Json => { println!( "{}", serde_json::to_string_pretty(&Explanation { @@ -300,24 +299,12 @@ pub fn explain(rule: &Rule, format: SerializationFormat) -> Result<()> { })? ); } - SerializationFormat::Junit => { - bail!("`--explain` does not support junit format") - } - SerializationFormat::Github => { - bail!("`--explain` does not support GitHub format") - } - SerializationFormat::Gitlab => { - bail!("`--explain` does not support GitLab format") - } - SerializationFormat::Pylint => { - bail!("`--explain` does not support pylint format") - } }; Ok(()) } /// Clear any caches in the current directory or any subdirectories. -pub fn clean(level: &LogLevel) -> Result<()> { +pub fn clean(level: LogLevel) -> Result<()> { for entry in WalkDir::new(&*path_dedot::CWD) .into_iter() .filter_map(Result::ok) @@ -325,7 +312,7 @@ pub fn clean(level: &LogLevel) -> Result<()> { { let cache = entry.path().join(CACHE_DIR_NAME); if cache.is_dir() { - if level >= &LogLevel::Default { + if level >= LogLevel::Default { eprintln!("Removing cache at: {}", fs::relativize_path(&cache).bold()); } remove_dir_all(&cache)?; diff --git a/ruff_cli/src/main.rs b/ruff_cli/src/main.rs index 52b876c5d0c4e3..d55f4189ed2f1d 100644 --- a/ruff_cli/src/main.rs +++ b/ruff_cli/src/main.rs @@ -17,8 +17,8 @@ use ::ruff::resolver::PyprojectDiscovery; use ::ruff::settings::types::SerializationFormat; use ::ruff::{fix, fs, warn_user_once}; use anyhow::Result; -use args::Args; -use clap::{CommandFactory, Parser}; +use args::{Args, CheckArgs, Command}; +use clap::{CommandFactory, Parser, Subcommand}; use colored::Colorize; use notify::{recommended_watcher, RecursiveMode, Watcher}; use printer::{Printer, Violations}; @@ -35,8 +35,32 @@ mod resolve; pub mod updates; fn inner_main() -> Result { + let mut args: Vec<_> = std::env::args_os().collect(); + + // Clap doesn't support default subcommands but we want to run `check` by + // default for convenience and backwards-compatibility, so we just + // preprocess the arguments accordingly before passing them to clap. + if let Some(arg1) = args.get(1).and_then(|s| s.to_str()) { + if !Command::has_subcommand(arg1) + && !arg1 + .strip_prefix("--") + .map(Command::has_subcommand) + .unwrap_or_default() + && arg1 != "-h" + && arg1 != "--help" + && arg1 != "-v" + && arg1 != "--version" + && arg1 != "help" + { + args.insert(1, "check".into()); + } + } + // Extract command-line arguments. - let args = Args::parse(); + let Args { + command, + log_level_args, + } = Args::parse_from(args); let default_panic_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { @@ -53,20 +77,25 @@ quoting the executed command, along with the relevant file contents and `pyproje default_panic_hook(info); })); - let log_level: LogLevel = (&args.log_level_args).into(); + let log_level: LogLevel = (&log_level_args).into(); set_up_logging(&log_level)?; - let (cli, overrides) = args.partition(); + match command { + Command::Explain { rule, format } => commands::explain(rule, format)?, + Command::Clean => commands::clean(log_level)?, + Command::GenerateShellCompletion { shell } => { + shell.generate(&mut Args::command(), &mut io::stdout()); + } - if let Some(shell) = cli.generate_shell_completion { - shell.generate(&mut Args::command(), &mut io::stdout()); - return Ok(ExitCode::SUCCESS); - } - if cli.clean { - commands::clean(&log_level)?; - return Ok(ExitCode::SUCCESS); + Command::Check(args) => return check(args, log_level), } + Ok(ExitCode::SUCCESS) +} + +fn check(args: CheckArgs, log_level: LogLevel) -> Result { + let (cli, overrides) = args.partition(); + // Construct the "default" settings. These are used when no `pyproject.toml` // files are present, or files are injected from outside of the hierarchy. let pyproject_strategy = resolve::resolve( @@ -76,6 +105,14 @@ quoting the executed command, along with the relevant file contents and `pyproje cli.stdin_filename.as_deref(), )?; + if cli.show_settings { + commands::show_settings(&cli.files, &pyproject_strategy, &overrides)?; + return Ok(ExitCode::SUCCESS); + } + if cli.show_files { + commands::show_files(&cli.files, &pyproject_strategy, &overrides)?; + } + // Extract options that are included in `Settings`, but only apply at the top // level. let CliSettings { @@ -89,19 +126,6 @@ quoting the executed command, along with the relevant file contents and `pyproje PyprojectDiscovery::Hierarchical(settings) => settings.cli.clone(), }; - if let Some(rule) = cli.explain { - commands::explain(rule, format)?; - return Ok(ExitCode::SUCCESS); - } - if cli.show_settings { - commands::show_settings(&cli.files, &pyproject_strategy, &overrides)?; - return Ok(ExitCode::SUCCESS); - } - if cli.show_files { - commands::show_files(&cli.files, &pyproject_strategy, &overrides)?; - return Ok(ExitCode::SUCCESS); - } - // Autofix rules are as follows: // - If `--fix` or `--fix-only` is set, always apply fixes to the filesystem (or // print them to stdout, if we're reading from stdin). @@ -135,14 +159,17 @@ quoting the executed command, along with the relevant file contents and `pyproje warn_user_once!("Detected debug build without --no-cache."); } - let printer = Printer::new(&format, &log_level, &autofix, &violations); - if cli.add_noqa { let modifications = commands::add_noqa(&cli.files, &pyproject_strategy, &overrides)?; if modifications > 0 && log_level >= LogLevel::Default { println!("Added {modifications} noqa directives."); } - } else if cli.watch { + return Ok(ExitCode::SUCCESS); + } + + let printer = Printer::new(&format, &log_level, &autofix, &violations); + + if cli.watch { if !matches!(autofix, fix::FixMode::None) { warn_user_once!("--fix is not enabled in watch mode."); } @@ -244,7 +271,6 @@ quoting the executed command, along with the relevant file contents and `pyproje } } } - Ok(ExitCode::SUCCESS) } diff --git a/ruff_cli/tests/integration_test.rs b/ruff_cli/tests/integration_test.rs index e82b774d84b352..ac84f8bfc6a5c0 100644 --- a/ruff_cli/tests/integration_test.rs +++ b/ruff_cli/tests/integration_test.rs @@ -163,8 +163,8 @@ fn test_show_source() -> Result<()> { #[test] fn explain_status_codes() -> Result<()> { let mut cmd = Command::cargo_bin(BIN_NAME)?; - cmd.args(["-", "--explain", "F401"]).assert().success(); + cmd.args(["--explain", "F401"]).assert().success(); let mut cmd = Command::cargo_bin(BIN_NAME)?; - cmd.args(["-", "--explain", "RUF404"]).assert().failure(); + cmd.args(["--explain", "RUF404"]).assert().failure(); Ok(()) } diff --git a/src/logging.rs b/src/logging.rs index dd7558dddd5fe9..97efadb1461c49 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -46,7 +46,7 @@ macro_rules! notify_user { } } -#[derive(Debug, Default, PartialOrd, Ord, PartialEq, Eq)] +#[derive(Debug, Default, PartialOrd, Ord, PartialEq, Eq, Copy, Clone)] pub enum LogLevel { // No output (+ `log::LevelFilter::Off`). Silent, @@ -60,6 +60,7 @@ pub enum LogLevel { } impl LogLevel { + #[allow(clippy::trivially_copy_pass_by_ref)] fn level_filter(&self) -> log::LevelFilter { match self { LogLevel::Default => log::LevelFilter::Info,