rustledger_parser/
error.rs

1//! Parse error types.
2
3use crate::Span;
4use std::fmt;
5
6/// A parse error with location information.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ParseError {
9    /// The kind of error.
10    pub kind: ParseErrorKind,
11    /// The span where the error occurred.
12    pub span: Span,
13    /// Optional context message.
14    pub context: Option<String>,
15    /// Optional hint for fixing the error.
16    pub hint: Option<String>,
17}
18
19impl ParseError {
20    /// Create a new parse error.
21    #[must_use]
22    pub const fn new(kind: ParseErrorKind, span: Span) -> Self {
23        Self {
24            kind,
25            span,
26            context: None,
27            hint: None,
28        }
29    }
30
31    /// Add context to this error.
32    #[must_use]
33    pub fn with_context(mut self, context: impl Into<String>) -> Self {
34        self.context = Some(context.into());
35        self
36    }
37
38    /// Add a hint for fixing this error.
39    #[must_use]
40    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
41        self.hint = Some(hint.into());
42        self
43    }
44
45    /// Get the span of this error.
46    #[must_use]
47    pub const fn span(&self) -> (usize, usize) {
48        (self.span.start, self.span.end)
49    }
50
51    /// Get a numeric code for the error kind.
52    #[must_use]
53    pub const fn kind_code(&self) -> u32 {
54        match &self.kind {
55            ParseErrorKind::UnexpectedChar(_) => 1,
56            ParseErrorKind::UnexpectedEof => 2,
57            ParseErrorKind::Expected(_) => 3,
58            ParseErrorKind::InvalidDate(_) => 4,
59            ParseErrorKind::InvalidNumber(_) => 5,
60            ParseErrorKind::InvalidAccount(_) => 6,
61            ParseErrorKind::InvalidCurrency(_) => 7,
62            ParseErrorKind::UnclosedString => 8,
63            ParseErrorKind::InvalidEscape(_) => 9,
64            ParseErrorKind::MissingField(_) => 10,
65            ParseErrorKind::IndentationError => 11,
66            ParseErrorKind::SyntaxError(_) => 12,
67            ParseErrorKind::MissingNewline => 13,
68            ParseErrorKind::MissingAccount => 14,
69            ParseErrorKind::InvalidDateValue(_) => 15,
70            ParseErrorKind::MissingAmount => 16,
71            ParseErrorKind::MissingCurrency => 17,
72            ParseErrorKind::InvalidAccountFormat(_) => 18,
73            ParseErrorKind::MissingDirective => 19,
74        }
75    }
76
77    /// Get the error message.
78    #[must_use]
79    pub fn message(&self) -> String {
80        format!("{}", self.kind)
81    }
82
83    /// Get a short label for the error.
84    #[must_use]
85    pub const fn label(&self) -> &str {
86        match &self.kind {
87            ParseErrorKind::UnexpectedChar(_) => "unexpected character",
88            ParseErrorKind::UnexpectedEof => "unexpected end of file",
89            ParseErrorKind::Expected(_) => "expected different token",
90            ParseErrorKind::InvalidDate(_) => "invalid date",
91            ParseErrorKind::InvalidNumber(_) => "invalid number",
92            ParseErrorKind::InvalidAccount(_) => "invalid account",
93            ParseErrorKind::InvalidCurrency(_) => "invalid currency",
94            ParseErrorKind::UnclosedString => "unclosed string",
95            ParseErrorKind::InvalidEscape(_) => "invalid escape",
96            ParseErrorKind::MissingField(_) => "missing field",
97            ParseErrorKind::IndentationError => "indentation error",
98            ParseErrorKind::SyntaxError(_) => "parse error",
99            ParseErrorKind::MissingNewline => "syntax error",
100            ParseErrorKind::MissingAccount => "expected account name",
101            ParseErrorKind::InvalidDateValue(_) => "invalid date value",
102            ParseErrorKind::MissingAmount => "expected amount",
103            ParseErrorKind::MissingCurrency => "expected currency",
104            ParseErrorKind::InvalidAccountFormat(_) => "invalid account format",
105            ParseErrorKind::MissingDirective => "expected directive",
106        }
107    }
108}
109
110impl fmt::Display for ParseError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(f, "{}", self.kind)?;
113        if let Some(ctx) = &self.context {
114            write!(f, " ({ctx})")?;
115        }
116        Ok(())
117    }
118}
119
120impl std::error::Error for ParseError {}
121
122/// Kinds of parse errors.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum ParseErrorKind {
125    /// Unexpected character in input.
126    UnexpectedChar(char),
127    /// Unexpected end of file.
128    UnexpectedEof,
129    /// Expected a specific token.
130    Expected(String),
131    /// Invalid date format.
132    InvalidDate(String),
133    /// Invalid number format.
134    InvalidNumber(String),
135    /// Invalid account name.
136    InvalidAccount(String),
137    /// Invalid currency code.
138    InvalidCurrency(String),
139    /// Unclosed string literal.
140    UnclosedString,
141    /// Invalid escape sequence in string.
142    InvalidEscape(char),
143    /// Missing required field.
144    MissingField(String),
145    /// Indentation error.
146    IndentationError,
147    /// Generic syntax error.
148    SyntaxError(String),
149    /// Missing final newline.
150    MissingNewline,
151    /// Missing account name (e.g., after 'open' keyword).
152    MissingAccount,
153    /// Invalid date value (e.g., month 13, day 32).
154    InvalidDateValue(String),
155    /// Missing amount in posting.
156    MissingAmount,
157    /// Missing currency after number.
158    MissingCurrency,
159    /// Invalid account format (e.g., missing colon).
160    InvalidAccountFormat(String),
161    /// Missing directive after date.
162    MissingDirective,
163}
164
165impl fmt::Display for ParseErrorKind {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        match self {
168            Self::UnexpectedChar(c) => write!(f, "syntax error: unexpected '{c}'"),
169            Self::UnexpectedEof => write!(f, "unexpected end of file"),
170            Self::Expected(what) => write!(f, "expected {what}"),
171            Self::InvalidDate(s) => write!(f, "invalid date '{s}'"),
172            Self::InvalidNumber(s) => write!(f, "invalid number '{s}'"),
173            Self::InvalidAccount(s) => write!(f, "invalid account '{s}'"),
174            Self::InvalidCurrency(s) => write!(f, "invalid currency '{s}'"),
175            Self::UnclosedString => write!(f, "unclosed string literal"),
176            Self::InvalidEscape(c) => write!(f, "invalid escape sequence '\\{c}'"),
177            Self::MissingField(field) => write!(f, "missing required field: {field}"),
178            Self::IndentationError => write!(f, "indentation error"),
179            Self::SyntaxError(msg) => write!(f, "parse error: {msg}"),
180            Self::MissingNewline => write!(f, "syntax error: missing final newline"),
181            Self::MissingAccount => write!(f, "expected account name"),
182            Self::InvalidDateValue(msg) => write!(f, "invalid date: {msg}"),
183            Self::MissingAmount => write!(f, "expected amount in posting"),
184            Self::MissingCurrency => write!(f, "expected currency after number"),
185            Self::InvalidAccountFormat(s) => {
186                write!(f, "invalid account '{s}': must contain ':'")
187            }
188            Self::MissingDirective => write!(f, "expected directive after date"),
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_parse_error_new() {
199        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5));
200        assert_eq!(err.span(), (0, 5));
201        assert!(err.context.is_none());
202        assert!(err.hint.is_none());
203    }
204
205    #[test]
206    fn test_parse_error_with_context() {
207        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
208            .with_context("in transaction");
209        assert_eq!(err.context, Some("in transaction".to_string()));
210    }
211
212    #[test]
213    fn test_parse_error_with_hint() {
214        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
215            .with_hint("add more input");
216        assert_eq!(err.hint, Some("add more input".to_string()));
217    }
218
219    #[test]
220    fn test_parse_error_display_with_context() {
221        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
222            .with_context("parsing header");
223        let display = format!("{err}");
224        assert!(display.contains("unexpected end of file"));
225        assert!(display.contains("parsing header"));
226    }
227
228    #[test]
229    fn test_kind_codes() {
230        // Test all error codes are unique and in expected range
231        let kinds = [
232            (ParseErrorKind::UnexpectedChar('x'), 1),
233            (ParseErrorKind::UnexpectedEof, 2),
234            (ParseErrorKind::Expected("foo".to_string()), 3),
235            (ParseErrorKind::InvalidDate("bad".to_string()), 4),
236            (ParseErrorKind::InvalidNumber("nan".to_string()), 5),
237            (ParseErrorKind::InvalidAccount("bad".to_string()), 6),
238            (ParseErrorKind::InvalidCurrency("???".to_string()), 7),
239            (ParseErrorKind::UnclosedString, 8),
240            (ParseErrorKind::InvalidEscape('n'), 9),
241            (ParseErrorKind::MissingField("name".to_string()), 10),
242            (ParseErrorKind::IndentationError, 11),
243            (ParseErrorKind::SyntaxError("oops".to_string()), 12),
244            (ParseErrorKind::MissingNewline, 13),
245            (ParseErrorKind::MissingAccount, 14),
246            (ParseErrorKind::InvalidDateValue("month 13".to_string()), 15),
247            (ParseErrorKind::MissingAmount, 16),
248            (ParseErrorKind::MissingCurrency, 17),
249            (
250                ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
251                18,
252            ),
253            (ParseErrorKind::MissingDirective, 19),
254        ];
255
256        for (kind, expected_code) in kinds {
257            let err = ParseError::new(kind, Span::new(0, 1));
258            assert_eq!(err.kind_code(), expected_code);
259        }
260    }
261
262    #[test]
263    fn test_error_labels() {
264        // Test that all error kinds have non-empty labels
265        let kinds = [
266            ParseErrorKind::UnexpectedChar('x'),
267            ParseErrorKind::UnexpectedEof,
268            ParseErrorKind::Expected("foo".to_string()),
269            ParseErrorKind::InvalidDate("bad".to_string()),
270            ParseErrorKind::InvalidNumber("nan".to_string()),
271            ParseErrorKind::InvalidAccount("bad".to_string()),
272            ParseErrorKind::InvalidCurrency("???".to_string()),
273            ParseErrorKind::UnclosedString,
274            ParseErrorKind::InvalidEscape('n'),
275            ParseErrorKind::MissingField("name".to_string()),
276            ParseErrorKind::IndentationError,
277            ParseErrorKind::SyntaxError("oops".to_string()),
278            ParseErrorKind::MissingNewline,
279            ParseErrorKind::MissingAccount,
280            ParseErrorKind::InvalidDateValue("month 13".to_string()),
281            ParseErrorKind::MissingAmount,
282            ParseErrorKind::MissingCurrency,
283            ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
284            ParseErrorKind::MissingDirective,
285        ];
286
287        for kind in kinds {
288            let err = ParseError::new(kind, Span::new(0, 1));
289            assert!(!err.label().is_empty());
290        }
291    }
292
293    #[test]
294    fn test_error_messages() {
295        // Test Display for all error kinds
296        let test_cases = [
297            (ParseErrorKind::UnexpectedChar('$'), "unexpected '$'"),
298            (ParseErrorKind::UnexpectedEof, "unexpected end of file"),
299            (
300                ParseErrorKind::Expected("number".to_string()),
301                "expected number",
302            ),
303            (
304                ParseErrorKind::InvalidDate("2024-13-01".to_string()),
305                "invalid date '2024-13-01'",
306            ),
307            (
308                ParseErrorKind::InvalidNumber("abc".to_string()),
309                "invalid number 'abc'",
310            ),
311            (
312                ParseErrorKind::InvalidAccount("bad".to_string()),
313                "invalid account 'bad'",
314            ),
315            (
316                ParseErrorKind::InvalidCurrency("???".to_string()),
317                "invalid currency '???'",
318            ),
319            (ParseErrorKind::UnclosedString, "unclosed string literal"),
320            (
321                ParseErrorKind::InvalidEscape('x'),
322                "invalid escape sequence '\\x'",
323            ),
324            (
325                ParseErrorKind::MissingField("date".to_string()),
326                "missing required field: date",
327            ),
328            (ParseErrorKind::IndentationError, "indentation error"),
329            (
330                ParseErrorKind::SyntaxError("bad token".to_string()),
331                "parse error: bad token",
332            ),
333            (ParseErrorKind::MissingNewline, "missing final newline"),
334            (ParseErrorKind::MissingAccount, "expected account name"),
335            (
336                ParseErrorKind::InvalidDateValue("month 13".to_string()),
337                "invalid date: month 13",
338            ),
339            (ParseErrorKind::MissingAmount, "expected amount in posting"),
340            (
341                ParseErrorKind::MissingCurrency,
342                "expected currency after number",
343            ),
344            (
345                ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
346                "must contain ':'",
347            ),
348            (
349                ParseErrorKind::MissingDirective,
350                "expected directive after date",
351            ),
352        ];
353
354        for (kind, expected_substring) in test_cases {
355            let msg = format!("{kind}");
356            assert!(
357                msg.contains(expected_substring),
358                "Expected '{expected_substring}' in '{msg}'"
359            );
360        }
361    }
362
363    #[test]
364    fn test_parse_error_is_error_trait() {
365        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 1));
366        // Verify it implements std::error::Error
367        let _: &dyn std::error::Error = &err;
368    }
369}