Skip to main content

varpulis_parser/
error.rs

1//! Parser error types
2
3use miette::{Diagnostic, NamedSource, SourceSpan};
4use thiserror::Error as ThisError;
5use varpulis_core::Span;
6
7/// Location in source code with line and column
8#[derive(Debug, Clone)]
9pub struct SourceLocation {
10    /// 1-based line number.
11    pub line: usize,
12    /// 1-based column number.
13    pub column: usize,
14    /// 0-based byte offset in the source string.
15    pub position: usize,
16}
17
18impl SourceLocation {
19    /// Convert a byte position to line/column using the source text
20    pub fn from_position(source: &str, position: usize) -> Self {
21        let mut line = 1;
22        let mut column = 1;
23
24        for (i, ch) in source.chars().enumerate() {
25            if i >= position {
26                break;
27            }
28            if ch == '\n' {
29                line += 1;
30                column = 1;
31            } else {
32                column += 1;
33            }
34        }
35
36        Self {
37            line,
38            column,
39            position,
40        }
41    }
42}
43
44/// Errors that can occur during VPL parsing.
45#[derive(Debug, ThisError, Clone)]
46pub enum ParseError {
47    /// Error with precise source location and an optional hint for the user.
48    #[error("Line {line}, column {column}: {message}")]
49    Located {
50        /// 1-based line number where the error occurred.
51        line: usize,
52        /// 1-based column number where the error occurred.
53        column: usize,
54        /// 0-based byte offset in the source string.
55        position: usize,
56        /// Human-readable description of the error.
57        message: String,
58        /// Optional suggestion for how to fix the error.
59        hint: Option<String>,
60    },
61
62    /// An unexpected token was encountered during parsing.
63    #[error("Unexpected token at position {position}: expected {expected}, found {found}")]
64    UnexpectedToken {
65        /// 0-based byte offset of the unexpected token.
66        position: usize,
67        /// Description of what was expected.
68        expected: String,
69        /// Description of what was found instead.
70        found: String,
71    },
72
73    /// The input ended unexpectedly (e.g., unclosed parenthesis).
74    #[error("Unexpected end of input")]
75    UnexpectedEof,
76
77    /// A token that does not belong to the VPL grammar was found.
78    #[error("Invalid token at position {position}: {message}")]
79    InvalidToken {
80        /// 0-based byte offset of the invalid token.
81        position: usize,
82        /// Description of why the token is invalid.
83        message: String,
84    },
85
86    /// A numeric literal could not be parsed.
87    #[error("Invalid number literal: {0}")]
88    InvalidNumber(String),
89
90    /// A duration literal (e.g., `5s`, `100ms`) could not be parsed.
91    #[error("Invalid duration literal: {0}")]
92    InvalidDuration(String),
93
94    /// A timestamp literal (e.g., `@2024-01-15`) could not be parsed.
95    #[error("Invalid timestamp literal: {0}")]
96    InvalidTimestamp(String),
97
98    /// A string literal was not closed before end of input.
99    #[error("Unterminated string starting at position {0}")]
100    UnterminatedString(
101        /// 0-based byte offset where the string started.
102        usize,
103    ),
104
105    /// An unrecognized escape sequence was found inside a string literal.
106    #[error("Invalid escape sequence: {0}")]
107    InvalidEscape(String),
108
109    /// A custom error with an associated source span.
110    #[error("{message}")]
111    Custom {
112        /// Source span where the error occurred.
113        span: Span,
114        /// Human-readable error message.
115        message: String,
116    },
117}
118
119impl ParseError {
120    /// Create a custom error from a source span and message.
121    pub fn custom(span: Span, message: impl Into<String>) -> Self {
122        Self::Custom {
123            span,
124            message: message.into(),
125        }
126    }
127
128    /// Create an error with source location and optional hint
129    pub fn at_location(
130        source: &str,
131        position: usize,
132        message: impl Into<String>,
133        hint: Option<String>,
134    ) -> Self {
135        let loc = SourceLocation::from_position(source, position);
136        Self::Located {
137            line: loc.line,
138            column: loc.column,
139            position,
140            message: message.into(),
141            hint,
142        }
143    }
144
145    /// Extract the byte offset and length for highlighting in the source.
146    ///
147    /// Returns `(offset, length)` where length defaults to 1 when only a
148    /// point position is available.
149    pub fn source_span(&self, source: &str) -> (usize, usize) {
150        match self {
151            Self::Located { position, .. }
152            | Self::UnexpectedToken { position, .. }
153            | Self::InvalidToken { position, .. }
154            | Self::UnterminatedString(position) => {
155                let pos = (*position).min(source.len());
156                // Highlight at least 1 char, or to end-of-line
157                let rest = &source[pos..];
158                let len = rest.find(['\n', '\r']).unwrap_or(rest.len()).max(1);
159                (pos, len)
160            }
161            Self::Custom { span, .. } => {
162                let start = span.start.min(source.len());
163                let end = span.end.min(source.len());
164                (start, (end - start).max(1))
165            }
166            // No position info — highlight the first character
167            Self::UnexpectedEof
168            | Self::InvalidNumber(_)
169            | Self::InvalidDuration(_)
170            | Self::InvalidTimestamp(_)
171            | Self::InvalidEscape(_) => (source.len().saturating_sub(1), 1),
172        }
173    }
174
175    /// Return the hint text, if the variant carries one.
176    pub fn hint(&self) -> Option<&str> {
177        match self {
178            Self::Located { hint, .. } => hint.as_deref(),
179            _ => None,
180        }
181    }
182}
183
184/// A [`ParseError`] bundled with source text for rich terminal rendering
185/// via [`miette`].
186///
187/// Consumers that only need the plain error can continue to use [`ParseError`]
188/// directly. The CLI wraps errors in this type to get coloured,
189/// source-highlighted diagnostics.
190#[derive(Debug)]
191pub struct RichParseError {
192    inner: ParseError,
193    src: NamedSource<String>,
194    span: SourceSpan,
195    help: Option<String>,
196}
197
198impl std::fmt::Display for RichParseError {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        write!(f, "{}", self.inner)
201    }
202}
203
204impl std::error::Error for RichParseError {}
205
206impl Diagnostic for RichParseError {
207    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
208        self.help
209            .as_ref()
210            .map(|h| Box::new(h.clone()) as Box<dyn std::fmt::Display>)
211    }
212
213    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
214        Some(&self.src)
215    }
216
217    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
218        Some(Box::new(std::iter::once(
219            miette::LabeledSpan::new_primary_with_span(Some("here".to_string()), self.span),
220        )))
221    }
222}
223
224impl RichParseError {
225    /// Wrap a [`ParseError`] with the source text and filename so that
226    /// `miette` can render a rich diagnostic.
227    pub fn new(error: ParseError, source: &str, filename: &str) -> Self {
228        let (offset, len) = error.source_span(source);
229        let help = error.hint().map(String::from);
230        Self {
231            src: NamedSource::new(filename, source.to_string()),
232            span: SourceSpan::new(offset.into(), len),
233            help,
234            inner: error,
235        }
236    }
237}
238
239/// Suggestions for common mistakes
240pub fn suggest_fix(token: &str) -> Option<String> {
241    match token.to_lowercase().as_str() {
242        "string" => Some("Did you mean 'str'? VPL uses 'str' for string types.".to_string()),
243        "integer" => Some("Did you mean 'int'? VPL uses 'int' for integer types.".to_string()),
244        "boolean" => Some("Did you mean 'bool'? VPL uses 'bool' for boolean types.".to_string()),
245        "&&" => Some("Use 'and' instead of '&&' for logical AND.".to_string()),
246        "||" => Some("Use 'or' instead of '||' for logical OR.".to_string()),
247        "!" => Some("Use 'not' instead of '!' for logical NOT.".to_string()),
248        "function" | "func" | "def" => Some("Use 'fn' to declare functions.".to_string()),
249        "class" | "struct" => Some("Use 'event' to declare event types.".to_string()),
250        _ => None,
251    }
252}
253
254/// Convenience alias for `Result<T, ParseError>`.
255pub type ParseResult<T> = Result<T, ParseError>;