blob: 79c1255c5ffe574786f4f094abff9c22ce984bc1 [file] [log] [blame] [edit]
//! JSON output and comparison functionality for Clippy warnings.
//!
//! This module handles serialization of Clippy warnings to JSON format,
//! loading warnings from JSON files, and generating human-readable diffs
//! between different linting runs.
use std::path::{Path, PathBuf};
use std::{fmt, fs};
use itertools::{EitherOrBoth, Itertools};
use serde::{Deserialize, Serialize};
use crate::ClippyWarning;
/// This is the total number. 300 warnings results in 100 messages per section.
const DEFAULT_LIMIT_PER_LINT: usize = 300;
/// Target for total warnings to display across all lints when truncating output.
const TRUNCATION_TOTAL_TARGET: usize = 1000;
#[derive(Debug, Deserialize, Serialize)]
struct LintJson {
/// The lint name e.g. `clippy::bytes_nth`
name: String,
/// The filename and line number e.g. `anyhow-1.0.86/src/error.rs:42`
file_line: String,
file_url: String,
rendered: String,
}
impl LintJson {
fn key(&self) -> impl Ord + '_ {
(self.name.as_str(), self.file_line.as_str())
}
/// Formats the warning information with an action verb for display.
fn info_text(&self, action: &str) -> String {
format!("{action} `{}` at [`{}`]({})", self.name, self.file_line, self.file_url)
}
}
#[derive(Debug, Serialize)]
struct SummaryRow {
name: String,
added: usize,
removed: usize,
changed: usize,
}
#[derive(Debug, Serialize)]
struct Summary(Vec<SummaryRow>);
impl fmt::Display for Summary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
"\
| Lint | Added | Removed | Changed |
| ---- | ----: | ------: | ------: |
",
)?;
for SummaryRow {
name,
added,
changed,
removed,
} in &self.0
{
let html_id = to_html_id(name);
writeln!(f, "| [`{name}`](#{html_id}) | {added} | {removed} | {changed} |")?;
}
Ok(())
}
}
impl Summary {
fn new(lints: &[LintWarnings]) -> Self {
Summary(
lints
.iter()
.map(|lint| SummaryRow {
name: lint.name.clone(),
added: lint.added.len(),
removed: lint.removed.len(),
changed: lint.changed.len(),
})
.collect(),
)
}
}
/// Creates the log file output for [`crate::config::OutputFormat::Json`]
pub(crate) fn output(clippy_warnings: Vec<ClippyWarning>) -> String {
let mut lints: Vec<LintJson> = clippy_warnings
.into_iter()
.map(|warning| {
let span = warning.span();
let file_name = span
.file_name
.strip_prefix("target/lintcheck/sources/")
.unwrap_or(&span.file_name);
let file_line = format!("{file_name}:{}", span.line_start);
LintJson {
name: warning.name,
file_line,
file_url: warning.url,
rendered: warning.diag.rendered.unwrap().trim().to_string(),
}
})
.collect();
lints.sort_by(|a, b| a.key().cmp(&b.key()));
serde_json::to_string(&lints).unwrap()
}
/// Loads lint warnings from a JSON file at the given path.
fn load_warnings(path: &Path) -> Vec<LintJson> {
let file = fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
serde_json::from_slice(&file).unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display()))
}
/// Generates and prints a diff between two sets of lint warnings.
///
/// Compares warnings from `old_path` and `new_path`, then displays a summary table
/// and detailed information about added, removed, and changed warnings.
pub(crate) fn diff(old_path: &Path, new_path: &Path, truncate: bool, write_summary: Option<PathBuf>) {
let old_warnings = load_warnings(old_path);
let new_warnings = load_warnings(new_path);
let mut lint_warnings = vec![];
for (name, changes) in &itertools::merge_join_by(old_warnings, new_warnings, |old, new| old.key().cmp(&new.key()))
.chunk_by(|change| change.as_ref().into_left().name.clone())
{
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
for change in changes {
match change {
EitherOrBoth::Both(old, new) => {
if old.rendered != new.rendered {
changed.push((old, new));
}
},
EitherOrBoth::Left(old) => removed.push(old),
EitherOrBoth::Right(new) => added.push(new),
}
}
if !added.is_empty() || !removed.is_empty() || !changed.is_empty() {
lint_warnings.push(LintWarnings {
name,
added,
removed,
changed,
});
}
}
if lint_warnings.is_empty() {
return;
}
let summary = Summary::new(&lint_warnings);
if let Some(path) = write_summary {
let json = serde_json::to_string(&summary).unwrap();
fs::write(path, json).unwrap();
}
let truncate_after = if truncate {
// Max 15 ensures that we at least have five messages per lint
DEFAULT_LIMIT_PER_LINT
.min(TRUNCATION_TOTAL_TARGET / lint_warnings.len())
.max(15)
} else {
// No lint should ever each this number of lint emissions, so this is equivialent to
// No truncation
usize::MAX
};
println!("{summary}");
for lint in lint_warnings {
print_lint_warnings(&lint, truncate_after);
}
}
/// Container for grouped lint warnings organized by status (added/removed/changed).
#[derive(Debug)]
struct LintWarnings {
name: String,
added: Vec<LintJson>,
removed: Vec<LintJson>,
changed: Vec<(LintJson, LintJson)>,
}
fn print_lint_warnings(lint: &LintWarnings, truncate_after: usize) {
let name = &lint.name;
let html_id = to_html_id(name);
println!(r#"<h2 id="{html_id}"><code>{name}</code></h2>"#);
println!();
print!(
r"{}, {}, {}",
count_string(name, "added", lint.added.len()),
count_string(name, "removed", lint.removed.len()),
count_string(name, "changed", lint.changed.len()),
);
println!();
print_warnings("Added", &lint.added, truncate_after / 3);
print_warnings("Removed", &lint.removed, truncate_after / 3);
print_changed_diff(&lint.changed, truncate_after / 3);
}
/// Prints a section of warnings with a header and formatted code blocks.
fn print_warnings(title: &str, warnings: &[LintJson], truncate_after: usize) {
if warnings.is_empty() {
return;
}
print_h3(&warnings[0].name, title);
println!();
let warnings = truncate(warnings, truncate_after);
for warning in warnings {
println!("{}", warning.info_text(title));
println!();
println!("```");
println!("{}", warning.rendered);
println!("```");
println!();
}
}
/// Prints a section of changed warnings with unified diff format.
fn print_changed_diff(changed: &[(LintJson, LintJson)], truncate_after: usize) {
if changed.is_empty() {
return;
}
print_h3(&changed[0].0.name, "Changed");
println!();
let changed = truncate(changed, truncate_after);
for (old, new) in changed {
println!("{}", new.info_text("Changed"));
println!();
println!("```diff");
for change in diff::lines(&old.rendered, &new.rendered) {
use diff::Result::{Both, Left, Right};
match change {
Both(unchanged, _) => {
println!(" {unchanged}");
},
Left(removed) => {
println!("-{removed}");
},
Right(added) => {
println!("+{added}");
},
}
}
println!("```");
}
}
/// Truncates a list to a maximum number of items and prints a message about truncation.
fn truncate<T>(list: &[T], truncate_after: usize) -> &[T] {
if list.len() > truncate_after {
println!(
"{} warnings have been truncated for this summary.",
list.len() - truncate_after
);
println!();
list.split_at(truncate_after).0
} else {
list
}
}
fn print_h3(lint: &str, title: &str) {
let html_id = to_html_id(lint);
// We have to use HTML here to be able to manually add an id, GitHub doesn't add them automatically
println!(r#"<h3 id="{html_id}-{title}">{title}</h3>"#);
}
/// Creates a custom ID allowed by GitHub, they must start with `user-content-` and cannot contain
/// `::`/`_`
fn to_html_id(lint_name: &str) -> String {
lint_name.replace("clippy::", "user-content-").replace('_', "-")
}
/// This generates the `x added` string for the start of the job summery.
/// It linkifies them if possible to jump to the respective heading.
fn count_string(lint: &str, label: &str, count: usize) -> String {
// Headlines are only added, if anything will be displayed under the headline.
// We therefore only want to add links to them if they exist
if count == 0 {
format!("0 {label}")
} else {
let html_id = to_html_id(lint);
format!("[{count} {label}](#{html_id}-{label})")
}
}