Skip to content

Commit a2c9782

Browse files
committed
Auto merge of #10398 - Alexendoo:auto-lintcheck, r=xFrednet
Run a diff of lintcheck against the merge base for pull requests changelog: none <!-- changelog_checked --> This is an MVP of sorts, it consists of #9764 + a GitHub action that feeds the output to the [job summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary). It doesn't yet do anything fancy like `--recursive` or adding comments to the PR, so you'd have to click through to the action to see the results Example output of a change (Alexendoo@0be1ab8): https://github.com/Alexendoo/rust-clippy/actions/runs/4264858870#summary-11583333018 r? `@flip1995`
2 parents 82345c3 + feb0671 commit a2c9782

File tree

5 files changed

+376
-67
lines changed

5 files changed

+376
-67
lines changed

.github/workflows/lintcheck.yml

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
name: Lintcheck
2+
3+
on: pull_request
4+
5+
env:
6+
RUST_BACKTRACE: 1
7+
CARGO_INCREMENTAL: 0
8+
9+
concurrency:
10+
# For a given workflow, if we push to the same PR, cancel all previous builds on that PR.
11+
group: "${{ github.workflow }}-${{ github.event.pull_request.number}}"
12+
cancel-in-progress: true
13+
14+
jobs:
15+
# Generates `lintcheck-logs/base.json` and stores it in a cache
16+
base:
17+
runs-on: ubuntu-latest
18+
19+
outputs:
20+
key: ${{ steps.key.outputs.key }}
21+
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
with:
26+
fetch-depth: 2
27+
28+
# HEAD is the generated merge commit `refs/pull/N/merge` between the PR and `master`, `HEAD^`
29+
# being the commit from `master` that is the base of the merge
30+
- name: Checkout base
31+
run: git checkout HEAD^
32+
33+
# Use the lintcheck from the PR to generate the JSON in case the PR modifies lintcheck in some
34+
# way
35+
- name: Checkout current lintcheck
36+
run: |
37+
rm -rf lintcheck
38+
git checkout ${{ github.sha }} -- lintcheck
39+
40+
- name: Create cache key
41+
id: key
42+
run: echo "key=lintcheck-base-${{ hashfiles('lintcheck/**') }}-$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
43+
44+
- name: Cache results
45+
id: cache
46+
uses: actions/cache@v4
47+
with:
48+
path: lintcheck-logs/base.json
49+
key: ${{ steps.key.outputs.key }}
50+
51+
- name: Run lintcheck
52+
if: steps.cache.outputs.cache-hit != 'true'
53+
run: cargo lintcheck --format json
54+
55+
- name: Rename JSON file
56+
if: steps.cache.outputs.cache-hit != 'true'
57+
run: mv lintcheck-logs/lintcheck_crates_logs.json lintcheck-logs/base.json
58+
59+
# Generates `lintcheck-logs/head.json` and stores it in a cache
60+
head:
61+
runs-on: ubuntu-latest
62+
63+
outputs:
64+
key: ${{ steps.key.outputs.key }}
65+
66+
steps:
67+
- name: Checkout
68+
uses: actions/checkout@v4
69+
70+
- name: Create cache key
71+
id: key
72+
run: echo "key=lintcheck-head-${{ github.sha }}" >> "$GITHUB_OUTPUT"
73+
74+
- name: Cache results
75+
id: cache
76+
uses: actions/cache@v4
77+
with:
78+
path: lintcheck-logs/head.json
79+
key: ${{ steps.key.outputs.key }}
80+
81+
- name: Run lintcheck
82+
if: steps.cache.outputs.cache-hit != 'true'
83+
run: cargo lintcheck --format json
84+
85+
- name: Rename JSON file
86+
if: steps.cache.outputs.cache-hit != 'true'
87+
run: mv lintcheck-logs/lintcheck_crates_logs.json lintcheck-logs/head.json
88+
89+
# Retrieves `lintcheck-logs/base.json` and `lintcheck-logs/head.json` from the cache and prints
90+
# the diff to the GH actions step summary
91+
diff:
92+
runs-on: ubuntu-latest
93+
94+
needs: [base, head]
95+
96+
steps:
97+
- name: Checkout
98+
uses: actions/checkout@v4
99+
100+
- name: Restore base JSON
101+
uses: actions/cache/restore@v4
102+
with:
103+
key: ${{ needs.base.outputs.key }}
104+
path: lintcheck-logs/base.json
105+
fail-on-cache-miss: true
106+
107+
- name: Restore head JSON
108+
uses: actions/cache/restore@v4
109+
with:
110+
key: ${{ needs.head.outputs.key }}
111+
path: lintcheck-logs/head.json
112+
fail-on-cache-miss: true
113+
114+
- name: Diff results
115+
run: cargo lintcheck diff lintcheck-logs/base.json lintcheck-logs/head.json >> $GITHUB_STEP_SUMMARY

lintcheck/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ cargo_metadata = "0.15.3"
1616
clap = { version = "4.4", features = ["derive", "env"] }
1717
crates_io_api = "0.8.1"
1818
crossbeam-channel = "0.5.6"
19+
diff = "0.1.13"
1920
flate2 = "1.0"
2021
indicatif = "0.17.3"
2122
rayon = "1.5.1"
2223
serde = { version = "1.0", features = ["derive"] }
2324
serde_json = "1.0.85"
25+
strip-ansi-escapes = "0.1.1"
2426
tar = "0.4"
2527
toml = "0.7.3"
2628
ureq = "2.2"

lintcheck/src/config.rs

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
use clap::Parser;
1+
use clap::{Parser, Subcommand, ValueEnum};
22
use std::num::NonZero;
33
use std::path::PathBuf;
44

5-
#[derive(Clone, Debug, Parser)]
5+
#[derive(Parser, Clone, Debug)]
6+
#[command(args_conflicts_with_subcommands = true)]
67
pub(crate) struct LintcheckConfig {
78
/// Number of threads to use (default: all unless --fix or --recursive)
89
#[clap(
@@ -35,12 +36,36 @@ pub(crate) struct LintcheckConfig {
3536
/// Apply a filter to only collect specified lints, this also overrides `allow` attributes
3637
#[clap(long = "filter", value_name = "clippy_lint_name", use_value_delimiter = true)]
3738
pub lint_filter: Vec<String>,
38-
/// Change the reports table to use markdown links
39-
#[clap(long)]
40-
pub markdown: bool,
39+
/// Set the output format of the log file
40+
#[clap(long, short, default_value = "text")]
41+
pub format: OutputFormat,
4142
/// Run clippy on the dependencies of crates specified in crates-toml
4243
#[clap(long, conflicts_with("max_jobs"))]
4344
pub recursive: bool,
45+
#[command(subcommand)]
46+
pub subcommand: Option<Commands>,
47+
}
48+
49+
#[derive(Subcommand, Clone, Debug)]
50+
pub(crate) enum Commands {
51+
Diff { old: PathBuf, new: PathBuf },
52+
}
53+
54+
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
55+
pub(crate) enum OutputFormat {
56+
Text,
57+
Markdown,
58+
Json,
59+
}
60+
61+
impl OutputFormat {
62+
fn file_extension(self) -> &'static str {
63+
match self {
64+
OutputFormat::Text => "txt",
65+
OutputFormat::Markdown => "md",
66+
OutputFormat::Json => "json",
67+
}
68+
}
4469
}
4570

4671
impl LintcheckConfig {
@@ -53,7 +78,7 @@ impl LintcheckConfig {
5378
config.lintcheck_results_path = PathBuf::from(format!(
5479
"lintcheck-logs/{}_logs.{}",
5580
filename.display(),
56-
if config.markdown { "md" } else { "txt" }
81+
config.format.file_extension(),
5782
));
5883

5984
// look at the --threads arg, if 0 is passed, use the threads count

lintcheck/src/json.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
use std::collections::HashMap;
2+
use std::fmt::Write;
3+
use std::fs;
4+
use std::hash::Hash;
5+
use std::path::Path;
6+
7+
use crate::ClippyWarning;
8+
9+
/// Creates the log file output for [`crate::config::OutputFormat::Json`]
10+
pub(crate) fn output(clippy_warnings: &[ClippyWarning]) -> String {
11+
serde_json::to_string(&clippy_warnings).unwrap()
12+
}
13+
14+
fn load_warnings(path: &Path) -> Vec<ClippyWarning> {
15+
let file = fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
16+
17+
serde_json::from_slice(&file).unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display()))
18+
}
19+
20+
/// Group warnings by their primary span location + lint name
21+
fn create_map(warnings: &[ClippyWarning]) -> HashMap<impl Eq + Hash + '_, Vec<&ClippyWarning>> {
22+
let mut map = HashMap::<_, Vec<_>>::with_capacity(warnings.len());
23+
24+
for warning in warnings {
25+
let span = warning.span();
26+
let key = (&warning.lint_type, &span.file_name, span.byte_start, span.byte_end);
27+
28+
map.entry(key).or_default().push(warning);
29+
}
30+
31+
map
32+
}
33+
34+
fn print_warnings(title: &str, warnings: &[&ClippyWarning]) {
35+
if warnings.is_empty() {
36+
return;
37+
}
38+
39+
println!("### {title}");
40+
println!("```");
41+
for warning in warnings {
42+
print!("{}", warning.diag);
43+
}
44+
println!("```");
45+
}
46+
47+
fn print_changed_diff(changed: &[(&[&ClippyWarning], &[&ClippyWarning])]) {
48+
fn render(warnings: &[&ClippyWarning]) -> String {
49+
let mut rendered = String::new();
50+
for warning in warnings {
51+
write!(&mut rendered, "{}", warning.diag).unwrap();
52+
}
53+
rendered
54+
}
55+
56+
if changed.is_empty() {
57+
return;
58+
}
59+
60+
println!("### Changed");
61+
println!("```diff");
62+
for &(old, new) in changed {
63+
let old_rendered = render(old);
64+
let new_rendered = render(new);
65+
66+
for change in diff::lines(&old_rendered, &new_rendered) {
67+
use diff::Result::{Both, Left, Right};
68+
69+
match change {
70+
Both(unchanged, _) => {
71+
println!(" {unchanged}");
72+
},
73+
Left(removed) => {
74+
println!("-{removed}");
75+
},
76+
Right(added) => {
77+
println!("+{added}");
78+
},
79+
}
80+
}
81+
}
82+
println!("```");
83+
}
84+
85+
pub(crate) fn diff(old_path: &Path, new_path: &Path) {
86+
let old_warnings = load_warnings(old_path);
87+
let new_warnings = load_warnings(new_path);
88+
89+
let old_map = create_map(&old_warnings);
90+
let new_map = create_map(&new_warnings);
91+
92+
let mut added = Vec::new();
93+
let mut removed = Vec::new();
94+
let mut changed = Vec::new();
95+
96+
for (key, new) in &new_map {
97+
if let Some(old) = old_map.get(key) {
98+
if old != new {
99+
changed.push((old.as_slice(), new.as_slice()));
100+
}
101+
} else {
102+
added.extend(new);
103+
}
104+
}
105+
106+
for (key, old) in &old_map {
107+
if !new_map.contains_key(key) {
108+
removed.extend(old);
109+
}
110+
}
111+
112+
print!(
113+
"{} added, {} removed, {} changed\n\n",
114+
added.len(),
115+
removed.len(),
116+
changed.len()
117+
);
118+
119+
print_warnings("Added", &added);
120+
print_warnings("Removed", &removed);
121+
print_changed_diff(&changed);
122+
}

0 commit comments

Comments
 (0)