use cargo_metadata::diagnostic::{Diagnostic, DiagnosticSpan}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt::{self, Write as _}; use std::fs; use std::path::Path; use std::process::ExitStatus; use crate::config::{LintcheckConfig, OutputFormat}; /// A single emitted output from clippy being executed on a crate. It may either be a /// `ClippyWarning`, or a `RustcIce` caused by a panic within clippy. A crate may have many /// `ClippyWarning`s but a maximum of one `RustcIce` (at which point clippy halts execution). #[derive(Debug)] pub enum ClippyCheckOutput { ClippyWarning(ClippyWarning), RustcIce(RustcIce), } #[derive(Debug)] pub struct RustcIce { pub crate_name: String, pub ice_content: String, } impl fmt::Display for RustcIce { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{}:\n{}\n========================================\n", self.crate_name, self.ice_content ) } } impl RustcIce { pub fn from_stderr_and_status(crate_name: &str, status: ExitStatus, stderr: &str) -> Option { if status.code().unwrap_or(0) == 101 /* ice exit status */ { Some(Self { crate_name: crate_name.to_owned(), ice_content: stderr.to_owned(), }) } else { None } } } /// A single warning that clippy issued while checking a `Crate` #[derive(Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ClippyWarning { pub name: String, pub diag: Diagnostic, pub krate: String, /// The URL that points to the file and line of the lint emission pub url: String, } impl ClippyWarning { pub fn new(mut diag: Diagnostic, base_url: &str, krate: &str) -> Option { let name = diag.code.clone()?.code; if !(name.contains("clippy") || diag.message.contains("clippy")) || diag.message.contains("could not read cargo metadata") { return None; } // --recursive bypasses cargo so we have to strip the rendered output ourselves let rendered = diag.rendered.as_mut().unwrap(); *rendered = strip_ansi_escapes::strip_str(&rendered); // Turns out that there are lints without spans... For example Rust's // `renamed_and_removed_lints` if the lint is given via the CLI. let span = diag .spans .iter() .find(|span| span.is_primary) .or(diag.spans.first()) .unwrap_or_else(|| panic!("Diagnostic without span: {diag}")); let file = &span.file_name; let url = if let Some(src_split) = file.find("/src/") { // This removes the initial `target/lintcheck/sources/-/` let src_split = src_split + "/src/".len(); let (_, file) = file.split_at(src_split); let line_no = span.line_start; base_url.replace("{file}", file).replace("{line}", &line_no.to_string()) } else { file.clone() }; Some(Self { name, diag, krate: krate.to_string(), url, }) } pub fn span(&self) -> &DiagnosticSpan { self.diag.spans.iter().find(|span| span.is_primary).unwrap() } pub fn to_output(&self, format: OutputFormat) -> String { let span = self.span(); let mut file = span.file_name.clone(); let file_with_pos = format!("{file}:{}:{}", span.line_start, span.line_end); match format { OutputFormat::Text => format!("{file_with_pos} {} \"{}\"\n", self.name, self.diag.message), OutputFormat::Markdown => { if file.starts_with("target") { file.insert_str(0, "../"); } let mut output = String::from("| "); write!(output, "[`{file_with_pos}`]({file}#L{})", span.line_start).unwrap(); write!(output, r#" | `{:<50}` | "{}" |"#, self.name, self.diag.message).unwrap(); output.push('\n'); output }, OutputFormat::Json => unreachable!("JSON output is handled via serde"), } } } /// Creates the log file output for [`OutputFormat::Text`] and [`OutputFormat::Markdown`] pub fn summarize_and_print_changes( warnings: &[ClippyWarning], ices: &[RustcIce], clippy_ver: String, config: &LintcheckConfig, ) -> String { // generate some stats let (stats_formatted, new_stats) = gather_stats(warnings); let old_stats = read_stats_from_file(&config.lintcheck_results_path); let mut all_msgs: Vec = warnings.iter().map(|warn| warn.to_output(config.format)).collect(); all_msgs.sort(); all_msgs.push("\n\n### Stats:\n\n".into()); all_msgs.push(stats_formatted); let mut text = clippy_ver; // clippy version number on top text.push_str("\n### Reports\n\n"); if config.format == OutputFormat::Markdown { text.push_str("| file | lint | message |\n"); text.push_str("| --- | --- | --- |\n"); } write!(text, "{}", all_msgs.join("")).unwrap(); text.push_str("\n\n### ICEs:\n"); for ice in ices { writeln!(text, "{ice}").unwrap(); } print_stats(old_stats, new_stats, &config.lint_filter); text } /// Generate a short list of occurring lints-types and their count fn gather_stats(warnings: &[ClippyWarning]) -> (String, HashMap<&String, usize>) { // count lint type occurrences let mut counter: HashMap<&String, usize> = HashMap::new(); warnings .iter() .for_each(|wrn| *counter.entry(&wrn.name).or_insert(0) += 1); // collect into a tupled list for sorting let mut stats: Vec<(&&String, &usize)> = counter.iter().collect(); // sort by "000{count} {clippy::lintname}" // to not have a lint with 200 and 2 warnings take the same spot stats.sort_by_key(|(lint, count)| format!("{count:0>4}, {lint}")); let mut header = String::from("| lint | count |\n"); header.push_str("| -------------------------------------------------- | ----- |\n"); let stats_string = stats .iter() .map(|(lint, count)| format!("| {lint:<50} | {count:>4} |\n")) .fold(header, |mut table, line| { table.push_str(&line); table }); (stats_string, counter) } /// read the previous stats from the lintcheck-log file fn read_stats_from_file(file_path: &Path) -> HashMap { let file_content: String = match fs::read_to_string(file_path).ok() { Some(content) => content, None => { return HashMap::new(); }, }; let lines: Vec = file_content.lines().map(ToString::to_string).collect(); lines .iter() .skip_while(|line| line.as_str() != "### Stats:") // Skipping the table header and the `Stats:` label .skip(4) .take_while(|line| line.starts_with("| ")) .filter_map(|line| { let mut spl = line.split('|'); // Skip the first `|` symbol spl.next(); if let (Some(lint), Some(count)) = (spl.next(), spl.next()) { Some((lint.trim().to_string(), count.trim().parse::().unwrap())) } else { None } }) .collect::>() } /// print how lint counts changed between runs fn print_stats(old_stats: HashMap, new_stats: HashMap<&String, usize>, lint_filter: &[String]) { let same_in_both_hashmaps = old_stats .iter() .filter(|(old_key, old_val)| new_stats.get::<&String>(old_key) == Some(old_val)) .map(|(k, v)| (k.to_string(), *v)) .collect::>(); let mut old_stats_deduped = old_stats; let mut new_stats_deduped = new_stats; // remove duplicates from both hashmaps for (k, v) in &same_in_both_hashmaps { assert!(old_stats_deduped.remove(k) == Some(*v)); assert!(new_stats_deduped.remove(k) == Some(*v)); } println!("\nStats:"); // list all new counts (key is in new stats but not in old stats) new_stats_deduped .iter() .filter(|(new_key, _)| !old_stats_deduped.contains_key::(new_key)) .for_each(|(new_key, new_value)| { println!("{new_key} 0 => {new_value}"); }); // list all changed counts (key is in both maps but value differs) new_stats_deduped .iter() .filter(|(new_key, _new_val)| old_stats_deduped.contains_key::(new_key)) .for_each(|(new_key, new_val)| { let old_val = old_stats_deduped.get::(new_key).unwrap(); println!("{new_key} {old_val} => {new_val}"); }); // list all gone counts (key is in old status but not in new stats) old_stats_deduped .iter() .filter(|(old_key, _)| !new_stats_deduped.contains_key::<&String>(old_key)) .filter(|(old_key, _)| lint_filter.is_empty() || lint_filter.contains(old_key)) .for_each(|(old_key, old_value)| { println!("{old_key} {old_value} => 0"); }); }