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    /// The file this map holds under `name`, if it holds one.
116    ///
117    /// For a caller that has a span and needs to know **which file it is in** rather than what it
118    /// says: an editor answering a question about one document holds spans from every module the
119    /// project linked, and two files' byte offsets overlap by construction. Compares whole names
120    /// rather than suffixes, because a `FileId` that is nearly right is a span pointing into the
121    /// wrong buffer.
122    pub fn find(&self, name: &str) -> Option<FileId> {
123        self.files
124            .iter()
125            .position(|f| f.name == name)
126            .map(|i| FileId(i as u32))
127    }
128
129    pub fn text(&self, file: FileId) -> &str {
130        &self.files[file.0 as usize].text
131    }
132
133    /// 1-based line and column (column counted in characters, not bytes).
134    ///
135    /// A span from a file this map does not hold reports `1:1` rather than panicking. That is a
136    /// compiler defect wherever it happens — a diagnostic that cannot be located is a diagnostic
137    /// nobody can act on — but the failure belongs in the message, not in a crash during rendering.
138    pub fn line_col(&self, file: FileId, offset: u32) -> (usize, usize) {
139        let Some(f) = self.files.get(file.0 as usize) else {
140            return (1, 1);
141        };
142        let line = f.line_starts.partition_point(|&s| s <= offset).max(1) - 1;
143        let start = f.line_starts[line] as usize;
144        let col = f.text[start..(offset as usize).min(f.text.len())]
145            .chars()
146            .count();
147        (line + 1, col + 1)
148    }
149
150    fn line_text(&self, file: FileId, line: usize) -> &str {
151        let f = &self.files[file.0 as usize];
152        let start = f.line_starts[line - 1] as usize;
153        let end = f
154            .line_starts
155            .get(line)
156            .map(|&e| e as usize - 1)
157            .unwrap_or(f.text.len());
158        f.text[start..end.min(f.text.len())].trim_end_matches('\r')
159    }
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
163pub enum Severity {
164    Error,
165    Warning,
166    Note,
167}
168
169impl Severity {
170    fn label(self) -> &'static str {
171        match self {
172            Severity::Error => "error",
173            Severity::Warning => "warning",
174            Severity::Note => "note",
175        }
176    }
177}
178
179/// A secondary span: somewhere else the reader needs to look.
180#[derive(Clone, Debug)]
181pub struct Label {
182    pub span: Span,
183    pub message: String,
184}
185
186/// One step of a macro expansion chain — "in `derive(Json)` expanded at orders.beck:12" (§4.5).
187#[derive(Clone, Debug)]
188pub struct ExpansionStep {
189    pub macro_name: String,
190    pub span: Span,
191}
192
193#[derive(Clone, Debug)]
194pub struct Diagnostic {
195    pub severity: Severity,
196    /// A stable code (`B0101`) so that tests, docs and the eventual error index agree.
197    pub code: &'static str,
198    pub message: String,
199    pub primary: Span,
200    pub primary_label: Option<String>,
201    pub labels: Vec<Label>,
202    pub notes: Vec<String>,
203    /// A suggested edit. Placement ambiguity is "a compile error with a suggested annotation —
204    /// never a silent guess" (§3.4), and this is where that suggestion rides.
205    pub fix: Option<String>,
206    pub expansion: Vec<ExpansionStep>,
207}
208
209impl Diagnostic {
210    pub fn error(code: &'static str, message: impl Into<String>, primary: Span) -> Diagnostic {
211        Diagnostic {
212            severity: Severity::Error,
213            code,
214            message: message.into(),
215            primary,
216            primary_label: None,
217            labels: Vec::new(),
218            notes: Vec::new(),
219            fix: None,
220            expansion: Vec::new(),
221        }
222    }
223
224    pub fn warning(code: &'static str, message: impl Into<String>, primary: Span) -> Diagnostic {
225        Diagnostic {
226            severity: Severity::Warning,
227            ..Diagnostic::error(code, message, primary)
228        }
229    }
230
231    pub fn with_primary_label(mut self, label: impl Into<String>) -> Diagnostic {
232        self.primary_label = Some(label.into());
233        self
234    }
235
236    pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Diagnostic {
237        self.labels.push(Label {
238            span,
239            message: message.into(),
240        });
241        self
242    }
243
244    pub fn with_note(mut self, note: impl Into<String>) -> Diagnostic {
245        self.notes.push(note.into());
246        self
247    }
248
249    pub fn with_fix(mut self, fix: impl Into<String>) -> Diagnostic {
250        self.fix = Some(fix.into());
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    /// Drop everything reported after a mark taken with [`Diagnostics::len`].
287    ///
288    /// For a check run only to *learn* something — the checker infers a typed macro call's
289    /// arguments before the macro has decided what to do with them, and the real check of the
290    /// expansion is the one whose diagnostics a reader should see. Truncation rather than a
291    /// separate `Diagnostics` because it catches everything the run reported, including whatever
292    /// pushed without going through the caller.
293    pub fn truncate(&mut self, mark: usize) {
294        self.items.truncate(mark);
295    }
296
297    pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
298        self.items.iter()
299    }
300
301    /// Sorted by position, so a snapshot does not depend on the order checks happened to run in.
302    pub fn sorted(&self) -> Vec<&Diagnostic> {
303        let mut out: Vec<&Diagnostic> = self.items.iter().collect();
304        out.sort_by_key(|d| (d.primary.file, d.primary.start, d.code));
305        out
306    }
307
308    pub fn render(&self, map: &SourceMap) -> String {
309        let mut out = String::new();
310        for d in self.sorted() {
311            out.push_str(&render(d, map));
312            out.push('\n');
313        }
314        out
315    }
316}
317
318/// The renderer. Modelled on rustc/Elm: severity line, location, the source line with a caret
319/// span, then secondary labels, notes and the fix-it.
320pub fn render(d: &Diagnostic, map: &SourceMap) -> String {
321    let mut out = String::new();
322    let _ = writeln!(out, "{}[{}]: {}", d.severity.label(), d.code, d.message);
323
324    if !d.primary.is_none() {
325        let (line, col) = map.line_col(d.primary.file, d.primary.start);
326        let _ = writeln!(out, "  --> {}:{}:{}", map.name(d.primary.file), line, col);
327        render_snippet(&mut out, map, d.primary, d.primary_label.as_deref());
328    }
329
330    for label in &d.labels {
331        if label.span.is_none() {
332            continue;
333        }
334        let (line, col) = map.line_col(label.span.file, label.span.start);
335        let _ = writeln!(out, "  --> {}:{}:{}", map.name(label.span.file), line, col);
336        render_snippet(&mut out, map, label.span, Some(&label.message));
337    }
338
339    for step in &d.expansion {
340        if step.span.is_none() {
341            let _ = writeln!(out, "  = in `{}`", step.macro_name);
342        } else {
343            let (line, col) = map.line_col(step.span.file, step.span.start);
344            let _ = writeln!(
345                out,
346                "  = in `{}` expanded at {}:{}:{}",
347                step.macro_name,
348                map.name(step.span.file),
349                line,
350                col
351            );
352        }
353    }
354
355    for note in &d.notes {
356        let _ = writeln!(out, "  = note: {note}");
357    }
358    if let Some(fix) = &d.fix {
359        let _ = writeln!(out, "  = help: {fix}");
360    }
361    out
362}
363
364fn render_snippet(out: &mut String, map: &SourceMap, span: Span, label: Option<&str>) {
365    let (line, col) = map.line_col(span.file, span.start);
366    let text = map.line_text(span.file, line);
367    let gutter = line.to_string();
368    let pad = " ".repeat(gutter.len());
369
370    let _ = writeln!(out, "{pad} |");
371    let _ = writeln!(out, "{gutter} | {text}");
372
373    // A span that runs past the end of its line is clamped: multi-line spans underline their
374    // first line and say so, rather than drawing a caret run across a line break.
375    let line_end = span.start + (text.chars().count() as u32 - (col as u32 - 1));
376    let end = span.end.min(line_end);
377    let width = map
378        .text(span.file)
379        .get(span.start as usize..end as usize)
380        .map(|s| s.chars().count())
381        .unwrap_or(1)
382        .max(1);
383
384    let _ = write!(out, "{pad} | {}{}", " ".repeat(col - 1), "^".repeat(width));
385    match label {
386        Some(l) => {
387            let _ = writeln!(out, " {l}");
388        }
389        None => {
390            let _ = writeln!(out);
391        }
392    }
393    let _ = writeln!(out, "{pad} |");
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn line_col_is_one_based_and_counts_characters() {
402        let mut map = SourceMap::new();
403        let f = map.add("t.beck", "def f():\n    return é + 1\n");
404        assert_eq!(map.line_col(f, 0), (1, 1));
405        assert_eq!(map.line_col(f, 9), (2, 1));
406        // `é` is two bytes; the column after it must be 13, not 14.
407        let idx = map.text(f).find('é').unwrap() as u32;
408        assert_eq!(map.line_col(f, idx), (2, 12));
409        assert_eq!(map.line_col(f, idx + 2), (2, 13));
410    }
411
412    #[test]
413    fn a_rendered_diagnostic_points_at_the_right_column() {
414        let mut map = SourceMap::new();
415        let src = "def f():\n    return g(1)\n";
416        let f = map.add("t.beck", src);
417        let span = Span::new(f, 20..21);
418        assert_eq!(&src[20..21], "g");
419        let d = Diagnostic::error("B0001", "cannot find `g` in this scope", span)
420            .with_primary_label("not found")
421            .with_note("`g` is not defined in this module")
422            .with_fix("did you mean `f`?");
423        let rendered = render(&d, &map);
424        assert!(rendered.contains("error[B0001]: cannot find `g` in this scope"));
425        assert!(rendered.contains("--> t.beck:2:12"));
426        assert!(rendered.contains("           ^ not found"));
427        assert!(rendered.contains("= help: did you mean `f`?"));
428    }
429
430    #[test]
431    fn sorting_makes_output_independent_of_check_order() {
432        let mut map = SourceMap::new();
433        let f = map.add("t.beck", "aaa\nbbb\n");
434        let mut ds = Diagnostics::new();
435        ds.push(Diagnostic::error("B0002", "second", Span::new(f, 4..7)));
436        ds.push(Diagnostic::error("B0001", "first", Span::new(f, 0..3)));
437        let codes: Vec<_> = ds.sorted().iter().map(|d| d.code).collect();
438        assert_eq!(codes, ["B0001", "B0002"]);
439        assert!(ds.has_errors());
440    }
441}