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