1mod source;
12mod lex_error;
13mod style;
14
15pub use source::Source;
16pub use lex_error::LexError;
17pub use style::Style;
18
19#[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#[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#[derive(Debug, Clone)]
63pub struct Diagnostic {
64 pub severity: Severity,
65 pub code: &'static str,
67 pub message: String,
68 pub span: Option<DiagSpan>,
70 pub source_hint: Option<String>,
72 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 pub fn with_note(mut self, msg: impl Into<String>) -> Self {
115 self.notes.push(msg.into());
116 self
117 }
118
119 pub fn render(&self, source: &Source) -> String {
124 self.render_styled(source, Style::Plain)
125 }
126
127 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 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 let location = match source.name() {
146 Some(name) => format!("{name}:{line}:{col}"),
147 None => format!("line {line}:{col}"),
148 };
149
150 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 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)); assert_eq!(s.line_col(10), (2, 5)); assert_eq!(s.line_col(12), (3,1)); }
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 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 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 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 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 let d = Diagnostic::error("E001", "x").with_note("hello");
369 assert!(d.to_string().contains("= note: hello"));
370 }
371}