1use std::collections::HashSet;
23
24use ariadne::{Label, Report, ReportKind, sources};
25use chumsky::error::{Rich, RichPattern, RichReason};
26use chumsky::span::SimpleSpan;
27
28use crate::lexer::Token;
29
30fn format_token(token: &Token) -> String {
39 match token {
40 Token::Null => "'null'".to_owned(),
42 Token::BoolLit(b) => format!("'{b}'"),
43 Token::IntLit(n) => format!("integer '{n}'"),
44 Token::FloatLit(f) => format!("float '{f}'"),
45 Token::StrLit(_) => "string literal".to_owned(),
46 Token::Dice((c, s)) => format!("dice '{c}d{s}'"),
47 Token::IdentPath(p) => format!("identifier '{}'", p.join(".")),
48
49 Token::Newline => "newline".to_owned(),
51
52 Token::Const => "'const'".to_owned(),
54 Token::Let => "'let'".to_owned(),
55 Token::Fn => "'fn'".to_owned(),
56 Token::Global => "'global'".to_owned(),
57 Token::Extern => "'extern'".to_owned(),
58 Token::If => "'if'".to_owned(),
59 Token::Else => "'else'".to_owned(),
60 Token::Elif => "'elif'".to_owned(),
61 Token::Label => "'label'".to_owned(),
62 Token::Menu => "'menu'".to_owned(),
63 Token::Return => "'return'".to_owned(),
64 Token::Jump => "'jump'".to_owned(),
65 Token::EndBang => "'end!'".to_owned(),
66 Token::TodoBang => "'todo!'".to_owned(),
67 Token::Enum => "'enum'".to_owned(),
68 Token::Struct => "'struct'".to_owned(),
69 Token::Match => "'match'".to_owned(),
70 Token::DecoratorKw => "'decorator'".to_owned(),
71 Token::Import => "'import'".to_owned(),
72 Token::From => "'from'".to_owned(),
73 Token::As => "'as'".to_owned(),
74 Token::Wildcard => "'_'".to_owned(),
75
76 Token::And => "'and'/'&&'".to_owned(),
78 Token::Or => "'or'/'||'".to_owned(),
79 Token::Not => "'not'".to_owned(),
80
81 Token::Plus => "'+'".to_owned(),
83 Token::Arrow => "'->'".to_owned(),
84 Token::Minus => "'-'".to_owned(),
85 Token::Star => "'*'".to_owned(),
86 Token::Slash => "'/'".to_owned(),
87 Token::DoubleSlash => "'//'".to_owned(),
88 Token::Percent => "'%'".to_owned(),
89 Token::BitwiseAnd => "'&'".to_owned(),
90 Token::BitwiseOr => "'|'".to_owned(),
91 Token::BitwiseXor => "'^'".to_owned(),
92 Token::BitwiseNot => "'!'".to_owned(),
93 Token::LeftShift => "'<<'".to_owned(),
94 Token::RightShift => "'>>'".to_owned(),
95 Token::DotDot => "'..'".to_owned(),
96 Token::DotDotEq => "'..='".to_owned(),
97 Token::In => "'in'".to_owned(),
98
99 Token::Assign => "'='".to_owned(),
101 Token::Equals => "'=='".to_owned(),
102 Token::NotEquals => "'!='".to_owned(),
103 Token::GreaterThan => "'>'".to_owned(),
104 Token::LessThan => "'<'".to_owned(),
105 Token::GreaterThanOrEquals => "'>='".to_owned(),
106 Token::LessThanOrEquals => "'<='".to_owned(),
107
108 Token::LeftParen => "'('".to_owned(),
110 Token::RightParen => "')'".to_owned(),
111 Token::LeftCurly => "'{'".to_owned(),
112 Token::RightCurly => "'}'".to_owned(),
113 Token::LeftBracket => "'['".to_owned(),
114 Token::RightBracket => "']'".to_owned(),
115 Token::DictStart => "':{'".to_owned(),
116 Token::Colon => "':'".to_owned(),
117 Token::Comma => "','".to_owned(),
118 Token::Semicolon => "';'".to_owned(),
119 Token::At => "'@'".to_owned(),
120
121 Token::Error(e) => format!("invalid token: {e}"),
126
127 Token::DocComment(s) => format!("doc comment '## {s}'"),
129 }
130}
131
132fn format_pattern(pattern: &RichPattern<'_, Token>) -> String {
136 match pattern {
137 RichPattern::Token(t) => format_token(t),
138 RichPattern::Label(l) => l.to_string(),
140 RichPattern::Identifier(i) => format!("'{i}'"),
141 RichPattern::EndOfInput => "end of input".to_owned(),
142 RichPattern::SomethingElse => "something else".to_owned(),
143 RichPattern::Any => "any token".to_owned(),
144 &_ => String::new(),
146 }
147}
148
149fn format_expected(patterns: &[RichPattern<'_, Token>]) -> String {
162 let mut seen = HashSet::new();
163 let mut items: Vec<String> = patterns
164 .iter()
165 .map(format_pattern)
166 .filter(|s| seen.insert(s.clone()))
167 .collect();
168
169 items.sort();
171
172 match items.as_slice() {
173 [] => String::new(),
174 [a] => format!("expected {a}"),
175 [a, b] => format!("expected {a} or {b}"),
176 many => {
177 many.split_last().map_or_else(String::new, |(last, rest)| {
182 format!("expected one of: {}, or {last}", rest.join(", "))
183 })
184 }
185 }
186}
187
188pub fn render_parse_errors<W: std::io::Write>(
206 errors: &[Rich<'_, Token, SimpleSpan>],
207 src: &str,
208 source_name: &str,
209 writer: &mut W,
210) -> std::io::Result<()> {
211 for error in errors {
212 let span = *error.span();
213 let src_len = src.len();
214 let range_start = span.start.min(src_len);
215 let range_end = span.end.min(src_len).max(range_start);
216 let range = range_start..range_end;
217 let caret_end = range_start.saturating_add(1).min(src_len);
225 let caret_range = range_start..caret_end.max(range_start);
226 let name = source_name.to_owned();
227
228 let (top_message, primary_label_msg) = match error.reason() {
230 RichReason::ExpectedFound { expected, found } => {
231 let found_str = match found {
232 Some(t) => format_token(t),
233 None => "end of input".to_owned(),
234 };
235 let expected_str = format_expected(expected);
236
237 let top = if expected_str.is_empty() {
238 format!("unexpected {found_str}")
239 } else {
240 format!("unexpected {found_str}, {expected_str}")
241 };
242
243 let label = if expected_str.is_empty() {
244 format!("unexpected {found_str} here")
245 } else {
246 format!("found {found_str} — {expected_str}")
247 };
248
249 (top, label)
250 }
251
252 RichReason::Custom(msg) => (msg.clone(), msg.clone()),
254 };
255
256 let mut report = Report::<(String, std::ops::Range<usize>)>::build(
257 ReportKind::Error,
258 (name.clone(), range.clone()),
259 )
260 .with_message(&top_message)
261 .with_label(Label::new((name.clone(), caret_range)).with_message(&primary_label_msg));
262
263 for (ctx_pattern, ctx_span) in error.contexts() {
270 let ctx_range = ctx_span.start..ctx_span.end;
271
272 if ctx_range.is_empty() || ctx_range == range {
273 continue;
274 }
275
276 report = report.with_label(
277 Label::new((name.clone(), ctx_range))
278 .with_message(format!("while parsing {}", format_pattern(ctx_pattern))),
279 );
280 }
281
282 report
283 .finish()
284 .write(sources([(name.clone(), src.to_owned())]), &mut *writer)
285 .map_err(|e| std::io::Error::other(e.to_string()))?;
286 }
287
288 Ok(())
289}
290
291pub fn render_parse_errors_stderr(
293 errors: &[Rich<'_, Token, SimpleSpan>],
294 src: &str,
295 source_name: &str,
296) {
297 let mut stderr = std::io::stderr();
298 if let Err(e) = render_parse_errors(errors, src, source_name, &mut stderr) {
299 eprintln!("warning: failed to render parse errors: {e}");
300 }
301}
302
303#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::parse_test;
309 use crate::parser::block::script;
310
311 fn parse_errors(src: &str) -> Vec<String> {
314 use crate::lexer::{Token, lex_src};
316 use chumsky::input::Stream;
317 use chumsky::prelude::*;
318
319 let lexer = lex_src(src).spanned().map(|(tok, span)| match tok {
320 Ok(tok) => (tok, span.into()),
321 Err(e) => (Token::Error(e), span.into()),
322 });
323 let stream = Stream::from_iter(lexer)
324 .map((0..src.len()).into(), |(t, s): (Token, SimpleSpan)| (t, s));
325
326 let (_, errs) = script().parse(stream).into_output_errors();
327 let mut buf = Vec::new();
328 for e in &errs {
330 render_parse_errors(std::slice::from_ref(e), src, "test.urd", &mut buf).unwrap();
331 }
332 let text = String::from_utf8_lossy(&buf).into_owned();
333 text.lines().map(str::to_owned).collect()
334 }
335
336 fn strip_ansi(s: &str) -> String {
337 let mut out = String::with_capacity(s.len());
338 let mut chars = s.chars().peekable();
339 while let Some(c) = chars.next() {
340 if c == '\x1b' {
341 for ch in chars.by_ref() {
342 if ch.is_ascii_alphabetic() {
343 break;
344 }
345 }
346 } else {
347 out.push(c);
348 }
349 }
350 out
351 }
352
353 #[test]
354 fn format_token_newline_is_human_readable() {
355 assert_eq!(format_token(&Token::Newline), "newline");
356 }
357
358 #[test]
359 fn format_token_ident_path_joined_with_dot() {
360 let tok = Token::IdentPath(vec!["my_mod".to_owned(), "Color".to_owned()]);
361 assert_eq!(format_token(&tok), "identifier 'my_mod.Color'");
362 }
363
364 #[test]
365 fn format_token_int_lit() {
366 assert_eq!(format_token(&Token::IntLit(42)), "integer '42'");
367 }
368
369 #[test]
370 fn format_token_keywords() {
371 assert_eq!(format_token(&Token::Return), "'return'");
372 assert_eq!(format_token(&Token::Jump), "'jump'");
373 assert_eq!(format_token(&Token::Let), "'let'");
374 assert_eq!(format_token(&Token::Label), "'label'");
375 }
376
377 #[test]
378 fn format_expected_empty_gives_empty_string() {
379 assert_eq!(format_expected(&[]), "");
380 }
381
382 #[test]
383 fn format_expected_single_item() {
384 let pats = vec![RichPattern::Label(std::borrow::Cow::Borrowed("expression"))];
385 assert_eq!(format_expected(&pats), "expected expression");
386 }
387
388 #[test]
389 fn format_expected_two_items() {
390 let pats = vec![
391 RichPattern::Token(chumsky::util::MaybeRef::Val(Token::Comma)),
392 RichPattern::EndOfInput,
393 ];
394 let result = format_expected(&pats);
395 assert!(
396 result.contains("or"),
397 "two-item expected list should use 'or': {result}"
398 );
399 }
400
401 #[test]
402 fn format_expected_deduplicates() {
403 let pats = vec![
404 RichPattern::Token(chumsky::util::MaybeRef::Val(Token::Comma)),
405 RichPattern::Token(chumsky::util::MaybeRef::Val(Token::Comma)),
406 ];
407 let result = format_expected(&pats);
408 assert_eq!(result, "expected ','");
410 }
411
412 #[test]
413 fn render_parse_errors_produces_nonempty_output_for_broken_source() {
414 let lines = parse_errors("= 5\n");
416 let all = lines.join("\n");
417 assert!(
418 !all.is_empty(),
419 "expected non-empty output for broken source"
420 );
421 }
422
423 #[test]
424 fn render_parse_errors_output_is_valid_utf8() {
425 let mut buf: Vec<u8> = Vec::new();
426
427 use crate::lexer::{Token, lex_src};
428 use chumsky::input::Stream;
429 use chumsky::prelude::*;
430
431 let src = "= 5\n";
432 let lexer = lex_src(src).spanned().map(|(tok, span)| match tok {
433 Ok(tok) => (tok, span.into()),
434 Err(e) => (Token::Error(e), span.into()),
435 });
436 let stream = Stream::from_iter(lexer)
437 .map((0..src.len()).into(), |(t, s): (Token, SimpleSpan)| (t, s));
438
439 let (_, errs) = script().parse(stream).into_output_errors();
440 render_parse_errors(&errs, src, "test.urd", &mut buf).unwrap();
441
442 assert!(
443 std::str::from_utf8(&buf).is_ok(),
444 "output must be valid UTF-8"
445 );
446 }
447
448 #[test]
449 fn render_parse_errors_mentions_found_token() {
450 let lines = parse_errors("}\n");
452 let all = strip_ansi(&lines.join("\n"));
453 assert!(
454 all.contains("'}'") || all.contains("unexpected"),
455 "output should mention the unexpected token or 'unexpected': {all}"
456 );
457 }
458
459 #[test]
460 fn render_parse_errors_eof_caret_is_clamped_to_source_bounds() {
461 use crate::lexer::{Token, lex_src};
464 use chumsky::input::Stream;
465 use chumsky::prelude::*;
466
467 let src = "label start {\n";
468 let lexer = lex_src(src).spanned().map(|(tok, span)| match tok {
469 Ok(tok) => (tok, span.into()),
470 Err(e) => (Token::Error(e), span.into()),
471 });
472 let stream = Stream::from_iter(lexer)
473 .map((0..src.len()).into(), |(t, s): (Token, SimpleSpan)| (t, s));
474
475 let (_, errs) = script().parse(stream).into_output_errors();
476 assert!(
477 !errs.is_empty(),
478 "expected parse errors for unterminated label block"
479 );
480
481 let mut buf: Vec<u8> = Vec::new();
482 let result = render_parse_errors(&errs, src, "test.urd", &mut buf);
483 assert!(
484 result.is_ok(),
485 "rendering EOF error should not fail: {result:?}"
486 );
487 assert!(
488 std::str::from_utf8(&buf).is_ok(),
489 "output must be valid UTF-8"
490 );
491 }
492
493 #[test]
494 fn render_empty_error_list_writes_nothing() {
495 let mut buf: Vec<u8> = Vec::new();
496 let errs: Vec<Rich<'_, Token, SimpleSpan>> = vec![];
497 render_parse_errors(&errs, "return\n", "test.urd", &mut buf).unwrap();
498 assert!(buf.is_empty(), "empty error list must produce no output");
499 }
500
501 #[test]
502 fn valid_source_produces_no_errors() {
503 let result = parse_test!(script(), "return\n");
505 assert!(result.is_ok(), "clean source should parse without errors");
506 }
507}