Skip to main content

styx_tree/
diagnostic.rs

1//! Diagnostic rendering for parser errors.
2
3use ariadne::{Color, Label, Report, ReportKind, Source};
4use styx_parse::{ParseErrorKind, Span};
5
6/// A parser error with source location.
7#[derive(Debug, Clone)]
8pub struct ParseError {
9    /// The kind of error.
10    pub kind: ParseErrorKind,
11    /// Source location.
12    pub span: Span,
13}
14
15impl ParseError {
16    /// Create a new parse error.
17    pub fn new(kind: ParseErrorKind, span: Span) -> Self {
18        Self { kind, span }
19    }
20
21    /// Render this error with ariadne.
22    ///
23    /// Returns a string containing the formatted error message with source context.
24    pub fn render(&self, filename: &str, source: &str) -> String {
25        let mut output = Vec::new();
26        self.write_report(filename, source, &mut output);
27        String::from_utf8(output).unwrap_or_else(|_| format!("{}", self))
28    }
29
30    /// Write the error report to a writer.
31    pub fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, writer: W) {
32        let report = self.build_report(filename);
33        let _ = report
34            .finish()
35            .write((filename, Source::from(source)), writer);
36    }
37
38    fn build_report<'a>(
39        &self,
40        filename: &'a str,
41    ) -> ariadne::ReportBuilder<'static, (&'a str, std::ops::Range<usize>)> {
42        let range = self.span.start as usize..self.span.end as usize;
43
44        match &self.kind {
45            // diag[impl diagnostic.parser.duplicate-key]
46            ParseErrorKind::DuplicateKey { original } => {
47                let original_range = original.start as usize..original.end as usize;
48                Report::build(ReportKind::Error, (filename, range.clone()))
49                    .with_message("duplicate key")
50                    .with_label(
51                        Label::new((filename, original_range))
52                            .with_message("first defined here")
53                            .with_color(Color::Blue),
54                    )
55                    .with_label(
56                        Label::new((filename, range))
57                            .with_message("duplicate key")
58                            .with_color(Color::Red),
59                    )
60                    .with_help("each key must appear only once in an object")
61            }
62
63            // diag[impl diagnostic.parser.unclosed]
64            ParseErrorKind::UnclosedObject => Report::build(ReportKind::Error, (filename, range.clone()))
65                .with_message("unclosed object")
66                .with_label(
67                    Label::new((filename, range))
68                        .with_message("object opened here")
69                        .with_color(Color::Red),
70                )
71                .with_help("add a closing '}'"),
72
73            // diag[impl diagnostic.parser.unclosed]
74            ParseErrorKind::UnclosedSequence => Report::build(ReportKind::Error, (filename, range.clone()))
75                .with_message("unclosed sequence")
76                .with_label(
77                    Label::new((filename, range))
78                        .with_message("sequence opened here")
79                        .with_color(Color::Red),
80                )
81                .with_help("add a closing ')'"),
82
83            // diag[impl diagnostic.parser.escape]
84            ParseErrorKind::InvalidEscape(seq) => Report::build(ReportKind::Error, (filename, range.clone()))
85                .with_message(format!("invalid escape sequence '{}'", seq))
86                .with_label(
87                    Label::new((filename, range))
88                        .with_message("invalid escape")
89                        .with_color(Color::Red),
90                )
91                .with_help("valid escapes are: \\\\, \\\", \\n, \\r, \\t, \\uXXXX, \\u{X...}"),
92
93            // diag[impl diagnostic.parser.unexpected]
94            ParseErrorKind::UnexpectedToken => Report::build(ReportKind::Error, (filename, range.clone()))
95                .with_message("unexpected token")
96                .with_label(
97                    Label::new((filename, range))
98                        .with_message("unexpected")
99                        .with_color(Color::Red),
100                ),
101
102            ParseErrorKind::ExpectedKey => Report::build(ReportKind::Error, (filename, range.clone()))
103                .with_message("expected key")
104                .with_label(
105                    Label::new((filename, range))
106                        .with_message("expected a key here")
107                        .with_color(Color::Red),
108                ),
109
110            ParseErrorKind::ExpectedValue => Report::build(ReportKind::Error, (filename, range.clone()))
111                .with_message("expected value")
112                .with_label(
113                    Label::new((filename, range))
114                        .with_message("expected a value here")
115                        .with_color(Color::Red),
116                ),
117
118            ParseErrorKind::UnexpectedEof => Report::build(ReportKind::Error, (filename, range.clone()))
119                .with_message("unexpected end of input")
120                .with_label(
121                    Label::new((filename, range))
122                        .with_message("input ends here")
123                        .with_color(Color::Red),
124                ),
125
126            ParseErrorKind::InvalidTagName => Report::build(ReportKind::Error, (filename, range.clone()))
127                .with_message("invalid tag name")
128                .with_label(
129                    Label::new((filename, range))
130                        .with_message("invalid tag")
131                        .with_color(Color::Red),
132                )
133                .with_help("tag names must match @[A-Za-z_][A-Za-z0-9_.-]*"),
134
135            ParseErrorKind::InvalidKey => Report::build(ReportKind::Error, (filename, range.clone()))
136                .with_message("invalid key")
137                .with_label(
138                    Label::new((filename, range))
139                        .with_message("cannot be used as a key")
140                        .with_color(Color::Red),
141                )
142                .with_help("keys must be scalars or unit, optionally tagged (no objects, sequences, or heredocs)"),
143
144            ParseErrorKind::DanglingDocComment => Report::build(ReportKind::Error, (filename, range.clone()))
145                .with_message("dangling doc comment")
146                .with_label(
147                    Label::new((filename, range))
148                        .with_message("doc comment not followed by entry")
149                        .with_color(Color::Red),
150                )
151                .with_help("doc comments (///) must be followed by an entry"),
152
153            // diag[impl diagnostic.parser.toomany]
154            ParseErrorKind::TooManyAtoms => Report::build(ReportKind::Error, (filename, range.clone()))
155                .with_message("unexpected atom after value")
156                .with_label(
157                    Label::new((filename, range))
158                        .with_message("unexpected third atom")
159                        .with_color(Color::Red),
160                )
161                .with_help("did you mean `@tag{}`? whitespace is not allowed between a tag and its payload"),
162
163            // diag[impl diagnostic.parser.reopened-path]
164            ParseErrorKind::ReopenedPath { closed_path } => {
165                let path_str = closed_path.join(".");
166                Report::build(ReportKind::Error, (filename, range.clone()))
167                    .with_message(format!("cannot reopen path `{}`", path_str))
168                    .with_label(
169                        Label::new((filename, range))
170                            .with_message("path was closed when sibling appeared")
171                            .with_color(Color::Red),
172                    )
173                    .with_help("sibling paths must appear contiguously; once you move to a different path, you cannot go back")
174            }
175
176            // diag[impl diagnostic.parser.nest-into-terminal]
177            ParseErrorKind::NestIntoTerminal { terminal_path } => {
178                let path_str = terminal_path.join(".");
179                Report::build(ReportKind::Error, (filename, range.clone()))
180                    .with_message(format!("cannot nest into `{}`", path_str))
181                    .with_label(
182                        Label::new((filename, range))
183                            .with_message("path has a terminal value")
184                            .with_color(Color::Red),
185                    )
186                    .with_help("you cannot add children to a path that already has a scalar, sequence, tag, or unit value")
187            }
188
189            // diag[impl diagnostic.parser.sequence-comma]
190            ParseErrorKind::CommaInSequence => Report::build(ReportKind::Error, (filename, range.clone()))
191                .with_message("unexpected comma in sequence")
192                .with_label(
193                    Label::new((filename, range))
194                        .with_message("comma not allowed here")
195                        .with_color(Color::Red),
196                )
197                .with_help("sequences are whitespace-separated, not comma-separated"),
198
199            // diag[impl diagnostic.parser.missing-whitespace]
200            ParseErrorKind::MissingWhitespaceBeforeBlock => Report::build(ReportKind::Error, (filename, range.clone()))
201                .with_message("missing whitespace before block")
202                .with_label(
203                    Label::new((filename, range))
204                        .with_message("add whitespace before this")
205                        .with_color(Color::Red),
206                )
207                .with_help("bare keys must be separated from `{` or `(` by whitespace (to distinguish from tags like `@tag{}`)"),
208
209            ParseErrorKind::TrailingContent => Report::build(ReportKind::Error, (filename, range.clone()))
210                .with_message("trailing content after explicit root object")
211                .with_label(
212                    Label::new((filename, range))
213                        .with_message("unexpected content here")
214                        .with_color(Color::Red),
215                )
216                .with_help("an explicit root object `{...}` is the entire document; nothing can follow it"),
217        }
218    }
219}
220
221impl std::fmt::Display for ParseError {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        match &self.kind {
224            ParseErrorKind::DuplicateKey { .. } => write!(f, "duplicate key"),
225            ParseErrorKind::UnclosedObject => write!(f, "unclosed object"),
226            ParseErrorKind::UnclosedSequence => write!(f, "unclosed sequence"),
227            ParseErrorKind::InvalidEscape(seq) => write!(f, "invalid escape sequence '{}'", seq),
228            ParseErrorKind::UnexpectedToken => write!(f, "unexpected token"),
229            ParseErrorKind::ExpectedKey => write!(f, "expected key"),
230            ParseErrorKind::ExpectedValue => write!(f, "expected value"),
231            ParseErrorKind::UnexpectedEof => write!(f, "unexpected end of input"),
232            ParseErrorKind::InvalidTagName => write!(f, "invalid tag name"),
233            ParseErrorKind::InvalidKey => write!(f, "invalid key"),
234            ParseErrorKind::DanglingDocComment => write!(f, "dangling doc comment"),
235            ParseErrorKind::TooManyAtoms => write!(f, "unexpected atom after value"),
236            ParseErrorKind::ReopenedPath { closed_path } => {
237                write!(f, "cannot reopen path `{}`", closed_path.join("."))
238            }
239            ParseErrorKind::NestIntoTerminal { terminal_path } => {
240                write!(f, "cannot nest into `{}`", terminal_path.join("."))
241            }
242            ParseErrorKind::CommaInSequence => write!(f, "unexpected comma in sequence"),
243            ParseErrorKind::MissingWhitespaceBeforeBlock => {
244                write!(f, "missing whitespace before block")
245            }
246            ParseErrorKind::TrailingContent => {
247                write!(f, "trailing content after explicit root object")
248            }
249        }?;
250        write!(f, " at offset {}", self.span.start)
251    }
252}
253
254impl std::error::Error for ParseError {}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn parse_with_errors(source: &str) -> Vec<ParseError> {
261        let mut parser = styx_parse::Parser::new(source);
262        let mut errors = Vec::new();
263        while let Some(event) = parser.next_event() {
264            if let styx_parse::Event {
265                kind: styx_parse::EventKind::Error { kind },
266                span,
267            } = event
268            {
269                errors.push(ParseError::new(kind, span));
270            }
271        }
272        errors
273    }
274
275    macro_rules! assert_snapshot_stripped {
276        ($value:expr) => {{
277            let stripped = String::from_utf8(strip_ansi_escapes::strip(&$value)).unwrap();
278            insta::assert_snapshot!(stripped);
279        }};
280    }
281
282    #[test]
283    fn test_duplicate_key_diagnostic() {
284        let source = "a 1\na 2";
285        let errors = parse_with_errors(source);
286        assert_eq!(errors.len(), 1);
287
288        assert_snapshot_stripped!(errors[0].render("test.styx", source));
289    }
290
291    #[test]
292    fn test_invalid_escape_diagnostic() {
293        let source = r#"name "hello\qworld""#;
294        let errors = parse_with_errors(source);
295        assert!(!errors.is_empty(), "expected InvalidEscape error");
296
297        assert_snapshot_stripped!(errors[0].render("test.styx", source));
298    }
299
300    #[test]
301    fn test_unclosed_object_diagnostic() {
302        let source = "server {\n  host localhost";
303        let errors = parse_with_errors(source);
304        assert!(!errors.is_empty(), "expected UnclosedObject error");
305
306        assert_snapshot_stripped!(errors[0].render("test.styx", source));
307    }
308}