Skip to content

Commit 1b6e7cc

Browse files
committed
lintcheck: Add JSON output, diff subcommand
1 parent 030b4b7 commit 1b6e7cc

File tree

4 files changed

+253
-58
lines changed

4 files changed

+253
-58
lines changed

lintcheck/Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ publish = false
1313
cargo_metadata = "0.14"
1414
clap = "3.2"
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

+46-5
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,41 @@ fn get_clap_config() -> ArgMatches {
3434
Arg::new("markdown")
3535
.long("markdown")
3636
.help("Change the reports table to use markdown links"),
37+
Arg::new("json")
38+
.long("json")
39+
.help("Output the diagnostics as JSON")
40+
.conflicts_with("markdown"),
3741
Arg::new("recursive")
3842
.long("--recursive")
3943
.help("Run clippy on the dependencies of crates specified in crates-toml")
4044
.conflicts_with("threads")
4145
.conflicts_with("fix"),
4246
])
47+
.subcommand(
48+
Command::new("diff")
49+
.about("Prints the difference between two `lintcheck --json` results")
50+
.args([Arg::new("old").required(true), Arg::new("new").required(true)]),
51+
)
4352
.get_matches()
4453
}
4554

55+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56+
pub(crate) enum OutputFormat {
57+
Text,
58+
Markdown,
59+
Json,
60+
}
61+
62+
impl OutputFormat {
63+
fn file_extension(self) -> &'static str {
64+
match self {
65+
OutputFormat::Text => "txt",
66+
OutputFormat::Markdown => "md",
67+
OutputFormat::Json => "json",
68+
}
69+
}
70+
}
71+
4672
#[derive(Debug, Clone)]
4773
pub(crate) struct LintcheckConfig {
4874
/// max number of jobs to spawn (default 1)
@@ -57,10 +83,12 @@ pub(crate) struct LintcheckConfig {
5783
pub fix: bool,
5884
/// A list of lints that this lintcheck run should focus on
5985
pub lint_filter: Vec<String>,
60-
/// Indicate if the output should support markdown syntax
61-
pub markdown: bool,
86+
/// The output format of the log file
87+
pub format: OutputFormat,
6288
/// Run clippy on the dependencies of crates
6389
pub recursive: bool,
90+
/// Diff the two `lintcheck --json` results
91+
pub diff: Option<(PathBuf, PathBuf)>,
6492
}
6593

6694
impl LintcheckConfig {
@@ -77,7 +105,13 @@ impl LintcheckConfig {
77105
.into()
78106
});
79107

80-
let markdown = clap_config.contains_id("markdown");
108+
let format = if clap_config.contains_id("markdown") {
109+
OutputFormat::Markdown
110+
} else if clap_config.contains_id("json") {
111+
OutputFormat::Json
112+
} else {
113+
OutputFormat::Text
114+
};
81115
let sources_toml_path = PathBuf::from(sources_toml);
82116

83117
// for the path where we save the lint results, get the filename without extension (so for
@@ -86,7 +120,7 @@ impl LintcheckConfig {
86120
let lintcheck_results_path = PathBuf::from(format!(
87121
"lintcheck-logs/{}_logs.{}",
88122
filename.display(),
89-
if markdown { "md" } else { "txt" }
123+
format.file_extension(),
90124
));
91125

92126
// look at the --threads arg, if 0 is passed, ask rayon rayon how many threads it would spawn and
@@ -117,15 +151,22 @@ impl LintcheckConfig {
117151
})
118152
.unwrap_or_default();
119153

154+
let diff = clap_config.subcommand_matches("diff").map(|args| {
155+
let path = |arg| PathBuf::from(args.get_one::<String>(arg).unwrap());
156+
157+
(path("old"), path("new"))
158+
});
159+
120160
LintcheckConfig {
121161
max_jobs,
122162
sources_toml_path,
123163
lintcheck_results_path,
124164
only: clap_config.get_one::<String>("only").map(String::from),
125165
fix: clap_config.contains_id("fix"),
126166
lint_filter,
127-
markdown,
167+
format,
128168
recursive: clap_config.contains_id("recursive"),
169+
diff,
129170
}
130171
}
131172
}

lintcheck/src/json.rs

+122
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)