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        }
105    }
106}
107
108impl fmt::Display for Diagnostic {
109    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(formatter, "{:?}: {}", self.kind, self.message)?;
111
112        if let Some(position) = self.position {
113            write!(formatter, " at {}:{}", position.line, position.column)?;
114        }
115
116        if !self.expected.is_empty() {
117            write!(formatter, " (expected: {})", self.expected.join(", "))?;
118        }
119
120        for note in &self.notes {
121            write!(formatter, "\nnote: {note}")?;
122        }
123
124        Ok(())
125    }
126}
127
128/// Diagnostic phase.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum DiagnosticKind {
131    /// Source validation failure.
132    Source,
133    /// Lexer failure.
134    Lexer,
135    /// Parser failure.
136    Parser,
137    /// Semantic graph or schema failure.
138    Semantic,
139    /// Typed overlay failure.
140    Typed,
141    /// Emitter failure.
142    Emitter,
143}
144
145impl DiagnosticKind {
146    const fn label(self) -> &'static str {
147        match self {
148            Self::Source => "source",
149            Self::Lexer => "lexer",
150            Self::Parser => "parser",
151            Self::Semantic => "semantic",
152            Self::Typed => "typed",
153            Self::Emitter => "emitter",
154        }
155    }
156}
157
158/// ANSI color policy for source-aware diagnostic rendering.
159#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
160pub enum DiagnosticColor {
161    /// Never emit ANSI escape sequences.
162    #[default]
163    Never,
164    /// Use standard terminal foreground colors and bold emphasis.
165    Always,
166}
167
168/// A source-aware, rustc-style diagnostic display adapter.
169#[derive(Debug, Clone, Copy)]
170pub struct DiagnosticRenderer<'a> {
171    diagnostic: &'a Diagnostic,
172    source: &'a str,
173    source_name: Option<&'a str>,
174    color: DiagnosticColor,
175}
176
177impl<'a> DiagnosticRenderer<'a> {
178    /// Sets the filename or logical input name shown in the location header.
179    #[must_use]
180    pub const fn with_source_name(mut self, source_name: &'a str) -> Self {
181        self.source_name = Some(source_name);
182        self
183    }
184
185    /// Sets the ANSI color policy.
186    #[must_use]
187    pub const fn with_color(mut self, color: DiagnosticColor) -> Self {
188        self.color = color;
189        self
190    }
191
192    fn styled(
193        &self,
194        formatter: &mut fmt::Formatter<'_>,
195        code: &str,
196        value: impl fmt::Display,
197    ) -> fmt::Result {
198        if self.color == DiagnosticColor::Always {
199            write!(formatter, "\x1b[{code}m{value}\x1b[0m")
200        } else {
201            write!(formatter, "{value}")
202        }
203    }
204}
205
206impl fmt::Display for DiagnosticRenderer<'_> {
207    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
208        self.styled(formatter, "1;31", "error")?;
209        write!(
210            formatter,
211            "[{}]: {}",
212            self.diagnostic.kind.label(),
213            self.diagnostic.message
214        )?;
215
216        let source_len = self.source.len();
217        let start = (self.diagnostic.span.start as usize).min(source_len);
218        let requested_end = self.diagnostic.span.end as usize;
219        let end = requested_end.max(start).min(source_len);
220        if !self.source.is_char_boundary(start) || !self.source.is_char_boundary(end) {
221            return write!(formatter, "\n{}", self.diagnostic);
222        }
223
224        let starts = line_starts(self.source);
225        let line_index = starts
226            .partition_point(|offset| *offset <= start)
227            .saturating_sub(1);
228        let line_start = starts[line_index];
229        let line_end = source_line_end(self.source, &starts, line_index);
230        let position = LineCol {
231            line: line_index + 1,
232            column: display_width(&self.source[line_start..start]) + 1,
233        };
234
235        write!(formatter, "\n ")?;
236        self.styled(formatter, "1;34", "-->")?;
237        write!(
238            formatter,
239            " {}:{}:{}",
240            self.source_name.unwrap_or("<input>"),
241            position.line,
242            position.column
243        )?;
244
245        let first_shown = line_index.saturating_sub(1);
246        let width = (line_index + 1).to_string().len();
247        write!(formatter, "\n{:width$} ", "", width = width)?;
248        self.styled(formatter, "1;34", "|")?;
249        for shown in first_shown..=line_index {
250            let shown_start = starts[shown];
251            let shown_end = source_line_end(self.source, &starts, shown);
252            let text = expand_tabs(&self.source[shown_start..shown_end]);
253            write!(formatter, "\n{:>width$} ", shown + 1, width = width)?;
254            self.styled(formatter, "1;34", "|")?;
255            write!(formatter, " {text}")?;
256        }
257
258        let prefix_width = display_width(&self.source[line_start..start]);
259        let underline_end = end.min(line_end);
260        let underline_width = if underline_end > start {
261            display_width(&self.source[start..underline_end]).max(1)
262        } else {
263            1
264        };
265        write!(formatter, "\n{:width$} ", "", width = width)?;
266        self.styled(formatter, "1;34", "|")?;
267        write!(formatter, " {}", " ".repeat(prefix_width))?;
268        self.styled(formatter, "1;31", "^".repeat(underline_width))?;
269        if !self.diagnostic.expected.is_empty() {
270            write!(
271                formatter,
272                " expected {}",
273                self.diagnostic.expected.join(", ")
274            )?;
275        }
276
277        let end_line = starts
278            .partition_point(|offset| *offset <= end)
279            .saturating_sub(1);
280        if end_line > line_index || requested_end > source_len {
281            write!(formatter, "\n{:width$} ", "", width = width)?;
282            self.styled(formatter, "1;34", "|")?;
283            write!(formatter, " ... span continues")?;
284        }
285        write!(formatter, "\n{:width$} ", "", width = width)?;
286        self.styled(formatter, "1;34", "|")?;
287
288        for note in &self.diagnostic.notes {
289            writeln!(formatter)?;
290            self.styled(formatter, "1;32", "note")?;
291            write!(formatter, ": {note}")?;
292        }
293        Ok(())
294    }
295}
296
297fn line_starts(source: &str) -> Vec<usize> {
298    let mut starts = vec![0];
299    starts.extend(
300        source
301            .bytes()
302            .enumerate()
303            .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
304    );
305    starts
306}
307
308fn source_line_end(source: &str, starts: &[usize], line_index: usize) -> usize {
309    let mut end = starts.get(line_index + 1).copied().unwrap_or(source.len());
310    if end > starts[line_index] && source.as_bytes()[end - 1] == b'\n' {
311        end -= 1;
312    }
313    if end > starts[line_index] && source.as_bytes()[end - 1] == b'\r' {
314        end -= 1;
315    }
316    end
317}
318
319fn display_width(text: &str) -> usize {
320    text.chars().fold(0, |width, character| match character {
321        '\t' => width + (4 - width % 4),
322        _ => width + 1,
323    })
324}
325
326fn expand_tabs(text: &str) -> String {
327    let mut expanded = String::with_capacity(text.len());
328    let mut width = 0;
329    for character in text.chars() {
330        if character == '\t' {
331            let spaces = 4 - width % 4;
332            expanded.push_str(&" ".repeat(spaces));
333            width += spaces;
334        } else {
335            expanded.push(character);
336            width += 1;
337        }
338    }
339    expanded
340}
341
342/// Alias for parse errors until richer phase-specific errors are introduced.
343pub type ParseError = YamlError;