Show partial fixability indicator in statistics output (#21513)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Micha Reiser <micha@reiser.io>
This commit is contained in:
Tsvika Shapira 2025-11-27 19:03:36 +02:00 committed by GitHub
parent efb23b01af
commit df66946b89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 112 additions and 41 deletions

View File

@ -34,9 +34,21 @@ struct ExpandedStatistics<'a> {
code: Option<&'a SecondaryCode>, code: Option<&'a SecondaryCode>,
name: &'static str, name: &'static str,
count: usize, 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 { pub(crate) struct Printer {
format: OutputFormat, format: OutputFormat,
log_level: LogLevel, log_level: LogLevel,
@ -133,7 +145,7 @@ impl Printer {
if fixables.applicable > 0 { if fixables.applicable > 0 {
writeln!( writeln!(
writer, writer,
"{fix_prefix} {} fixable with the --fix option.", "{fix_prefix} {} fixable with the `--fix` option.",
fixables.applicable fixables.applicable
)?; )?;
} }
@ -256,35 +268,41 @@ impl Printer {
diagnostics: &Diagnostics, diagnostics: &Diagnostics,
writer: &mut dyn Write, writer: &mut dyn Write,
) -> Result<()> { ) -> Result<()> {
let required_applicability = self.unsafe_fixes.required_applicability();
let statistics: Vec<ExpandedStatistics> = diagnostics let statistics: Vec<ExpandedStatistics> = diagnostics
.inner .inner
.iter() .iter()
.map(|message| (message.secondary_code(), message)) .sorted_by_key(|diagnostic| diagnostic.secondary_code())
.sorted_by_key(|(code, message)| (*code, message.fixable())) .fold(vec![], |mut acc: Vec<DiagnosticGroup>, diagnostic| {
.fold( let is_fixable = diagnostic
vec![], .fix()
|mut acc: Vec<((Option<&SecondaryCode>, &Diagnostic), usize)>, (code, message)| { .is_some_and(|fix| fix.applies(required_applicability));
if let Some(((prev_code, _prev_message), count)) = acc.last_mut() { let code = diagnostic.secondary_code();
if *prev_code == code {
*count += 1; if let Some((prev_code, _prev_message, count, fixable_count)) = acc.last_mut() {
return acc; 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)) .sorted_by_key(|statistic| Reverse(statistic.count))
.collect(); .collect();
@ -308,13 +326,14 @@ impl Printer {
.map(|statistic| statistic.code.map_or(0, |s| s.len())) .map(|statistic| statistic.code.map_or(0, |s| s.len()))
.max() .max()
.unwrap(); .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 = "[ ] "; let unfixable = "[ ] ";
// By default, we mimic Flake8's `--statistics` format. // By default, we mimic Flake8's `--statistics` format.
for statistic in statistics { for statistic in &statistics {
writeln!( writeln!(
writer, writer,
"{:>count_width$}\t{:<code_width$}\t{}{}", "{:>count_width$}\t{:<code_width$}\t{}{}",
@ -326,8 +345,10 @@ impl Printer {
.red() .red()
.bold(), .bold(),
if any_fixable { if any_fixable {
if statistic.fixable { if statistic.all_fixable {
&fixable &all_fixable
} else if statistic.any_fixable() {
&partially_fixable
} else { } else {
unfixable unfixable
} }

View File

@ -1043,7 +1043,7 @@ def mvce(keys, values):
----- stdout ----- ----- stdout -----
1 C416 [*] unnecessary-comprehension 1 C416 [*] unnecessary-comprehension
Found 1 error. Found 1 error.
[*] 1 fixable with the --fix option. [*] 1 fixable with the `--fix` option.
----- stderr ----- ----- stderr -----
"); ");
@ -1073,7 +1073,8 @@ def mvce(keys, values):
"code": "C416", "code": "C416",
"name": "unnecessary-comprehension", "name": "unnecessary-comprehension",
"count": 1, "count": 1,
"fixable": false "fixable": false,
"fixable_count": 0
} }
] ]
@ -1106,7 +1107,8 @@ def mvce(keys, values):
"code": "C416", "code": "C416",
"name": "unnecessary-comprehension", "name": "unnecessary-comprehension",
"count": 1, "count": 1,
"fixable": true "fixable": true,
"fixable_count": 1
} }
] ]
@ -1114,6 +1116,54 @@ def mvce(keys, values):
"#); "#);
} }
#[test]
fn show_statistics_json_partial_fix() {
let mut cmd = RuffCheck::default()
.args([
"--select",
"UP035",
"--statistics",
"--output-format",
"json",
])
.build();
assert_cmd_snapshot!(cmd
.pass_stdin("from typing import List, AsyncGenerator"), @r#"
success: false
exit_code: 1
----- stdout -----
[
{
"code": "UP035",
"name": "deprecated-import",
"count": 2,
"fixable": false,
"fixable_count": 1
}
]
----- stderr -----
"#);
}
#[test]
fn show_statistics_partial_fix() {
let mut cmd = RuffCheck::default()
.args(["--select", "UP035", "--statistics"])
.build();
assert_cmd_snapshot!(cmd
.pass_stdin("from typing import List, AsyncGenerator"), @r"
success: false
exit_code: 1
----- stdout -----
2 UP035 [-] deprecated-import
Found 2 errors.
[*] 1 fixable with the `--fix` option.
----- stderr -----
");
}
#[test] #[test]
fn show_statistics_syntax_errors() { fn show_statistics_syntax_errors() {
let mut cmd = RuffCheck::default() let mut cmd = RuffCheck::default()
@ -1810,7 +1860,7 @@ fn check_no_hint_for_hidden_unsafe_fixes_when_disabled() {
--> -:1:1 --> -:1:1
Found 2 errors. Found 2 errors.
[*] 1 fixable with the --fix option. [*] 1 fixable with the `--fix` option.
----- stderr ----- ----- stderr -----
"); ");
@ -1853,7 +1903,7 @@ fn check_shows_unsafe_fixes_with_opt_in() {
--> -:1:1 --> -:1:1
Found 2 errors. Found 2 errors.
[*] 2 fixable with the --fix option. [*] 2 fixable with the `--fix` option.
----- stderr ----- ----- stderr -----
"); ");

View File

@ -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/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 crates/ruff_linter/resources/test/project/project/import_file.py:1:1: I001 [*] Import block is un-sorted or un-formatted
Found 7 errors. 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: 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/file.py:1:8: F401 [*] `os` imported but unused
project/import_file.py:1:1: I001 [*] Import block is un-sorted or un-formatted project/import_file.py:1:1: I001 [*] Import block is un-sorted or un-formatted
Found 7 errors. 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 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: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 docs/file.py:8:5: F841 [*] Local variable `x` is assigned to but never used
Found 2 errors. 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 `--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/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 crates/ruff_linter/resources/test/project/project/file.py:1:8: F401 [*] `os` imported but unused
Found 9 errors. 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 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 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 excluded/script.py:5:5: F841 [*] Local variable `x` is assigned to but never used
Found 4 errors. 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: 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/ ∴ 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 crates/ruff_linter/resources/test/project/examples/excluded/script.py:1:8: F401 [*] `os` imported but unused
Found 1 error. Found 1 error.
[*] 1 potentially fixable with the --fix option. [*] 1 potentially fixable with the `--fix` option.
``` ```
Unless we `--force-exclude`: Unless we `--force-exclude`:

View File

@ -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:1: E402 Module level import not at top of file
Untitled.ipynb:cell_2:1:8: F401 `os` imported but unused Untitled.ipynb:cell_2:1:8: F401 `os` imported but unused
Found 3 errors. 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? ## Does Ruff support NumPy- or Google-style docstrings?