Skip to main content

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            ParseErrorKind::InvalidPoptag(_) => 20,
75            ParseErrorKind::UnclosedPushtag(_) => 21,
76            ParseErrorKind::InvalidPopmeta(_) => 22,
77            ParseErrorKind::UnclosedPushmeta(_) => 23,
78            ParseErrorKind::DeprecatedPipeSymbol => 24,
79            ParseErrorKind::InvalidBookingMethod(_) => 25,
80        }
81    }
82
83    /// Get the error message.
84    #[must_use]
85    pub fn message(&self) -> String {
86        format!("{}", self.kind)
87    }
88
89    /// Get a short label for the error.
90    #[must_use]
91    pub const fn label(&self) -> &str {
92        match &self.kind {
93            ParseErrorKind::UnexpectedChar(_) => "unexpected character",
94            ParseErrorKind::UnexpectedEof => "unexpected end of file",
95            ParseErrorKind::Expected(_) => "expected different token",
96            ParseErrorKind::InvalidDate(_) => "invalid date",
97            ParseErrorKind::InvalidNumber(_) => "invalid number",
98            ParseErrorKind::InvalidAccount(_) => "invalid account",
99            ParseErrorKind::InvalidCurrency(_) => "invalid currency",
100            ParseErrorKind::UnclosedString => "unclosed string",
101            ParseErrorKind::InvalidEscape(_) => "invalid escape",
102            ParseErrorKind::MissingField(_) => "missing field",
103            ParseErrorKind::IndentationError => "indentation error",
104            ParseErrorKind::SyntaxError(_) => "parse error",
105            ParseErrorKind::MissingNewline => "syntax error",
106            ParseErrorKind::MissingAccount => "expected account name",
107            ParseErrorKind::InvalidDateValue(_) => "invalid date value",
108            ParseErrorKind::MissingAmount => "expected amount",
109            ParseErrorKind::MissingCurrency => "expected currency",
110            ParseErrorKind::InvalidAccountFormat(_) => "invalid account format",
111            ParseErrorKind::MissingDirective => "expected directive",
112            ParseErrorKind::InvalidPoptag(_) => "invalid poptag",
113            ParseErrorKind::UnclosedPushtag(_) => "unclosed pushtag",
114            ParseErrorKind::InvalidPopmeta(_) => "invalid popmeta",
115            ParseErrorKind::UnclosedPushmeta(_) => "unclosed pushmeta",
116            ParseErrorKind::DeprecatedPipeSymbol => "deprecated pipe symbol",
117            ParseErrorKind::InvalidBookingMethod(_) => "invalid booking method",
118        }
119    }
120}
121
122impl fmt::Display for ParseError {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        write!(f, "{}", self.kind)?;
125        if let Some(ctx) = &self.context {
126            write!(f, " ({ctx})")?;
127        }
128        Ok(())
129    }
130}
131
132impl std::error::Error for ParseError {}
133
134/// Kinds of parse errors.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum ParseErrorKind {
137    /// Unexpected character in input.
138    UnexpectedChar(char),
139    /// Unexpected end of file.
140    UnexpectedEof,
141    /// Expected a specific token.
142    Expected(String),
143    /// Invalid date format.
144    InvalidDate(String),
145    /// Invalid number format.
146    InvalidNumber(String),
147    /// Invalid account name.
148    InvalidAccount(String),
149    /// Invalid currency code.
150    InvalidCurrency(String),
151    /// Unclosed string literal.
152    UnclosedString,
153    /// Invalid escape sequence in string.
154    InvalidEscape(char),
155    /// Missing required field.
156    MissingField(String),
157    /// Indentation error.
158    IndentationError,
159    /// Generic syntax error.
160    SyntaxError(String),
161    /// Missing final newline.
162    MissingNewline,
163    /// Missing account name (e.g., after 'open' keyword).
164    MissingAccount,
165    /// Invalid date value (e.g., month 13, day 32).
166    InvalidDateValue(String),
167    /// Missing amount in posting.
168    MissingAmount,
169    /// Missing currency after number.
170    MissingCurrency,
171    /// Invalid account format (e.g., missing colon).
172    InvalidAccountFormat(String),
173    /// Missing directive after date.
174    MissingDirective,
175    /// Poptag for a tag that was never pushed.
176    InvalidPoptag(String),
177    /// Pushtag that was never popped (unclosed).
178    UnclosedPushtag(String),
179    /// Popmeta for a key that was never pushed.
180    InvalidPopmeta(String),
181    /// Pushmeta that was never popped (unclosed).
182    UnclosedPushmeta(String),
183    /// Deprecated pipe symbol in transaction.
184    DeprecatedPipeSymbol,
185    /// Invalid booking method (must be uppercase: FIFO, STRICT, `STRICT_WITH_SIZE`, LIFO, HIFO, NONE, AVERAGE).
186    InvalidBookingMethod(String),
187}
188
189impl fmt::Display for ParseErrorKind {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        match self {
192            Self::UnexpectedChar(c) => write!(f, "syntax error: unexpected '{c}'"),
193            Self::UnexpectedEof => write!(f, "unexpected end of file"),
194            Self::Expected(what) => write!(f, "expected {what}"),
195            Self::InvalidDate(s) => write!(f, "invalid date '{s}'"),
196            Self::InvalidNumber(s) => write!(f, "invalid number '{s}'"),
197            Self::InvalidAccount(s) => write!(f, "Invalid account '{s}'"),
198            Self::InvalidCurrency(s) => write!(f, "invalid currency '{s}'"),
199            Self::UnclosedString => write!(f, "unclosed string literal"),
200            Self::InvalidEscape(c) => write!(f, "invalid escape sequence '\\{c}'"),
201            Self::MissingField(field) => write!(f, "missing required field: {field}"),
202            Self::IndentationError => write!(f, "indentation error"),
203            Self::SyntaxError(msg) => write!(f, "parse error: {msg}"),
204            Self::MissingNewline => write!(f, "syntax error: missing final newline"),
205            Self::MissingAccount => write!(f, "expected account name"),
206            Self::InvalidDateValue(msg) => write!(f, "invalid date: {msg}"),
207            Self::MissingAmount => write!(f, "expected amount in posting"),
208            Self::MissingCurrency => write!(f, "expected currency after number"),
209            Self::InvalidAccountFormat(s) => {
210                write!(f, "invalid account '{s}': must contain ':'")
211            }
212            Self::MissingDirective => write!(f, "expected directive after date"),
213            Self::InvalidPoptag(tag) => {
214                write!(f, "poptag attempted on tag '{tag}' which was never pushed")
215            }
216            Self::UnclosedPushtag(tag) => {
217                write!(f, "pushtag '{tag}' was never popped")
218            }
219            Self::InvalidPopmeta(key) => {
220                write!(f, "popmeta attempted on key '{key}' which was never pushed")
221            }
222            Self::UnclosedPushmeta(key) => {
223                write!(f, "pushmeta '{key}' was never popped")
224            }
225            Self::DeprecatedPipeSymbol => {
226                write!(f, "Pipe symbol is deprecated")
227            }
228            Self::InvalidBookingMethod(m) => {
229                write!(
230                    f,
231                    "invalid booking method '{m}': must be one of FIFO, STRICT, STRICT_WITH_SIZE, LIFO, HIFO, NONE, AVERAGE"
232                )
233            }
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_parse_error_new() {
244        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5));
245        assert_eq!(err.span(), (0, 5));
246        assert!(err.context.is_none());
247        assert!(err.hint.is_none());
248    }
249
250    #[test]
251    fn test_parse_error_with_context() {
252        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
253            .with_context("in transaction");
254        assert_eq!(err.context, Some("in transaction".to_string()));
255    }
256
257    #[test]
258    fn test_parse_error_with_hint() {
259        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
260            .with_hint("add more input");
261        assert_eq!(err.hint, Some("add more input".to_string()));
262    }
263
264    #[test]
265    fn test_parse_error_display_with_context() {
266        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
267            .with_context("parsing header");
268        let display = format!("{err}");
269        assert!(display.contains("unexpected end of file"));
270        assert!(display.contains("parsing header"));
271    }
272
273    #[test]
274    fn test_kind_codes() {
275        // Test all error codes are unique and in expected range
276        let kinds = [
277            (ParseErrorKind::UnexpectedChar('x'), 1),
278            (ParseErrorKind::UnexpectedEof, 2),
279            (ParseErrorKind::Expected("foo".to_string()), 3),
280            (ParseErrorKind::InvalidDate("bad".to_string()), 4),
281            (ParseErrorKind::InvalidNumber("nan".to_string()), 5),
282            (ParseErrorKind::InvalidAccount("bad".to_string()), 6),
283            (ParseErrorKind::InvalidCurrency("???".to_string()), 7),
284            (ParseErrorKind::UnclosedString, 8),
285            (ParseErrorKind::InvalidEscape('n'), 9),
286            (ParseErrorKind::MissingField("name".to_string()), 10),
287            (ParseErrorKind::IndentationError, 11),
288            (ParseErrorKind::SyntaxError("oops".to_string()), 12),
289            (ParseErrorKind::MissingNewline, 13),
290            (ParseErrorKind::MissingAccount, 14),
291            (ParseErrorKind::InvalidDateValue("month 13".to_string()), 15),
292            (ParseErrorKind::MissingAmount, 16),
293            (ParseErrorKind::MissingCurrency, 17),
294            (
295                ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
296                18,
297            ),
298            (ParseErrorKind::MissingDirective, 19),
299            (ParseErrorKind::InvalidPoptag("bad".to_string()), 20),
300            (ParseErrorKind::UnclosedPushtag("tag".to_string()), 21),
301            (ParseErrorKind::InvalidPopmeta("key".to_string()), 22),
302            (ParseErrorKind::UnclosedPushmeta("key".to_string()), 23),
303            (ParseErrorKind::DeprecatedPipeSymbol, 24),
304            (ParseErrorKind::InvalidBookingMethod("BAD".to_string()), 25),
305        ];
306
307        for (kind, expected_code) in kinds {
308            let err = ParseError::new(kind, Span::new(0, 1));
309            assert_eq!(err.kind_code(), expected_code);
310        }
311    }
312
313    #[test]
314    fn test_error_labels() {
315        // Test that all error kinds have non-empty labels
316        let kinds = [
317            ParseErrorKind::UnexpectedChar('x'),
318            ParseErrorKind::UnexpectedEof,
319            ParseErrorKind::Expected("foo".to_string()),
320            ParseErrorKind::InvalidDate("bad".to_string()),
321            ParseErrorKind::InvalidNumber("nan".to_string()),
322            ParseErrorKind::InvalidAccount("bad".to_string()),
323            ParseErrorKind::InvalidCurrency("???".to_string()),
324            ParseErrorKind::UnclosedString,
325            ParseErrorKind::InvalidEscape('n'),
326            ParseErrorKind::MissingField("name".to_string()),
327            ParseErrorKind::IndentationError,
328            ParseErrorKind::SyntaxError("oops".to_string()),
329            ParseErrorKind::MissingNewline,
330            ParseErrorKind::MissingAccount,
331            ParseErrorKind::InvalidDateValue("month 13".to_string()),
332            ParseErrorKind::MissingAmount,
333            ParseErrorKind::MissingCurrency,
334            ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
335            ParseErrorKind::MissingDirective,
336            ParseErrorKind::InvalidPoptag("bad".to_string()),
337            ParseErrorKind::UnclosedPushtag("tag".to_string()),
338            ParseErrorKind::InvalidPopmeta("key".to_string()),
339            ParseErrorKind::UnclosedPushmeta("key".to_string()),
340            ParseErrorKind::DeprecatedPipeSymbol,
341            ParseErrorKind::InvalidBookingMethod("BAD".to_string()),
342        ];
343
344        for kind in kinds {
345            let err = ParseError::new(kind, Span::new(0, 1));
346            assert!(!err.label().is_empty());
347        }
348    }
349
350    #[test]
351    fn test_error_messages() {
352        // Test Display for all error kinds
353        let test_cases = [
354            (ParseErrorKind::UnexpectedChar('$'), "unexpected '$'"),
355            (ParseErrorKind::UnexpectedEof, "unexpected end of file"),
356            (
357                ParseErrorKind::Expected("number".to_string()),
358                "expected number",
359            ),
360            (
361                ParseErrorKind::InvalidDate("2024-13-01".to_string()),
362                "invalid date '2024-13-01'",
363            ),
364            (
365                ParseErrorKind::InvalidNumber("abc".to_string()),
366                "invalid number 'abc'",
367            ),
368            (
369                ParseErrorKind::InvalidAccount("bad".to_string()),
370                "Invalid account 'bad'",
371            ),
372            (
373                ParseErrorKind::InvalidCurrency("???".to_string()),
374                "invalid currency '???'",
375            ),
376            (ParseErrorKind::UnclosedString, "unclosed string literal"),
377            (
378                ParseErrorKind::InvalidEscape('x'),
379                "invalid escape sequence '\\x'",
380            ),
381            (
382                ParseErrorKind::MissingField("date".to_string()),
383                "missing required field: date",
384            ),
385            (ParseErrorKind::IndentationError, "indentation error"),
386            (
387                ParseErrorKind::SyntaxError("bad token".to_string()),
388                "parse error: bad token",
389            ),
390            (ParseErrorKind::MissingNewline, "missing final newline"),
391            (ParseErrorKind::MissingAccount, "expected account name"),
392            (
393                ParseErrorKind::InvalidDateValue("month 13".to_string()),
394                "invalid date: month 13",
395            ),
396            (ParseErrorKind::MissingAmount, "expected amount in posting"),
397            (
398                ParseErrorKind::MissingCurrency,
399                "expected currency after number",
400            ),
401            (
402                ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
403                "must contain ':'",
404            ),
405            (
406                ParseErrorKind::MissingDirective,
407                "expected directive after date",
408            ),
409            (
410                ParseErrorKind::InvalidPoptag("bad".to_string()),
411                "poptag attempted on tag 'bad'",
412            ),
413            (
414                ParseErrorKind::UnclosedPushtag("tag".to_string()),
415                "pushtag 'tag' was never popped",
416            ),
417            (
418                ParseErrorKind::InvalidPopmeta("key".to_string()),
419                "popmeta attempted on key 'key'",
420            ),
421            (
422                ParseErrorKind::UnclosedPushmeta("key".to_string()),
423                "pushmeta 'key' was never popped",
424            ),
425            (
426                ParseErrorKind::DeprecatedPipeSymbol,
427                "Pipe symbol is deprecated",
428            ),
429            (
430                ParseErrorKind::InvalidBookingMethod("BAD".to_string()),
431                "invalid booking method 'BAD'",
432            ),
433        ];
434
435        for (kind, expected_substring) in test_cases {
436            let msg = format!("{kind}");
437            assert!(
438                msg.contains(expected_substring),
439                "Expected '{expected_substring}' in '{msg}'"
440            );
441        }
442    }
443
444    #[test]
445    fn test_parse_error_is_error_trait() {
446        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 1));
447        // Verify it implements std::error::Error
448        let _: &dyn std::error::Error = &err;
449    }
450}