Skip to main content

espforge_diagnostics/
render.rs

1//! The human renderer: rustc-style snippets, exactly the output the CLI had
2//! before the diagnostic model moved here.
3
4use annotate_snippets::{AnnotationKind, Group, Level, Renderer, Snippet};
5
6use crate::{Diagnostic, Severity};
7
8/// Render each diagnostic as its own snippet group.
9///
10/// The primary label's line is shown first; every other label's line follows
11/// in label order. Lines are folded to what each snippet annotates, and the
12/// group keeps one `error:`/`warning:` title, so a two-location diagnostic
13/// (a conflict, say) reads as one problem with two underlines.
14pub fn to_human(file_path: &str, src: &str, diagnostics: &[Diagnostic]) -> String {
15    let starts = line_start_offsets(src);
16    let mut out = String::new();
17
18    for d in diagnostics {
19        let group = render_group(file_path, src, &starts, d);
20        let rendered = renderer().render(&[group]);
21        out.push_str(&rendered);
22        if !out.ends_with('\n') {
23            out.push('\n');
24        }
25        if let Some(help) = &d.help {
26            out.push_str(&format!("help: {help}\n"));
27        }
28    }
29
30    out
31}
32
33/// Output renderer; styled (colored) only when stderr is a real terminal.
34fn renderer() -> Renderer {
35    use std::io::IsTerminal;
36    let base = if std::io::stderr().is_terminal() {
37        Renderer::styled()
38    } else {
39        Renderer::plain()
40    };
41    base.term_width(100)
42}
43
44fn render_group<'a>(
45    file_path: &'a str,
46    src: &'a str,
47    starts: &[usize],
48    d: &'a Diagnostic,
49) -> Group<'a> {
50    let level = match d.severity {
51        Severity::Error => Level::ERROR,
52        Severity::Warning => Level::WARNING,
53        Severity::Note => Level::NOTE,
54    };
55    let mut group = Group::with_title(level.primary_title(d.message.clone()));
56
57    // The primary line first, then every other annotated line in label order.
58    let primary_line = line_bounds(src, starts, d.primary.start).0;
59    let mut lines = vec![primary_line];
60    for label in &d.labels {
61        let start = line_bounds(src, starts, label.span.start).0;
62        if !lines.contains(&start) {
63            lines.push(start);
64        }
65    }
66
67    for line_start in lines {
68        let (_, line_end, line_number) = line_bounds(src, starts, line_start);
69        let view = src[line_start..line_end].trim_end();
70        let mut snippet = Snippet::source(view)
71            .line_start(line_number)
72            .path(file_path)
73            .fold(true);
74
75        let mut annotated = false;
76        for label in &d.labels {
77            let label_line = line_bounds(src, starts, label.span.start).0;
78            if label_line != line_start {
79                continue;
80            }
81            let (col_start, col_end) = columns(label.span.start, label.span.end, line_start, view);
82            let annotation = if label.span == d.primary {
83                AnnotationKind::Primary
84                    .span(col_start..col_end)
85                    .label(label.text.clone())
86                    .highlight_source(true)
87            } else {
88                AnnotationKind::Context
89                    .span(col_start..col_end)
90                    .label(label.text.clone())
91            };
92            snippet = snippet.annotation(annotation);
93            annotated = true;
94        }
95
96        // No label matched the primary span: underline it with no text rather
97        // than losing the location.
98        if !annotated && line_start == primary_line {
99            let (col_start, col_end) = columns(d.primary.start, d.primary.end, line_start, view);
100            snippet = snippet.annotation(
101                AnnotationKind::Primary
102                    .span(col_start..col_end)
103                    .highlight_source(true),
104            );
105        }
106
107        group = group.element(snippet);
108    }
109
110    group
111}
112
113/// Columns (relative to `view`) for a span that starts on this line.
114fn columns(span_start: usize, span_end: usize, line_start: usize, view: &str) -> (usize, usize) {
115    let col_start = span_start.saturating_sub(line_start).min(view.len());
116    let col_end = span_end
117        .saturating_sub(line_start)
118        .max(col_start + 1)
119        .min(view.len());
120    (col_start, col_end)
121}
122
123/// Line-start byte offsets of every line in `src` (0-indexed line numbers).
124fn line_start_offsets(src: &str) -> Vec<usize> {
125    std::iter::once(0)
126        .chain(
127            src.char_indices()
128                .filter_map(|(i, c)| (c == '\n').then_some(i + 1)),
129        )
130        .collect()
131}
132
133/// Byte offsets of the line that contains `offset`, plus its 1-based number.
134fn line_bounds(src: &str, line_starts: &[usize], offset: usize) -> (usize, usize, usize) {
135    let idx = line_starts.partition_point(|&start| start <= offset) - 1;
136    let line_start = line_starts[idx];
137    let line_end = src[line_start..]
138        .find('\n')
139        .map_or(src.len(), |i| line_start + i);
140    (line_start, line_end, idx + 1)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::{DiagCode, Edit, Fix, codes};
147
148    const SRC: &str = "# peripherals\n  <gpio id=\"gpio18\" pin=\"18\" direction=\"output\" />\n# components\n  <led id=\"red-led\" gpio=\"$gpio18\" />\n  <led id=\"other\" gpio=\"$gpio18\" />\n";
149
150    #[test]
151    fn one_label_renders_one_snippet_group() {
152        let start = SRC.find("<led id=\"other\"").unwrap();
153        let d = Diagnostic::error(
154            codes::CONFLICT_PIN,
155            start..start + 4,
156            "pin conflict",
157            "GPIO18 is already claimed",
158        );
159        let out = to_human("blink.espf", SRC, &[d]);
160
161        assert_eq!(out.matches("error:").count(), 1, "{out}");
162        assert!(out.contains("blink.espf:5"), "{out}");
163        assert!(out.contains("GPIO18 is already claimed"), "{out}");
164    }
165
166    #[test]
167    fn two_labels_on_two_lines_keep_one_title() {
168        let first = SRC.find("<gpio id=\"gpio18\"").unwrap();
169        let second = SRC.find("<led id=\"other\"").unwrap();
170        let d = Diagnostic::error(
171            codes::CONFLICT_PIN,
172            first..first + 5,
173            "pin conflict",
174            "<gpio id=\"gpio18\"> declares GPIO18 here",
175        )
176        .with_label(second..second + 4, "<led id=\"other\"> claims it here");
177
178        let out = to_human("blink.espf", SRC, &[d]);
179
180        assert_eq!(out.matches("error:").count(), 1, "{out}");
181        assert!(out.contains("declares GPIO18 here"), "{out}");
182        assert!(out.contains("claims it here"), "{out}");
183        assert!(out.contains("blink.espf:2"), "{out}");
184        assert!(out.contains("blink.espf:5"), "{out}");
185    }
186
187    #[test]
188    fn several_labels_on_one_line_share_a_snippet() {
189        let src = "a <led> and <button> collide\n";
190        let d = Diagnostic::error(codes::CONFLICT_PIN, 2..7, "pin clash", "led uses GPIO9")
191            .with_label(12..20, "button also uses GPIO9");
192        let out = to_human("x.espf", src, &[d]);
193
194        assert_eq!(out.matches("error:").count(), 1, "{out}");
195        assert!(out.contains("led uses GPIO9"), "{out}");
196        assert!(out.contains("button also uses GPIO9"), "{out}");
197    }
198
199    #[test]
200    fn help_renders_after_the_snippet() {
201        let d = Diagnostic::error(codes::UNCLASSIFIED, 0..1, "bad", "here")
202            .with_help("use another pin");
203        let out = to_human("x.espf", "abc\n", &[d]);
204        assert!(out.contains("help: use another pin"), "{out}");
205    }
206
207    #[test]
208    fn a_primary_span_without_a_label_still_underlines_it() {
209        let d = Diagnostic {
210            code: DiagCode::new("E0701"),
211            severity: Severity::Warning,
212            message: "heads up".into(),
213            primary: 2..5,
214            labels: Vec::new(),
215            help: None,
216            fixes: Vec::new(),
217        };
218        let out = to_human("x.espf", "abcdef\n", &[d]);
219        assert!(out.contains("warning:"), "{out}");
220        assert!(out.contains("heads up"), "{out}");
221    }
222
223    #[test]
224    fn fixes_do_not_change_human_output() {
225        let d = Diagnostic::error(codes::UNCLASSIFIED, 0..3, "bad", "here").with_fix(Fix {
226            title: "rename it".into(),
227            edits: vec![Edit {
228                span: 0..3,
229                text: "new".into(),
230            }],
231        });
232        let with_fix = to_human("x.espf", "abcdef\n", &[d.clone()]);
233        let without = to_human(
234            "x.espf",
235            "abcdef\n",
236            &[Diagnostic {
237                fixes: Vec::new(),
238                ..d
239            }],
240        );
241        assert_eq!(with_fix, without);
242    }
243}