Skip to main content

submilli_engine/
diagnostics.rs

1use crate::source::{SourceError, Sources};
2use crate::{LineIndex, Span};
3
4#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
5pub enum Severity {
6    Error,
7    Warning,
8}
9
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Diagnostic {
12    pub severity: Severity,
13    pub span: Span,
14    pub message: String,
15    /// Unanchored hint blocks rendered after the primary, before notes. Span-anchored secondaries go in `notes`.
16    pub help: Vec<String>,
17    pub notes: Vec<(Span, String)>,
18}
19
20use crate::rendering::{RenderError, RenderLimits, RenderedText, Writer};
21
22/// Borrowed diagnostic fields for callers with a different owned diagnostic model.
23pub struct DiagnosticView<'a> {
24    pub severity: Severity,
25    pub span: Span,
26    pub message: &'a str,
27    pub help: &'a [String],
28    pub notes: &'a [(Span, String)],
29}
30
31impl<'a> From<&'a Diagnostic> for DiagnosticView<'a> {
32    fn from(value: &'a Diagnostic) -> Self {
33        Self {
34            severity: value.severity,
35            span: value.span,
36            message: &value.message,
37            help: &value.help,
38            notes: &value.notes,
39        }
40    }
41}
42
43pub fn render(diagnostic: &Diagnostic, sources: &Sources) -> String {
44    render_checked(diagnostic, sources).map_or_else(
45        |error| crate::rendering::failure_text(&diagnostic.message, &error),
46        |rendered| rendered.text,
47    )
48}
49
50pub fn render_checked(
51    diagnostic: &Diagnostic,
52    sources: &Sources,
53) -> Result<RenderedText, RenderError> {
54    render_with_limits(diagnostic, sources, RenderLimits::default())
55}
56
57pub fn render_with_limits(
58    diagnostic: &Diagnostic,
59    sources: &Sources,
60    limits: RenderLimits,
61) -> Result<RenderedText, RenderError> {
62    Writer::render(limits, |out| {
63        write_diagnostic(out, &diagnostic.into(), sources)
64    })
65}
66
67pub fn render_collection(
68    diagnostics: &[Diagnostic],
69    sources: &Sources,
70) -> Result<RenderedText, RenderError> {
71    render_collection_with_limits(diagnostics, sources, RenderLimits::collection())
72}
73
74pub fn render_collection_with_limits(
75    diagnostics: &[Diagnostic],
76    sources: &Sources,
77    limits: RenderLimits,
78) -> Result<RenderedText, RenderError> {
79    render_views(
80        diagnostics.iter().map(|diagnostic| Ok(diagnostic.into())),
81        sources,
82        limits,
83    )
84}
85
86pub fn render_views<'a>(
87    diagnostics: impl IntoIterator<Item = Result<DiagnosticView<'a>, RenderError>>,
88    sources: &Sources,
89    limits: RenderLimits,
90) -> Result<RenderedText, RenderError> {
91    Writer::render(limits, |out| {
92        for diagnostic in diagnostics {
93            out.step()?;
94            let diagnostic = diagnostic?;
95            let rendered = Writer::render(out.child_limits(), |child| {
96                write_diagnostic(child, &diagnostic, sources)
97            })?;
98            out.append(rendered)?;
99        }
100        Ok(())
101    })
102}
103
104fn write_diagnostic(
105    out: &mut Writer,
106    diagnostic: &DiagnosticView<'_>,
107    sources: &Sources,
108) -> Result<(), RenderError> {
109    validate_span(sources, diagnostic.span)?;
110    let severity = match diagnostic.severity {
111        Severity::Error => "error",
112        Severity::Warning => "warning",
113    };
114    let mut max_line = 1;
115    let validation_allowance = out.remaining_steps() / 2;
116    let mut validation_truncated = false;
117    for (visited, span) in std::iter::once(diagnostic.span)
118        .chain(diagnostic.notes.iter().map(|(span, _)| *span))
119        .enumerate()
120    {
121        if visited >= validation_allowance {
122            validation_truncated = true;
123            break;
124        }
125        out.step()?;
126        validate_span(sources, span)?;
127        if let Some(source) = sources.get(span.file) {
128            let index = source.line_index();
129            let (line, _) = index.line_col(span.end)?;
130            max_line = max_line.max(line.saturating_add(1).min(index.line_count()));
131        }
132    }
133    out.format(format_args!("{severity}: {}\n", diagnostic.message))?;
134    if validation_truncated {
135        return Err(RenderError::Truncated);
136    }
137    let width = max_line.to_string().len();
138    write_context(out, sources, diagnostic.span, width)?;
139    for help in diagnostic.help {
140        out.format(format_args!("{:width$} |\nhelp: {help}\n", ""))?;
141    }
142    for (span, message) in diagnostic.notes {
143        out.format(format_args!("{:width$} |\nnote: {message}\n", ""))?;
144        write_context(out, sources, *span, width)?;
145    }
146    Ok(())
147}
148
149pub(crate) fn validate_span(sources: &Sources, span: Span) -> Result<(), SourceError> {
150    Span::new(span.file, span.start, span.end)?;
151    if span.file.reserved_path().is_some() {
152        return Ok(());
153    }
154    sources
155        .get(span.file)
156        .ok_or(SourceError::UnknownFile { file: span.file })?
157        .span_text(span)?;
158    Ok(())
159}
160
161fn write_context(
162    out: &mut Writer,
163    sources: &Sources,
164    span: Span,
165    width: usize,
166) -> Result<(), RenderError> {
167    if let Some(path) = span.file.reserved_path() {
168        return out.format(format_args!("{:width$}--> {path}\n", ""));
169    }
170    let source = sources
171        .get(span.file)
172        .ok_or(SourceError::UnknownFile { file: span.file })?;
173    let index = source.line_index();
174    let (line, col) = index.line_col(span.start)?;
175    out.format(format_args!(
176        "{:width$}--> {}:{line}:{col}\n{:width$} |\n",
177        "", source.path, ""
178    ))?;
179    write_source_block(out, index, span, width)
180}
181
182pub(crate) fn write_source_block(
183    out: &mut Writer,
184    index: &LineIndex,
185    span: Span,
186    width: usize,
187) -> Result<(), RenderError> {
188    span.text(index.source(), span.file)?;
189    let (start, start_col) = index.line_col(span.start)?;
190    let (end, end_col) = index.line_col(span.end)?;
191    if start > 1 {
192        write_line(out, index, start - 1, width)?;
193    }
194    for line in start..=end {
195        out.step()?;
196        let text = index.line_text(line)?;
197        write_line(out, index, line, width)?;
198        let indent = if line == start {
199            display_column(out, text, start_col.saturating_sub(1))?
200        } else {
201            0
202        };
203        let last = if line == end {
204            end_col.saturating_sub(1)
205        } else {
206            u32::MAX
207        };
208        let carets = display_column(out, text, last)?
209            .saturating_sub(indent)
210            .max(1);
211        out.format(format_args!("{:width$} | ", ""))?;
212        out.repeat(' ', indent)?;
213        out.repeat('^', carets)?;
214        out.push("\n")?;
215    }
216    if end < index.line_count() {
217        write_line(out, index, end + 1, width)?;
218    }
219    Ok(())
220}
221
222fn write_line(
223    out: &mut Writer,
224    index: &LineIndex,
225    line: u32,
226    width: usize,
227) -> Result<(), RenderError> {
228    use unicode_width::UnicodeWidthStr;
229    out.format(format_args!("{line:>width$} | "))?;
230    let text = index.line_text(line)?;
231    let mut column = 0usize;
232    for (i, segment) in text.split('\t').enumerate() {
233        out.step()?;
234        if i != 0 {
235            let spaces = 4 - column % 4;
236            out.repeat(' ', spaces)?;
237            column = column.checked_add(spaces).ok_or(RenderError::Formatting)?;
238        }
239        // The byte limit is checked before scanning potentially huge text for width.
240        out.push(segment)?;
241        column = column
242            .checked_add(segment.width())
243            .ok_or(RenderError::Formatting)?;
244    }
245    out.push("\n")
246}
247
248fn display_column(out: &mut Writer, line: &str, offset: u32) -> Result<usize, RenderError> {
249    use unicode_width::UnicodeWidthStr;
250    let mut end = (offset as usize).min(line.len());
251    while !line.is_char_boundary(end) {
252        end = end.saturating_sub(1);
253    }
254    let text = line.get(..end).ok_or(RenderError::Formatting)?;
255    let mut column = 0usize;
256    for (i, segment) in text.split('\t').enumerate() {
257        out.step()?;
258        if i != 0 {
259            column = column
260                .checked_add(4 - column % 4)
261                .ok_or(RenderError::Formatting)?;
262        }
263        // A line successfully emitted above is already bounded by the output limit.
264        column = column
265            .checked_add(segment.width())
266            .ok_or(RenderError::Formatting)?;
267    }
268    Ok(column)
269}
270
271/// Individually rendered warnings with a shared collection byte/work allowance.
272pub fn render_list(
273    diagnostics: &[Diagnostic],
274    sources: &Sources,
275) -> Result<Vec<String>, RenderError> {
276    let mut result = Vec::new();
277    let mut remaining = RenderLimits::collection().bytes;
278    let mut steps = RenderLimits::collection().steps;
279    for diagnostic in diagnostics.iter().take(RenderLimits::collection().steps) {
280        if remaining < crate::rendering::TRUNCATED.len() * 2 || steps == 0 {
281            break;
282        }
283        let rendered = render_with_limits(
284            diagnostic,
285            sources,
286            RenderLimits {
287                bytes: remaining
288                    .min(RenderLimits::default().bytes)
289                    .saturating_sub(crate::rendering::TRUNCATED.len()),
290                steps,
291                ..RenderLimits::default()
292            },
293        )?;
294        steps = steps.saturating_sub(rendered.steps());
295        remaining = remaining.saturating_sub(rendered.text.len());
296        result.try_reserve(1).map_err(|_| RenderError::Allocation)?;
297        result.push(rendered.text);
298    }
299    if result.len() < diagnostics.len() {
300        result.try_reserve(1).map_err(|_| RenderError::Allocation)?;
301        result.push(crate::rendering::TRUNCATED.into());
302    }
303    Ok(result)
304}
305
306#[cfg(test)]
307mod tests {
308    use super::{Diagnostic, Severity, render};
309    use crate::source::Sources;
310    use crate::{FileId, Span};
311
312    const F: FileId = FileId(0);
313
314    fn sources(text: &str) -> Sources {
315        let (sources, _) = Sources::single("script.subm", text).unwrap();
316        sources
317    }
318
319    #[test]
320    fn construct_and_read_fields() {
321        let d = Diagnostic {
322            severity: Severity::Error,
323            span: Span::new(F, 10, 20).unwrap(),
324            message: "type mismatch".to_string(),
325            help: vec![],
326            notes: vec![(Span::new(F, 5, 8).unwrap(), "defined here".to_string())],
327        };
328        assert_eq!(d.severity, Severity::Error);
329        assert_eq!(d.span, Span::new(F, 10, 20).unwrap());
330        assert_eq!(d.message, "type mismatch");
331        assert_eq!(d.notes.len(), 1);
332        assert_eq!(d.notes[0].0, Span::new(F, 5, 8).unwrap());
333        assert_eq!(d.notes[0].1, "defined here");
334    }
335
336    #[test]
337    fn clone_is_equal() {
338        let d = Diagnostic {
339            severity: Severity::Warning,
340            span: Span::new(F, 0, 3).unwrap(),
341            message: "unused variable".to_string(),
342            help: vec![],
343            notes: vec![],
344        };
345        assert_eq!(d.clone(), d);
346    }
347
348    #[test]
349    fn render_single_line_error() {
350        let source = "let x = 1;\nlet y: number = \"hello\";\nlet z = 2;\n";
351        let diag = Diagnostic {
352            severity: Severity::Error,
353            span: Span::new(F, 27, 34).unwrap(),
354            message: "type mismatch: expected `number`, found `string`".to_string(),
355            help: vec![],
356            notes: vec![],
357        };
358        insta::assert_snapshot!(render(&diag, &sources(source)));
359    }
360
361    #[test]
362    fn carets_count_characters_not_bytes() {
363        // `é` and `—` are 2 and 3 bytes; the carets must still sit under `bad`.
364        let source = "let s = \"é—\"; bad;\n";
365        let start = source.find("bad").unwrap() as u32;
366        let diag = Diagnostic {
367            severity: Severity::Error,
368            span: Span::new(F, start, start + 3).unwrap(),
369            message: "unresolved identifier `bad`".to_string(),
370            help: vec![],
371            notes: vec![],
372        };
373        let rendered = render(&diag, &sources(source));
374        let caret_line = rendered.lines().find(|l| l.contains('^')).unwrap();
375        let source_line = rendered.lines().find(|l| l.contains("bad;")).unwrap();
376        let caret_col = caret_line.chars().position(|c| c == '^').unwrap();
377        let bad_col = source_line[..source_line.find("bad").unwrap()]
378            .chars()
379            .count();
380        assert_eq!(caret_col, bad_col, "{rendered}");
381        assert_eq!(caret_line.matches('^').count(), 3, "{rendered}");
382    }
383
384    #[test]
385    fn carets_follow_terminal_columns() {
386        for (source, target, indent, width) in [
387            ("let s = \"日本😀\"; bad;", "bad", 18, 3),
388            ("\tlet s = \"e\u{301}\";\tbad;", "bad", 20, 3),
389            ("x\t日本😀;", "日本😀", 4, 6),
390            ("x\tbad;", "\tbad", 1, 6),
391        ] {
392            let start = source.find(target).unwrap();
393            let diag = Diagnostic {
394                severity: Severity::Error,
395                span: Span::new(F, start as u32, (start + target.len()) as u32).unwrap(),
396                message: "bad value".into(),
397                help: vec![],
398                notes: vec![],
399            };
400            let rendered = render(&diag, &sources(source));
401            assert!(!rendered.contains('\t'), "{rendered}");
402            let caret = rendered.lines().find(|line| line.contains('^')).unwrap();
403            assert_eq!(caret.find('^').unwrap(), 4 + indent, "{rendered}");
404            assert_eq!(caret.matches('^').count(), width, "{rendered}");
405            assert!(rendered.contains(&format!("script.subm:1:{}", start + 1)));
406        }
407    }
408
409    #[test]
410    fn multiline_carets_use_terminal_widths() {
411        let source = "a\t日\n\t😀e\u{301}\n終z";
412        let end = source.find('終').unwrap() + '終'.len_utf8();
413        let diag = Diagnostic {
414            severity: Severity::Error,
415            span: Span::new(F, 1, end as u32).unwrap(),
416            message: "multiline".into(),
417            help: vec![],
418            notes: vec![],
419        };
420        let rendered = render(&diag, &sources(source));
421        let carets: Vec<_> = rendered.lines().filter(|line| line.contains('^')).collect();
422        assert_eq!(
423            carets,
424            ["  |  ^^^^^", "  | ^^^^^^^", "  | ^^"],
425            "{rendered}"
426        );
427        assert!(!rendered.contains('\t'));
428    }
429
430    #[test]
431    fn render_multi_note_error_with_gutter_alignment() {
432        // 12 lines, each "N\n" padded so line 11 exists to force 2-digit gutter.
433        let source = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\n";
434        let diag = Diagnostic {
435            severity: Severity::Error,
436            span: Span::new(F, 2, 3).unwrap(), // "b" on line 2
437            message: "missing return in all code paths".to_string(),
438            help: vec![],
439            notes: vec![
440                (
441                    Span::new(F, 0, 1).unwrap(),
442                    "function declared here".to_string(),
443                ),
444                (
445                    Span::new(F, 20, 21).unwrap(),
446                    "this branch returns, but else does not".to_string(),
447                ),
448            ],
449        };
450        insta::assert_snapshot!(render(&diag, &sources(source)));
451    }
452
453    #[test]
454    fn render_zero_length_span_at_eof() {
455        let source = "hello";
456        let diag = Diagnostic {
457            severity: Severity::Error,
458            span: Span::new(F, 5, 5).unwrap(),
459            message: "expected `;`".to_string(),
460            help: vec![],
461            notes: vec![],
462        };
463        insta::assert_snapshot!(render(&diag, &sources(source)));
464    }
465
466    #[test]
467    fn render_warning_severity() {
468        let source = "let unused = 42;\n";
469        let diag = Diagnostic {
470            severity: Severity::Warning,
471            span: Span::new(F, 4, 10).unwrap(),
472            message: "unused variable `unused`".to_string(),
473            help: vec![],
474            notes: vec![],
475        };
476        insta::assert_snapshot!(render(&diag, &sources(source)));
477    }
478
479    #[test]
480    fn render_clamps_at_sof() {
481        // Primary on line 1: no line above; line below should still render.
482        let source = "first\nsecond\nthird\n";
483        let diag = Diagnostic {
484            severity: Severity::Error,
485            span: Span::new(F, 0, 5).unwrap(),
486            message: "boom".to_string(),
487            help: vec![],
488            notes: vec![],
489        };
490        insta::assert_snapshot!(render(&diag, &sources(source)));
491    }
492
493    #[test]
494    fn render_clamps_at_eof() {
495        // Primary on the last content line of a file without trailing
496        // newline: line above renders, no line below.
497        let source = "first\nsecond\nthird";
498        let diag = Diagnostic {
499            severity: Severity::Error,
500            span: Span::new(F, 13, 18).unwrap(),
501            message: "boom".to_string(),
502            help: vec![],
503            notes: vec![],
504        };
505        insta::assert_snapshot!(render(&diag, &sources(source)));
506    }
507
508    #[test]
509    fn render_multiline_span_carets() {
510        // 3-line span starting mid-line 2 and ending mid-line 4: start
511        // line carets extend to EOL, middle line is fully under
512        // carets, end line stops at end column.
513        let source = "line1\nline2 start\nmiddle\nend stop here\nline5\n";
514        let start = source.find("start").unwrap() as u32;
515        let end = source.find("end").unwrap() as u32 + 3;
516        let diag = Diagnostic {
517            severity: Severity::Error,
518            span: Span::new(F, start, end).unwrap(),
519            message: "multi-line span".to_string(),
520            help: vec![],
521            notes: vec![],
522        };
523        insta::assert_snapshot!(render(&diag, &sources(source)));
524    }
525
526    #[test]
527    fn render_with_help() {
528        let source = "let x = foo.bar;\n";
529        let diag = Diagnostic {
530            severity: Severity::Error,
531            span: Span::new(F, 12, 15).unwrap(),
532            message: "field `bar` does not exist on `Foo`".to_string(),
533            help: vec!["interface Foo {\n  baz: number;\n}".to_string()],
534            notes: vec![],
535        };
536        insta::assert_snapshot!(render(&diag, &sources(source)));
537    }
538
539    #[test]
540    fn render_with_help_and_notes() {
541        let source = "a\nb\nc\n";
542        let diag = Diagnostic {
543            severity: Severity::Error,
544            span: Span::new(F, 2, 3).unwrap(),
545            message: "bad".to_string(),
546            help: vec!["try this instead".to_string()],
547            notes: vec![(Span::new(F, 0, 1).unwrap(), "declared here".to_string())],
548        };
549        insta::assert_snapshot!(render(&diag, &sources(source)));
550    }
551
552    #[test]
553    fn render_multiple_helps() {
554        let source = "x\n";
555        let diag = Diagnostic {
556            severity: Severity::Error,
557            span: Span::new(F, 0, 1).unwrap(),
558            message: "bad".to_string(),
559            help: vec!["first hint".to_string(), "second hint".to_string()],
560            notes: vec![],
561        };
562        insta::assert_snapshot!(render(&diag, &sources(source)));
563    }
564}