1use crate::Span;
4use std::fmt;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ParseError {
9 pub kind: ParseErrorKind,
11 pub span: Span,
13 pub context: Option<String>,
15 pub hint: Option<String>,
17}
18
19impl ParseError {
20 #[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 #[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 #[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 #[must_use]
47 pub const fn span(&self) -> (usize, usize) {
48 (self.span.start, self.span.end)
49 }
50
51 #[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 #[must_use]
85 pub fn message(&self) -> String {
86 format!("{}", self.kind)
87 }
88
89 #[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#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum ParseErrorKind {
137 UnexpectedChar(char),
139 UnexpectedEof,
141 Expected(String),
143 InvalidDate(String),
145 InvalidNumber(String),
147 InvalidAccount(String),
149 InvalidCurrency(String),
151 UnclosedString,
153 InvalidEscape(char),
155 MissingField(String),
157 IndentationError,
159 SyntaxError(String),
161 MissingNewline,
163 MissingAccount,
165 InvalidDateValue(String),
167 MissingAmount,
169 MissingCurrency,
171 InvalidAccountFormat(String),
173 MissingDirective,
175 InvalidPoptag(String),
177 UnclosedPushtag(String),
179 InvalidPopmeta(String),
181 UnclosedPushmeta(String),
183 DeprecatedPipeSymbol,
185 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 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 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 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 let _: &dyn std::error::Error = &err;
449 }
450}