diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index b0577a6c2b333c..da5ce97892e582 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -1,5 +1,32 @@ # Breaking Changes +## Unreleased + +The subcommand handling of the CLI has been revised: + +* For backwards-compatibility subcommands can still start with a + double-dash `--`, they now however have to be passed as the first + argument and subcommands now fail when used together with unsupported + argument instead of silently ignoring them. + + For example previously you could run: + + ruff --respect-gitignore --format json --explain E402 + + While the `--explain` command doesn't at all support the + `--respect-gitignore` argument, ruff previously didn't complain, now + it does. With the new synopsis you also have to pass the command + first, so the previous command now becomes: + + ruff --explain E402 --format json + + or, with the new syntax: + + ruff explain E402 --format json + +* `--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 e0e487a34a85cc..46694766768fce 100644 --- a/README.md +++ b/README.md @@ -345,86 +345,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) + add-noqa Automatically add `noqa` directives to failing lines + explain Explain a rule + clean Clear any caches in the current directory or any subdirectories + 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 Options: - --config - Path to the `pyproject.toml` or `ruff.toml` file to use for configuration - -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) - -e, --exit-zero - Exit with status code "0", even upon detecting lint violations - -w, --watch - Run in watch mode by re-running whenever files change - --fix - Attempt to automatically fix lint violations - --fix-only - Fix any fixable lint violations, but don't report on leftover violations. Implies `--fix` - --diff - Avoid writing any fixed files back; instead, output a diff for each changed file to stdout - -n, --no-cache - Disable cache reads - --isolated - Ignore all configuration files - --select - Comma-separated list of rule codes to enable (or ALL, to enable all rules) - --extend-select - Like --select, but adds additional rule codes on top of the selected ones - --ignore - Comma-separated list of rule codes to disable - --extend-ignore - Like --ignore, but adds additional rule codes on top of the ignored ones - --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 - --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`) - --per-file-ignores - List of mappings from file pattern to code to exclude - --format - Output serialization format for violations [env: RUFF_FORMAT=] [possible values: text, json, junit, grouped, github, gitlab, pylint] - --stdin-filename - The name of the file when passing it through stdin - --cache-dir - Path to the cache directory [env: RUFF_CACHE_DIR=] - --show-source - Show violations with source code - --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 - --update-check - Enable or disable automatic update checks - --dummy-variable-rgx - Regular expression matching the name of dummy variables - --target-version - The minimum Python version that should be supported - --line-length - Set the line-length for length-associated rules and automatic formatting - --add-noqa - Enable automatic additions of `noqa` directives to failing lines - --clean - Clear any caches in the current directory or any subdirectories - --explain - Explain a rule - --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 information - -V, --version - Print version information + -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) + -h, --help Print help information + -V, --version Print version information + +To get help about a specific command run --help. ``` diff --git a/ruff_cli/src/args.rs b/ruff_cli/src/args.rs index 5fe8b9b44544e5..1a9a5b387dd93c 100644 --- a/ruff_cli/src/args.rs +++ b/ruff_cli/src/args.rs @@ -1,3 +1,4 @@ +#![allow(clippy::module_name_repetitions)] use std::path::PathBuf; use clap::{command, Parser}; @@ -15,25 +16,90 @@ 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 run --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)] +#[clap(disable_help_subcommand = true)] +pub enum Command { + #[clap(flatten)] + Lint(LintCommand), + /// 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 }, + /// See the files Ruff will be run against with the current settings. + #[clap(alias = "--show-files")] + ShowFiles(CommonOptions), + /// See the settings Ruff will use to lint a given Python file. + #[clap(alias = "--show-settings")] + ShowSettings(CommonOptions), +} + +#[derive(Debug, clap::Subcommand)] +pub enum LintCommand { + /// Run ruff on the given files or directories (this command is used by + /// default and may be omitted) + Check { + #[clap(flatten)] + options: CommonOptions, + #[clap(flatten)] + check_only_args: CheckOnlyArgs, + }, + /// Automatically add `noqa` directives to failing lines. + #[clap(alias = "--add-noqa")] + AddNoqa(CommonOptions), +} + +#[derive(Debug, clap::Args)] +pub struct CheckOnlyArgs { + /// Run in watch mode by re-running whenever files change. + #[arg(short, long)] + pub watch: bool, + /// The name of the file when passing it through stdin. + #[arg(long)] + pub stdin_filename: Option, +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum HelpFormat { + Text, + Json, +} + +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, clap::Args)] +pub struct CommonOptions { + #[arg(required = true)] pub files: Vec, /// Path to the `pyproject.toml` or `ruff.toml` file to use for /// configuration. #[arg(long, conflicts_with = "isolated")] pub config: Option, - #[clap(flatten)] - pub log_level_args: LogLevelArgs, /// Exit with status code "0", even upon detecting lint violations. #[arg(short, long)] pub exit_zero: bool, - /// Run in watch mode by re-running whenever files change. - #[arg(short, long)] - pub watch: bool, /// Attempt to automatically fix lint violations. #[arg(long, overrides_with("no_fix"))] fix: bool, @@ -91,9 +157,6 @@ pub struct Args { /// Output serialization format for violations. #[arg(long, value_enum, env = "RUFF_FORMAT")] pub format: Option, - /// The name of the file when passing it through stdin. - #[arg(long)] - pub stdin_filename: Option, /// Path to the cache directory. #[arg(long, env = "RUFF_CACHE_DIR")] pub cache_dir: Option, @@ -129,101 +192,8 @@ pub struct Args { /// formatting. #[arg(long)] pub line_length: Option, - /// Enable automatic additions of `noqa` directives to failing lines. - #[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. - conflicts_with = "stdin_filename", - conflicts_with = "watch", - )] - pub add_noqa: bool, - /// Clear any caches in the current directory or any subdirectories. - #[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. - conflicts_with = "stdin_filename", - conflicts_with = "watch", - )] - pub clean: bool, - /// Explain a rule. - #[arg( - long, - value_parser=Rule::from_code, - // 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>, - /// 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. - conflicts_with = "stdin_filename", - conflicts_with = "watch", - )] - pub show_files: bool, - /// See the settings Ruff will use to lint a given Python file. - #[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. - conflicts_with = "stdin_filename", - conflicts_with = "watch", - )] - pub show_settings: bool, } -#[allow(clippy::module_name_repetitions)] #[derive(Debug, clap::Args)] pub struct LogLevelArgs { /// Enable verbose logging. @@ -252,26 +222,18 @@ impl From<&LogLevelArgs> for LogLevel { } } -impl Args { +impl CommonOptions { /// 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, - show_settings: self.show_settings, - stdin_filename: self.stdin_filename, - watch: self.watch, }, Overrides { dummy_variable_rgx: self.dummy_variable_rgx, @@ -316,20 +278,12 @@ fn resolve_bool_arg(yes: bool, no: bool) -> Option { /// etc.). #[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, - pub show_settings: bool, - pub stdin_filename: Option, - pub watch: bool, } /// CLI settings that function as configuration overrides. diff --git a/ruff_cli/src/commands.rs b/ruff_cli/src/commands.rs index 734d2a02f5c958..bf3457fb88410d 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 2044e6669d0678..f548525e7c2910 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, CheckOnlyArgs, Command, LintCommand}; +use clap::{CommandFactory, Parser, Subcommand}; use colored::Colorize; use notify::{recommended_watcher, RecursiveMode, Watcher}; use printer::{Printer, Violations}; @@ -35,8 +35,31 @@ mod resolve; pub mod updates; pub fn 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 backwards-compatibility, so we just preprocess the arguments + // accordingly before passing them to clap. + if args + .get(1) + .and_then(|s| s.to_str()) + .filter(|s| { + Command::has_subcommand(s) + || *s == "-h" + || *s == "--help" + || *s == "-v" + || *s == "--version" + }) + .is_none() + { + 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,27 +76,56 @@ 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()); + } + Command::ShowFiles(options) => { + let (cli, overrides) = options.partition(); - 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); + let pyproject_strategy = + resolve::resolve(cli.isolated, cli.config.as_deref(), &overrides, None)?; + commands::show_files(&cli.files, &pyproject_strategy, &overrides)?; + } + Command::ShowSettings(options) => { + let (cli, overrides) = options.partition(); + + let pyproject_strategy = + resolve::resolve(cli.isolated, cli.config.as_deref(), &overrides, None)?; + commands::show_settings(&cli.files, &pyproject_strategy, &overrides)?; + } + Command::Lint(command) => return lint(command, log_level), } + Ok(ExitCode::SUCCESS) +} + +fn lint(command: LintCommand, log_level: LogLevel) -> Result { + let (options, check_only_args) = match command { + LintCommand::Check { + options, + check_only_args, + .. + } => (options, Some(check_only_args)), + LintCommand::AddNoqa(options) => (options, None), + }; + + let (cli, overrides) = options.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( cli.isolated, cli.config.as_deref(), &overrides, - cli.stdin_filename.as_deref(), + check_only_args + .as_ref() + .and_then(|args| args.stdin_filename.as_deref()), )?; // Extract options that are included in `Settings`, but only apply at the top @@ -89,19 +141,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). @@ -137,12 +176,17 @@ quoting the executed command, along with the relevant file contents and `pyproje let printer = Printer::new(&format, &log_level, &autofix, &violations); - if cli.add_noqa { + let Some(CheckOnlyArgs { watch, stdin_filename }) = check_only_args else { + // The subcommand is --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); + }; + + if watch { if !matches!(autofix, fix::FixMode::None) { warn_user_once!("--fix is not enabled in watch mode."); } @@ -202,7 +246,7 @@ quoting the executed command, along with the relevant file contents and `pyproje // Generate lint violations. let diagnostics = if is_stdin { commands::run_stdin( - cli.stdin_filename.map(fs::normalize_path).as_deref(), + stdin_filename.map(fs::normalize_path).as_deref(), &pyproject_strategy, &overrides, autofix, @@ -244,6 +288,5 @@ 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 78d158560dd1a7..6d0917db1fbc6f 100644 --- a/ruff_cli/tests/integration_test.rs +++ b/ruff_cli/tests/integration_test.rs @@ -155,8 +155,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,