Skip to main content

kyu_parser/
error.rs

1//! Parse error types and ariadne-based diagnostic rendering.
2
3use ariadne::{Color, Label, Report, ReportKind, Source};
4use chumsky::error::Simple;
5
6use crate::span::Span;
7use crate::token::Token;
8
9/// Result of parsing: an optional AST plus any collected errors.
10/// Error recovery may produce both an AST and errors simultaneously.
11pub struct ParseResult<T> {
12    pub ast: Option<T>,
13    pub errors: Vec<ParseError>,
14}
15
16/// A located parse error with context.
17#[derive(Debug, Clone)]
18pub struct ParseError {
19    pub span: Span,
20    pub message: String,
21    pub expected: Vec<String>,
22    pub found: Option<String>,
23    pub label: Option<&'static str>,
24}
25
26impl ParseError {
27    /// Convert a chumsky `Simple<Token>` error into our error type.
28    pub fn from_chumsky(err: Simple<Token>) -> Self {
29        let span = err.span();
30        let message = format!("{err}");
31        let expected: Vec<String> = err
32            .expected()
33            .map(|e| match e {
34                Some(tok) => format!("{tok}"),
35                None => "end of input".to_string(),
36            })
37            .collect();
38        let found = err.found().map(|t| format!("{t}"));
39        let label = err.label();
40
41        Self {
42            span,
43            message,
44            expected,
45            found,
46            label,
47        }
48    }
49
50    /// Render this error as a rich diagnostic string using ariadne.
51    pub fn render(&self, source_name: &str, source: &str) -> String {
52        let mut buf = Vec::new();
53
54        let msg = if !self.expected.is_empty() {
55            let expected_str = self.expected.join(", ");
56            match &self.found {
57                Some(found) => format!("expected {expected_str}, found {found}"),
58                None => format!("expected {expected_str}"),
59            }
60        } else {
61            self.message.clone()
62        };
63
64        let label_msg = match self.label {
65            Some(label) => format!("in {label}"),
66            None => msg.clone(),
67        };
68
69        Report::build(ReportKind::Error, source_name, self.span.start)
70            .with_message(&msg)
71            .with_label(
72                Label::new((source_name, self.span.clone()))
73                    .with_message(label_msg)
74                    .with_color(Color::Red),
75            )
76            .finish()
77            .write((source_name, Source::from(source)), &mut buf)
78            .unwrap();
79
80        String::from_utf8(buf).unwrap()
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn render_error() {
90        let err = ParseError {
91            span: 6..7,
92            message: "unexpected token".to_string(),
93            expected: vec!["(".to_string()],
94            found: Some(")".to_string()),
95            label: None,
96        };
97        let rendered = err.render("test.cyp", "MATCH ) foo");
98        assert!(rendered.contains("test.cyp"));
99        assert!(rendered.contains("expected"));
100    }
101
102    #[test]
103    fn render_with_label() {
104        let err = ParseError {
105            span: 0..5,
106            message: "unexpected".to_string(),
107            expected: vec![],
108            found: None,
109            label: Some("match pattern"),
110        };
111        let rendered = err.render("query.cyp", "XXXXX (n)");
112        assert!(rendered.contains("match pattern"));
113    }
114}