1use crate::diagnostic::{Diagnostic, Label, SuggestedEdit};
9use crate::source::{FileId, SourceFile, SourceMap};
10use crate::span::Span;
11
12pub fn render_human(map: &SourceMap, diagnostic: &Diagnostic) -> String {
14 let file = map.file(diagnostic.file);
15 let primary = file.location(diagnostic.primary.span.start);
16
17 let widest_line = std::iter::once(primary.line)
22 .chain(diagnostic.secondary.iter().map(|label| {
23 map.file(label.file_or(diagnostic.file))
24 .location(label.span.start)
25 .line
26 }))
27 .max()
28 .unwrap_or(1);
29 let gutter = widest_line.to_string().len();
30
31 let mut out = String::new();
32 out.push_str(&format!(
33 "{}[{}]: {}\n",
34 diagnostic.severity.as_str(),
35 diagnostic.code,
36 diagnostic.message
37 ));
38 out.push_str(&format!(
39 "{:gutter$}--> {}:{}:{}\n",
40 "",
41 file.name(),
42 primary.line,
43 primary.column,
44 gutter = gutter + 1
45 ));
46
47 push_snippet(&mut out, file, &diagnostic.primary, '^', gutter);
48 for label in &diagnostic.secondary {
49 let label_file = map.file(label.file_or(diagnostic.file));
50 if label.file.is_some_and(|other| other != diagnostic.file) {
54 let at = label_file.location(label.span.start);
55 out.push_str(&format!(
56 "{:gutter$} |\n{:gutter$}--> {}:{}:{}\n",
57 "",
58 "",
59 label_file.name(),
60 at.line,
61 at.column,
62 gutter = gutter + 1
63 ));
64 }
65 push_snippet(&mut out, label_file, label, '-', gutter);
66 }
67
68 if !diagnostic.notes.is_empty() || diagnostic.fix.is_some() {
69 out.push_str(&format!("{:gutter$} |\n", "", gutter = gutter + 1));
70 }
71 for note in &diagnostic.notes {
72 out.push_str(&format!(
73 "{:gutter$} = note: {note}\n",
74 "",
75 gutter = gutter + 1
76 ));
77 }
78 if let Some(fix) = &diagnostic.fix {
79 out.push_str(&format!("help: {}\n", fix.message));
80 match fix.edits.as_slice() {
81 [edit] => {
83 if !edit.replacement.is_empty() {
84 out.push_str(&format!(
85 "{:gutter$} | {}\n",
86 "",
87 edit.replacement,
88 gutter = gutter + 1
89 ));
90 }
91 }
92 edits => {
96 if let Some(line) = rewritten_line(file, edits) {
97 out.push_str(&format!("{:gutter$} | {}\n", "", line, gutter = gutter + 1));
98 }
99 }
100 }
101 }
102
103 out
104}
105
106fn rewritten_line(file: &SourceFile, edits: &[SuggestedEdit]) -> Option<String> {
111 let text = file.text();
112 let first = edits.first()?.span.start as usize;
113 let last = edits.last()?.span.end as usize;
114 if first > last || last > text.len() {
115 return None;
116 }
117
118 let start = text[..first].rfind('\n').map_or(0, |at| at + 1);
119 let end = text[last..].find('\n').map_or(text.len(), |at| last + at);
120 if text[start..end].contains('\n') {
121 return None;
122 }
123
124 let mut line = text[start..end].to_string();
125 for edit in edits.iter().rev() {
126 let from = (edit.span.start as usize).checked_sub(start)?;
127 let to = (edit.span.end as usize).checked_sub(start)?;
128 if from > to
129 || to > line.len()
130 || !line.is_char_boundary(from)
131 || !line.is_char_boundary(to)
132 {
133 return None;
134 }
135 line.replace_range(from..to, &edit.replacement);
136 }
137 Some(line.trim_start().to_string())
138}
139
140fn push_snippet(out: &mut String, file: &SourceFile, label: &Label, caret: char, gutter: usize) {
141 let location = file.location(label.span.start);
142 let line_text = file.line_text(location.line);
143 let width = underline_width(file, label.span);
144
145 out.push_str(&format!("{:gutter$} |\n", "", gutter = gutter + 1));
146 out.push_str(&format!(
147 "{:>gutter$} | {}\n",
148 location.line,
149 line_text,
150 gutter = gutter + 1
151 ));
152 out.push_str(&format!(
153 "{:gutter$} | {}{} {}\n",
154 "",
155 " ".repeat(location.column.saturating_sub(1) as usize),
156 caret.to_string().repeat(width),
157 label.message,
158 gutter = gutter + 1
159 ));
160}
161
162fn underline_width(file: &SourceFile, span: Span) -> usize {
164 let text = file.slice(span);
165 let first_line = text.split(['\n', '\r']).next().unwrap_or("");
166 first_line.chars().count().max(1)
167}
168
169pub fn render_json(map: &SourceMap, diagnostic: &Diagnostic) -> String {
175 let file = map.file(diagnostic.file);
176 let mut out = String::from("{");
177
178 push_field(&mut out, "code", &json_string(diagnostic.code), true);
179 push_field(
180 &mut out,
181 "severity",
182 &json_string(diagnostic.severity.as_str()),
183 false,
184 );
185 push_field(
186 &mut out,
187 "message",
188 &json_string(&diagnostic.message),
189 false,
190 );
191 push_field(&mut out, "file", &json_string(file.name()), false);
192 push_field(
193 &mut out,
194 "primary",
195 &json_label(map, diagnostic.file, &diagnostic.primary),
196 false,
197 );
198
199 let secondary: Vec<String> = diagnostic
200 .secondary
201 .iter()
202 .map(|label| json_label(map, diagnostic.file, label))
203 .collect();
204 push_field(&mut out, "secondary", &json_array(&secondary), false);
205
206 let notes: Vec<String> = diagnostic.notes.iter().map(|n| json_string(n)).collect();
207 push_field(&mut out, "notes", &json_array(¬es), false);
208
209 let fix = match &diagnostic.fix {
210 None => "null".to_string(),
211 Some(fix) => {
212 let edits: Vec<String> = fix
213 .edits
214 .iter()
215 .map(|edit| {
216 format!(
217 "{{\"span\":{},\"replacement\":{}}}",
218 json_span(file, edit.span),
219 json_string(&edit.replacement)
220 )
221 })
222 .collect();
223 format!(
224 "{{\"message\":{},\"applicability\":{},\"edits\":{}}}",
225 json_string(&fix.message),
226 json_string(fix.applicability.as_str()),
227 json_array(&edits)
228 )
229 }
230 };
231 push_field(&mut out, "fix", &fix, false);
232
233 out.push('}');
234 out
235}
236
237fn push_field(out: &mut String, name: &str, value: &str, first: bool) {
238 if !first {
239 out.push(',');
240 }
241 out.push_str(&format!("\"{name}\":{value}"));
242}
243
244fn json_label(map: &SourceMap, diagnostic: FileId, label: &Label) -> String {
251 let file = map.file(label.file_or(diagnostic));
252 format!(
253 "{{\"file\":{},\"span\":{},\"message\":{}}}",
254 json_string(file.name()),
255 json_span(file, label.span),
256 json_string(&label.message)
257 )
258}
259
260fn json_span(file: &SourceFile, span: Span) -> String {
261 let start = file.location(span.start);
262 let end = file.location(span.end);
263 format!(
264 "{{\"start\":{},\"end\":{},\"startLine\":{},\"startColumn\":{},\"endLine\":{},\"endColumn\":{}}}",
265 span.start, span.end, start.line, start.column, end.line, end.column
266 )
267}
268
269fn json_array(items: &[String]) -> String {
270 format!("[{}]", items.join(","))
271}
272
273pub fn json_string(value: &str) -> String {
279 let mut out = String::with_capacity(value.len() + 2);
280 out.push('"');
281 for ch in value.chars() {
282 match ch {
283 '"' => out.push_str("\\\""),
284 '\\' => out.push_str("\\\\"),
285 '\n' => out.push_str("\\n"),
286 '\r' => out.push_str("\\r"),
287 '\t' => out.push_str("\\t"),
288 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
289 c => out.push(c),
290 }
291 }
292 out.push('"');
293 out
294}
295
296#[cfg(test)]
297mod tests {
298 use super::{render_human, render_json};
299 use crate::diagnostic::{Applicability, Diagnostic, SuggestedEdit};
300 use crate::source::SourceMap;
301 use crate::span::Span;
302
303 #[test]
304 fn human_output_points_at_the_right_column() {
305 let source = "module a\nlet x = 1\n";
306 let mut map = SourceMap::new();
307 let file = map.add("t.deed", source);
308 let span = Span::new(
309 source.find('x').unwrap() as u32,
310 source.find('x').unwrap() as u32 + 1,
311 );
312 let d =
313 Diagnostic::error("DEED9001", file, span, "example problem").with_primary_label("here");
314
315 let text = render_human(&map, &d);
316 assert!(text.starts_with("error[DEED9001]: example problem\n"));
317 assert!(text.contains("--> t.deed:2:5"));
318 assert!(text.contains("2 | let x = 1"));
319 assert!(text.contains(" ^ here"));
320 }
321
322 #[test]
323 fn underline_stops_at_the_end_of_the_line() {
324 let source = "\"abc\ndef\n";
325 let mut map = SourceMap::new();
326 let file = map.add("t.deed", source);
327 let d = Diagnostic::error("DEED9002", file, Span::new(0, 8), "spans two lines");
328
329 let text = render_human(&map, &d);
330 assert!(text.contains("^^^^ "), "unexpected rendering:\n{text}");
331 }
332
333 #[test]
334 fn json_escapes_and_carries_locations() {
335 let source = "let s = \"x\n";
336 let mut map = SourceMap::new();
337 let file = map.add("t.deed", source);
338 let d = Diagnostic::error(
339 "DEED9003",
340 file,
341 Span::new(8, 10),
342 "unterminated \"string\"",
343 )
344 .with_note("line one\nline two")
345 .with_fix(
346 "close it",
347 Span::new(10, 10),
348 "\"",
349 Applicability::MachineApplicable,
350 );
351
352 let json = render_json(&map, &d);
353 assert!(json.contains("\"code\":\"DEED9003\""));
354 assert!(json.contains("unterminated \\\"string\\\""));
355 assert!(json.contains("line one\\nline two"));
356 assert!(json.contains("\"applicability\":\"machine-applicable\""));
357 assert!(json.contains("\"startLine\":1,\"startColumn\":9"));
358 }
359
360 #[test]
361 fn json_fix_is_null_when_absent() {
362 let mut map = SourceMap::new();
363 let file = map.add("t.deed", "abc");
364 let d = Diagnostic::error("DEED9004", file, Span::new(0, 1), "no fix");
365 assert!(render_json(&map, &d).contains("\"fix\":null"));
366 }
367
368 #[test]
372 fn a_fix_that_wraps_something_is_shown_as_the_line_it_makes() {
373 let source = "fn f() -> String {\n n as String\n}\n";
374 let mut map = SourceMap::new();
375 let file = map.add("t.deed", source);
376 let d = Diagnostic::error("DEED9005", file, Span::new(25, 34), "no cast").with_edits(
377 "call `to_string`",
378 vec![
379 SuggestedEdit {
380 span: Span::at(23),
381 replacement: "to_string(".to_string(),
382 },
383 SuggestedEdit {
384 span: Span::new(24, 34),
385 replacement: ")".to_string(),
386 },
387 ],
388 Applicability::MachineApplicable,
389 );
390
391 let text = render_human(&map, &d);
392 assert!(text.contains("help: call `to_string`"), "{text}");
393 assert!(text.contains("| to_string(n)\n"), "{text}");
394 assert!(!text.contains("| )\n"), "{text}");
395 }
396
397 fn line_before<'a>(text: &'a str, line: &str) -> &'a str {
400 let lines: Vec<&str> = text.lines().collect();
401 let at = lines
402 .iter()
403 .position(|written| written.trim_start().starts_with(line))
404 .unwrap_or_else(|| panic!("{line:?} should be in:\n{text}"));
405 assert!(at > 0, "{line:?} is the first line, so nothing is above it");
406 lines[at - 1]
407 }
408
409 #[test]
416 fn a_note_is_separated_from_the_code_it_is_about() {
417 let mut map = SourceMap::new();
418 let file = map.add("t.deed", "module a\nlet x = 1\n");
419 let d = Diagnostic::error("DEED9006", file, Span::new(13, 14), "example problem")
420 .with_primary_label("here")
421 .with_note("something worth saying");
422
423 let text = render_human(&map, &d);
424 assert_eq!(
425 line_before(&text, "= note:").trim(),
426 "|",
427 "the note should not run straight on from the caret:\n{text}"
428 );
429 }
430
431 #[test]
432 fn a_fix_with_no_note_is_separated_the_same_way() {
433 let mut map = SourceMap::new();
434 let file = map.add("t.deed", "module a\nlet x = 1\n");
435 let d = Diagnostic::error("DEED9007", file, Span::new(13, 14), "example problem")
436 .with_primary_label("here")
437 .with_fix(
438 "call it something else",
439 Span::new(13, 14),
440 "y",
441 Applicability::MachineApplicable,
442 );
443
444 let text = render_human(&map, &d);
445 assert!(!text.contains("= note:"), "{text}");
446 assert_eq!(
447 line_before(&text, "help:").trim(),
448 "|",
449 "the help should not run straight on from the caret:\n{text}"
450 );
451 }
452
453 #[test]
454 fn a_fix_that_only_deletes_offers_no_line_to_read() {
455 let mut map = SourceMap::new();
459 let file = map.add("t.deed", "module a\nlet x = 1\n");
460 let d = Diagnostic::error("DEED9008", file, Span::new(13, 14), "example problem")
461 .with_primary_label("here")
462 .with_fix(
463 "take it out",
464 Span::new(13, 14),
465 "",
466 Applicability::MachineApplicable,
467 );
468
469 let text = render_human(&map, &d);
470 assert!(text.contains("help: take it out"), "{text}");
471 assert_eq!(
472 text.lines().last(),
473 Some("help: take it out"),
474 "nothing should be offered after the help line:\n{text}"
475 );
476 }
477
478 fn two_files() -> (SourceMap, crate::source::FileId, crate::source::FileId) {
480 let mut map = SourceMap::new();
481 let caller = map.add("caller.deed", "module a\n\nfn f() -> Int {\n g(1)\n}\n");
482 let callee = map.add(
483 "callee.deed",
484 "module b\n\n// a longer file, on purpose\n\nfn g(n: Int) -> Int\n where\n n > 1,\n{\n n\n}\n",
485 );
486 (map, caller, callee)
487 }
488
489 fn spanning(map: &SourceMap, file: crate::source::FileId, text: &str) -> Span {
492 let at = map
493 .file(file)
494 .text()
495 .find(text)
496 .unwrap_or_else(|| panic!("{text:?} should be in {}", map.file(file).name()))
497 as u32;
498 Span::new(at, at + text.len() as u32)
499 }
500
501 #[test]
502 fn a_label_about_another_file_says_which_file_it_is_about() {
503 let (map, caller, callee) = two_files();
509 let d = Diagnostic::error(
510 "DEED9006",
511 caller,
512 spanning(&map, caller, "g(1)"),
513 "the call is wrong",
514 )
515 .with_primary_label("here")
516 .with_secondary_in(
517 callee,
518 spanning(&map, callee, "n > 1"),
519 "the clause it does not satisfy",
520 );
521
522 let text = render_human(&map, &d);
523 assert!(text.contains("--> caller.deed:4:5"), "{text}");
524 assert!(text.contains("--> callee.deed:7:5"), "{text}");
525 assert!(
526 text.contains("----- the clause it does not satisfy"),
527 "{text}"
528 );
529 assert!(text.contains("7 | n > 1,"), "{text}");
531 }
532
533 #[test]
534 fn a_label_about_the_diagnostics_own_file_says_nothing_extra() {
535 let (map, caller, _) = two_files();
538 let d = Diagnostic::error(
539 "DEED9007",
540 caller,
541 spanning(&map, caller, "g(1)"),
542 "the call is wrong",
543 )
544 .with_primary_label("here")
545 .with_secondary(spanning(&map, caller, "fn f()"), "the function it is in");
546
547 let text = render_human(&map, &d);
548 assert_eq!(text.matches("-->").count(), 1, "{text}");
549 }
550
551 #[test]
552 fn passing_the_diagnostics_own_file_reads_the_same_as_not_passing_one() {
553 let (map, caller, _) = two_files();
558 let at = spanning(&map, caller, "g(1)");
559 let there = spanning(&map, caller, "fn f()");
560 let plain =
561 Diagnostic::error("DEED9008", caller, at, "problem").with_secondary(there, "there");
562 let spelled = Diagnostic::error("DEED9008", caller, at, "problem")
563 .with_secondary_in(caller, there, "there");
564
565 assert_eq!(render_human(&map, &plain), render_human(&map, &spelled));
566 assert_eq!(render_json(&map, &plain), render_json(&map, &spelled));
567 }
568
569 #[test]
570 fn json_says_which_file_every_label_is_about() {
571 let (map, caller, callee) = two_files();
576 let d = Diagnostic::error(
577 "DEED9009",
578 caller,
579 spanning(&map, caller, "g(1)"),
580 "the call is wrong",
581 )
582 .with_secondary_in(
583 callee,
584 spanning(&map, callee, "n > 1"),
585 "the clause it does not satisfy",
586 );
587
588 let json = render_json(&map, &d);
589 assert!(
590 json.contains("\"primary\":{\"file\":\"caller.deed\""),
591 "{json}"
592 );
593 assert!(
594 json.contains("\"secondary\":[{\"file\":\"callee.deed\""),
595 "{json}"
596 );
597 }
598}