Skip to main content

lisette_diagnostics/
diagnostic.rs

1use miette::{Diagnostic, LabeledSpan, Severity};
2use owo_colors::OwoColorize;
3use std::fmt;
4use std::sync::Arc;
5
6use syntax::ParseError;
7use syntax::ast::Span;
8
9/// Source text with a precomputed line-offset index for O(log n) span lookups.
10#[derive(Clone, Debug)]
11pub struct IndexedSource {
12    source: Arc<str>,
13    line_starts: Arc<[usize]>,
14}
15
16impl IndexedSource {
17    pub fn new(s: &str) -> Self {
18        let mut line_starts = vec![0usize];
19        for (i, byte) in s.bytes().enumerate() {
20            if byte == b'\n' {
21                line_starts.push(i + 1);
22            }
23        }
24        Self {
25            source: Arc::from(s),
26            line_starts: Arc::from(line_starts),
27        }
28    }
29
30    pub fn line_col(&self, offset: usize) -> (usize, usize) {
31        let line = match self.line_starts.binary_search(&offset) {
32            Ok(exact) => exact,
33            Err(idx) => idx.saturating_sub(1),
34        };
35        (line + 1, offset - self.line_starts[line] + 1)
36    }
37}
38
39impl miette::SourceCode for IndexedSource {
40    fn read_span<'a>(
41        &'a self,
42        span: &miette::SourceSpan,
43        context_lines_before: usize,
44        context_lines_after: usize,
45    ) -> Result<Box<dyn miette::SpanContents<'a> + 'a>, miette::MietteError> {
46        let src = self.source.as_ref();
47        let offset = span.offset();
48        let len = span.len();
49
50        if offset + len > src.len() {
51            return Err(miette::MietteError::OutOfBounds);
52        }
53
54        let span_line = match self.line_starts.binary_search(&offset) {
55            Ok(exact) => exact,
56            Err(idx) => idx.saturating_sub(1),
57        };
58
59        let start_line = span_line.saturating_sub(context_lines_before);
60        let start_offset = self.line_starts[start_line];
61        let start_column = if context_lines_before == 0 {
62            offset - self.line_starts[span_line]
63        } else {
64            0
65        };
66
67        let span_end = offset + len.saturating_sub(1);
68        let end_line = match self.line_starts.binary_search(&span_end) {
69            Ok(exact) => exact,
70            Err(idx) => idx.saturating_sub(1),
71        };
72
73        let last_line = (end_line + context_lines_after).min(self.line_starts.len() - 1);
74        let end_offset = if last_line + 1 < self.line_starts.len() {
75            self.line_starts[last_line + 1].min(src.len())
76        } else {
77            src.len()
78        };
79
80        Ok(Box::new(miette::MietteSpanContents::new(
81            &src.as_bytes()[start_offset..end_offset],
82            (start_offset, end_offset - start_offset).into(),
83            start_line,
84            start_column,
85            last_line + 1,
86        )))
87    }
88}
89
90fn strip_period(s: &str, strip: bool) -> &str {
91    if strip {
92        s.strip_suffix('.').unwrap_or(s)
93    } else {
94        s
95    }
96}
97
98fn span_to_labeled(span: &Span, text: String, primary: bool) -> LabeledSpan {
99    let source_span = miette::SourceSpan::new(
100        (span.byte_offset as usize).into(),
101        span.byte_length as usize,
102    );
103    if primary {
104        LabeledSpan::new_primary_with_span(Some(text), source_span)
105    } else {
106        LabeledSpan::new_with_span(Some(text), source_span)
107    }
108}
109
110pub use miette::Report;
111
112impl From<ParseError> for LisetteDiagnostic {
113    fn from(err: ParseError) -> Self {
114        let mut diagnostic = LisetteDiagnostic::error(&err.message);
115
116        for (span, label) in &err.labels {
117            diagnostic = diagnostic.with_span_label(span, label);
118        }
119
120        if let Some(help) = err.help {
121            diagnostic = diagnostic.with_help(help);
122        }
123
124        if let Some(note) = err.note {
125            diagnostic = diagnostic.with_note(note);
126        }
127
128        if !err.code.is_empty() {
129            diagnostic = diagnostic.with_code(err.code);
130        }
131
132        diagnostic
133    }
134}
135
136fn format_with_backticks<F>(text: &str, use_color: bool, base_style: F) -> String
137where
138    F: Fn(&str) -> String,
139{
140    if !use_color {
141        return text.to_string();
142    }
143
144    let mut result = String::new();
145    let mut chars = text.char_indices().peekable();
146    let mut segment_start = 0;
147
148    while let Some((i, ch)) = chars.next() {
149        if ch == '`' {
150            if i > segment_start {
151                result.push_str(&base_style(&text[segment_start..i]));
152            }
153
154            let mut found_closing = false;
155            for (j, inner_ch) in chars.by_ref() {
156                if inner_ch == '`' {
157                    let quoted = &text[i + 1..j];
158                    result.push_str(&format!("{}", quoted.bright_magenta()));
159                    segment_start = j + 1;
160                    found_closing = true;
161                    break;
162                }
163            }
164
165            if !found_closing {
166                result.push_str(&base_style(&text[i..]));
167                segment_start = text.len();
168            }
169        }
170    }
171
172    if segment_start < text.len() {
173        result.push_str(&base_style(&text[segment_start..]));
174    }
175
176    result
177}
178
179#[derive(Debug, Clone)]
180#[must_use]
181pub struct LisetteDiagnostic {
182    message: String,
183    labels: Vec<LabeledSpan>,
184    help: Option<String>,
185    note: Option<String>,
186    severity: Severity,
187    code: Option<String>,
188    file_id: Option<u32>,
189    use_color: bool,
190}
191
192impl fmt::Display for LisetteDiagnostic {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        if self.use_color {
195            let styled_message = match self.severity {
196                Severity::Error => {
197                    format_with_backticks(&self.message, true, |s| format!("{}", s.red().bold()))
198                }
199                Severity::Warning => {
200                    format_with_backticks(&self.message, true, |s| format!("{}", s.yellow().bold()))
201                }
202                Severity::Advice => {
203                    format_with_backticks(&self.message, true, |s| format!("{}", s.blue().bold()))
204                }
205            };
206            write!(f, "{}", styled_message)?;
207        } else {
208            self.message.fmt(f)?;
209        }
210        Ok(())
211    }
212}
213
214impl std::error::Error for LisetteDiagnostic {}
215
216struct HelpText<'a> {
217    help: Option<&'a str>,
218    note: Option<&'a str>,
219    diagnostic_code: Option<&'a str>,
220    use_color: bool,
221}
222
223impl fmt::Display for HelpText<'_> {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        let use_color = self.use_color;
226        let has_code = self.diagnostic_code.is_some();
227
228        let combined = match (self.help, self.note) {
229            (Some(h), Some(n)) => format!("{} {}", h, strip_period(n, has_code)),
230            (Some(h), None) => strip_period(h, has_code).to_string(),
231            (None, Some(n)) => strip_period(n, has_code).to_string(),
232            (None, None) => String::new(),
233        };
234
235        if !combined.is_empty() {
236            if use_color {
237                let styled = format_with_backticks(&combined, true, |s| format!("{}", s.dimmed()));
238                write!(f, "{}", styled)?;
239            } else {
240                write!(f, "{}", combined)?;
241            }
242        }
243
244        if let Some(code) = self.diagnostic_code {
245            let is_listing = self
246                .help
247                .is_some_and(|h| h.lines().skip(1).any(|line| line.starts_with("  ")));
248            let prefix = if is_listing { "\ncode: " } else { " ยท code: " };
249            if use_color {
250                write!(f, "{}{}", prefix.dimmed(), format!("[{}]", code).dimmed())?;
251            } else {
252                write!(f, "{}[{}]", prefix, code)?;
253            }
254        }
255
256        Ok(())
257    }
258}
259
260impl Diagnostic for LisetteDiagnostic {
261    fn severity(&self) -> Option<Severity> {
262        Some(self.severity)
263    }
264
265    fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
266        let diagnostic_code = self.code.as_deref();
267
268        if self.help.is_none() && self.note.is_none() && diagnostic_code.is_none() {
269            return None;
270        }
271        Some(Box::new(HelpText {
272            help: self.help.as_deref(),
273            note: self.note.as_deref(),
274            diagnostic_code,
275            use_color: self.use_color,
276        }))
277    }
278
279    fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
280        let use_color = self.use_color;
281        let severity = self.severity;
282
283        let formatted_labels = self.labels.iter().map(move |span| {
284            if let Some(label) = span.label() {
285                let formatted = if use_color {
286                    let base_style = match severity {
287                        Severity::Error => |s: &str| format!("{}", s.red()),
288                        Severity::Warning => |s: &str| format!("{}", s.yellow()),
289                        Severity::Advice => |s: &str| format!("{}", s.blue()),
290                    };
291                    format_with_backticks(label, true, base_style)
292                } else {
293                    label.to_string()
294                };
295                if span.primary() {
296                    LabeledSpan::new_primary_with_span(Some(formatted), *span.inner())
297                } else {
298                    LabeledSpan::new_with_span(Some(formatted), *span.inner())
299                }
300            } else {
301                span.clone()
302            }
303        });
304
305        Some(Box::new(formatted_labels))
306    }
307
308    fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
309        None // rendered with the help text instead
310    }
311}
312
313impl LisetteDiagnostic {
314    pub fn plain_message(&self) -> &str {
315        &self.message
316    }
317
318    pub fn plain_help(&self) -> Option<&str> {
319        self.help.as_deref()
320    }
321
322    pub fn plain_note(&self) -> Option<&str> {
323        self.note.as_deref()
324    }
325
326    fn new(message: impl Into<String>, severity: Severity) -> Self {
327        Self {
328            message: message.into(),
329            labels: Vec::new(),
330            help: None,
331            note: None,
332            severity,
333            code: None,
334            file_id: None,
335            use_color: false,
336        }
337    }
338
339    pub fn error(message: impl Into<String>) -> Self {
340        Self::new(message, Severity::Error)
341    }
342
343    pub fn warn(message: impl Into<String>) -> Self {
344        Self::new(message, Severity::Warning)
345    }
346
347    pub fn info(message: impl Into<String>) -> Self {
348        Self::new(message, Severity::Advice)
349    }
350
351    pub fn with_color(mut self, use_color: bool) -> Self {
352        self.use_color = use_color;
353        self
354    }
355
356    pub fn with_span_label(mut self, span: &Span, text: impl Into<String>) -> Self {
357        if self.file_id.is_none() {
358            self.file_id = Some(span.file_id);
359        }
360        self.labels.push(span_to_labeled(span, text.into(), false));
361        self
362    }
363
364    pub fn with_span_primary_label(mut self, span: &Span, text: impl Into<String>) -> Self {
365        if self.file_id.is_none() {
366            self.file_id = Some(span.file_id);
367        }
368        self.labels.push(span_to_labeled(span, text.into(), true));
369        self
370    }
371
372    pub fn with_labels(mut self, labels: Vec<LabeledSpan>) -> Self {
373        self.labels.extend(labels);
374        self
375    }
376
377    pub fn with_help(mut self, help: impl Into<String>) -> Self {
378        self.help = Some(help.into());
379        self
380    }
381
382    pub fn with_note(mut self, note: impl Into<String>) -> Self {
383        self.note = Some(note.into());
384        self
385    }
386
387    pub fn with_lex_code(mut self, code: &str) -> Self {
388        self.code = Some(format!("lex.{}", code));
389        self
390    }
391
392    pub fn with_parse_code(mut self, code: &str) -> Self {
393        self.code = Some(format!("parse.{}", code));
394        self
395    }
396
397    pub fn with_resolve_code(mut self, code: &str) -> Self {
398        self.code = Some(format!("resolve.{}", code));
399        self
400    }
401
402    pub fn with_infer_code(mut self, code: &str) -> Self {
403        self.code = Some(format!("infer.{}", code));
404        self
405    }
406
407    pub fn with_lint_code(mut self, code: &str) -> Self {
408        debug_assert!(
409            matches!(self.severity, Severity::Warning | Severity::Advice),
410            "with_lint_code requires Warning or Advice severity (got {:?}); \
411             use a phase-specific code constructor for errors",
412            self.severity,
413        );
414        self.code = Some(format!("lint.{}", code));
415        self
416    }
417
418    pub fn with_attribute_code(mut self, code: &str) -> Self {
419        self.code = Some(format!("attribute.{}", code));
420        self
421    }
422
423    pub fn with_emit_code(mut self, code: &str) -> Self {
424        self.code = Some(format!("emit.{}", code));
425        self
426    }
427
428    pub fn with_code(mut self, code: impl Into<String>) -> Self {
429        self.code = Some(code.into());
430        self
431    }
432
433    pub fn with_source_code(self, source: IndexedSource, filename: String) -> miette::Report {
434        miette::Report::new(self).with_source_code(miette::NamedSource::new(filename, source))
435    }
436
437    pub fn code_str(&self) -> Option<&str> {
438        self.code.as_deref()
439    }
440
441    pub fn primary_offset(&self) -> usize {
442        self.labels.first().map(|l| l.offset()).unwrap_or(0)
443    }
444
445    pub fn location_offset(&self) -> Option<usize> {
446        self.labels
447            .iter()
448            .find(|l| l.primary())
449            .or_else(|| self.labels.first())
450            .map(|l| l.offset())
451    }
452
453    pub fn severity_word(&self) -> &'static str {
454        match self.severity {
455            Severity::Error => "error",
456            Severity::Warning => "warning",
457            Severity::Advice => "info",
458        }
459    }
460
461    pub fn file_id(&self) -> Option<u32> {
462        self.file_id
463    }
464
465    pub fn is_error(&self) -> bool {
466        self.severity == Severity::Error
467    }
468
469    pub fn is_warning(&self) -> bool {
470        self.severity == Severity::Warning
471    }
472
473    pub fn is_info(&self) -> bool {
474        self.severity == Severity::Advice
475    }
476
477    pub fn sort_key(a: &Self, b: &Self) -> std::cmp::Ordering {
478        a.file_id()
479            .cmp(&b.file_id())
480            .then_with(|| a.primary_offset().cmp(&b.primary_offset()))
481            .then_with(|| a.code_str().cmp(&b.code_str()))
482            .then_with(|| a.plain_message().cmp(b.plain_message()))
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn info_constructor_is_advice_severity() {
492        let diagnostic = LisetteDiagnostic::info("advisory");
493        assert!(diagnostic.is_info());
494        assert!(!diagnostic.is_error());
495        assert!(!diagnostic.is_warning());
496        assert_eq!(diagnostic.severity_word(), "info");
497    }
498}