diff --git a/crates/ruff/src/args.rs b/crates/ruff/src/args.rs index da6d3833b7..088576bbc3 100644 --- a/crates/ruff/src/args.rs +++ b/crates/ruff/src/args.rs @@ -246,12 +246,40 @@ pub struct CheckCommand { /// Output serialization format for violations. /// The default serialization format is "full". + /// + /// Note: For multiple output formats, use --output-file with format:target syntax instead. + /// Example: --output-file gitlab:gl-code-quality.json --output-file full:stdout #[arg(long, value_enum, env = "RUFF_OUTPUT_FORMAT")] pub output_format: Option, - /// Specify file to write the linter output to (default: stdout). - #[arg(short, long, env = "RUFF_OUTPUT_FILE")] - pub output_file: Option, + /// Specify output destination(s) using format:target syntax. + /// Can be specified multiple times for multiple outputs. + /// Also supports comma-separated values: --output-file gitlab:file.json,full:stdout + /// + /// Examples: + /// --output-file gitlab:gl-code-quality.json + /// --output-file full:stdout + /// --output-file concise:stderr + /// --output-file gitlab:gl-code-quality.json --output-file full:stdout + /// --output-file gitlab:file.json,full:stdout + /// + /// Environment variable `RUFF_OUTPUT_FILE` also supports comma-separated values: + /// RUFF_OUTPUT_FILE="gitlab:gl-code-quality.json,full:stdout" + /// + /// Targets can be: stdout, stderr, or a file path. + /// Formats: full, concise, grouped, json, json-lines, junit, github, gitlab, pylint, rdjson, azure, sarif + /// + /// Legacy: If used with --output-format (without format: prefix), writes that format to the specified file. + #[arg( + short, + long, + action = clap::ArgAction::Append, + value_parser = OutputTargetPairParser, + env = "RUFF_OUTPUT_FILE", + value_delimiter = ',', + value_name = "FORMAT:TARGET" + )] + pub output_file: Vec, /// The minimum Python version that should be supported. #[arg(long, value_enum)] pub target_version: Option, @@ -746,6 +774,7 @@ impl CheckCommand { ignore_noqa: self.ignore_noqa, no_cache: self.no_cache, output_file: self.output_file, + output_format: self.output_format, show_files: self.show_files, show_settings: self.show_settings, statistics: self.statistics, @@ -898,6 +927,136 @@ impl InvalidConfigFlagReason { } } +/// Represents a destination for output (stdout, stderr, or a file path). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OutputTarget { + Stdout, + Stderr, + File(PathBuf), +} + +/// Represents a format:target pair for output (e.g., "gitlab:gl-code-quality.json" or "full:stdout"). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OutputTargetPair { + pub format: OutputFormat, + pub target: OutputTarget, +} + +/// Parser for output target pairs in the format "FORMAT:target". +/// Examples: +/// - "gitlab:gl-code-quality.json" -> `OutputFormat::Gitlab`, `OutputTarget::File("gl-code-quality.json`") +/// - "full:stdout" -> `OutputFormat::Full`, `OutputTarget::Stdout` +/// - "concise:stderr" -> `OutputFormat::Concise`, `OutputTarget::Stderr` +#[derive(Clone)] +pub struct OutputTargetPairParser; + +/// Represents either a format:target pair or a plain path (for backward compatibility). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OutputTargetPairOrPath { + Pair(OutputTargetPair), + Path(PathBuf), +} + +impl TypedValueParser for OutputTargetPairParser { + type Value = OutputTargetPairOrPath; + + fn parse_ref( + &self, + cmd: &clap::Command, + arg: Option<&clap::Arg>, + value: &std::ffi::OsStr, + ) -> Result { + let value = value + .to_str() + .ok_or_else(|| clap::Error::new(clap::error::ErrorKind::InvalidUtf8))?; + + // If it contains a colon, parse as format:target + if let Some((format_str, target_str)) = value.split_once(':') { + // Parse the format using clap's ValueEnum parsing + // We need to handle both kebab-case (from clap) and the display format + let format = match format_str { + "full" => OutputFormat::Full, + "concise" => OutputFormat::Concise, + "grouped" => OutputFormat::Grouped, + "json" => OutputFormat::Json, + "json-lines" | "json_lines" => OutputFormat::JsonLines, + "junit" => OutputFormat::Junit, + "github" => OutputFormat::Github, + "gitlab" => OutputFormat::Gitlab, + "pylint" => OutputFormat::Pylint, + "rdjson" => OutputFormat::Rdjson, + "azure" => OutputFormat::Azure, + "sarif" => OutputFormat::Sarif, + _ => { + 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::InvalidValue, + clap::error::ContextValue::String(format!( + "Invalid output format '{}'. Valid formats: {}", + format_str, + [ + "full", + "concise", + "grouped", + "json", + "json-lines", + "junit", + "github", + "gitlab", + "pylint", + "rdjson", + "azure", + "sarif" + ] + .join(", ") + )), + ); + return Err(error); + } + }; + + let target = match target_str { + "stdout" => OutputTarget::Stdout, + "stderr" => OutputTarget::Stderr, + path => { + // Expand environment variables and tildes + let expanded_path = shellexpand::full(path) + .map(|expanded| PathBuf::from(expanded.as_ref())) + .unwrap_or_else(|_| PathBuf::from(path)); + OutputTarget::File(expanded_path) + } + }; + + Ok(OutputTargetPairOrPath::Pair(OutputTargetPair { + format, + target, + })) + } else { + // No colon: treat as a plain path (for backward compatibility with --output-format) + // Expand environment variables and tildes + let expanded_path = shellexpand::full(value) + .map(|expanded| PathBuf::from(expanded.as_ref())) + .unwrap_or_else(|_| PathBuf::from(value)); + Ok(OutputTargetPairOrPath::Path(expanded_path)) + } + } +} + +impl ValueParserFactory for OutputTargetPairOrPath { + type Parser = OutputTargetPairParser; + + fn value_parser() -> Self::Parser { + OutputTargetPairParser + } +} + /// Enumeration to represent a single `--config` argument /// passed via the CLI. /// @@ -1080,7 +1239,8 @@ pub struct CheckArguments { pub files: Vec, pub ignore_noqa: bool, pub no_cache: bool, - pub output_file: Option, + pub output_file: Vec, + pub output_format: Option, pub show_files: bool, pub show_settings: bool, pub statistics: bool, @@ -1088,6 +1248,56 @@ pub struct CheckArguments { pub watch: bool, } +impl CheckArguments { + /// Resolve output targets, handling backward compatibility with old --output-format and --output-file flags. + /// Returns a list of format:target pairs. + pub fn resolve_output_targets(&self) -> anyhow::Result> { + let mut targets = Vec::new(); + + // Process output_file entries + for entry in &self.output_file { + match entry { + OutputTargetPairOrPath::Pair(pair) => { + targets.push(pair.clone()); + } + OutputTargetPairOrPath::Path(path) => { + // Backward compatibility: if --output-format is set, use it with this path + if let Some(format) = self.output_format { + targets.push(OutputTargetPair { + format, + target: OutputTarget::File(path.clone()), + }); + } else { + return Err(anyhow::anyhow!( + "Cannot use plain file path '{}' without --output-format. Use format:target syntax instead (e.g., 'full:{}').", + path.display(), + path.display() + )); + } + } + } + } + + // If no output_file entries but --output-format is specified, default to stdout + if targets.is_empty() { + if let Some(format) = self.output_format { + targets.push(OutputTargetPair { + format, + target: OutputTarget::Stdout, + }); + } else { + // Default: full format to stdout + targets.push(OutputTargetPair { + format: OutputFormat::Full, + target: OutputTarget::Stdout, + }); + } + } + + Ok(targets) + } +} + /// CLI settings that are distinct from configuration (commands, lists of files, /// etc.). #[expect(clippy::struct_excessive_bools)] diff --git a/crates/ruff/src/lib.rs b/crates/ruff/src/lib.rs index 3ecefd6dde..a7c6acbe06 100644 --- a/crates/ruff/src/lib.rs +++ b/crates/ruff/src/lib.rs @@ -21,7 +21,7 @@ use ruff_linter::{fs, warn_user, warn_user_once}; use ruff_workspace::Settings; use crate::args::{ - AnalyzeCommand, AnalyzeGraphCommand, Args, CheckCommand, Command, FormatCommand, + AnalyzeCommand, AnalyzeGraphCommand, Args, CheckCommand, Command, FormatCommand, OutputTarget, }; use crate::printer::{Flags as PrinterFlags, Printer}; @@ -240,23 +240,45 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result = match cli.output_file { - Some(path) if !cli.watch => { - colored::control::set_override(false); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let file = File::create(path)?; - Box::new(BufWriter::new(file)) - } - _ => Box::new(BufWriter::new(io::stdout())), + // Resolve output targets (handles backward compatibility) + let output_targets = cli.resolve_output_targets()?; + + // Helper function to create a default writer for show_settings/show_files + let create_default_writer = || -> Box { + output_targets + .iter() + .find(|t| t.format.is_human_readable()) + .or(output_targets.first()) + .map(|t| -> Box { + match &t.target { + OutputTarget::Stdout => Box::new(BufWriter::new(io::stdout())), + OutputTarget::Stderr => Box::new(BufWriter::new(io::stderr())), + OutputTarget::File(path) => { + if !cli.watch { + colored::control::set_override(false); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(file) = File::create(path) { + Box::new(BufWriter::new(file)) + } else { + Box::new(BufWriter::new(io::stdout())) + } + } else { + Box::new(BufWriter::new(io::stdout())) + } + } + } + }) + .unwrap_or_else(|| Box::new(BufWriter::new(io::stdout()))) }; - let stderr_writer = Box::new(BufWriter::new(io::stderr())); let is_stdin = is_stdin(&cli.files, cli.stdin_filename.as_deref()); let files = resolve_default_files(cli.files, is_stdin); if cli.show_settings { + // For show_settings, use stdout (these are informational commands) + let mut writer = BufWriter::new(io::stdout()); commands::show_settings::show_settings( &files, &pyproject_config, @@ -266,6 +288,8 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result Result Result Result Result Result Result Result return Err(err.into()), } @@ -444,18 +474,79 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result = match &target_pair.target { + OutputTarget::Stdout => Box::new(BufWriter::new(io::stdout())), + OutputTarget::Stderr => Box::new(BufWriter::new(io::stderr())), + OutputTarget::File(path) => { + colored::control::set_override(false); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let file = File::create(path)?; + Box::new(BufWriter::new(file)) + } + }; + + let target_printer = Printer::new( + target_pair.format, + config_arguments.log_level, + fix_mode, + unsafe_fixes, + printer_flags, + ); + + if cli.statistics { + target_printer.write_statistics(&diagnostics, &mut *writer)?; + } else { + target_printer.write_once(&diagnostics, &mut *writer, preview)?; + } + } + + // Special handling for stdin with fixes: write summary to stderr + if is_stdin_with_fixes { + // Check if we have a stderr target, otherwise create a temporary one for the summary + let has_stderr_target = output_targets + .iter() + .any(|t| matches!(t.target, OutputTarget::Stderr)); + if !has_stderr_target { + // Write summary to stderr (old behavior) + let mut stderr_writer = BufWriter::new(io::stderr()); + // Find the format to use for the summary (prefer human-readable, or first format) + let summary_format = output_targets + .iter() + .find(|t| t.format.is_human_readable()) + .or(output_targets.first()) + .map(|t| t.format) + .unwrap_or(OutputFormat::Full); + + let summary_printer = Printer::new( + summary_format, + config_arguments.log_level, + fix_mode, + unsafe_fixes, + printer_flags, + ); + + if cli.statistics { + summary_printer.write_statistics(&diagnostics, &mut stderr_writer)?; + } else { + summary_printer.write_once(&diagnostics, &mut stderr_writer, preview)?; + } + } } if !cli.exit_zero { diff --git a/crates/ruff/tests/cli/lint.rs b/crates/ruff/tests/cli/lint.rs index a86d8e81be..874ff5b014 100644 --- a/crates/ruff/tests/cli/lint.rs +++ b/crates/ruff/tests/cli/lint.rs @@ -3876,3 +3876,161 @@ fn supported_file_extensions_preview_enabled() -> Result<()> { "); Ok(()) } + +#[test] +fn multiple_output_formats() -> Result<()> { + let test = CliTest::new()?; + test.write_file("test.py", "import os # F401\n")?; + + let output = test + .command() + .args([ + "check", + "--no-cache", + "--output-file", + "gitlab:gl-code-quality.json", + "--output-file", + "full:stdout", + "--select", + "F401", + "test.py", + ]) + .output()?; + + // Check that stdout contains the full format output + let stdout = str::from_utf8(&output.stdout)?; + // Full format shows diagnostics differently than concise format + assert!( + stdout.contains("F401") && stdout.contains("test.py"), + "Expected full format output with F401 and test.py. Got: {stdout}" + ); + assert!(stdout.contains("Found") || stdout.contains("All checks passed")); + + // Check that the GitLab format file was created + let gitlab_content = fs::read_to_string(test.root().join("gl-code-quality.json"))?; + assert!(gitlab_content.contains("check_name")); + assert!(gitlab_content.contains("F401")); + + Ok(()) +} + +#[test] +fn multiple_output_formats_env_var_comma_separated() -> Result<()> { + let test = CliTest::new()?; + test.write_file("test.py", "import os # F401\n")?; + + let output = test + .command() + .env( + "RUFF_OUTPUT_FILE", + "gitlab:gl-code-quality.json,full:stdout", + ) + .args(["check", "--no-cache", "--select", "F401", "test.py"]) + .output()?; + + // Check that stdout contains the full format output + let stdout = str::from_utf8(&output.stdout)?; + assert!( + stdout.contains("F401") && stdout.contains("test.py"), + "Expected full format output with F401 and test.py. Got: {stdout}" + ); + + // Check that the GitLab format file was created + let gitlab_content = fs::read_to_string(test.root().join("gl-code-quality.json"))?; + assert!(gitlab_content.contains("check_name")); + assert!(gitlab_content.contains("F401")); + + Ok(()) +} + +#[test] +fn multiple_output_formats_cli_comma_separated() -> Result<()> { + let test = CliTest::new()?; + test.write_file("test.py", "import os # F401\n")?; + + let output = test + .command() + .args([ + "check", + "--no-cache", + "--output-file", + "gitlab:gl-code-quality.json,full:stdout", + "--select", + "F401", + "test.py", + ]) + .output()?; + + // Check that stdout contains the full format output + let stdout = str::from_utf8(&output.stdout)?; + assert!( + stdout.contains("F401") && stdout.contains("test.py"), + "Expected full format output with F401 and test.py. Got: {stdout}" + ); + + // Check that the GitLab format file was created + let gitlab_content = fs::read_to_string(test.root().join("gl-code-quality.json"))?; + assert!(gitlab_content.contains("check_name")); + assert!(gitlab_content.contains("F401")); + + Ok(()) +} + +#[test] +fn multiple_output_formats_stdout_stderr() -> Result<()> { + let test = CliTest::new()?; + test.write_file("test.py", "import os # F401\n")?; + + let output = test + .command() + .args([ + "check", + "--no-cache", + "--output-file", + "concise:stdout", + "--output-file", + "json:stderr", + "--select", + "F401", + "test.py", + ]) + .output()?; + + // Check that stdout contains concise format + let stdout = str::from_utf8(&output.stdout)?; + assert!(stdout.contains("test.py:1:8: F401")); + + // Check that stderr contains JSON format + let stderr = str::from_utf8(&output.stderr)?; + assert!(stderr.contains("\"code\"")); + assert!(stderr.contains("F401")); + + Ok(()) +} + +#[test] +fn backward_compatibility_output_format_file() -> Result<()> { + let test = CliTest::new()?; + test.write_file("test.py", "import os # F401\n")?; + + test.command() + .args([ + "check", + "--no-cache", + "--output-format", + "gitlab", + "--output-file", + "gl-code-quality.json", + "--select", + "F401", + "test.py", + ]) + .output()?; + + // Check that the GitLab format file was created + let gitlab_content = fs::read_to_string(test.root().join("gl-code-quality.json"))?; + assert!(gitlab_content.contains("check_name")); + assert!(gitlab_content.contains("F401")); + + Ok(()) +} diff --git a/docs/configuration.md b/docs/configuration.md index a88f71488f..cb78147bee 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -605,9 +605,11 @@ Options: format is "full" [env: RUFF_OUTPUT_FORMAT=] [possible values: concise, full, json, json-lines, junit, grouped, github, gitlab, pylint, rdjson, azure, sarif] - -o, --output-file - Specify file to write the linter output to (default: stdout) [env: - RUFF_OUTPUT_FILE=] + -o, --output-file + Specify output destination(s) using format:target syntax. Can be + specified multiple times for multiple outputs. Also supports + comma-separated values: --output-file gitlab:file.json,full:stdout + [env: RUFF_OUTPUT_FILE=] --target-version The minimum Python version that should be supported [possible values: py37, py38, py39, py310, py311, py312, py313, py314]