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