mirror of https://github.com/astral-sh/ruff
Merge f5d4d2d119 into b0bc990cbf
This commit is contained in:
commit
38e1a49916
|
|
@ -246,12 +246,40 @@ pub struct CheckCommand {
|
||||||
|
|
||||||
/// Output serialization format for violations.
|
/// Output serialization format for violations.
|
||||||
/// The default serialization format is "full".
|
/// 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")]
|
#[arg(long, value_enum, env = "RUFF_OUTPUT_FORMAT")]
|
||||||
pub output_format: Option<OutputFormat>,
|
pub output_format: Option<OutputFormat>,
|
||||||
|
|
||||||
/// Specify file to write the linter output to (default: stdout).
|
/// Specify output destination(s) using format:target syntax.
|
||||||
#[arg(short, long, env = "RUFF_OUTPUT_FILE")]
|
/// Can be specified multiple times for multiple outputs.
|
||||||
pub output_file: Option<PathBuf>,
|
/// 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<OutputTargetPairOrPath>,
|
||||||
/// The minimum Python version that should be supported.
|
/// The minimum Python version that should be supported.
|
||||||
#[arg(long, value_enum)]
|
#[arg(long, value_enum)]
|
||||||
pub target_version: Option<PythonVersion>,
|
pub target_version: Option<PythonVersion>,
|
||||||
|
|
@ -746,6 +774,7 @@ impl CheckCommand {
|
||||||
ignore_noqa: self.ignore_noqa,
|
ignore_noqa: self.ignore_noqa,
|
||||||
no_cache: self.no_cache,
|
no_cache: self.no_cache,
|
||||||
output_file: self.output_file,
|
output_file: self.output_file,
|
||||||
|
output_format: self.output_format,
|
||||||
show_files: self.show_files,
|
show_files: self.show_files,
|
||||||
show_settings: self.show_settings,
|
show_settings: self.show_settings,
|
||||||
statistics: self.statistics,
|
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<Self::Value, clap::Error> {
|
||||||
|
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
|
/// Enumeration to represent a single `--config` argument
|
||||||
/// passed via the CLI.
|
/// passed via the CLI.
|
||||||
///
|
///
|
||||||
|
|
@ -1080,7 +1239,8 @@ pub struct CheckArguments {
|
||||||
pub files: Vec<PathBuf>,
|
pub files: Vec<PathBuf>,
|
||||||
pub ignore_noqa: bool,
|
pub ignore_noqa: bool,
|
||||||
pub no_cache: bool,
|
pub no_cache: bool,
|
||||||
pub output_file: Option<PathBuf>,
|
pub output_file: Vec<OutputTargetPairOrPath>,
|
||||||
|
pub output_format: Option<OutputFormat>,
|
||||||
pub show_files: bool,
|
pub show_files: bool,
|
||||||
pub show_settings: bool,
|
pub show_settings: bool,
|
||||||
pub statistics: bool,
|
pub statistics: bool,
|
||||||
|
|
@ -1088,6 +1248,56 @@ pub struct CheckArguments {
|
||||||
pub watch: bool,
|
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<Vec<OutputTargetPair>> {
|
||||||
|
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,
|
/// CLI settings that are distinct from configuration (commands, lists of files,
|
||||||
/// etc.).
|
/// etc.).
|
||||||
#[expect(clippy::struct_excessive_bools)]
|
#[expect(clippy::struct_excessive_bools)]
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ use ruff_linter::{fs, warn_user, warn_user_once};
|
||||||
use ruff_workspace::Settings;
|
use ruff_workspace::Settings;
|
||||||
|
|
||||||
use crate::args::{
|
use crate::args::{
|
||||||
AnalyzeCommand, AnalyzeGraphCommand, Args, CheckCommand, Command, FormatCommand,
|
AnalyzeCommand, AnalyzeGraphCommand, Args, CheckCommand, Command, FormatCommand, OutputTarget,
|
||||||
};
|
};
|
||||||
use crate::printer::{Flags as PrinterFlags, Printer};
|
use crate::printer::{Flags as PrinterFlags, Printer};
|
||||||
|
|
||||||
|
|
@ -240,23 +240,45 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
|
||||||
// files are present, or files are injected from outside of the hierarchy.
|
// files are present, or files are injected from outside of the hierarchy.
|
||||||
let pyproject_config = resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?;
|
let pyproject_config = resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?;
|
||||||
|
|
||||||
let mut writer: Box<dyn Write> = match cli.output_file {
|
// Resolve output targets (handles backward compatibility)
|
||||||
Some(path) if !cli.watch => {
|
let output_targets = cli.resolve_output_targets()?;
|
||||||
colored::control::set_override(false);
|
|
||||||
if let Some(parent) = path.parent() {
|
// Helper function to create a default writer for show_settings/show_files
|
||||||
std::fs::create_dir_all(parent)?;
|
let create_default_writer = || -> Box<dyn Write> {
|
||||||
}
|
output_targets
|
||||||
let file = File::create(path)?;
|
.iter()
|
||||||
Box::new(BufWriter::new(file))
|
.find(|t| t.format.is_human_readable())
|
||||||
}
|
.or(output_targets.first())
|
||||||
_ => Box::new(BufWriter::new(io::stdout())),
|
.map(|t| -> Box<dyn Write> {
|
||||||
|
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 is_stdin = is_stdin(&cli.files, cli.stdin_filename.as_deref());
|
||||||
let files = resolve_default_files(cli.files, is_stdin);
|
let files = resolve_default_files(cli.files, is_stdin);
|
||||||
|
|
||||||
if cli.show_settings {
|
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(
|
commands::show_settings::show_settings(
|
||||||
&files,
|
&files,
|
||||||
&pyproject_config,
|
&pyproject_config,
|
||||||
|
|
@ -266,6 +288,8 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
|
||||||
return Ok(ExitStatus::Success);
|
return Ok(ExitStatus::Success);
|
||||||
}
|
}
|
||||||
if cli.show_files {
|
if cli.show_files {
|
||||||
|
// For show_files, use stdout (these are informational commands)
|
||||||
|
let mut writer = BufWriter::new(io::stdout());
|
||||||
commands::show_files::show_files(
|
commands::show_files::show_files(
|
||||||
&files,
|
&files,
|
||||||
&pyproject_config,
|
&pyproject_config,
|
||||||
|
|
@ -281,7 +305,7 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
|
||||||
fix,
|
fix,
|
||||||
fix_only,
|
fix_only,
|
||||||
unsafe_fixes,
|
unsafe_fixes,
|
||||||
output_format,
|
output_format: _,
|
||||||
show_fixes,
|
show_fixes,
|
||||||
..
|
..
|
||||||
} = pyproject_config.settings;
|
} = pyproject_config.settings;
|
||||||
|
|
@ -343,14 +367,6 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
|
||||||
return Ok(ExitStatus::Success);
|
return Ok(ExitStatus::Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
let printer = Printer::new(
|
|
||||||
output_format,
|
|
||||||
config_arguments.log_level,
|
|
||||||
fix_mode,
|
|
||||||
unsafe_fixes,
|
|
||||||
printer_flags,
|
|
||||||
);
|
|
||||||
|
|
||||||
// the settings should already be combined with the CLI overrides at this point
|
// the settings should already be combined with the CLI overrides at this point
|
||||||
// TODO(jane): let's make this `PreviewMode`
|
// TODO(jane): let's make this `PreviewMode`
|
||||||
// TODO: this should reference the global preview mode once https://github.com/astral-sh/ruff/issues/8232
|
// TODO: this should reference the global preview mode once https://github.com/astral-sh/ruff/issues/8232
|
||||||
|
|
@ -358,7 +374,11 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
|
||||||
let preview = pyproject_config.settings.linter.preview.is_enabled();
|
let preview = pyproject_config.settings.linter.preview.is_enabled();
|
||||||
|
|
||||||
if cli.watch {
|
if cli.watch {
|
||||||
if output_format != OutputFormat::default() {
|
if !output_targets.is_empty()
|
||||||
|
&& output_targets
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.format != OutputFormat::default())
|
||||||
|
{
|
||||||
warn_user!(
|
warn_user!(
|
||||||
"`--output-format {}` is always used in watch mode.",
|
"`--output-format {}` is always used in watch mode.",
|
||||||
OutputFormat::default()
|
OutputFormat::default()
|
||||||
|
|
@ -377,7 +397,15 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
|
||||||
|
|
||||||
// Perform an initial run instantly.
|
// Perform an initial run instantly.
|
||||||
Printer::clear_screen()?;
|
Printer::clear_screen()?;
|
||||||
printer.write_to_user("Starting linter in watch mode...\n");
|
// Create a printer for watch mode (always uses default format)
|
||||||
|
let watch_printer = Printer::new(
|
||||||
|
OutputFormat::default(),
|
||||||
|
config_arguments.log_level,
|
||||||
|
fix_mode,
|
||||||
|
unsafe_fixes,
|
||||||
|
printer_flags,
|
||||||
|
);
|
||||||
|
watch_printer.write_to_user("Starting linter in watch mode...\n");
|
||||||
|
|
||||||
let diagnostics = commands::check::check(
|
let diagnostics = commands::check::check(
|
||||||
&files,
|
&files,
|
||||||
|
|
@ -388,7 +416,8 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
|
||||||
fix_mode,
|
fix_mode,
|
||||||
unsafe_fixes,
|
unsafe_fixes,
|
||||||
)?;
|
)?;
|
||||||
printer.write_continuously(&mut writer, &diagnostics, preview)?;
|
let mut watch_writer = create_default_writer();
|
||||||
|
watch_printer.write_continuously(&mut *watch_writer, &diagnostics, preview)?;
|
||||||
|
|
||||||
// In watch mode, we may need to re-resolve the configuration.
|
// In watch mode, we may need to re-resolve the configuration.
|
||||||
// TODO(charlie): Re-compute other derivative values, like the `printer`.
|
// TODO(charlie): Re-compute other derivative values, like the `printer`.
|
||||||
|
|
@ -406,7 +435,7 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
|
||||||
resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?;
|
resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?;
|
||||||
}
|
}
|
||||||
Printer::clear_screen()?;
|
Printer::clear_screen()?;
|
||||||
printer.write_to_user("File change detected...\n");
|
watch_printer.write_to_user("File change detected...\n");
|
||||||
|
|
||||||
let diagnostics = commands::check::check(
|
let diagnostics = commands::check::check(
|
||||||
&files,
|
&files,
|
||||||
|
|
@ -417,7 +446,8 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
|
||||||
fix_mode,
|
fix_mode,
|
||||||
unsafe_fixes,
|
unsafe_fixes,
|
||||||
)?;
|
)?;
|
||||||
printer.write_continuously(&mut writer, &diagnostics, preview)?;
|
let mut watch_writer = create_default_writer();
|
||||||
|
watch_printer.write_continuously(&mut *watch_writer, &diagnostics, preview)?;
|
||||||
}
|
}
|
||||||
Err(err) => return Err(err.into()),
|
Err(err) => return Err(err.into()),
|
||||||
}
|
}
|
||||||
|
|
@ -444,18 +474,79 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Always try to print violations (though the printer itself may suppress output)
|
// Write to all output targets
|
||||||
// If we're writing fixes via stdin, the transformed source code goes to the writer
|
// Special case: If we're writing fixes via stdin, the transformed source code goes to stdout,
|
||||||
// so send the summary to stderr instead
|
// so we need to handle summary separately for stdout targets
|
||||||
let mut summary_writer = if is_stdin && matches!(fix_mode, FixMode::Apply | FixMode::Diff) {
|
let is_stdin_with_fixes = is_stdin && matches!(fix_mode, FixMode::Apply | FixMode::Diff);
|
||||||
stderr_writer
|
|
||||||
} else {
|
for target_pair in &output_targets {
|
||||||
writer
|
// For stdin with fixes, if this is a stdout target, skip it (source code already written)
|
||||||
};
|
// and we'll handle the summary separately
|
||||||
if cli.statistics {
|
if is_stdin_with_fixes && matches!(target_pair.target, OutputTarget::Stdout) {
|
||||||
printer.write_statistics(&diagnostics, &mut summary_writer)?;
|
// Skip writing diagnostics to stdout when fixes are applied via stdin
|
||||||
} else {
|
// The source code transformation is already written to stdout
|
||||||
printer.write_once(&diagnostics, &mut summary_writer, preview)?;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut writer: Box<dyn Write> = 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 {
|
if !cli.exit_zero {
|
||||||
|
|
|
||||||
|
|
@ -3876,3 +3876,161 @@ fn supported_file_extensions_preview_enabled() -> Result<()> {
|
||||||
");
|
");
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -605,9 +605,11 @@ Options:
|
||||||
format is "full" [env: RUFF_OUTPUT_FORMAT=] [possible values:
|
format is "full" [env: RUFF_OUTPUT_FORMAT=] [possible values:
|
||||||
concise, full, json, json-lines, junit, grouped, github, gitlab,
|
concise, full, json, json-lines, junit, grouped, github, gitlab,
|
||||||
pylint, rdjson, azure, sarif]
|
pylint, rdjson, azure, sarif]
|
||||||
-o, --output-file <OUTPUT_FILE>
|
-o, --output-file <FORMAT:TARGET>
|
||||||
Specify file to write the linter output to (default: stdout) [env:
|
Specify output destination(s) using format:target syntax. Can be
|
||||||
RUFF_OUTPUT_FILE=]
|
specified multiple times for multiple outputs. Also supports
|
||||||
|
comma-separated values: --output-file gitlab:file.json,full:stdout
|
||||||
|
[env: RUFF_OUTPUT_FILE=]
|
||||||
--target-version <TARGET_VERSION>
|
--target-version <TARGET_VERSION>
|
||||||
The minimum Python version that should be supported [possible values:
|
The minimum Python version that should be supported [possible values:
|
||||||
py37, py38, py39, py310, py311, py312, py313, py314]
|
py37, py38, py39, py310, py311, py312, py313, py314]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue