Skip to main content

bock_errors/
lib.rs

1//! Bock errors — diagnostic types, span, file-id, and error reporting infrastructure.
2//!
3//! `Span` and `FileId` are defined here (not in `bock-source`) to avoid circular
4//! dependencies. Every crate in the pipeline uses these types transitively.
5
6use ariadne::{Color, Label as AriadneLabel, Report, ReportKind, Source};
7
8pub mod catalog;
9
10// ─── Source location types ────────────────────────────────────────────────────
11
12/// Identifies a source file within the compilation session.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub struct FileId(pub u32);
15
16/// A byte-offset span within a source file.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct Span {
19    pub file: FileId,
20    /// Start byte offset (inclusive).
21    pub start: usize,
22    /// End byte offset (exclusive).
23    pub end: usize,
24}
25
26impl Span {
27    /// Returns the smallest span that contains both `a` and `b`.
28    /// Uses `a`'s `FileId`; callers should ensure both spans belong to the same file.
29    #[must_use]
30    pub fn merge(a: Span, b: Span) -> Span {
31        Span {
32            file: a.file,
33            start: a.start.min(b.start),
34            end: a.end.max(b.end),
35        }
36    }
37
38    /// A sentinel span for synthetic/compiler-generated nodes.
39    #[must_use]
40    pub fn dummy() -> Span {
41        Span {
42            file: FileId(0),
43            start: 0,
44            end: 0,
45        }
46    }
47}
48
49// ─── Diagnostics ─────────────────────────────────────────────────────────────
50
51/// Diagnostic severity level.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Severity {
54    Error,
55    Warning,
56    Info,
57    Hint,
58}
59
60/// A structured diagnostic code like `E0001` or `W0042`.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct DiagnosticCode {
63    /// Category prefix character (`E` for error, `W` for warning, etc.).
64    pub prefix: char,
65    /// Numeric code.
66    pub number: u16,
67}
68
69impl std::fmt::Display for DiagnosticCode {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(f, "{}{:04}", self.prefix, self.number)
72    }
73}
74
75/// A secondary label pointing at a span with an explanatory message.
76#[derive(Debug, Clone)]
77pub struct Label {
78    pub span: Span,
79    pub message: String,
80}
81
82/// A structured compiler diagnostic.
83#[derive(Debug, Clone)]
84pub struct Diagnostic {
85    pub severity: Severity,
86    pub code: DiagnosticCode,
87    pub message: String,
88    pub span: Span,
89    pub labels: Vec<Label>,
90    pub notes: Vec<String>,
91}
92
93impl Diagnostic {
94    /// Attach an additional label to this diagnostic (builder-style).
95    pub fn label(&mut self, span: Span, message: impl Into<String>) -> &mut Self {
96        self.labels.push(Label {
97            span,
98            message: message.into(),
99        });
100        self
101    }
102
103    /// Attach a trailing note to this diagnostic (builder-style).
104    pub fn note(&mut self, message: impl Into<String>) -> &mut Self {
105        self.notes.push(message.into());
106        self
107    }
108}
109
110// ─── DiagnosticBag ───────────────────────────────────────────────────────────
111
112/// Accumulates diagnostics emitted during a compilation pass.
113#[derive(Debug, Default)]
114pub struct DiagnosticBag {
115    items: Vec<Diagnostic>,
116}
117
118impl DiagnosticBag {
119    /// Create an empty bag.
120    #[must_use]
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// Emit an error diagnostic and return a mutable reference for further decoration.
126    pub fn error(
127        &mut self,
128        code: DiagnosticCode,
129        message: impl Into<String>,
130        span: Span,
131    ) -> &mut Diagnostic {
132        self.push(Severity::Error, code, message, span)
133    }
134
135    /// Emit a warning diagnostic and return a mutable reference for further decoration.
136    pub fn warning(
137        &mut self,
138        code: DiagnosticCode,
139        message: impl Into<String>,
140        span: Span,
141    ) -> &mut Diagnostic {
142        self.push(Severity::Warning, code, message, span)
143    }
144
145    /// Emit an info diagnostic and return a mutable reference for further decoration.
146    pub fn info(
147        &mut self,
148        code: DiagnosticCode,
149        message: impl Into<String>,
150        span: Span,
151    ) -> &mut Diagnostic {
152        self.push(Severity::Info, code, message, span)
153    }
154
155    /// Emit a hint diagnostic and return a mutable reference for further decoration.
156    pub fn hint(
157        &mut self,
158        code: DiagnosticCode,
159        message: impl Into<String>,
160        span: Span,
161    ) -> &mut Diagnostic {
162        self.push(Severity::Hint, code, message, span)
163    }
164
165    /// Returns `true` if any error-severity diagnostics have been emitted.
166    #[must_use]
167    pub fn has_errors(&self) -> bool {
168        self.items.iter().any(|d| d.severity == Severity::Error)
169    }
170
171    /// Returns the number of error-severity diagnostics emitted so far.
172    #[must_use]
173    pub fn error_count(&self) -> usize {
174        self.items
175            .iter()
176            .filter(|d| d.severity == Severity::Error)
177            .count()
178    }
179
180    /// Returns the number of warning-severity diagnostics emitted so far.
181    #[must_use]
182    pub fn warning_count(&self) -> usize {
183        self.items
184            .iter()
185            .filter(|d| d.severity == Severity::Warning)
186            .count()
187    }
188
189    /// Iterate over all collected diagnostics.
190    pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
191        self.items.iter()
192    }
193
194    /// Total number of diagnostics.
195    #[must_use]
196    pub fn len(&self) -> usize {
197        self.items.len()
198    }
199
200    /// Returns `true` if no diagnostics have been emitted.
201    #[must_use]
202    pub fn is_empty(&self) -> bool {
203        self.items.is_empty()
204    }
205
206    fn push(
207        &mut self,
208        severity: Severity,
209        code: DiagnosticCode,
210        message: impl Into<String>,
211        span: Span,
212    ) -> &mut Diagnostic {
213        self.items.push(Diagnostic {
214            severity,
215            code,
216            message: message.into(),
217            span,
218            labels: Vec::new(),
219            notes: Vec::new(),
220        });
221        self.items.last_mut().expect("just pushed")
222    }
223}
224
225// ─── String distance helpers ─────────────────────────────────────────────────
226
227/// Levenshtein edit distance between two strings.
228///
229/// Counts the minimum number of single-character insertions, deletions,
230/// and substitutions required to transform `a` into `b`. Used by diagnostic
231/// passes to produce "did you mean X?" suggestions.
232#[must_use]
233pub fn levenshtein(a: &str, b: &str) -> usize {
234    let a: Vec<char> = a.chars().collect();
235    let b: Vec<char> = b.chars().collect();
236    if a.is_empty() {
237        return b.len();
238    }
239    if b.is_empty() {
240        return a.len();
241    }
242    let mut prev: Vec<usize> = (0..=b.len()).collect();
243    let mut curr: Vec<usize> = vec![0; b.len() + 1];
244    for (i, ca) in a.iter().enumerate() {
245        curr[0] = i + 1;
246        for (j, cb) in b.iter().enumerate() {
247            let cost = if ca == cb { 0 } else { 1 };
248            curr[j + 1] = (curr[j] + 1)
249                .min(prev[j + 1] + 1)
250                .min(prev[j] + cost);
251        }
252        std::mem::swap(&mut prev, &mut curr);
253    }
254    prev[b.len()]
255}
256
257/// Find the closest candidate within `max_distance` edits of `name`.
258///
259/// Returns a clone of the closest matching candidate. Ties are broken by
260/// insertion order (the first candidate wins).
261#[must_use]
262pub fn suggest_similar<S, I>(name: &str, candidates: I, max_distance: usize) -> Option<String>
263where
264    S: AsRef<str>,
265    I: IntoIterator<Item = S>,
266{
267    candidates
268        .into_iter()
269        .map(|s| {
270            let d = levenshtein(name, s.as_ref());
271            (s, d)
272        })
273        .filter(|(_, d)| *d <= max_distance)
274        .min_by_key(|(_, d)| *d)
275        .map(|(s, _)| s.as_ref().to_string())
276}
277
278// ─── Rendering ───────────────────────────────────────────────────────────────
279
280/// Render a slice of diagnostics to a string using ariadne for source context.
281///
282/// `filename` and `source` must correspond to the file referenced by the
283/// diagnostics' spans. This function is intentionally decoupled from
284/// `bock-source` types so that `bock-errors` stays dependency-free.
285#[must_use]
286pub fn render(diagnostics: &[Diagnostic], filename: &str, source: &str) -> String {
287    let mut out = Vec::new();
288    let cache = (filename, Source::from(source));
289
290    for diag in diagnostics {
291        let kind = severity_to_kind(diag.severity);
292        let span_range = diag.span.start..diag.span.end;
293
294        let mut builder = Report::build(kind, filename, diag.span.start)
295            .with_message(format!("[{}] {}", diag.code, diag.message))
296            .with_label(
297                AriadneLabel::new((filename, span_range))
298                    .with_message(&diag.message)
299                    .with_color(severity_color(diag.severity)),
300            );
301
302        for label in &diag.labels {
303            builder = builder.with_label(
304                AriadneLabel::new((filename, label.span.start..label.span.end))
305                    .with_message(&label.message)
306                    .with_color(Color::Blue),
307            );
308        }
309
310        for note in &diag.notes {
311            builder = builder.with_note(note);
312        }
313
314        builder
315            .finish()
316            .write(cache.clone(), &mut out)
317            .expect("write to Vec is infallible");
318    }
319
320    String::from_utf8_lossy(&out).into_owned()
321}
322
323fn severity_to_kind(severity: Severity) -> ReportKind<'static> {
324    match severity {
325        Severity::Error => ReportKind::Error,
326        Severity::Warning => ReportKind::Warning,
327        Severity::Info | Severity::Hint => ReportKind::Advice,
328    }
329}
330
331fn severity_color(severity: Severity) -> Color {
332    match severity {
333        Severity::Error => Color::Red,
334        Severity::Warning => Color::Yellow,
335        Severity::Info => Color::Cyan,
336        Severity::Hint => Color::Green,
337    }
338}
339
340// ─── Tests ───────────────────────────────────────────────────────────────────
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    fn make_span(start: usize, end: usize) -> Span {
347        Span {
348            file: FileId(1),
349            start,
350            end,
351        }
352    }
353
354    // ── Span ──────────────────────────────────────────────────────────────────
355
356    #[test]
357    fn span_merge_basic() {
358        let a = make_span(2, 5);
359        let b = make_span(3, 8);
360        let m = Span::merge(a, b);
361        assert_eq!(m.start, 2);
362        assert_eq!(m.end, 8);
363        assert_eq!(m.file, FileId(1));
364    }
365
366    #[test]
367    fn span_merge_disjoint() {
368        let a = make_span(0, 3);
369        let b = make_span(10, 15);
370        let m = Span::merge(a, b);
371        assert_eq!(m.start, 0);
372        assert_eq!(m.end, 15);
373    }
374
375    #[test]
376    fn span_merge_identical() {
377        let s = make_span(4, 9);
378        let m = Span::merge(s, s);
379        assert_eq!(m, s);
380    }
381
382    #[test]
383    fn span_dummy_is_zero() {
384        let d = Span::dummy();
385        assert_eq!(d.file, FileId(0));
386        assert_eq!(d.start, 0);
387        assert_eq!(d.end, 0);
388    }
389
390    // ── DiagnosticCode display ────────────────────────────────────────────────
391
392    #[test]
393    fn diagnostic_code_display() {
394        let c = DiagnosticCode {
395            prefix: 'E',
396            number: 42,
397        };
398        assert_eq!(c.to_string(), "E0042");
399    }
400
401    // ── DiagnosticBag ─────────────────────────────────────────────────────────
402
403    #[test]
404    fn bag_has_errors_false_when_empty() {
405        let bag = DiagnosticBag::new();
406        assert!(!bag.has_errors());
407    }
408
409    #[test]
410    fn bag_has_errors_false_for_warnings() {
411        let mut bag = DiagnosticBag::new();
412        let code = DiagnosticCode {
413            prefix: 'W',
414            number: 1,
415        };
416        bag.warning(code, "watch out", make_span(0, 1));
417        assert!(!bag.has_errors());
418    }
419
420    #[test]
421    fn bag_has_errors_true_for_error() {
422        let mut bag = DiagnosticBag::new();
423        let code = DiagnosticCode {
424            prefix: 'E',
425            number: 1,
426        };
427        bag.error(code, "oops", make_span(0, 1));
428        assert!(bag.has_errors());
429    }
430
431    #[test]
432    fn bag_iter_yields_all() {
433        let mut bag = DiagnosticBag::new();
434        let ec = DiagnosticCode {
435            prefix: 'E',
436            number: 1,
437        };
438        let wc = DiagnosticCode {
439            prefix: 'W',
440            number: 2,
441        };
442        bag.error(ec, "err", make_span(0, 1));
443        bag.warning(wc, "warn", make_span(1, 2));
444        let items: Vec<_> = bag.iter().collect();
445        assert_eq!(items.len(), 2);
446    }
447
448    #[test]
449    fn bag_labels_and_notes() {
450        let mut bag = DiagnosticBag::new();
451        let code = DiagnosticCode {
452            prefix: 'E',
453            number: 5,
454        };
455        bag.error(code, "main", make_span(0, 3))
456            .label(make_span(1, 2), "secondary")
457            .note("fix it");
458        let d = bag.iter().next().unwrap();
459        assert_eq!(d.labels.len(), 1);
460        assert_eq!(d.notes.len(), 1);
461    }
462
463    // ── render ────────────────────────────────────────────────────────────────
464
465    #[test]
466    fn render_error_contains_message() {
467        let source = "let x = ;";
468        let span = Span {
469            file: FileId(1),
470            start: 8,
471            end: 9,
472        };
473        let diag = Diagnostic {
474            severity: Severity::Error,
475            code: DiagnosticCode {
476                prefix: 'E',
477                number: 1,
478            },
479            message: "unexpected token".into(),
480            span,
481            labels: vec![],
482            notes: vec![],
483        };
484        let out = render(&[diag], "test.bock", source);
485        assert!(out.contains("unexpected token"), "output: {out}");
486    }
487
488    #[test]
489    fn render_empty_produces_empty_string() {
490        let out = render(&[], "test.bock", "let x = 1;");
491        assert!(out.is_empty());
492    }
493
494    // ── levenshtein ───────────────────────────────────────────────────────────
495
496    #[test]
497    fn levenshtein_equal_strings_is_zero() {
498        assert_eq!(levenshtein("foo", "foo"), 0);
499    }
500
501    #[test]
502    fn levenshtein_empty_strings() {
503        assert_eq!(levenshtein("", ""), 0);
504        assert_eq!(levenshtein("abc", ""), 3);
505        assert_eq!(levenshtein("", "abc"), 3);
506    }
507
508    #[test]
509    fn levenshtein_single_substitution() {
510        assert_eq!(levenshtein("cat", "bat"), 1);
511    }
512
513    #[test]
514    fn levenshtein_insert_and_delete() {
515        assert_eq!(levenshtein("kitten", "sitting"), 3);
516    }
517
518    #[test]
519    fn suggest_similar_finds_close_match() {
520        let names = vec!["println", "print", "printf"];
521        assert_eq!(suggest_similar("printn", names, 2), Some("println".into()));
522    }
523
524    #[test]
525    fn suggest_similar_rejects_far_matches() {
526        let names = vec!["elephant", "giraffe"];
527        assert_eq!(suggest_similar("cat", names, 2), None);
528    }
529
530    #[test]
531    fn render_with_note() {
532        let source = "foo bar";
533        let span = Span {
534            file: FileId(1),
535            start: 0,
536            end: 3,
537        };
538        let mut diag = Diagnostic {
539            severity: Severity::Warning,
540            code: DiagnosticCode {
541                prefix: 'W',
542                number: 99,
543            },
544            message: "test warning".into(),
545            span,
546            labels: vec![],
547            notes: vec![],
548        };
549        diag.note("consider renaming");
550        let out = render(&[diag], "src.bock", source);
551        assert!(out.contains("consider renaming"), "output: {out}");
552    }
553}