Skip to main content

open_cypher/
diagnostic.rs

1//! Structured, parser-backend-neutral diagnostics.
2
3use std::error::Error;
4use std::fmt::{self, Write as _};
5
6use crate::span::Span;
7
8const TAB_WIDTH: usize = 4;
9
10/// Machine-readable diagnostic categories.
11#[non_exhaustive]
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub enum DiagnosticCode {
15    InvalidToken,
16    UnterminatedString,
17    UnterminatedIdentifier,
18    UnterminatedComment,
19    InvalidEscape,
20    InvalidNumber,
21    UnexpectedToken,
22    UnexpectedEof,
23    ExtraToken,
24    UnclosedDelimiter,
25    MismatchedDelimiter,
26    Recovery,
27    TooManyErrors,
28    UnsupportedSyntax,
29    Internal,
30}
31
32impl DiagnosticCode {
33    /// Returns the stable short code used in rendered diagnostics.
34    #[must_use]
35    pub const fn as_str(self) -> &'static str {
36        match self {
37            Self::InvalidToken => "OCY-L001",
38            Self::UnterminatedString => "OCY-L002",
39            Self::UnterminatedIdentifier => "OCY-L003",
40            Self::UnterminatedComment => "OCY-L004",
41            Self::InvalidEscape => "OCY-L005",
42            Self::InvalidNumber => "OCY-L006",
43            Self::UnexpectedToken => "OCY-P001",
44            Self::UnexpectedEof => "OCY-P002",
45            Self::ExtraToken => "OCY-P003",
46            Self::UnclosedDelimiter => "OCY-P004",
47            Self::MismatchedDelimiter => "OCY-P005",
48            Self::Recovery => "OCY-P006",
49            Self::TooManyErrors => "OCY-P007",
50            Self::UnsupportedSyntax => "OCY-P008",
51            Self::Internal => "OCY-P999",
52        }
53    }
54}
55
56impl fmt::Display for DiagnosticCode {
57    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58        formatter.write_str(self.as_str())
59    }
60}
61
62/// The importance of a diagnostic.
63#[non_exhaustive]
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub enum Severity {
67    Error,
68    Warning,
69    Note,
70}
71
72impl Severity {
73    /// Returns the lowercase display spelling.
74    #[must_use]
75    pub const fn as_str(self) -> &'static str {
76        match self {
77            Self::Error => "error",
78            Self::Warning => "warning",
79            Self::Note => "note",
80        }
81    }
82
83    const fn sort_key(self) -> u8 {
84        match self {
85            Self::Error => 0,
86            Self::Warning => 1,
87            Self::Note => 2,
88        }
89    }
90}
91
92impl fmt::Display for Severity {
93    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
94        formatter.write_str(self.as_str())
95    }
96}
97
98/// A secondary source annotation attached to a diagnostic.
99#[derive(Clone, Debug, PartialEq, Eq, Hash)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
101pub struct Label {
102    /// The source range being annotated.
103    pub span: Span,
104    /// A concise explanation of the range's relevance.
105    pub message: String,
106}
107
108impl Label {
109    /// Creates a secondary source annotation.
110    #[must_use]
111    pub fn new(span: Span, message: impl Into<String>) -> Self {
112        Self {
113            span,
114            message: message.into(),
115        }
116    }
117}
118
119/// A structured lexer or parser diagnostic.
120#[derive(Clone, Debug, PartialEq, Eq, Hash)]
121#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
122pub struct Diagnostic {
123    /// Stable machine-readable category.
124    pub code: DiagnosticCode,
125    /// Diagnostic importance.
126    pub severity: Severity,
127    /// Primary human-readable explanation.
128    pub message: String,
129    /// Primary source range.
130    pub primary_span: Span,
131    /// Additional annotated source ranges.
132    pub labels: Vec<Label>,
133    /// Supplemental explanatory notes.
134    pub notes: Vec<String>,
135    /// An actionable correction, when one is known.
136    pub help: Option<String>,
137}
138
139impl Diagnostic {
140    /// Creates a diagnostic with no secondary annotations.
141    #[must_use]
142    pub fn new(
143        code: DiagnosticCode,
144        severity: Severity,
145        message: impl Into<String>,
146        primary_span: Span,
147    ) -> Self {
148        Self {
149            code,
150            severity,
151            message: message.into(),
152            primary_span,
153            labels: Vec::new(),
154            notes: Vec::new(),
155            help: None,
156        }
157    }
158
159    /// Creates an error diagnostic.
160    #[must_use]
161    pub fn error(code: DiagnosticCode, message: impl Into<String>, primary_span: Span) -> Self {
162        Self::new(code, Severity::Error, message, primary_span)
163    }
164
165    /// Adds a secondary source annotation.
166    #[must_use]
167    pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Self {
168        self.labels.push(Label::new(span, message));
169        self
170    }
171
172    /// Adds a supplemental note.
173    #[must_use]
174    pub fn with_note(mut self, note: impl Into<String>) -> Self {
175        self.notes.push(note.into());
176        self
177    }
178
179    /// Adds an actionable correction.
180    #[must_use]
181    pub fn with_help(mut self, help: impl Into<String>) -> Self {
182        self.help = Some(help.into());
183        self
184    }
185
186    /// Returns `true` if this diagnostic makes a strict parse fail.
187    #[must_use]
188    pub const fn is_error(&self) -> bool {
189        matches!(self.severity, Severity::Error)
190    }
191
192    /// Renders this diagnostic against `source` without terminal colors.
193    ///
194    /// Line and column numbers are one-based. Columns count Unicode scalar
195    /// values after expanding tabs to four-column tab stops.
196    #[must_use]
197    pub fn render(&self, source: &str) -> String {
198        let mut rendered = String::new();
199        render_one(&mut rendered, source, self).expect("writing to a String cannot fail");
200        rendered
201    }
202}
203
204impl fmt::Display for Diagnostic {
205    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
206        write!(
207            formatter,
208            "{}[{}]: {} at {}",
209            self.severity, self.code, self.message, self.primary_span
210        )
211    }
212}
213
214/// One or more diagnostics returned by strict parsing.
215#[derive(Clone, Debug, PartialEq, Eq)]
216#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
217pub struct ParseErrors {
218    diagnostics: Vec<Diagnostic>,
219}
220
221impl ParseErrors {
222    /// Creates an error collection and puts diagnostics in deterministic source
223    /// order.
224    ///
225    /// # Panics
226    ///
227    /// Panics when `diagnostics` is empty. Use [`Self::try_new`] when emptiness
228    /// is expected.
229    #[must_use]
230    pub fn new(mut diagnostics: Vec<Diagnostic>) -> Self {
231        assert!(
232            !diagnostics.is_empty(),
233            "ParseErrors requires at least one diagnostic"
234        );
235        sort_diagnostics(&mut diagnostics);
236        Self { diagnostics }
237    }
238
239    /// Creates an error collection, returning `None` for an empty vector.
240    #[must_use]
241    pub fn try_new(diagnostics: Vec<Diagnostic>) -> Option<Self> {
242        if diagnostics.is_empty() {
243            None
244        } else {
245            Some(Self::new(diagnostics))
246        }
247    }
248
249    /// Returns the diagnostics in deterministic source order.
250    #[must_use]
251    pub fn diagnostics(&self) -> &[Diagnostic] {
252        &self.diagnostics
253    }
254
255    /// Consumes the collection and returns its diagnostics.
256    #[must_use]
257    pub fn into_diagnostics(self) -> Vec<Diagnostic> {
258        self.diagnostics
259    }
260
261    /// Returns the number of diagnostics.
262    #[must_use]
263    pub fn len(&self) -> usize {
264        self.diagnostics.len()
265    }
266
267    /// Returns whether the collection is empty.
268    ///
269    /// Values created through the Rust constructors are always nonempty. This
270    /// method still checks the backing collection so data deserialized through
271    /// the optional `serde` feature is reported faithfully.
272    #[must_use]
273    pub fn is_empty(&self) -> bool {
274        self.diagnostics.is_empty()
275    }
276
277    /// Renders all diagnostics against `source` in deterministic source order.
278    #[must_use]
279    pub fn render(&self, source: &str) -> String {
280        let mut rendered = String::new();
281        for (index, diagnostic) in self.diagnostics.iter().enumerate() {
282            if index != 0 {
283                rendered.push_str("\n\n");
284            }
285            render_one(&mut rendered, source, diagnostic).expect("writing to a String cannot fail");
286        }
287        rendered
288    }
289}
290
291impl From<Diagnostic> for ParseErrors {
292    fn from(diagnostic: Diagnostic) -> Self {
293        Self::new(vec![diagnostic])
294    }
295}
296
297impl AsRef<[Diagnostic]> for ParseErrors {
298    fn as_ref(&self) -> &[Diagnostic] {
299        self.diagnostics()
300    }
301}
302
303impl<'errors> IntoIterator for &'errors ParseErrors {
304    type Item = &'errors Diagnostic;
305    type IntoIter = std::slice::Iter<'errors, Diagnostic>;
306
307    fn into_iter(self) -> Self::IntoIter {
308        self.diagnostics.iter()
309    }
310}
311
312impl IntoIterator for ParseErrors {
313    type Item = Diagnostic;
314    type IntoIter = std::vec::IntoIter<Diagnostic>;
315
316    fn into_iter(self) -> Self::IntoIter {
317        self.diagnostics.into_iter()
318    }
319}
320
321impl fmt::Display for ParseErrors {
322    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
323        let Some(first) = self.diagnostics.first() else {
324            return formatter.write_str("no parse diagnostics");
325        };
326        if self.diagnostics.len() == 1 {
327            first.fmt(formatter)
328        } else {
329            write!(
330                formatter,
331                "{} (and {} more diagnostic{})",
332                first,
333                self.diagnostics.len() - 1,
334                if self.diagnostics.len() == 2 { "" } else { "s" }
335            )
336        }
337    }
338}
339
340impl Error for ParseErrors {}
341
342fn sort_diagnostics(diagnostics: &mut [Diagnostic]) {
343    diagnostics.sort_by(|left, right| {
344        (
345            left.primary_span.start,
346            left.primary_span.end,
347            left.severity.sort_key(),
348            left.code,
349            left.message.as_str(),
350        )
351            .cmp(&(
352                right.primary_span.start,
353                right.primary_span.end,
354                right.severity.sort_key(),
355                right.code,
356                right.message.as_str(),
357            ))
358    });
359}
360
361fn render_one(output: &mut String, source: &str, diagnostic: &Diagnostic) -> fmt::Result {
362    writeln!(
363        output,
364        "{}[{}]: {}",
365        diagnostic.severity, diagnostic.code, diagnostic.message
366    )?;
367
368    let primary = snippet(source, diagnostic.primary_span);
369    writeln!(output, " --> {}:{}", primary.line_number, primary.column)?;
370    writeln!(output, "  |")?;
371
372    let primary_label = diagnostic
373        .labels
374        .iter()
375        .find(|label| label.span == diagnostic.primary_span)
376        .map(|label| label.message.as_str())
377        .unwrap_or("");
378    render_snippet(output, &primary, '^', primary_label)?;
379
380    let mut labels: Vec<_> = diagnostic
381        .labels
382        .iter()
383        .filter(|label| label.span != diagnostic.primary_span)
384        .collect();
385    labels.sort_by(|left, right| {
386        (left.span.start, left.span.end, left.message.as_str()).cmp(&(
387            right.span.start,
388            right.span.end,
389            right.message.as_str(),
390        ))
391    });
392
393    for label in labels {
394        writeln!(output, "  |")?;
395        let secondary = snippet(source, label.span);
396        render_snippet(output, &secondary, '-', &label.message)?;
397    }
398
399    for note in &diagnostic.notes {
400        writeln!(output, "  = note: {note}")?;
401    }
402    if let Some(help) = &diagnostic.help {
403        writeln!(output, "  = help: {help}")?;
404    }
405
406    if output.ends_with('\n') {
407        output.pop();
408    }
409    Ok(())
410}
411
412fn render_snippet(
413    output: &mut String,
414    snippet: &Snippet,
415    marker: char,
416    label: &str,
417) -> fmt::Result {
418    let gutter_width = decimal_width(snippet.line_number);
419    writeln!(
420        output,
421        "{:>gutter_width$} | {}",
422        snippet.line_number, snippet.line
423    )?;
424    write!(
425        output,
426        "{:>gutter_width$} | {}{}",
427        "",
428        " ".repeat(snippet.marker_start),
429        marker.to_string().repeat(snippet.marker_len)
430    )?;
431    if !label.is_empty() {
432        write!(output, " {label}")?;
433    }
434    if snippet.continues {
435        write!(output, " (continues on the next line)")?;
436    }
437    writeln!(output)
438}
439
440struct Snippet {
441    line_number: usize,
442    column: usize,
443    line: String,
444    marker_start: usize,
445    marker_len: usize,
446    continues: bool,
447}
448
449fn snippet(source: &str, span: Span) -> Snippet {
450    let start = floor_char_boundary(source, span.start.min(source.len()));
451    let requested_end = floor_char_boundary(source, span.end.min(source.len()));
452    let before = &source[..start];
453    let line_start = before.rfind('\n').map_or(0, |index| index + 1);
454    let line_number = before.bytes().filter(|byte| *byte == b'\n').count() + 1;
455    let mut line_end = source[start..]
456        .find('\n')
457        .map_or(source.len(), |offset| start + offset);
458    if line_end > line_start && source.as_bytes()[line_end - 1] == b'\r' {
459        line_end -= 1;
460    }
461
462    let visible_start = start.min(line_end);
463    let visible_end = requested_end.min(line_end).max(visible_start);
464    let marker_start = display_width(&source[line_start..visible_start]);
465    let marker_len = display_width(&source[visible_start..visible_end]).max(1);
466
467    Snippet {
468        line_number,
469        column: marker_start + 1,
470        line: expand_tabs(&source[line_start..line_end]),
471        marker_start,
472        marker_len,
473        continues: requested_end > line_end,
474    }
475}
476
477fn floor_char_boundary(source: &str, mut offset: usize) -> usize {
478    while offset > 0 && !source.is_char_boundary(offset) {
479        offset -= 1;
480    }
481    offset
482}
483
484fn display_width(text: &str) -> usize {
485    let mut column = 0;
486    for character in text.chars() {
487        if character == '\t' {
488            column += TAB_WIDTH - (column % TAB_WIDTH);
489        } else {
490            column += 1;
491        }
492    }
493    column
494}
495
496fn expand_tabs(text: &str) -> String {
497    let mut expanded = String::with_capacity(text.len());
498    let mut column = 0;
499    for character in text.chars() {
500        if character == '\t' {
501            let spaces = TAB_WIDTH - (column % TAB_WIDTH);
502            expanded.extend(std::iter::repeat_n(' ', spaces));
503            column += spaces;
504        } else {
505            expanded.push(character);
506            column += 1;
507        }
508    }
509    expanded
510}
511
512fn decimal_width(mut value: usize) -> usize {
513    let mut width = 1;
514    while value >= 10 {
515        value /= 10;
516        width += 1;
517    }
518    width
519}