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.mixed-separators]
64            ParseErrorKind::MixedSeparators => Report::build(ReportKind::Error, (filename, range.clone()))
65                .with_message("mixed separators in object")
66                .with_label(
67                    Label::new((filename, range))
68                        .with_message("mixing commas and newlines")
69                        .with_color(Color::Red),
70                )
71                .with_help("use either commas or newlines to separate entries, not both"),
72
73            // diag[impl diagnostic.parser.unclosed]
74            ParseErrorKind::UnclosedObject => Report::build(ReportKind::Error, (filename, range.clone()))
75                .with_message("unclosed object")
76                .with_label(
77                    Label::new((filename, range))
78                        .with_message("object opened here")
79                        .with_color(Color::Red),
80                )
81                .with_help("add a closing '}'"),
82
83            ParseErrorKind::UnclosedSequence => Report::build(ReportKind::Error, (filename, range.clone()))
84                .with_message("unclosed sequence")
85                .with_label(
86                    Label::new((filename, range))
87                        .with_message("sequence opened here")
88                        .with_color(Color::Red),
89                )
90                .with_help("add a closing ')'"),
91
92            // diag[impl diagnostic.parser.escape]
93            ParseErrorKind::InvalidEscape(seq) => Report::build(ReportKind::Error, (filename, range.clone()))
94                .with_message(format!("invalid escape sequence '{}'", seq))
95                .with_label(
96                    Label::new((filename, range))
97                        .with_message("invalid escape")
98                        .with_color(Color::Red),
99                )
100                .with_help("valid escapes are: \\\\, \\\", \\n, \\r, \\t, \\uXXXX, \\u{X...}"),
101
102            // diag[impl diagnostic.parser.unexpected]
103            ParseErrorKind::UnexpectedToken => Report::build(ReportKind::Error, (filename, range.clone()))
104                .with_message("unexpected token")
105                .with_label(
106                    Label::new((filename, range))
107                        .with_message("unexpected")
108                        .with_color(Color::Red),
109                ),
110
111            ParseErrorKind::ExpectedKey => Report::build(ReportKind::Error, (filename, range.clone()))
112                .with_message("expected key")
113                .with_label(
114                    Label::new((filename, range))
115                        .with_message("expected a key here")
116                        .with_color(Color::Red),
117                ),
118
119            ParseErrorKind::ExpectedValue => Report::build(ReportKind::Error, (filename, range.clone()))
120                .with_message("expected value")
121                .with_label(
122                    Label::new((filename, range))
123                        .with_message("expected a value here")
124                        .with_color(Color::Red),
125                ),
126
127            ParseErrorKind::UnexpectedEof => Report::build(ReportKind::Error, (filename, range.clone()))
128                .with_message("unexpected end of input")
129                .with_label(
130                    Label::new((filename, range))
131                        .with_message("input ends here")
132                        .with_color(Color::Red),
133                ),
134
135            ParseErrorKind::InvalidTagName => Report::build(ReportKind::Error, (filename, range.clone()))
136                .with_message("invalid tag name")
137                .with_label(
138                    Label::new((filename, range))
139                        .with_message("invalid tag")
140                        .with_color(Color::Red),
141                )
142                .with_help("tag names must match @[A-Za-z_][A-Za-z0-9_.-]*"),
143
144            ParseErrorKind::InvalidKey => Report::build(ReportKind::Error, (filename, range.clone()))
145                .with_message("invalid key")
146                .with_label(
147                    Label::new((filename, range))
148                        .with_message("cannot be used as a key")
149                        .with_color(Color::Red),
150                )
151                .with_help("keys must be scalars or unit, optionally tagged (no objects, sequences, or heredocs)"),
152
153            ParseErrorKind::DanglingDocComment => Report::build(ReportKind::Error, (filename, range.clone()))
154                .with_message("dangling doc comment")
155                .with_label(
156                    Label::new((filename, range))
157                        .with_message("doc comment not followed by entry")
158                        .with_color(Color::Red),
159                )
160                .with_help("doc comments (///) must be followed by an entry"),
161
162            // diag[impl diagnostic.parser.toomany]
163            ParseErrorKind::TooManyAtoms => Report::build(ReportKind::Error, (filename, range.clone()))
164                .with_message("unexpected atom after value")
165                .with_label(
166                    Label::new((filename, range))
167                        .with_message("unexpected third atom")
168                        .with_color(Color::Red),
169                )
170                .with_help("did you mean `@tag{}`? whitespace is not allowed between a tag and its payload"),
171
172            // diag[impl diagnostic.parser.reopened-path]
173            ParseErrorKind::ReopenedPath { closed_path } => {
174                let path_str = closed_path.join(".");
175                Report::build(ReportKind::Error, (filename, range.clone()))
176                    .with_message(format!("cannot reopen path `{}`", path_str))
177                    .with_label(
178                        Label::new((filename, range))
179                            .with_message("path was closed when sibling appeared")
180                            .with_color(Color::Red),
181                    )
182                    .with_help("sibling paths must appear contiguously; once you move to a different path, you cannot go back")
183            }
184
185            // diag[impl diagnostic.parser.nest-into-terminal]
186            ParseErrorKind::NestIntoTerminal { terminal_path } => {
187                let path_str = terminal_path.join(".");
188                Report::build(ReportKind::Error, (filename, range.clone()))
189                    .with_message(format!("cannot nest into `{}`", path_str))
190                    .with_label(
191                        Label::new((filename, range))
192                            .with_message("path has a terminal value")
193                            .with_color(Color::Red),
194                    )
195                    .with_help("you cannot add children to a path that already has a scalar, sequence, tag, or unit value")
196            }
197
198            // diag[impl diagnostic.parser.comma-in-sequence]
199            ParseErrorKind::CommaInSequence => Report::build(ReportKind::Error, (filename, range.clone()))
200                .with_message("unexpected comma in sequence")
201                .with_label(
202                    Label::new((filename, range))
203                        .with_message("comma not allowed here")
204                        .with_color(Color::Red),
205                )
206                .with_help("sequences are whitespace-separated, not comma-separated"),
207
208            // diag[impl diagnostic.parser.missing-whitespace]
209            ParseErrorKind::MissingWhitespaceBeforeBlock => Report::build(ReportKind::Error, (filename, range.clone()))
210                .with_message("missing whitespace before block")
211                .with_label(
212                    Label::new((filename, range))
213                        .with_message("add whitespace before this")
214                        .with_color(Color::Red),
215                )
216                .with_help("bare keys must be separated from `{` or `(` by whitespace (to distinguish from tags like `@tag{}`)"),
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::MixedSeparators => write!(f, "mixed separators in object"),
226            ParseErrorKind::UnclosedObject => write!(f, "unclosed object"),
227            ParseErrorKind::UnclosedSequence => write!(f, "unclosed sequence"),
228            ParseErrorKind::InvalidEscape(seq) => write!(f, "invalid escape sequence '{}'", seq),
229            ParseErrorKind::UnexpectedToken => write!(f, "unexpected token"),
230            ParseErrorKind::ExpectedKey => write!(f, "expected key"),
231            ParseErrorKind::ExpectedValue => write!(f, "expected value"),
232            ParseErrorKind::UnexpectedEof => write!(f, "unexpected end of input"),
233            ParseErrorKind::InvalidTagName => write!(f, "invalid tag name"),
234            ParseErrorKind::InvalidKey => write!(f, "invalid key"),
235            ParseErrorKind::DanglingDocComment => write!(f, "dangling doc comment"),
236            ParseErrorKind::TooManyAtoms => write!(f, "unexpected atom after value"),
237            ParseErrorKind::ReopenedPath { closed_path } => {
238                write!(f, "cannot reopen path `{}`", closed_path.join("."))
239            }
240            ParseErrorKind::NestIntoTerminal { terminal_path } => {
241                write!(f, "cannot nest into `{}`", terminal_path.join("."))
242            }
243            ParseErrorKind::CommaInSequence => write!(f, "unexpected comma in sequence"),
244            ParseErrorKind::MissingWhitespaceBeforeBlock => {
245                write!(f, "missing whitespace before block")
246            }
247        }?;
248        write!(f, " at offset {}", self.span.start)
249    }
250}
251
252impl std::error::Error for ParseError {}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn parse_with_errors(source: &str) -> Vec<ParseError> {
259        let parser = styx_parse::Parser::new(source);
260        let mut events = Vec::new();
261        parser.parse(&mut events);
262        events
263            .into_iter()
264            .filter_map(|event| {
265                if let styx_parse::Event::Error { span, kind } = event {
266                    Some(ParseError::new(kind, span))
267                } else {
268                    None
269                }
270            })
271            .collect()
272    }
273
274    #[test]
275    fn test_duplicate_key_diagnostic() {
276        let source = "a 1\na 2";
277        let errors = parse_with_errors(source);
278        assert_eq!(errors.len(), 1);
279
280        let rendered = errors[0].render("test.styx", source);
281        insta::assert_snapshot!(rendered);
282    }
283
284    #[test]
285    fn test_mixed_separators_diagnostic() {
286        let source = "{\n  a 1,\n  b 2\n}";
287        let errors = parse_with_errors(source);
288        assert!(!errors.is_empty());
289
290        let rendered = errors[0].render("test.styx", source);
291        insta::assert_snapshot!(rendered);
292    }
293
294    #[test]
295    fn test_invalid_escape_diagnostic() {
296        let source = r#"name "hello\qworld""#;
297        let errors = parse_with_errors(source);
298        assert!(!errors.is_empty(), "expected InvalidEscape error");
299
300        let rendered = errors[0].render("test.styx", source);
301        insta::assert_snapshot!(rendered);
302    }
303
304    #[test]
305    fn test_unclosed_object_diagnostic() {
306        let source = "server {\n  host localhost";
307        let errors = parse_with_errors(source);
308        assert!(!errors.is_empty(), "expected UnclosedObject error");
309
310        let rendered = errors[0].render("test.styx", source);
311        insta::assert_snapshot!(rendered);
312    }
313}