Skip to main content

oxyl_diagnostics/
lib.rs

1// oxyl-diagnostics 
2// 
3// Shared severity and diagnostic and lex error types used across 
4// the other oxyl crates. sits at bottom of the dep graph :)
5//
6// source (byte offset to line/col mapping) in source, conversions
7// done in LexError and its Into<Diagnostic> conversion is done too.
8// core diag/diagpspan and severity stuff is chilling here too - which 
9// produces the error output with that awesome caret (fyi a caret is ^)
10
11mod source; 
12mod lex_error;
13mod style;
14
15pub use source::Source;
16pub use lex_error::LexError;
17pub use style::Style;
18
19/// How serious a diagnostic is.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Severity {
22    Error,
23    Warning, 
24    Note,
25}
26
27impl std::fmt::Display for Severity {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            Severity::Error => write!(f, "error"),
31            Severity::Warning => write!(f, "warning"),
32            Severity::Note => write!(f, "note"),
33        }
34    }
35}
36
37/// A byte range used to point at source text in a diagnostic
38///
39/// Every diagnostic has a severity, a short code (e.g. "E001"), and a message.
40/// Below - mirrors `Span` in oxyl-lexer but lives here so that the diagnostics 
41/// stay independent of the lexer crate. Will keep the two types in sync 
42/// manually for now; will unify when the crate graph is refactored,
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct DiagSpan {
45    pub start: usize,
46    pub end: usize,
47}
48
49impl DiagSpan {
50    pub fn new(start: usize, end: usize) -> Self {
51        Self { start, end }
52    }
53}
54
55impl std::fmt::Display for DiagSpan {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        write!(f, "{}..{}", self.start, self.end)
58    }
59}
60
61/// A single compiler diagnostic with an optional source location.
62#[derive(Debug, Clone)]
63pub struct Diagnostic {
64    pub severity: Severity,
65    /// Short alphanumeric code, e.g. "E001".
66    pub code: &'static str,
67    pub message: String,
68    /// Byte range in the source file, if known.
69    pub span: Option<DiagSpan>,
70    /// A short extract of the source shown below the message, if provided.
71    pub source_hint: Option<String>,
72    /// Free-form follow-up lines following common formatting / conventions 
73    /// cos why the hell not (`=note = ...`). Preserves order.
74    pub notes: Vec<String>,
75}
76
77impl Diagnostic {
78    pub fn error(code: &'static str, message: impl Into<String>) -> Self {
79        Self { 
80            severity: Severity::Error,
81            code,
82            message: message.into(),
83            span: None,
84            source_hint: None,
85            notes: Vec::new(),
86        }
87    }
88
89    pub fn warning(code: &'static str, message: impl Into<String>) -> Self {
90        Self { 
91            severity: Severity::Warning,
92            code,
93            message: message.into(),
94            span: None,
95            source_hint: None,
96            notes: Vec::new(),
97        }
98    }
99
100    pub fn with_span(mut self, span: DiagSpan) -> Self {
101        self.span = Some(span);
102        self
103    }
104
105    pub fn with_source_hint(mut self, hint: impl Into<String>) -> Self {
106        self.source_hint = Some(hint.into());
107        self 
108    }
109   
110    /// Attach a follow-up note. Notes appear after the caret line 
111    /// when using styled render output - printed in the note colour 
112    /// for extra awesomeness. Multiple notes pushed will stack in the order 
113    /// they are inserted.
114    pub fn with_note(mut self, msg: impl Into<String>) -> Self {
115        self.notes.push(msg.into());
116        self
117    }
118
119    /// Render the diagnostic with a source listing and a caret under 
120    /// the span that actually caused it. If the diagnostic has no span,
121    /// falls back to `Display` representation. Output has no escape codes 
122    /// (not styled) - used if writing out to a file or pipe :D
123    pub fn render(&self, source: &Source) -> String {
124        self.render_styled(source, Style::Plain)
125    }
126
127    /// 
128    pub fn render_styled(&self, source: &Source, style: Style) -> String {
129        let span = match self.span {
130            Some(s) => s,
131            None => return self.to_string(),
132        };
133
134        let (line, col) = source.line_col(span.start);
135        let line_text = source.line_text(line);
136        let gutter_w = line.to_string().len();
137        let pad = " ".repeat(col.saturating_sub(1));
138        // clamp the caret length so it never overflows the displayed line.
139        let visible_room = line_text.len().saturating_sub(col.saturating_sub(1));
140        let caret_len = (span.end - span.start).max(1).min(visible_room.max(1));
141        let carets_raw = "^".repeat(caret_len);
142        let blank_gutter = " ".repeat(gutter_w);
143
144        // --> file:line:col if the source carries a name, else line:col
145        let location = match source.name() {
146            Some(name) => format!("{name}:{line}:{col}"),
147            None => format!("line {line}:{col}"),
148        };
149
150        // I knew this would be needed thanks to dennis lol but basically
151        // the width of line in the gutter needs to be set before we paint it,
152        // because when its wrapped in escape codes, the byte length wont match 
153        // the visible width :(
154        let line_num_padded = format!("{line:>w$}", line = line, w = gutter_w);
155        let sev_word = style.severity(self.severity, &self.severity.to_string());
156        let code_word = style.bold(&format!("[{}]", self.code));
157        let msg_word = style.bold(&self.message);
158        let arrow = style.gutter("-->");
159        let bar = style.gutter("|");
160        let line_num = style.gutter(&line_num_padded);
161        let carets = style.caret(self.severity, &carets_raw);
162
163        let mut out = format!(
164            "{sev_word} {code_word}: {msg_word}\n\
165             {blank} {arrow} {location}\n\
166             {blank} {bar}\n\
167             {line_num} {bar} {line_text}\n\
168             {blank} {bar} {pad}{carets}",
169            blank = blank_gutter,
170        );
171        
172        // rustc style because i like to follow conventions and i am not 
173        // re-inventing the wheel fr 
174        // the = should line up with the | above since it also sits in the 
175        // gutter - msg stays plain so it remains readable.
176        for note in &self.notes {
177            let eq = style.gutter("=");
178            let note_word = style.severity(Severity::Note, "note:");
179            out.push('\n');
180            out.push_str(&format!("{blank_gutter} {eq} {note_word} {note}"))
181        }
182        out
183    }
184}
185
186impl std::fmt::Display for Diagnostic {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        write!(f, "{} [{}]: {}", self.severity, self.code, self.message)?;
189        if let Some(span) = &self.span {
190            write!(f, " (at {span})")?;
191        }
192        if let Some(hint) = &self.source_hint {
193            write!(f, "\n  | {hint}")?;
194        }
195        for note in &self.notes {
196            write!(f, "\n  = note: {note}")?;
197        }
198        Ok(())
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn error_display_no_span() {
208        let d = Diagnostic::error("E001", "undefined control sequence");
209        assert_eq!(d.to_string(), "error [E001]: undefined control sequence");
210    }
211
212    #[test]
213    fn error_display_with_span() {
214        let d = Diagnostic::error("E001", "bad input")
215            .with_span(DiagSpan::new(4, 9));
216        assert!(d.to_string().contains("at 4..9"));
217    }
218
219    #[test]
220    fn error_display_with_hint() {
221        let d = Diagnostic::error("E001", "bad input")
222            .with_span(DiagSpan::new(0,3))
223            .with_source_hint("abc");
224        assert!(d.to_string().contains("| abc"));
225    }
226
227    #[test]
228    fn lex_error_into_diagnostic_carries_span() {
229        let e = LexError::UnexpectedEndAfterBackslash { pos: 7 };
230        let d: Diagnostic =  e.into();
231        assert!(d.span.is_some());
232    }
233
234    #[test]
235    fn source_line_col_first_line() {
236        let s = Source::new("hello\nworld\n");
237        assert_eq!(s.line_col(0), (1, 1));
238        assert_eq!(s.line_col(4), (1,5));
239    }
240
241    #[test]
242    fn source_line_col_subsequent_lines() {
243        let s = Source::new("hello\nworld\n!");
244        assert_eq!(s.line_col(6), (2,1)); // w
245        assert_eq!(s.line_col(10), (2, 5)); // d 
246        assert_eq!(s.line_col(12), (3,1)); // !
247    }
248
249    #[test]
250    fn source_line_text() {
251        let s = Source::new("hello\nworld\n!");
252        assert_eq!(s.line_text(1), "hello");
253        assert_eq!(s.line_text(2), "world");
254        assert_eq!(s.line_text(3), "!");
255    }
256
257    #[test]
258    fn render_include_caret_and_line_number() {
259        let src = Source::new("foo {bar\n");
260        let d = Diagnostic::error("E020", "unclosed '{'")
261            .with_span(DiagSpan::new(4, 5));
262        let out = d.render(&src);
263        assert!(out.contains("line 1:5"));
264        assert!(out.contains("foo {bar"));
265        assert!(out.contains("^"));
266    }
267
268    #[test]
269    fn render_falls_back_when_no_span() {
270        let src = Source::new("anything");
271        let d = Diagnostic::error("E001", "no location");
272        // Without a span, render should match plain Display.
273        assert_eq!(d.render(&src), d.to_string());
274    }
275
276    #[test]
277    fn render_uses_source_name() {
278        let src = Source::with_name("foo {bar\n", "main.tex");
279        let d = Diagnostic::error("E020", "unclosed '{'")
280            .with_span(DiagSpan::new(4, 5));
281        let out = d.render(&src);
282        assert!(out.contains("main.tex:1:5"), "got: {out}");
283    }
284
285    #[test]
286    fn render_drops_name_prefix_when_unnamed() {
287        let src = Source::new("foo {bar\n");
288        let d = Diagnostic::error("E020", "unclosed '{'")
289            .with_span(DiagSpan::new(4, 5));
290        let out = d.render(&src);
291        assert!(out.contains("line 1:5"));
292        assert!(!out.contains("foo {bar:"), "name should not leak");
293    }
294
295    #[test]
296    fn render_plain_has_no_escape_codes() {
297        let src = Source::new("foo {bar\n");
298        let d = Diagnostic::error("E020", "unclosed '{'")
299            .with_span(DiagSpan::new(4, 5));
300        let plain = d.render(&src);
301        assert!(!plain.contains('\x1b'), "plain render should not contain ESC: {plain:?}");
302    }
303
304    #[test]
305    fn render_ansi_paints_severity_and_carets() {
306        let src = Source::new("foo {bar\n");
307        let d = Diagnostic::error("E020", "unclosed '{'")
308            .with_span(DiagSpan::new(4, 5));
309        let ansi = d.render_styled(&src, Style::Ansi);
310
311        // the bare error word should in theory be wrapped in an 
312        // SGR sequence and so should the caret.
313        // loc. should still be in the plain text in the output !!
314        assert!(ansi.contains('\x1b'), "ansi render should contain ESC");
315        assert!(ansi.contains("error"));
316        assert!(ansi.contains("line 1:5"));
317        assert!(ansi.contains('^'));
318    }
319
320    #[test]
321    fn render_warning_uses_yellow_not_red() {
322        let src = Source::new("foo\n");
323        let err = Diagnostic::error("E001", "x")
324            .with_span(DiagSpan::new(0, 1))
325            .render_styled(&src, Style::Ansi);
326        let warn = Diagnostic::warning("W001", "x")
327            .with_span(DiagSpan::new(0, 1))
328            .render_styled(&src, Style::Ansi);
329        assert_ne!(err, warn, "error and warning should pick different colours");
330    }
331
332    #[test]
333    fn render_includes_notes_after_caret() {
334        let src = Source::new("foo {bar\n");
335        let d = Diagnostic::error("E020", "unclosed '{'")
336            .with_span(DiagSpan::new(4, 5))
337            .with_note("braces must be balanced")
338            .with_note("did you forget a '}'?");
339        let out = d.render(&src);
340        assert!(out.contains("= note: braces must be balanced"), "got: {out}");
341        assert!(out.contains("= note: did you forget a '}'"));
342        // notes always come after the caret line
343        let caret_idx = out.find('^').unwrap();
344        let note_idx = out.find("braces").unwrap();
345        assert!(note_idx > caret_idx, "notes must follow the caret");
346    }
347
348    #[test]
349    fn ansi_render_paints_note_word() {
350        let src = Source::new("x\n");
351        let d = Diagnostic::error("E001", "boom")
352            .with_span(DiagSpan::new(0, 1))
353            .with_note("a follow-up");
354        let ansi = d.render_styled(&src, Style::Ansi);
355        let plain = d.render_styled(&src, Style::Plain);
356        assert!(ansi.contains("a follow-up"));
357        assert!(plain.contains("a follow-up"));
358        // the worde "note" should be wrapped in sgr if ansi on
359        // but not at all in plain mode
360        assert!(ansi.contains("\x1b[1;36mnote:\x1b[0m"));
361        assert!(!plain.contains('\x1b'));
362    }
363
364    #[test]
365    fn display_renders_notes_when_no_source_available() {
366        // the display path is the fallback for callers w/out a source
367        // so notes should still render anyway
368        let d = Diagnostic::error("E001", "x").with_note("hello");
369        assert!(d.to_string().contains("= note: hello"));
370    }
371}