Skip to content

Commit 73af4e6

Browse files
committed
lintcheck: Add JSON output, diff subcommand
1 parent b528cc9 commit 73af4e6

File tree

4 files changed

+260
-61
lines changed

4 files changed

+260
-61
lines changed

lintcheck/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ publish = false
1313
cargo_metadata = "0.15.3"
1414
clap = "4.1.4"
1515
crossbeam-channel = "0.5.6"
16+
diff = "0.1.13"
1617
flate2 = "1.0"
1718
rayon = "1.5.1"
1819
serde = { version = "1.0", features = ["derive"] }
1920
serde_json = "1.0.85"
21+
strip-ansi-escapes = "0.1.1"
2022
tar = "0.4"
2123
toml = "0.5"
2224
ureq = "2.2"

lintcheck/src/config.rs

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ fn get_clap_config() -> ArgMatches {
2424
.long("jobs")
2525
.help("Number of threads to use, 0 automatic choice"),
2626
Arg::new("fix")
27+
.action(ArgAction::SetTrue)
2728
.long("fix")
2829
.help("Runs cargo clippy --fix and checks if all suggestions apply"),
2930
Arg::new("filter")
@@ -32,17 +33,46 @@ fn get_clap_config() -> ArgMatches {
3233
.value_name("clippy_lint_name")
3334
.help("Apply a filter to only collect specified lints, this also overrides `allow` attributes"),
3435
Arg::new("markdown")
36+
.action(ArgAction::SetTrue)
3537
.long("markdown")
3638
.help("Change the reports table to use markdown links"),
39+
Arg::new("json")
40+
.action(ArgAction::SetTrue)
41+
.long("json")
42+
.help("Output the diagnostics as JSON")
43+
.conflicts_with("markdown"),
3744
Arg::new("recursive")
45+
.action(ArgAction::SetTrue)
3846
.long("recursive")
3947
.help("Run clippy on the dependencies of crates specified in crates-toml")
4048
.conflicts_with("threads")
4149
.conflicts_with("fix"),
4250
])
51+
.subcommand(
52+
Command::new("diff")
53+
.about("Prints the difference between two `lintcheck --json` results")
54+
.args([Arg::new("old").required(true), Arg::new("new").required(true)]),
55+
)
4356
.get_matches()
4457
}
4558

59+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60+
pub(crate) enum OutputFormat {
61+
Text,
62+
Markdown,
63+
Json,
64+
}
65+
66+
impl OutputFormat {
67+
fn file_extension(self) -> &'static str {
68+
match self {
69+
OutputFormat::Text => "txt",
70+
OutputFormat::Markdown => "md",
71+
OutputFormat::Json => "json",
72+
}
73+
}
74+
}
75+
4676
#[derive(Debug, Clone)]
4777
pub(crate) struct LintcheckConfig {
4878
/// max number of jobs to spawn (default 1)
@@ -57,10 +87,12 @@ pub(crate) struct LintcheckConfig {
5787
pub fix: bool,
5888
/// A list of lints that this lintcheck run should focus on
5989
pub lint_filter: Vec<String>,
60-
/// Indicate if the output should support markdown syntax
61-
pub markdown: bool,
90+
/// The output format of the log file
91+
pub format: OutputFormat,
6292
/// Run clippy on the dependencies of crates
6393
pub recursive: bool,
94+
/// Diff the two `lintcheck --json` results
95+
pub diff: Option<(PathBuf, PathBuf)>,
6496
}
6597

6698
impl LintcheckConfig {
@@ -77,7 +109,13 @@ impl LintcheckConfig {
77109
.into()
78110
});
79111

80-
let markdown = clap_config.contains_id("markdown");
112+
let format = if clap_config.get_flag("markdown") {
113+
OutputFormat::Markdown
114+
} else if clap_config.get_flag("json") {
115+
OutputFormat::Json
116+
} else {
117+
OutputFormat::Text
118+
};
81119
let sources_toml_path = PathBuf::from(sources_toml);
82120

83121
// for the path where we save the lint results, get the filename without extension (so for
@@ -86,7 +124,7 @@ impl LintcheckConfig {
86124
let lintcheck_results_path = PathBuf::from(format!(
87125
"lintcheck-logs/{}_logs.{}",
88126
filename.display(),
89-
if markdown { "md" } else { "txt" }
127+
format.file_extension(),
90128
));
91129

92130
// look at the --threads arg, if 0 is passed, ask rayon rayon how many threads it would spawn and
@@ -117,15 +155,22 @@ impl LintcheckConfig {
117155
})
118156
.unwrap_or_default();
119157

158+
let diff = clap_config.subcommand_matches("diff").map(|args| {
159+
let path = |arg| PathBuf::from(args.get_one::<String>(arg).unwrap());
160+
161+
(path("old"), path("new"))
162+
});
163+
120164
LintcheckConfig {
121165
max_jobs,
122166
sources_toml_path,
123167
lintcheck_results_path,
124168
only: clap_config.get_one::<String>("only").map(String::from),
125-
fix: clap_config.contains_id("fix"),
169+
fix: clap_config.get_flag("fix"),
126170
lint_filter,
127-
markdown,
128-
recursive: clap_config.contains_id("recursive"),
171+
format,
172+
recursive: clap_config.get_flag("recursive"),
173+
diff,
129174
}
130175
}
131176
}

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)