Skip to main content

deed_diagnostics/
render.rs

1//! Renderings of a [`Diagnostic`].
2//!
3//! Both renderers read the same struct. Neither is derived from the other, and
4//! in particular the machine readable form is not produced by parsing the human
5//! one. That is the whole point of P7: the data is the source of truth and the
6//! text is a view.
7
8use crate::diagnostic::{Diagnostic, Label, SuggestedEdit};
9use crate::source::{FileId, SourceFile, SourceMap};
10use crate::span::Span;
11
12/// Renders a diagnostic for a person, with the offending line and an underline.
13pub 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    // The gutter is wide enough for every line number that will be printed,
18    // whichever file each of them came out of. One width for the whole
19    // diagnostic, because two of them would make the carets stop lining up
20    // down the page.
21    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        // A label about another file says so. Without this the caret moves
51        // files and the reader is told nothing, which is worse than the label
52        // being missing: they would read it as another line of the file above.
53        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            // One replacement is the text that goes in, and reads as itself.
82            [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            // Several only mean anything together and in the place they go. A
93            // `to_string(` and a `)` on two lines of their own say nothing
94            // about the line they were going to make, so show the line.
95            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
106/// The line the edits fall on, with all of them applied.
107///
108/// `None` when they do not all land on one line, which is a repair with no
109/// single line to show rather than a repair to be shown badly.
110fn 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
162/// Number of carets to draw, clamped to the first line the span touches.
163fn 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
169/// Renders a diagnostic as a single line of JSON, for tools.
170///
171/// Written by hand so the compiler has no dependencies while it is this small.
172/// If the shape grows past what is comfortable here, reach for a real
173/// serializer rather than making this cleverer.
174pub 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(&notes), 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
244/// One label, with the file its span is an offset into.
245///
246/// The name is on every label rather than only on the ones that differ from
247/// the diagnostic's. A reader of this output would otherwise have to know the
248/// rule to work out what a missing key meant, and P7 says this form is the API
249/// rather than a view of the other one.
250fn 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
273/// Quotes and escapes `value` as a JSON string, delimiters included.
274///
275/// Public because this crate is not the only thing that writes this format:
276/// `deed-wasm` hands a page the same shape, and a second implementation of
277/// the escaping rules is a second place for them to be wrong.
278pub 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    /// One replacement is the text that goes in and reads as itself. Two of
369    /// them are `to_string(` and `)`, which on lines of their own say nothing
370    /// about the line they were going to make.
371    #[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    /// The line above `line`, so a test can say "there is a gap here" rather
398    /// than count spaces.
399    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    /// The blank gutter line between the code and what is said about it.
410    ///
411    /// `cargo mutants` found this one: with the separator gone a note runs
412    /// straight on from the caret line and nothing noticed. Both halves of
413    /// the condition get a test, because a note with no fix and a fix with no
414    /// note are both ordinary and either one alone leaves the other unheld.
415    #[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        // The replacement is what a reader is shown, and an empty one is a
456        // gutter with nothing after it. Saying "here is what it would look
457        // like" and then showing a blank line is worse than saying nothing.
458        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    /// Two files, so that a wrong answer cannot come out looking right.
479    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    /// Where a piece of text is, so a test says what it means rather than a
490    /// pair of byte offsets nobody can check by reading.
491    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        // Before a label could carry a file, this label was either dropped or
504        // drawn over whatever sat at those byte offsets in the file above it.
505        // The header is the part that matters: without it the caret changes
506        // files and a reader is told nothing, so they read it as another line
507        // of the first one.
508        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        // Drawn from the other file's text, not from the first one's bytes.
530        assert!(text.contains("7 |     n > 1,"), "{text}");
531    }
532
533    #[test]
534    fn a_label_about_the_diagnostics_own_file_says_nothing_extra() {
535        // The other 23 of the 31 places this compiler builds a secondary
536        // label. Adding a file to `Label` may not add a line to any of them.
537        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        // `with_secondary_in` is for a producer that has a file in hand and
554        // cannot always tell whether it is the same one. Making it say
555        // something different when the two agree would push that decision back
556        // onto every caller, which is the arrangement this replaced.
557        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        // P7: this form is the API rather than a view of the human one. A
572        // reader of it cannot apply the rule that a missing key means the
573        // diagnostic's own file unless they already know the rule, so the name
574        // is on every label including the primary.
575        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}