beck_diag/
lib.rs

1//! Diagnostics as structured values, and the one renderer that prints them.
2//!
3//! [`docs/04-compiler-architecture.md`](../../../../docs/04-compiler-architecture.md) §4.5: "for a
4//! language whose main feature is inference, error quality *is* the product". Concretely that means
5//! three things, and this crate is where all three live:
6//!
7//! * every diagnostic is a **value** (code, primary span, secondary spans, notes, fix-its), never a
8//!   formatted string thrown at stderr;
9//! * one renderer is shared by the CLI, the snapshot suite and (later) the LSP, so the two cannot
10//!   drift;
11//! * macro-generated code carries its **expansion chain**, so a type error inside `derive(Json)`
12//!   says where the derive was written.
13
14pub mod depth;
15pub mod index;
16
17use std::fmt::Write as _;
18use std::ops::Range;
19
20/// A file in the compilation. Interned by [`SourceMap`].
21#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct FileId(pub u32);
23
24/// A byte range within a file.
25///
26/// Every `Node` carries one, every `Core` node carries provenance back to a `Node`; that chain is
27/// what lets a placement error point at the annotation the programmer actually wrote.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub struct Span {
30    pub file: FileId,
31    pub start: u32,
32    pub end: u32,
33}
34
35impl Default for Span {
36    /// A node with no source position — macro-generated code that chose not to borrow one.
37    fn default() -> Span {
38        Span::NONE
39    }
40}
41
42impl Span {
43    pub const NONE: Span = Span {
44        file: FileId(u32::MAX),
45        start: 0,
46        end: 0,
47    };
48
49    pub fn new(file: FileId, range: Range<usize>) -> Span {
50        Span {
51            file,
52            start: range.start as u32,
53            end: range.end as u32,
54        }
55    }
56
57    pub fn is_none(&self) -> bool {
58        self.file.0 == u32::MAX
59    }
60
61    /// The smallest span covering both. Used when a parser builds a node from several tokens.
62    pub fn to(self, other: Span) -> Span {
63        if self.is_none() {
64            return other;
65        }
66        if other.is_none() || other.file != self.file {
67            return self;
68        }
69        Span {
70            file: self.file,
71            start: self.start.min(other.start),
72            end: self.end.max(other.end),
73        }
74    }
75}
76
77#[derive(Clone, Debug, Default)]
78pub struct SourceMap {
79    files: Vec<SourceFile>,
80}
81
82#[derive(Clone, Debug)]
83struct SourceFile {
84    name: String,
85    text: String,
86    /// Byte offset of the start of each line, for O(log n) offset → line/col.
87    line_starts: Vec<u32>,
88}
89
90impl SourceMap {
91    pub fn new() -> SourceMap {
92        SourceMap::default()
93    }
94
95    pub fn add(&mut self, name: impl Into<String>, text: impl Into<String>) -> FileId {
96        let (name, text) = (name.into(), text.into());
97        let mut line_starts = vec![0u32];
98        for (i, b) in text.bytes().enumerate() {
99            if b == b'\n' {
100                line_starts.push(i as u32 + 1);
101            }
102        }
103        self.files.push(SourceFile {
104            name,
105            text,
106            line_starts,
107        });
108        FileId(self.files.len() as u32 - 1)
109    }
110
111    pub fn name(&self, file: FileId) -> &str {
112        &self.files[file.0 as usize].name
113    }
114
115    pub fn text(&self, file: FileId) -> &str {
116        &self.files[file.0 as usize].text
117    }
118
119    pub fn snippet(&self, span: Span) -> &str {
120        if span.is_none() {
121            return "";
122        }
123        let text = self.text(span.file);
124        let (s, e) = (span.start as usize, (span.end as usize).min(text.len()));
125        text.get(s..e).unwrap_or("")
126    }
127
128    /// 1-based line and column (column counted in characters, not bytes).
129    ///
130    /// A span from a file this map does not hold reports `1:1` rather than panicking. That is a
131    /// compiler defect wherever it happens — a diagnostic that cannot be located is a diagnostic
132    /// nobody can act on — but the failure belongs in the message, not in a crash during rendering.
133    pub fn line_col(&self, file: FileId, offset: u32) -> (usize, usize) {
134        let Some(f) = self.files.get(file.0 as usize) else {
135            return (1, 1);
136        };
137        let line = f.line_starts.partition_point(|&s| s <= offset).max(1) - 1;
138        let start = f.line_starts[line] as usize;
139        let col = f.text[start..(offset as usize).min(f.text.len())]
140            .chars()
141            .count();
142        (line + 1, col + 1)
143    }
144
145    fn line_text(&self, file: FileId, line: usize) -> &str {
146        let f = &self.files[file.0 as usize];
147        let start = f.line_starts[line - 1] as usize;
148        let end = f
149            .line_starts
150            .get(line)
151            .map(|&e| e as usize - 1)
152            .unwrap_or(f.text.len());
153        f.text[start..end.min(f.text.len())].trim_end_matches('\r')
154    }
155}
156
157#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
158pub enum Severity {
159    Error,
160    Warning,
161    Note,
162}
163
164impl Severity {
165    fn label(self) -> &'static str {
166        match self {
167            Severity::Error => "error",
168            Severity::Warning => "warning",
169            Severity::Note => "note",
170        }
171    }
172}
173
174/// A secondary span: somewhere else the reader needs to look.
175#[derive(Clone, Debug)]
176pub struct Label {
177    pub span: Span,
178    pub message: String,
179}
180
181/// One step of a macro expansion chain — "in `derive(Json)` expanded at orders.beck:12" (§4.5).
182#[derive(Clone, Debug)]
183pub struct ExpansionStep {
184    pub macro_name: String,
185    pub span: Span,
186}
187
188#[derive(Clone, Debug)]
189pub struct Diagnostic {
190    pub severity: Severity,
191    /// A stable code (`B0101`) so that tests, docs and the eventual error index agree.
192    pub code: &'static str,
193    pub message: String,
194    pub primary: Span,
195    pub primary_label: Option<String>,
196    pub labels: Vec<Label>,
197    pub notes: Vec<String>,
198    /// A suggested edit. Placement ambiguity is "a compile error with a suggested annotation —
199    /// never a silent guess" (§3.4), and this is where that suggestion rides.
200    pub fix: Option<String>,
201    pub expansion: Vec<ExpansionStep>,
202}
203
204impl Diagnostic {
205    pub fn error(code: &'static str, message: impl Into<String>, primary: Span) -> Diagnostic {
206        Diagnostic {
207            severity: Severity::Error,
208            code,
209            message: message.into(),
210            primary,
211            primary_label: None,
212            labels: Vec::new(),
213            notes: Vec::new(),
214            fix: None,
215            expansion: Vec::new(),
216        }
217    }
218
219    pub fn warning(code: &'static str, message: impl Into<String>, primary: Span) -> Diagnostic {
220        Diagnostic {
221            severity: Severity::Warning,
222            ..Diagnostic::error(code, message, primary)
223        }
224    }
225
226    pub fn with_primary_label(mut self, label: impl Into<String>) -> Diagnostic {
227        self.primary_label = Some(label.into());
228        self
229    }
230
231    pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Diagnostic {
232        self.labels.push(Label {
233            span,
234            message: message.into(),
235        });
236        self
237    }
238
239    pub fn with_note(mut self, note: impl Into<String>) -> Diagnostic {
240        self.notes.push(note.into());
241        self
242    }
243
244    pub fn with_fix(mut self, fix: impl Into<String>) -> Diagnostic {
245        self.fix = Some(fix.into());
246        self
247    }
248
249    pub fn with_expansion(mut self, steps: Vec<ExpansionStep>) -> Diagnostic {
250        self.expansion = steps;
251        self
252    }
253}
254
255/// Diagnostics accumulated by one compilation.
256#[derive(Clone, Debug, Default)]
257pub struct Diagnostics {
258    items: Vec<Diagnostic>,
259}
260
261impl Diagnostics {
262    pub fn new() -> Diagnostics {
263        Diagnostics::default()
264    }
265
266    pub fn push(&mut self, d: Diagnostic) {
267        self.items.push(d);
268    }
269
270    pub fn extend(&mut self, other: Diagnostics) {
271        self.items.extend(other.items);
272    }
273
274    pub fn has_errors(&self) -> bool {
275        self.items.iter().any(|d| d.severity == Severity::Error)
276    }
277
278    pub fn is_empty(&self) -> bool {
279        self.items.is_empty()
280    }
281
282    pub fn len(&self) -> usize {
283        self.items.len()
284    }
285
286    pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
287        self.items.iter()
288    }
289
290    /// Sorted by position, so a snapshot does not depend on the order checks happened to run in.
291    pub fn sorted(&self) -> Vec<&Diagnostic> {
292        let mut out: Vec<&Diagnostic> = self.items.iter().collect();
293        out.sort_by_key(|d| (d.primary.file, d.primary.start, d.code));
294        out
295    }
296
297    pub fn render(&self, map: &SourceMap) -> String {
298        let mut out = String::new();
299        for d in self.sorted() {
300            out.push_str(&render(d, map));
301            out.push('\n');
302        }
303        out
304    }
305}
306
307/// The renderer. Modelled on rustc/Elm: severity line, location, the source line with a caret
308/// span, then secondary labels, notes and the fix-it.
309pub fn render(d: &Diagnostic, map: &SourceMap) -> String {
310    let mut out = String::new();
311    let _ = writeln!(out, "{}[{}]: {}", d.severity.label(), d.code, d.message);
312
313    if !d.primary.is_none() {
314        let (line, col) = map.line_col(d.primary.file, d.primary.start);
315        let _ = writeln!(out, "  --> {}:{}:{}", map.name(d.primary.file), line, col);
316        render_snippet(&mut out, map, d.primary, d.primary_label.as_deref());
317    }
318
319    for label in &d.labels {
320        if label.span.is_none() {
321            continue;
322        }
323        let (line, col) = map.line_col(label.span.file, label.span.start);
324        let _ = writeln!(out, "  --> {}:{}:{}", map.name(label.span.file), line, col);
325        render_snippet(&mut out, map, label.span, Some(&label.message));
326    }
327
328    for step in &d.expansion {
329        if step.span.is_none() {
330            let _ = writeln!(out, "  = in `{}`", step.macro_name);
331        } else {
332            let (line, col) = map.line_col(step.span.file, step.span.start);
333            let _ = writeln!(
334                out,
335                "  = in `{}` expanded at {}:{}:{}",
336                step.macro_name,
337                map.name(step.span.file),
338                line,
339                col
340            );
341        }
342    }
343
344    for note in &d.notes {
345        let _ = writeln!(out, "  = note: {note}");
346    }
347    if let Some(fix) = &d.fix {
348        let _ = writeln!(out, "  = help: {fix}");
349    }
350    out
351}
352
353fn render_snippet(out: &mut String, map: &SourceMap, span: Span, label: Option<&str>) {
354    let (line, col) = map.line_col(span.file, span.start);
355    let text = map.line_text(span.file, line);
356    let gutter = line.to_string();
357    let pad = " ".repeat(gutter.len());
358
359    let _ = writeln!(out, "{pad} |");
360    let _ = writeln!(out, "{gutter} | {text}");
361
362    // A span that runs past the end of its line is clamped: multi-line spans underline their
363    // first line and say so, rather than drawing a caret run across a line break.
364    let line_end = span.start + (text.chars().count() as u32 - (col as u32 - 1));
365    let end = span.end.min(line_end);
366    let width = map
367        .text(span.file)
368        .get(span.start as usize..end as usize)
369        .map(|s| s.chars().count())
370        .unwrap_or(1)
371        .max(1);
372
373    let _ = write!(out, "{pad} | {}{}", " ".repeat(col - 1), "^".repeat(width));
374    match label {
375        Some(l) => {
376            let _ = writeln!(out, " {l}");
377        }
378        None => {
379            let _ = writeln!(out);
380        }
381    }
382    let _ = writeln!(out, "{pad} |");
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn line_col_is_one_based_and_counts_characters() {
391        let mut map = SourceMap::new();
392        let f = map.add("t.beck", "def f():\n    return é + 1\n");
393        assert_eq!(map.line_col(f, 0), (1, 1));
394        assert_eq!(map.line_col(f, 9), (2, 1));
395        // `é` is two bytes; the column after it must be 13, not 14.
396        let idx = map.text(f).find('é').unwrap() as u32;
397        assert_eq!(map.line_col(f, idx), (2, 12));
398        assert_eq!(map.line_col(f, idx + 2), (2, 13));
399    }
400
401    #[test]
402    fn a_rendered_diagnostic_points_at_the_right_column() {
403        let mut map = SourceMap::new();
404        let src = "def f():\n    return g(1)\n";
405        let f = map.add("t.beck", src);
406        let span = Span::new(f, 20..21);
407        assert_eq!(&src[20..21], "g");
408        let d = Diagnostic::error("B0001", "cannot find `g` in this scope", span)
409            .with_primary_label("not found")
410            .with_note("`g` is not defined in this module")
411            .with_fix("did you mean `f`?");
412        let rendered = render(&d, &map);
413        assert!(rendered.contains("error[B0001]: cannot find `g` in this scope"));
414        assert!(rendered.contains("--> t.beck:2:12"));
415        assert!(rendered.contains("           ^ not found"));
416        assert!(rendered.contains("= help: did you mean `f`?"));
417    }
418
419    #[test]
420    fn sorting_makes_output_independent_of_check_order() {
421        let mut map = SourceMap::new();
422        let f = map.add("t.beck", "aaa\nbbb\n");
423        let mut ds = Diagnostics::new();
424        ds.push(Diagnostic::error("B0002", "second", Span::new(f, 4..7)));
425        ds.push(Diagnostic::error("B0001", "first", Span::new(f, 0..3)));
426        let codes: Vec<_> = ds.sorted().iter().map(|d| d.code).collect();
427        assert_eq!(codes, ["B0001", "B0002"]);
428        assert!(ds.has_errors());
429    }
430}