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::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 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 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 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 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 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 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 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 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}