Skip to main content

urd/parser/
errors.rs

1//! # Parse Error Rendering
2//!
3//! Pretty-prints chumsky [`Rich`] parse errors using ariadne for rich,
4//! source-annotated diagnostics — the same visual style as the static
5//! analysis error output in [`crate::analysis`].
6//!
7//! ## Usage
8//!
9//! ```rust,ignore
10//! use crate::parser::errors::render_parse_errors_stderr;
11//!
12//! let result = script().parse(stream).into_result();
13//! if let Err(errs) = result {
14//!     render_parse_errors_stderr(&errs, src, "my_script.urd");
15//! }
16//! ```
17//!
18//! ## Note
19//!
20//! This module must be declared in `parser/mod.rs` with `pub mod errors;`.
21
22use 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
30// ── Token display helpers ──────────────────────────────────────────────────
31
32/// Format a single [`Token`] into a short, human-readable description.
33///
34/// This is intentionally more user-friendly than the raw `Debug`/`Display`
35/// output from `logos_display`, which exposes Rust type names and internal
36/// structure. For example, `Token::IdentPath(["foo", "bar"])` becomes
37/// `"identifier 'foo.bar'"` instead of `"IdentPath(["foo", "bar"])"`.
38fn format_token(token: &Token) -> String {
39    match token {
40        // ── Literals ──────────────────────────────────────────────────────
41        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        // ── Whitespace / structure ─────────────────────────────────────────
50        Token::Newline => "newline".to_owned(),
51
52        // ── Keywords ──────────────────────────────────────────────────────
53        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        // ── Logical operators (keyword forms) ─────────────────────────────
77        Token::And => "'and'/'&&'".to_owned(),
78        Token::Or => "'or'/'||'".to_owned(),
79        Token::Not => "'not'".to_owned(),
80
81        // ── Arithmetic / bitwise operators ────────────────────────────────
82        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        // ── Comparison operators ───────────────────────────────────────────
100        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        // ── Delimiters / punctuation ──────────────────────────────────────
109        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        // ── Lexer error embedded in token stream ──────────────────────────
122        // This happens when the lexer encounters something it cannot tokenise
123        // at all (e.g. a stray `§` or a malformed number). Surfacing the
124        // underlying lexer message gives the user an actionable clue.
125        Token::Error(e) => format!("invalid token: {e}"),
126
127        // ── Documentation comment ─────────────────────────────────────────
128        Token::DocComment(s) => format!("doc comment '## {s}'"),
129    }
130}
131
132/// Format a [`RichPattern`] into a short, human-readable string.
133///
134/// `RichPattern` is how chumsky describes what it *expected* to find.
135fn format_pattern(pattern: &RichPattern<'_, Token>) -> String {
136    match pattern {
137        RichPattern::Token(t) => format_token(t),
138        // Labels come from `.labelled("…")` calls on parser combinators.
139        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        // RichPattern is #[non_exhaustive] in chumsky 0.12+; handle future variants gracefully.
145        &_ => String::new(),
146    }
147}
148
149// ── Expected list formatting ───────────────────────────────────────────────
150
151/// Build a human-readable "expected …" clause from a list of [`RichPattern`]s.
152///
153/// Deduplicates and sorts for stable output:
154///
155/// | Count | Output                                      |
156/// |-------|---------------------------------------------|
157/// | 0     | `""` (empty string)                         |
158/// | 1     | `"expected foo"`                            |
159/// | 2     | `"expected foo or bar"`                     |
160/// | N     | `"expected one of: foo, bar, …, or baz"`    |
161fn 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    // Stable ordering makes diagnostics reproducible across runs.
170    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            // INVARIANT: `many` is non-empty — this arm is only reached when
178            // `items` has ≥ 3 elements (the `[]`, `[a]`, and `[a, b]` arms
179            // above exhaust the 0-, 1-, and 2-element cases), so `split_last`
180            // cannot return `None`.
181            many.split_last().map_or_else(String::new, |(last, rest)| {
182                format!("expected one of: {}, or {last}", rest.join(", "))
183            })
184        }
185    }
186}
187
188// ── Main renderer ──────────────────────────────────────────────────────────
189
190/// Render chumsky [`Rich`] parse errors as ariadne diagnostics, writing to
191/// `writer`.
192///
193/// Each error produces one ariadne [`Report`] with:
194///
195/// - A **primary span** underlining the offending token and a
196///   `"found X — expected Y"` message.
197/// - **Secondary labels** for each `.labelled("…")` context that was active
198///   when the error occurred — e.g. `"while parsing expression"` or
199///   `"while parsing declaration"`.  These help orient the user in larger
200///   constructs.
201///
202/// # Errors
203///
204/// Propagates any [`std::io::Error`] from writing to `writer`.
205pub 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        // Pin the primary caret to a single character at the token's start so
218        // that ariadne's `┬` always points at the beginning of the offending
219        // token rather than somewhere inside a wide (potentially multi-token)
220        // span that chumsky produces after backtracking through `.or()` chains.
221        //
222        // Clamp to source bounds so EOF errors (where start == src.len()) never
223        // create an out-of-bounds range like `len..len+1`.
224        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        // ── Derive the top-level message and primary label ─────────────────
229        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            // Custom messages come from `.map_err` / `.validate` calls.
253            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        // ── Context labels from .labelled() calls ─────────────────────────
264        //
265        // These are added as secondary labels pointing at the *start* of the
266        // construct that was being parsed, telling the user which grammar rule
267        // the error falls inside.  We skip contexts whose span is identical
268        // to the primary span (no extra information) or zero-length.
269        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
291/// Convenience wrapper: render parse errors directly to `stderr`.
292pub 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// ── Tests ──────────────────────────────────────────────────────────────────
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::parse_test;
309    use crate::parser::block::script;
310
311    /// Parse `src` and return any errors. Panics if there are no errors (the
312    /// test source must be syntactically broken on purpose).
313    fn parse_errors(src: &str) -> Vec<String> {
314        // We need owned errors — use into_output_errors so we can inspect them.
315        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        // Collect owned errors so they can be rendered.
329        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        // Should only appear once despite being listed twice.
409        assert_eq!(result, "expected ','");
410    }
411
412    #[test]
413    fn render_parse_errors_produces_nonempty_output_for_broken_source() {
414        // A bare `=` at the top level is not a valid statement.
415        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        // A `}` with nothing preceding it is unexpected at top level.
451        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        // Missing closing brace forces an EOF parse error. Regression check:
462        // rendering must not create an out-of-bounds caret span.
463        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        // A clean script must not trigger the renderer at all.
504        let result = parse_test!(script(), "return\n");
505        assert!(result.is_ok(), "clean source should parse without errors");
506    }
507}