1use ariadne::{Color, Label, Report, ReportKind, Source};
4use styx_parse::{ParseErrorKind, Span};
5
6#[derive(Debug, Clone)]
8pub struct ParseError {
9 pub kind: ParseErrorKind,
11 pub span: Span,
13}
14
15impl ParseError {
16 pub fn new(kind: ParseErrorKind, span: Span) -> Self {
18 Self { kind, span }
19 }
20
21 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 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 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 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 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 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 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 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 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 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 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 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}