1use annotate_snippets::{AnnotationKind, Group, Level, Renderer, Snippet};
5
6use crate::{Diagnostic, Severity};
7
8pub 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
33fn 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 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 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
113fn 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
123fn 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
133fn 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::CORE_UNKNOWN_ELEMENT,
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::CORE_UNKNOWN_ELEMENT,
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(
191 codes::CORE_UNKNOWN_ELEMENT,
192 2..7,
193 "pin clash",
194 "led uses GPIO9",
195 )
196 .with_label(12..20, "button also uses GPIO9");
197 let out = to_human("x.espf", src, &[d]);
198
199 assert_eq!(out.matches("error:").count(), 1, "{out}");
200 assert!(out.contains("led uses GPIO9"), "{out}");
201 assert!(out.contains("button also uses GPIO9"), "{out}");
202 }
203
204 #[test]
205 fn help_renders_after_the_snippet() {
206 let d = Diagnostic::error(codes::CORE_UNKNOWN_ELEMENT, 0..1, "bad", "here")
207 .with_help("use another pin");
208 let out = to_human("x.espf", "abc\n", &[d]);
209 assert!(out.contains("help: use another pin"), "{out}");
210 }
211
212 #[test]
213 fn a_primary_span_without_a_label_still_underlines_it() {
214 let d = Diagnostic {
215 code: DiagCode::new("E0701"),
216 severity: Severity::Warning,
217 message: "heads up".into(),
218 primary: 2..5,
219 labels: Vec::new(),
220 help: None,
221 fixes: Vec::new(),
222 };
223 let out = to_human("x.espf", "abcdef\n", &[d]);
224 assert!(out.contains("warning:"), "{out}");
225 assert!(out.contains("heads up"), "{out}");
226 }
227
228 #[test]
229 fn fixes_do_not_change_human_output() {
230 let d = Diagnostic::error(codes::CORE_UNKNOWN_ELEMENT, 0..3, "bad", "here").with_fix(Fix {
231 title: "rename it".into(),
232 edits: vec![Edit {
233 span: 0..3,
234 text: "new".into(),
235 }],
236 });
237 let with_fix = to_human("x.espf", "abcdef\n", &[d.clone()]);
238 let without = to_human(
239 "x.espf",
240 "abcdef\n",
241 &[Diagnostic {
242 fixes: Vec::new(),
243 ..d
244 }],
245 );
246 assert_eq!(with_fix, without);
247 }
248}