Skip to main content

nova_diagnostics/
lib.rs

1//! Error reporting infrastructure for the Nova compiler.
2//!
3//! Provides `Span`, `FileId`, `Spanned<T>`, and diagnostic rendering via
4//! `codespan-reporting`. Every compiler crate that produces user-facing errors
5//! depends on this crate.
6
7pub mod files;
8pub mod line_index;
9pub mod lines;
10pub mod render;
11pub mod suggest;
12
13pub use files::{FileDb, FileId};
14pub use line_index::LineIndex;
15
16/// A byte-range span inside a single source file.
17///
18/// Uses `u32` offsets to keep `Spanned<T>` structs compact.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub struct Span {
21    pub start: u32,
22    pub end: u32,
23    pub file: FileId,
24}
25
26impl Span {
27    pub fn new(start: u32, end: u32, file: FileId) -> Self {
28        Self { start, end, file }
29    }
30
31    /// A zero-width span at a single byte position.
32    pub fn point(offset: u32, file: FileId) -> Self {
33        Self {
34            start: offset,
35            end: offset,
36            file,
37        }
38    }
39
40    /// Merge two spans into one that covers both.
41    pub fn merge(self, other: Span) -> Self {
42        debug_assert_eq!(
43            self.file, other.file,
44            "cannot merge spans from different files"
45        );
46        Self {
47            start: self.start.min(other.start),
48            end: self.end.max(other.end),
49            file: self.file,
50        }
51    }
52
53    pub fn as_range(self) -> std::ops::Range<usize> {
54        self.start as usize..self.end as usize
55    }
56}
57
58/// A value paired with the source span it came from.
59#[derive(Debug, Clone)]
60pub struct Spanned<T> {
61    pub value: T,
62    pub span: Span,
63}
64
65impl<T> Spanned<T> {
66    pub fn new(value: T, span: Span) -> Self {
67        Self { value, span }
68    }
69
70    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Spanned<U> {
71        Spanned {
72            value: f(self.value),
73            span: self.span,
74        }
75    }
76}
77
78/// Severity level of a diagnostic.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Severity {
81    Error,
82    Warning,
83    Note,
84    Help,
85}
86
87/// A single compiler diagnostic shown to the user.
88///
89/// Every diagnostic has an error code (e.g. `E0042`), a title, at least one
90/// source span, and an optional suggestion.
91#[derive(Debug, Clone)]
92pub struct Diagnostic {
93    pub severity: Severity,
94    pub code: String,
95    pub message: String,
96    pub labels: Vec<Label>,
97    pub notes: Vec<String>,
98    /// Suggested changes, shown as `help:` lines and offered as quick
99    /// fixes (spec 3.4b §3).
100    pub fixes: Vec<Fix>,
101}
102
103/// A labelled source location within a diagnostic.
104#[derive(Debug, Clone)]
105pub struct Label {
106    pub span: Span,
107    pub message: String,
108    pub primary: bool,
109}
110/// A suggested change: a title, and the text edits that make it (spec
111/// `docs/superpowers/specs/2026-10-10-phase-3-4b-fixes-and-colour-design.md`
112/// §3.1). A span carries its file, so one fix may edit several files.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct Fix {
115    pub title: String,
116    pub edits: Vec<Edit>,
117}
118
119/// Replace `span`'s text with `text`. An empty span inserts.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Edit {
122    pub span: Span,
123    pub text: String,
124}
125
126impl Edit {
127    /// Insert `text` at byte `at` of `file`.
128    pub fn insert(at: u32, file: FileId, text: impl Into<String>) -> Edit {
129        Edit {
130            span: Span::point(at, file),
131            text: text.into(),
132        }
133    }
134
135    /// Replace `span` with `text`.
136    pub fn replace(span: Span, text: impl Into<String>) -> Edit {
137        Edit {
138            span,
139            text: text.into(),
140        }
141    }
142}
143
144impl Fix {
145    pub fn new(title: impl Into<String>, edits: Vec<Edit>) -> Fix {
146        Fix {
147            title: title.into(),
148            edits,
149        }
150    }
151
152    /// Each file the fix edits, in the order its edits first name them,
153    /// with the text its edits make. `None` when an edit lies outside its
154    /// file, splits a character, or overlaps another edit.
155    pub fn apply(&self, db: &FileDb) -> Option<Vec<(FileId, String)>> {
156        let mut files: Vec<FileId> = Vec::new();
157        for e in &self.edits {
158            if !files.contains(&e.span.file) {
159                files.push(e.span.file);
160            }
161        }
162        let mut out = Vec::new();
163        for file in files {
164            let text = db.get_source(file)?;
165            let mut edits: Vec<&Edit> = self.edits.iter().filter(|e| e.span.file == file).collect();
166            edits.sort_by_key(|e| (e.span.start, e.span.end));
167            if edits.windows(2).any(|w| w[0].span.end > w[1].span.start) {
168                return None;
169            }
170            let mut new = text.to_string();
171            for e in edits.iter().rev() {
172                let (start, end) = (e.span.start as usize, e.span.end as usize);
173                if start > end
174                    || end > text.len()
175                    || !text.is_char_boundary(start)
176                    || !text.is_char_boundary(end)
177                {
178                    return None;
179                }
180                new.replace_range(start..end, &e.text);
181            }
182            out.push((file, new));
183        }
184        Some(out)
185    }
186}
187
188impl Diagnostic {
189    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
190        Self {
191            severity: Severity::Error,
192            code: code.into(),
193            message: message.into(),
194            labels: Vec::new(),
195            notes: Vec::new(),
196            fixes: Vec::new(),
197        }
198    }
199
200    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
201        Self {
202            severity: Severity::Warning,
203            code: code.into(),
204            message: message.into(),
205            labels: Vec::new(),
206            notes: Vec::new(),
207            fixes: Vec::new(),
208        }
209    }
210
211    pub fn with_label(mut self, span: Span, message: impl Into<String>, primary: bool) -> Self {
212        self.labels.push(Label {
213            span,
214            message: message.into(),
215            primary,
216        });
217        self
218    }
219
220    pub fn with_primary_label(self, span: Span, message: impl Into<String>) -> Self {
221        self.with_label(span, message, true)
222    }
223
224    pub fn with_secondary_label(self, span: Span, message: impl Into<String>) -> Self {
225        self.with_label(span, message, false)
226    }
227
228    pub fn with_note(mut self, note: impl Into<String>) -> Self {
229        self.notes.push(note.into());
230        self
231    }
232
233    pub fn with_fix(mut self, fix: Fix) -> Self {
234        self.fixes.push(fix);
235        self
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn two_files() -> (FileDb, FileId, FileId) {
244        let mut db = FileDb::new();
245        let a = db.add("a.nova", "let x = 1\n");
246        let b = db.add("b.nova", "fn f() {}\n");
247        (db, a, b)
248    }
249
250    #[test]
251    fn a_fix_rides_on_its_diagnostic() {
252        let (_, a, _) = two_files();
253        let fix = Fix::new("make `x` mutable", vec![Edit::insert(4, a, "mut ")]);
254        let d = Diagnostic::error("E0060", "cannot assign").with_fix(fix.clone());
255        assert_eq!(d.fixes, vec![fix]);
256        assert!(Diagnostic::warning("E0021", "unreachable").fixes.is_empty());
257    }
258
259    #[test]
260    fn a_fix_applies_to_each_file_it_edits() {
261        let (db, a, b) = two_files();
262        let fix = Fix::new(
263            "two files",
264            vec![
265                Edit::replace(Span::new(3, 4, b), "g"),
266                Edit::insert(4, a, "mut "),
267                Edit::insert(0, b, "pub "),
268            ],
269        );
270        assert_eq!(
271            fix.apply(&db),
272            Some(vec![
273                (b, "pub fn g() {}\n".to_string()),
274                (a, "let mut x = 1\n".to_string()),
275            ])
276        );
277    }
278
279    #[test]
280    fn a_fix_outside_its_file_splitting_a_character_or_overlapping_does_not_apply() {
281        let mut db = FileDb::new();
282        // "ก" is three bytes.
283        let t = db.add("t.nova", "ก = 1\n");
284        let outside = Fix::new("outside", vec![Edit::insert(99, t, "x")]);
285        let split = Fix::new("split", vec![Edit::insert(1, t, "x")]);
286        let overlap = Fix::new(
287            "overlap",
288            vec![
289                Edit::replace(Span::new(0, 3, t), "a"),
290                Edit::replace(Span::new(2, 5, t), "b"),
291            ],
292        );
293        assert_eq!(outside.apply(&db), None);
294        assert_eq!(split.apply(&db), None);
295        assert_eq!(overlap.apply(&db), None);
296    }
297
298    #[test]
299    fn the_renderer_prints_each_fix_as_a_help_line_after_the_notes() {
300        let (db, a, _) = two_files();
301        let d = Diagnostic::error("E0060", "cannot assign to immutable variable `x`")
302            .with_primary_label(Span::new(4, 5, a), "here")
303            .with_note("a note")
304            .with_fix(Fix::new(
305                "make `x` mutable",
306                vec![Edit::insert(4, a, "mut ")],
307            ));
308        let out = render::render_to_string(&db, &[d]);
309        let note = out.find("= a note").unwrap_or_else(|| panic!("{out}"));
310        let help = out
311            .find("= help: make `x` mutable")
312            .unwrap_or_else(|| panic!("{out}"));
313        assert!(note < help, "{out}");
314    }
315}