Skip to main content

yaml_rt_core/
diagnostic.rs

1use std::fmt;
2
3use crate::{LineCol, Source, Span};
4
5/// Error type for YAML parsing, semantic lookup, typed overlays, and emission.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct YamlError {
8    /// Primary diagnostic.
9    pub diagnostic: Diagnostic,
10}
11
12impl YamlError {
13    /// Creates a new error from a diagnostic.
14    #[must_use]
15    pub const fn new(diagnostic: Diagnostic) -> Self {
16        Self { diagnostic }
17    }
18
19    /// Adds line/column information from `source` when the diagnostic does not
20    /// already have a position.
21    #[must_use]
22    pub fn with_position_from(mut self, source: &Source) -> Self {
23        if self.diagnostic.position.is_none() {
24            self.diagnostic.position = Some(source.diagnostic_position(&self.diagnostic));
25        }
26        self
27    }
28
29    /// Creates a source-aware renderer for this error.
30    #[must_use]
31    pub fn render<'a>(&'a self, source: &'a str) -> DiagnosticRenderer<'a> {
32        self.diagnostic.render(source)
33    }
34}
35
36impl fmt::Display for YamlError {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        self.diagnostic.fmt(formatter)
39    }
40}
41
42impl std::error::Error for YamlError {}
43
44/// Structured user-facing diagnostic.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Diagnostic {
47    /// Error phase.
48    pub kind: DiagnosticKind,
49    /// Primary message.
50    pub message: String,
51    /// Primary source span.
52    pub span: Span,
53    /// One-based line/column for the primary span when a source is available.
54    pub position: Option<LineCol>,
55    /// Expected syntax or semantic items.
56    pub expected: Vec<String>,
57    /// Additional context notes.
58    pub notes: Vec<String>,
59}
60
61impl Diagnostic {
62    /// Creates a diagnostic with no expected items or notes.
63    #[must_use]
64    pub fn new(kind: DiagnosticKind, message: impl Into<String>, span: Span) -> Self {
65        Self {
66            kind,
67            message: message.into(),
68            span,
69            position: None,
70            expected: Vec::new(),
71            notes: Vec::new(),
72        }
73    }
74
75    /// Sets a one-based line/column position for the primary span.
76    #[must_use]
77    pub const fn with_position(mut self, position: LineCol) -> Self {
78        self.position = Some(position);
79        self
80    }
81
82    /// Adds one expected syntax or semantic item.
83    #[must_use]
84    pub fn with_expected(mut self, expected: impl Into<String>) -> Self {
85        self.expected.push(expected.into());
86        self
87    }
88
89    /// Adds one explanatory note.
90    #[must_use]
91    pub fn with_note(mut self, note: impl Into<String>) -> Self {
92        self.notes.push(note.into());
93        self
94    }
95
96    /// Creates a source-aware renderer for this diagnostic.
97    #[must_use]
98    pub const fn render<'a>(&'a self, source: &'a str) -> DiagnosticRenderer<'a> {
99        DiagnosticRenderer {
100            diagnostic: self,
101            source,
102            source_name: None,
103            color: DiagnosticColor::Never,
104            label: None,
105        }
106    }
107}
108
109impl fmt::Display for Diagnostic {
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(formatter, "{:?}: {}", self.kind, self.message)?;
112
113        if let Some(position) = self.position {
114            write!(formatter, " at {}:{}", position.line, position.column)?;
115        }
116
117        if !self.expected.is_empty() {
118            write!(formatter, " (expected: {})", self.expected.join(", "))?;
119        }
120
121        for note in &self.notes {
122            write!(formatter, "\nnote: {note}")?;
123        }
124
125        Ok(())
126    }
127}
128
129/// Diagnostic phase.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum DiagnosticKind {
132    /// Source validation failure.
133    Source,
134    /// Lexer failure.
135    Lexer,
136    /// Parser failure.
137    Parser,
138    /// Semantic graph or schema failure.
139    Semantic,
140    /// Typed overlay failure.
141    Typed,
142    /// Emitter failure.
143    Emitter,
144}
145
146impl DiagnosticKind {
147    const fn label(self) -> &'static str {
148        match self {
149            Self::Source => "source",
150            Self::Lexer => "lexer",
151            Self::Parser => "parser",
152            Self::Semantic => "semantic",
153            Self::Typed => "typed",
154            Self::Emitter => "emitter",
155        }
156    }
157}
158
159/// ANSI color policy for source-aware diagnostic rendering.
160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
161pub enum DiagnosticColor {
162    /// Never emit ANSI escape sequences.
163    #[default]
164    Never,
165    /// Use standard terminal foreground colors and bold emphasis.
166    Always,
167}
168
169/// A source-aware, rustc-style diagnostic display adapter.
170#[derive(Debug, Clone, Copy)]
171pub struct DiagnosticRenderer<'a> {
172    diagnostic: &'a Diagnostic,
173    source: &'a str,
174    source_name: Option<&'a str>,
175    color: DiagnosticColor,
176    label: Option<&'a str>,
177}
178
179impl<'a> DiagnosticRenderer<'a> {
180    /// Overrides the diagnostic phase label.
181    #[must_use]
182    pub const fn with_label(mut self, label: &'a str) -> Self {
183        self.label = Some(label);
184        self
185    }
186    /// Sets the filename or logical input name shown in the location header.
187    #[must_use]
188    pub const fn with_source_name(mut self, source_name: &'a str) -> Self {
189        self.source_name = Some(source_name);
190        self
191    }
192
193    /// Sets the ANSI color policy.
194    #[must_use]
195    pub const fn with_color(mut self, color: DiagnosticColor) -> Self {
196        self.color = color;
197        self
198    }
199
200    fn styled(
201        &self,
202        formatter: &mut fmt::Formatter<'_>,
203        code: &str,
204        value: impl fmt::Display,
205    ) -> fmt::Result {
206        if self.color == DiagnosticColor::Always {
207            write!(formatter, "\x1b[{code}m{value}\x1b[0m")
208        } else {
209            write!(formatter, "{value}")
210        }
211    }
212}
213
214impl fmt::Display for DiagnosticRenderer<'_> {
215    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
216        self.styled(formatter, "1;31", "error")?;
217        write!(
218            formatter,
219            "[{}]: {}",
220            self.label.unwrap_or(self.diagnostic.kind.label()),
221            self.diagnostic.message
222        )?;
223
224        let source_len = self.source.len();
225        let start = (self.diagnostic.span.start as usize).min(source_len);
226        let requested_end = self.diagnostic.span.end as usize;
227        let end = requested_end.max(start).min(source_len);
228        if !self.source.is_char_boundary(start) || !self.source.is_char_boundary(end) {
229            return write!(formatter, "\n{}", self.diagnostic);
230        }
231
232        let starts = line_starts(self.source);
233        let line_index = starts
234            .partition_point(|offset| *offset <= start)
235            .saturating_sub(1);
236        let line_start = starts[line_index];
237        let line_end = source_line_end(self.source, &starts, line_index);
238        let position = LineCol {
239            line: line_index + 1,
240            column: display_width(&self.source[line_start..start]) + 1,
241        };
242
243        write!(formatter, "\n ")?;
244        self.styled(formatter, "1;34", "-->")?;
245        write!(
246            formatter,
247            " {}:{}:{}",
248            self.source_name.unwrap_or("<input>"),
249            position.line,
250            position.column
251        )?;
252
253        let first_shown = line_index.saturating_sub(1);
254        let width = (line_index + 1).to_string().len();
255        write!(formatter, "\n{:width$} ", "", width = width)?;
256        self.styled(formatter, "1;34", "|")?;
257        for shown in first_shown..=line_index {
258            let shown_start = starts[shown];
259            let shown_end = source_line_end(self.source, &starts, shown);
260            let text = expand_tabs(&self.source[shown_start..shown_end]);
261            write!(formatter, "\n{:>width$} ", shown + 1, width = width)?;
262            self.styled(formatter, "1;34", "|")?;
263            write!(formatter, " {text}")?;
264        }
265
266        let prefix_width = display_width(&self.source[line_start..start]);
267        let underline_end = end.min(line_end);
268        let underline_width = if underline_end > start {
269            display_width(&self.source[start..underline_end]).max(1)
270        } else {
271            1
272        };
273        write!(formatter, "\n{:width$} ", "", width = width)?;
274        self.styled(formatter, "1;34", "|")?;
275        write!(formatter, " {}", " ".repeat(prefix_width))?;
276        self.styled(formatter, "1;31", "^".repeat(underline_width))?;
277        if !self.diagnostic.expected.is_empty() {
278            write!(
279                formatter,
280                " expected {}",
281                self.diagnostic.expected.join(", ")
282            )?;
283        }
284
285        let end_line = starts
286            .partition_point(|offset| *offset <= end)
287            .saturating_sub(1);
288        if end_line > line_index || requested_end > source_len {
289            write!(formatter, "\n{:width$} ", "", width = width)?;
290            self.styled(formatter, "1;34", "|")?;
291            write!(formatter, " ... span continues")?;
292        }
293        write!(formatter, "\n{:width$} ", "", width = width)?;
294        self.styled(formatter, "1;34", "|")?;
295
296        for note in &self.diagnostic.notes {
297            writeln!(formatter)?;
298            self.styled(formatter, "1;32", "note")?;
299            write!(formatter, ": {note}")?;
300        }
301        Ok(())
302    }
303}
304
305fn line_starts(source: &str) -> Vec<usize> {
306    let mut starts = vec![0];
307    starts.extend(
308        source
309            .bytes()
310            .enumerate()
311            .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
312    );
313    starts
314}
315
316fn source_line_end(source: &str, starts: &[usize], line_index: usize) -> usize {
317    let mut end = starts.get(line_index + 1).copied().unwrap_or(source.len());
318    if end > starts[line_index] && source.as_bytes()[end - 1] == b'\n' {
319        end -= 1;
320    }
321    if end > starts[line_index] && source.as_bytes()[end - 1] == b'\r' {
322        end -= 1;
323    }
324    end
325}
326
327fn display_width(text: &str) -> usize {
328    text.chars().fold(0, |width, character| match character {
329        '\t' => width + (4 - width % 4),
330        _ => width + 1,
331    })
332}
333
334fn expand_tabs(text: &str) -> String {
335    let mut expanded = String::with_capacity(text.len());
336    let mut width = 0;
337    for character in text.chars() {
338        if character == '\t' {
339            let spaces = 4 - width % 4;
340            expanded.push_str(&" ".repeat(spaces));
341            width += spaces;
342        } else {
343            expanded.push(character);
344            width += 1;
345        }
346    }
347    expanded
348}
349
350/// Alias for parse errors until richer phase-specific errors are introduced.
351pub type ParseError = YamlError;