From df66946b8959589307e4278b0fd5c1d0b187e854 Mon Sep 17 00:00:00 2001 From: Tsvika Shapira Date: Thu, 27 Nov 2025 19:03:36 +0200 Subject: [PATCH] Show partial fixability indicator in statistics output (#21513) Co-authored-by: Claude Co-authored-by: Micha Reiser --- crates/ruff/src/printer.rs | 79 ++++++++++++------- crates/ruff/tests/integration_test.rs | 60 ++++++++++++-- .../resources/test/project/README.md | 12 +-- docs/faq.md | 2 +- 4 files changed, 112 insertions(+), 41 deletions(-) diff --git a/crates/ruff/src/printer.rs b/crates/ruff/src/printer.rs index 1de440ac6e..70431dda9d 100644 --- a/crates/ruff/src/printer.rs +++ b/crates/ruff/src/printer.rs @@ -34,9 +34,21 @@ struct ExpandedStatistics<'a> { code: Option<&'a SecondaryCode>, name: &'static str, count: usize, - fixable: bool, + #[serde(rename = "fixable")] + all_fixable: bool, + fixable_count: usize, } +impl ExpandedStatistics<'_> { + fn any_fixable(&self) -> bool { + self.fixable_count > 0 + } +} + +/// Accumulator type for grouping diagnostics by code. +/// Format: (`code`, `representative_diagnostic`, `total_count`, `fixable_count`) +type DiagnosticGroup<'a> = (Option<&'a SecondaryCode>, &'a Diagnostic, usize, usize); + pub(crate) struct Printer { format: OutputFormat, log_level: LogLevel, @@ -133,7 +145,7 @@ impl Printer { if fixables.applicable > 0 { writeln!( writer, - "{fix_prefix} {} fixable with the --fix option.", + "{fix_prefix} {} fixable with the `--fix` option.", fixables.applicable )?; } @@ -256,35 +268,41 @@ impl Printer { diagnostics: &Diagnostics, writer: &mut dyn Write, ) -> Result<()> { + let required_applicability = self.unsafe_fixes.required_applicability(); let statistics: Vec = diagnostics .inner .iter() - .map(|message| (message.secondary_code(), message)) - .sorted_by_key(|(code, message)| (*code, message.fixable())) - .fold( - vec![], - |mut acc: Vec<((Option<&SecondaryCode>, &Diagnostic), usize)>, (code, message)| { - if let Some(((prev_code, _prev_message), count)) = acc.last_mut() { - if *prev_code == code { - *count += 1; - return acc; + .sorted_by_key(|diagnostic| diagnostic.secondary_code()) + .fold(vec![], |mut acc: Vec, diagnostic| { + let is_fixable = diagnostic + .fix() + .is_some_and(|fix| fix.applies(required_applicability)); + let code = diagnostic.secondary_code(); + + if let Some((prev_code, _prev_message, count, fixable_count)) = acc.last_mut() { + if *prev_code == code { + *count += 1; + if is_fixable { + *fixable_count += 1; } + return acc; } - acc.push(((code, message), 1)); - acc + } + acc.push((code, diagnostic, 1, usize::from(is_fixable))); + acc + }) + .iter() + .map( + |&(code, message, count, fixable_count)| ExpandedStatistics { + code, + name: message.name(), + count, + // Backward compatibility: `fixable` is true only when all violations are fixable. + // See: https://github.com/astral-sh/ruff/pull/21513 + all_fixable: fixable_count == count, + fixable_count, }, ) - .iter() - .map(|&((code, message), count)| ExpandedStatistics { - code, - name: message.name(), - count, - fixable: if let Some(fix) = message.fix() { - fix.applies(self.unsafe_fixes.required_applicability()) - } else { - false - }, - }) .sorted_by_key(|statistic| Reverse(statistic.count)) .collect(); @@ -308,13 +326,14 @@ impl Printer { .map(|statistic| statistic.code.map_or(0, |s| s.len())) .max() .unwrap(); - let any_fixable = statistics.iter().any(|statistic| statistic.fixable); + let any_fixable = statistics.iter().any(ExpandedStatistics::any_fixable); - let fixable = format!("[{}] ", "*".cyan()); + let all_fixable = format!("[{}] ", "*".cyan()); + let partially_fixable = format!("[{}] ", "-".cyan()); let unfixable = "[ ] "; // By default, we mimic Flake8's `--statistics` format. - for statistic in statistics { + for statistic in &statistics { writeln!( writer, "{:>count_width$}\t{: -:1:1 Found 2 errors. - [*] 1 fixable with the --fix option. + [*] 1 fixable with the `--fix` option. ----- stderr ----- "); @@ -1853,7 +1903,7 @@ fn check_shows_unsafe_fixes_with_opt_in() { --> -:1:1 Found 2 errors. - [*] 2 fixable with the --fix option. + [*] 2 fixable with the `--fix` option. ----- stderr ----- "); diff --git a/crates/ruff_linter/resources/test/project/README.md b/crates/ruff_linter/resources/test/project/README.md index dbd91047e7..fe44d49565 100644 --- a/crates/ruff_linter/resources/test/project/README.md +++ b/crates/ruff_linter/resources/test/project/README.md @@ -17,7 +17,7 @@ crates/ruff_linter/resources/test/project/examples/docs/docs/file.py:8:5: F841 [ crates/ruff_linter/resources/test/project/project/file.py:1:8: F401 [*] `os` imported but unused crates/ruff_linter/resources/test/project/project/import_file.py:1:1: I001 [*] Import block is un-sorted or un-formatted Found 7 errors. -[*] 7 potentially fixable with the --fix option. +[*] 7 potentially fixable with the `--fix` option. ``` Running from the project directory itself should exhibit the same behavior: @@ -32,7 +32,7 @@ examples/docs/docs/file.py:8:5: F841 [*] Local variable `x` is assigned to but n project/file.py:1:8: F401 [*] `os` imported but unused project/import_file.py:1:1: I001 [*] Import block is un-sorted or un-formatted Found 7 errors. -[*] 7 potentially fixable with the --fix option. +[*] 7 potentially fixable with the `--fix` option. ``` Running from the sub-package directory should exhibit the same behavior, but omit the top-level @@ -43,7 +43,7 @@ files: docs/file.py:1:1: I001 [*] Import block is un-sorted or un-formatted docs/file.py:8:5: F841 [*] Local variable `x` is assigned to but never used Found 2 errors. -[*] 2 potentially fixable with the --fix option. +[*] 2 potentially fixable with the `--fix` option. ``` `--config` should force Ruff to use the specified `pyproject.toml` for all files, and resolve @@ -61,7 +61,7 @@ crates/ruff_linter/resources/test/project/examples/docs/docs/file.py:4:27: F401 crates/ruff_linter/resources/test/project/examples/excluded/script.py:1:8: F401 [*] `os` imported but unused crates/ruff_linter/resources/test/project/project/file.py:1:8: F401 [*] `os` imported but unused Found 9 errors. -[*] 9 potentially fixable with the --fix option. +[*] 9 potentially fixable with the `--fix` option. ``` Running from a parent directory should "ignore" the `exclude` (hence, `concepts/file.py` gets @@ -74,7 +74,7 @@ docs/docs/file.py:1:1: I001 [*] Import block is un-sorted or un-formatted docs/docs/file.py:8:5: F841 [*] Local variable `x` is assigned to but never used excluded/script.py:5:5: F841 [*] Local variable `x` is assigned to but never used Found 4 errors. -[*] 4 potentially fixable with the --fix option. +[*] 4 potentially fixable with the `--fix` option. ``` Passing an excluded directory directly should report errors in the contained files: @@ -83,7 +83,7 @@ Passing an excluded directory directly should report errors in the contained fil ∴ cargo run -p ruff -- check crates/ruff_linter/resources/test/project/examples/excluded/ crates/ruff_linter/resources/test/project/examples/excluded/script.py:1:8: F401 [*] `os` imported but unused Found 1 error. -[*] 1 potentially fixable with the --fix option. +[*] 1 potentially fixable with the `--fix` option. ``` Unless we `--force-exclude`: diff --git a/docs/faq.md b/docs/faq.md index 68e69ee9bd..b94f0b64e4 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -454,7 +454,7 @@ Untitled.ipynb:cell_1:2:5: F841 Local variable `x` is assigned to but never used Untitled.ipynb:cell_2:1:1: E402 Module level import not at top of file Untitled.ipynb:cell_2:1:8: F401 `os` imported but unused Found 3 errors. -1 potentially fixable with the --fix option. +1 potentially fixable with the `--fix` option. ``` ## Does Ruff support NumPy- or Google-style docstrings?