Skip to main content

ephemeris_report/
clippy.rs

1//! The converter for `cargo clippy --message-format json` output.
2
3use facet::Facet;
4
5use crate::{ConvertError, Converted, Details, Diagnostic, DiagnosticSummary, Level};
6
7#[derive(Facet)]
8struct Line {
9    reason: String,
10    #[facet(default)]
11    message: Option<Message>,
12}
13
14#[derive(Facet)]
15struct Message {
16    level: String,
17    #[facet(rename = "message")]
18    text: String,
19    #[facet(default)]
20    code: Option<Code>,
21    #[facet(default)]
22    spans: Vec<Span>,
23    #[facet(default)]
24    rendered: Option<String>,
25}
26
27#[derive(Facet)]
28struct Code {
29    code: String,
30}
31
32#[derive(Facet)]
33struct Span {
34    file_name: String,
35    line_start: u64,
36    column_start: u64,
37    is_primary: bool,
38}
39
40/// Convert cargo JSON lines into diagnostics.
41///
42/// Only lines with the reason `compiler-message` count. The level `error` and
43/// `warning` map to themselves and every other level is a note. The location
44/// is the primary span. The final `N warnings emitted` and `aborting due to`
45/// messages of rustc are dropped, because they repeat the counts.
46///
47/// # Errors
48///
49/// Returns an error when the input is not UTF-8 or a line is not a valid cargo
50/// message.
51pub(crate) fn convert(bytes: &[u8]) -> Result<Converted, ConvertError> {
52    let text = str::from_utf8(bytes)?;
53    let mut items = Vec::new();
54    let mut rendered = Vec::new();
55    for (index, line) in text.lines().enumerate() {
56        if line.trim().is_empty() {
57            continue;
58        }
59        let parsed: Line = facet_json::from_str(line).map_err(|source| ConvertError::Json {
60            line: index.saturating_add(1),
61            source: Box::new(source),
62        })?;
63        let Some(message) = parsed
64            .message
65            .filter(|_| parsed.reason == "compiler-message")
66        else {
67            continue;
68        };
69        if let Some(output) = &message.rendered {
70            rendered.push(output.clone());
71        }
72        if !is_summary(&message) {
73            items.push(diagnostic(message));
74        }
75    }
76    let count = |level| items.iter().filter(|item| item.level == level).count() as u64;
77    Ok(Converted {
78        details: Details::Diagnostics {
79            summary: DiagnosticSummary {
80                errors: count(Level::Error),
81                warnings: count(Level::Warning),
82            },
83            items,
84        },
85        terminal: rendered.concat(),
86    })
87}
88
89fn is_summary(message: &Message) -> bool {
90    let bare = message.code.is_none() && message.spans.is_empty();
91    bare && (message.text.starts_with("aborting due to") || message.text.contains(" emitted"))
92}
93
94fn diagnostic(message: Message) -> Diagnostic {
95    let level = match message.level.as_str() {
96        "error" => Level::Error,
97        "warning" => Level::Warning,
98        _ => Level::Note,
99    };
100    let span = message.spans.into_iter().find(|span| span.is_primary);
101    Diagnostic {
102        level,
103        message: message.text,
104        file: span.as_ref().map(|span| span.file_name.clone()),
105        line: span.as_ref().map(|span| span.line_start),
106        column: span.as_ref().map(|span| span.column_start),
107        code: message.code.map(|code| code.code),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::convert;
114    use crate::{ConvertError, Details, Level};
115
116    const ARTIFACT: &str = r#"{"reason":"compiler-artifact","package_id":"a 0.1.0","target":{"name":"a"},"fresh":true}"#;
117    const WARNING: &str = r#"{"reason":"compiler-message","package_id":"a 0.1.0","message":{"message":"unused variable: `x`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","line_start":3,"line_end":3,"column_start":9,"column_end":10,"is_primary":true,"text":[]},{"file_name":"src/other.rs","line_start":1,"column_start":1,"is_primary":false}],"children":[],"rendered":"warning: unused variable: `x`\n --> src/lib.rs:3:9\n"}}"#;
118    const SUMMARY: &str = r#"{"reason":"compiler-message","message":{"message":"1 warning emitted","code":null,"level":"warning","spans":[],"children":[],"rendered":"warning: 1 warning emitted\n"}}"#;
119    const FINISHED: &str = r#"{"reason":"build-finished","success":true}"#;
120
121    #[test]
122    fn empty_output_passes_with_zero_counts() {
123        let converted = convert(b"").unwrap();
124        let Details::Diagnostics { summary, items } = converted.details else {
125            panic!("expected diagnostics");
126        };
127        assert_eq!(items.len(), 0);
128        assert_eq!((summary.errors, summary.warnings), (0, 0));
129        assert_eq!(converted.terminal, "");
130    }
131
132    #[test]
133    fn a_warning_keeps_code_and_primary_span() {
134        let input = format!("{ARTIFACT}\n{WARNING}\n{SUMMARY}\n{FINISHED}\n");
135        let converted = convert(input.as_bytes()).unwrap();
136        let Details::Diagnostics { summary, items } = converted.details else {
137            panic!("expected diagnostics");
138        };
139        assert_eq!((summary.errors, summary.warnings), (0, 1));
140        assert_eq!(items.len(), 1);
141        let item = &items[0];
142        assert_eq!(item.level, Level::Warning);
143        assert_eq!(item.message, "unused variable: `x`");
144        assert_eq!(item.code.as_deref(), Some("unused_variables"));
145        assert_eq!(item.file.as_deref(), Some("src/lib.rs"));
146        assert_eq!((item.line, item.column), (Some(3), Some(9)));
147        assert!(converted.terminal.starts_with("warning: unused variable"));
148        assert!(converted.terminal.ends_with("1 warning emitted\n"));
149    }
150
151    #[test]
152    fn other_levels_are_notes_and_errors_count() {
153        let error = r#"{"reason":"compiler-message","message":{"message":"boom","level":"error","spans":[]}}"#;
154        let note = r#"{"reason":"compiler-message","message":{"message":"look","level":"help","spans":[]}}"#;
155        let input = format!("{error}\n{note}\n");
156        let Details::Diagnostics { summary, items } = convert(input.as_bytes()).unwrap().details
157        else {
158            panic!("expected diagnostics");
159        };
160        assert_eq!((summary.errors, summary.warnings), (1, 0));
161        assert_eq!(items[1].level, Level::Note);
162        assert_eq!(items[0].file, None);
163    }
164
165    #[test]
166    fn a_line_that_is_not_json_is_an_error() {
167        let input = format!("{ARTIFACT}\nnot json\n");
168        assert!(matches!(
169            convert(input.as_bytes()),
170            Err(ConvertError::Json { line: 2, source: _ })
171        ));
172    }
173}