1use crate::Span;
4use std::fmt;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub struct ParseError {
16 pub kind: ParseErrorKind,
18 pub span: Span,
20 pub context: Option<String>,
22 pub hint: Option<String>,
24}
25
26impl ParseError {
27 #[must_use]
29 pub const fn new(kind: ParseErrorKind, span: Span) -> Self {
30 Self {
31 kind,
32 span,
33 context: None,
34 hint: None,
35 }
36 }
37
38 #[must_use]
40 pub fn with_context(mut self, context: impl Into<String>) -> Self {
41 self.context = Some(context.into());
42 self
43 }
44
45 #[must_use]
47 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
48 self.hint = Some(hint.into());
49 self
50 }
51
52 #[must_use]
54 pub const fn span(&self) -> (usize, usize) {
55 (self.span.start, self.span.end)
56 }
57
58 #[must_use]
60 pub const fn kind_code(&self) -> u32 {
61 match &self.kind {
62 ParseErrorKind::UnexpectedChar(_) => 1,
63 ParseErrorKind::UnexpectedEof => 2,
64 ParseErrorKind::Expected(_) => 3,
65 ParseErrorKind::InvalidDate(_) => 4,
66 ParseErrorKind::InvalidNumber(_) => 5,
67 ParseErrorKind::InvalidAccount(_) => 6,
68 ParseErrorKind::InvalidCurrency(_) => 7,
69 ParseErrorKind::UnclosedString => 8,
70 ParseErrorKind::InvalidEscape(_) => 9,
71 ParseErrorKind::MissingField(_) => 10,
72 ParseErrorKind::IndentationError => 11,
73 ParseErrorKind::SyntaxError(_) => 12,
74 ParseErrorKind::MissingNewline => 13,
75 ParseErrorKind::MissingAccount => 14,
76 ParseErrorKind::InvalidDateValue(_) => 15,
77 ParseErrorKind::MissingAmount => 16,
78 ParseErrorKind::MissingCurrency => 17,
79 ParseErrorKind::InvalidAccountFormat(_) => 18,
80 ParseErrorKind::MissingDirective => 19,
81 ParseErrorKind::InvalidPoptag(_) => 20,
82 ParseErrorKind::UnclosedPushtag(_) => 21,
83 ParseErrorKind::InvalidPopmeta(_) => 22,
84 ParseErrorKind::UnclosedPushmeta(_) => 23,
85 ParseErrorKind::DeprecatedPipeSymbol => 24,
86 ParseErrorKind::InvalidBookingMethod(_) => 25,
87 ParseErrorKind::BomInDirectiveBody => 26,
88 }
89 }
90
91 #[must_use]
93 pub fn message(&self) -> String {
94 format!("{}", self.kind)
95 }
96
97 #[doc(hidden)]
120 #[must_use]
121 pub fn every_kind_sample() -> Vec<ParseErrorKind> {
122 vec![
123 ParseErrorKind::UnexpectedChar('x'),
124 ParseErrorKind::UnexpectedEof,
125 ParseErrorKind::Expected(String::new()),
126 ParseErrorKind::InvalidDate(String::new()),
127 ParseErrorKind::InvalidNumber(String::new()),
128 ParseErrorKind::InvalidAccount(String::new()),
129 ParseErrorKind::InvalidCurrency(String::new()),
130 ParseErrorKind::UnclosedString,
131 ParseErrorKind::InvalidEscape('x'),
132 ParseErrorKind::MissingField(String::new()),
133 ParseErrorKind::IndentationError,
134 ParseErrorKind::SyntaxError(String::new()),
135 ParseErrorKind::MissingNewline,
136 ParseErrorKind::MissingAccount,
137 ParseErrorKind::InvalidDateValue(String::new()),
138 ParseErrorKind::MissingAmount,
139 ParseErrorKind::MissingCurrency,
140 ParseErrorKind::InvalidAccountFormat(String::new()),
141 ParseErrorKind::MissingDirective,
142 ParseErrorKind::InvalidPoptag(String::new()),
143 ParseErrorKind::UnclosedPushtag(String::new()),
144 ParseErrorKind::InvalidPopmeta(String::new()),
145 ParseErrorKind::UnclosedPushmeta(String::new()),
146 ParseErrorKind::DeprecatedPipeSymbol,
147 ParseErrorKind::InvalidBookingMethod(String::new()),
148 ParseErrorKind::BomInDirectiveBody,
149 ]
150 }
151
152 #[must_use]
154 pub const fn label(&self) -> &str {
155 match &self.kind {
156 ParseErrorKind::UnexpectedChar(_) => "unexpected character",
157 ParseErrorKind::UnexpectedEof => "unexpected end of file",
158 ParseErrorKind::Expected(_) => "expected different token",
159 ParseErrorKind::InvalidDate(_) => "invalid date",
160 ParseErrorKind::InvalidNumber(_) => "invalid number",
161 ParseErrorKind::InvalidAccount(_) => "invalid account",
162 ParseErrorKind::InvalidCurrency(_) => "invalid currency",
163 ParseErrorKind::UnclosedString => "unclosed string",
164 ParseErrorKind::InvalidEscape(_) => "invalid escape",
165 ParseErrorKind::MissingField(_) => "missing field",
166 ParseErrorKind::IndentationError => "indentation error",
167 ParseErrorKind::SyntaxError(_) => "parse error",
168 ParseErrorKind::MissingNewline => "syntax error",
169 ParseErrorKind::MissingAccount => "expected account name",
170 ParseErrorKind::InvalidDateValue(_) => "invalid date value",
171 ParseErrorKind::MissingAmount => "expected amount",
172 ParseErrorKind::MissingCurrency => "expected currency",
173 ParseErrorKind::InvalidAccountFormat(_) => "invalid account format",
174 ParseErrorKind::MissingDirective => "expected directive",
175 ParseErrorKind::InvalidPoptag(_) => "invalid poptag",
176 ParseErrorKind::UnclosedPushtag(_) => "unclosed pushtag",
177 ParseErrorKind::InvalidPopmeta(_) => "invalid popmeta",
178 ParseErrorKind::UnclosedPushmeta(_) => "unclosed pushmeta",
179 ParseErrorKind::DeprecatedPipeSymbol => "deprecated pipe symbol",
180 ParseErrorKind::InvalidBookingMethod(_) => "invalid booking method",
181 ParseErrorKind::BomInDirectiveBody => "mid-file BOM",
182 }
183 }
184}
185
186impl fmt::Display for ParseError {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 write!(f, "{}", self.kind)?;
189 if let Some(ctx) = &self.context {
190 write!(f, " ({ctx})")?;
191 }
192 Ok(())
193 }
194}
195
196impl std::error::Error for ParseError {}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
206#[non_exhaustive]
207pub enum ParseErrorKind {
208 UnexpectedChar(char),
210 UnexpectedEof,
212 Expected(String),
214 InvalidDate(String),
216 InvalidNumber(String),
218 InvalidAccount(String),
220 InvalidCurrency(String),
222 UnclosedString,
224 InvalidEscape(char),
226 MissingField(String),
228 IndentationError,
230 SyntaxError(String),
232 MissingNewline,
234 MissingAccount,
236 InvalidDateValue(String),
238 MissingAmount,
240 MissingCurrency,
242 InvalidAccountFormat(String),
244 MissingDirective,
246 InvalidPoptag(String),
248 UnclosedPushtag(String),
250 InvalidPopmeta(String),
252 UnclosedPushmeta(String),
254 DeprecatedPipeSymbol,
256 InvalidBookingMethod(String),
258 BomInDirectiveBody,
276}
277
278impl fmt::Display for ParseErrorKind {
279 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280 match self {
281 Self::UnexpectedChar(c) => write!(f, "syntax error: unexpected '{c}'"),
282 Self::UnexpectedEof => write!(f, "unexpected end of file"),
283 Self::Expected(what) => write!(f, "expected {what}"),
284 Self::InvalidDate(s) => write!(f, "invalid date '{s}'"),
285 Self::InvalidNumber(s) => write!(f, "invalid number '{s}'"),
286 Self::InvalidAccount(s) => write!(f, "Invalid account '{s}'"),
287 Self::InvalidCurrency(s) => write!(f, "invalid currency '{s}'"),
288 Self::UnclosedString => write!(f, "unclosed string literal"),
289 Self::InvalidEscape(c) => write!(f, "invalid escape sequence '\\{c}'"),
290 Self::MissingField(field) => write!(f, "missing required field: {field}"),
291 Self::IndentationError => write!(f, "indentation error"),
292 Self::SyntaxError(msg) => write!(f, "parse error: {msg}"),
293 Self::MissingNewline => write!(f, "syntax error: missing final newline"),
294 Self::MissingAccount => write!(f, "expected account name"),
295 Self::InvalidDateValue(msg) => write!(f, "invalid date: {msg}"),
296 Self::MissingAmount => write!(f, "expected amount in posting"),
297 Self::MissingCurrency => write!(f, "expected currency after number"),
298 Self::InvalidAccountFormat(s) => {
299 write!(f, "invalid account '{s}': must contain ':'")
300 }
301 Self::MissingDirective => write!(f, "expected directive after date"),
302 Self::InvalidPoptag(tag) => {
303 write!(f, "poptag attempted on tag '{tag}' which was never pushed")
304 }
305 Self::UnclosedPushtag(tag) => {
306 write!(f, "pushtag '{tag}' was never popped")
307 }
308 Self::InvalidPopmeta(key) => {
309 write!(f, "popmeta attempted on key '{key}' which was never pushed")
310 }
311 Self::UnclosedPushmeta(key) => {
312 write!(f, "pushmeta '{key}' was never popped")
313 }
314 Self::DeprecatedPipeSymbol => {
315 write!(f, "Pipe symbol is deprecated")
316 }
317 Self::InvalidBookingMethod(m) => {
318 write!(
319 f,
320 "invalid booking method '{m}': must be one of FIFO, STRICT, STRICT_WITH_SIZE, LIFO, HIFO, NONE, AVERAGE"
321 )
322 }
323 Self::BomInDirectiveBody => f.write_str(BOM_MIDFILE_DIAGNOSTIC),
324 }
325 }
326}
327
328const BOM_MIDFILE_DIAGNOSTIC: &str = concat!(
340 "Invalid token: UTF-8 BOM detected in directive body ",
341 "(only a leading BOM is permitted); ",
342 "did you concatenate two BOM-prefixed files or paste content with an embedded BOM?",
343);
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
364 fn every_kind_sample_covers_every_variant() {
365 fn variant_index(k: &ParseErrorKind) -> u32 {
369 match k {
370 ParseErrorKind::UnexpectedChar(_) => 0,
371 ParseErrorKind::UnexpectedEof => 1,
372 ParseErrorKind::Expected(_) => 2,
373 ParseErrorKind::InvalidDate(_) => 3,
374 ParseErrorKind::InvalidNumber(_) => 4,
375 ParseErrorKind::InvalidAccount(_) => 5,
376 ParseErrorKind::InvalidCurrency(_) => 6,
377 ParseErrorKind::UnclosedString => 7,
378 ParseErrorKind::InvalidEscape(_) => 8,
379 ParseErrorKind::MissingField(_) => 9,
380 ParseErrorKind::IndentationError => 10,
381 ParseErrorKind::SyntaxError(_) => 11,
382 ParseErrorKind::MissingNewline => 12,
383 ParseErrorKind::MissingAccount => 13,
384 ParseErrorKind::InvalidDateValue(_) => 14,
385 ParseErrorKind::MissingAmount => 15,
386 ParseErrorKind::MissingCurrency => 16,
387 ParseErrorKind::InvalidAccountFormat(_) => 17,
388 ParseErrorKind::MissingDirective => 18,
389 ParseErrorKind::InvalidPoptag(_) => 19,
390 ParseErrorKind::UnclosedPushtag(_) => 20,
391 ParseErrorKind::InvalidPopmeta(_) => 21,
392 ParseErrorKind::UnclosedPushmeta(_) => 22,
393 ParseErrorKind::DeprecatedPipeSymbol => 23,
394 ParseErrorKind::InvalidBookingMethod(_) => 24,
395 ParseErrorKind::BomInDirectiveBody => 25,
396 }
397 }
398 let samples = ParseError::every_kind_sample();
399 let indices: std::collections::BTreeSet<u32> = samples.iter().map(variant_index).collect();
400 assert_eq!(
401 indices.len(),
402 samples.len(),
403 "every_kind_sample has duplicate variants (collapsed by variant_index): \
404 samples = {samples:?}, unique indices = {indices:?}"
405 );
406 let max = indices.iter().max().copied().unwrap_or(0);
407 assert_eq!(
408 samples.len() as u32,
409 max + 1,
410 "every_kind_sample is missing variants: highest variant_index = {max}, \
411 expected {} entries in the vec, got {}. Add the missing constructor \
412 to every_kind_sample's vec.",
413 max + 1,
414 samples.len()
415 );
416 }
417
418 #[test]
419 fn test_parse_error_new() {
420 let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5));
421 assert_eq!(err.span(), (0, 5));
422 assert!(err.context.is_none());
423 assert!(err.hint.is_none());
424 }
425
426 #[test]
427 fn test_parse_error_with_context() {
428 let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
429 .with_context("in transaction");
430 assert_eq!(err.context, Some("in transaction".to_string()));
431 }
432
433 #[test]
434 fn test_parse_error_with_hint() {
435 let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
436 .with_hint("add more input");
437 assert_eq!(err.hint, Some("add more input".to_string()));
438 }
439
440 #[test]
441 fn test_parse_error_display_with_context() {
442 let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
443 .with_context("parsing header");
444 let display = format!("{err}");
445 assert!(display.contains("unexpected end of file"));
446 assert!(display.contains("parsing header"));
447 }
448
449 #[test]
450 fn test_kind_codes() {
451 let kinds = [
453 (ParseErrorKind::UnexpectedChar('x'), 1),
454 (ParseErrorKind::UnexpectedEof, 2),
455 (ParseErrorKind::Expected("foo".to_string()), 3),
456 (ParseErrorKind::InvalidDate("bad".to_string()), 4),
457 (ParseErrorKind::InvalidNumber("nan".to_string()), 5),
458 (ParseErrorKind::InvalidAccount("bad".to_string()), 6),
459 (ParseErrorKind::InvalidCurrency("???".to_string()), 7),
460 (ParseErrorKind::UnclosedString, 8),
461 (ParseErrorKind::InvalidEscape('n'), 9),
462 (ParseErrorKind::MissingField("name".to_string()), 10),
463 (ParseErrorKind::IndentationError, 11),
464 (ParseErrorKind::SyntaxError("oops".to_string()), 12),
465 (ParseErrorKind::MissingNewline, 13),
466 (ParseErrorKind::MissingAccount, 14),
467 (ParseErrorKind::InvalidDateValue("month 13".to_string()), 15),
468 (ParseErrorKind::MissingAmount, 16),
469 (ParseErrorKind::MissingCurrency, 17),
470 (
471 ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
472 18,
473 ),
474 (ParseErrorKind::MissingDirective, 19),
475 (ParseErrorKind::InvalidPoptag("bad".to_string()), 20),
476 (ParseErrorKind::UnclosedPushtag("tag".to_string()), 21),
477 (ParseErrorKind::InvalidPopmeta("key".to_string()), 22),
478 (ParseErrorKind::UnclosedPushmeta("key".to_string()), 23),
479 (ParseErrorKind::DeprecatedPipeSymbol, 24),
480 (ParseErrorKind::InvalidBookingMethod("BAD".to_string()), 25),
481 (ParseErrorKind::BomInDirectiveBody, 26),
482 ];
483
484 for (kind, expected_code) in kinds {
485 let err = ParseError::new(kind, Span::new(0, 1));
486 assert_eq!(err.kind_code(), expected_code);
487 }
488 }
489
490 #[test]
491 fn test_error_labels() {
492 let kinds = [
494 ParseErrorKind::UnexpectedChar('x'),
495 ParseErrorKind::UnexpectedEof,
496 ParseErrorKind::Expected("foo".to_string()),
497 ParseErrorKind::InvalidDate("bad".to_string()),
498 ParseErrorKind::InvalidNumber("nan".to_string()),
499 ParseErrorKind::InvalidAccount("bad".to_string()),
500 ParseErrorKind::InvalidCurrency("???".to_string()),
501 ParseErrorKind::UnclosedString,
502 ParseErrorKind::InvalidEscape('n'),
503 ParseErrorKind::MissingField("name".to_string()),
504 ParseErrorKind::IndentationError,
505 ParseErrorKind::SyntaxError("oops".to_string()),
506 ParseErrorKind::MissingNewline,
507 ParseErrorKind::MissingAccount,
508 ParseErrorKind::InvalidDateValue("month 13".to_string()),
509 ParseErrorKind::MissingAmount,
510 ParseErrorKind::MissingCurrency,
511 ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
512 ParseErrorKind::MissingDirective,
513 ParseErrorKind::InvalidPoptag("bad".to_string()),
514 ParseErrorKind::UnclosedPushtag("tag".to_string()),
515 ParseErrorKind::InvalidPopmeta("key".to_string()),
516 ParseErrorKind::UnclosedPushmeta("key".to_string()),
517 ParseErrorKind::DeprecatedPipeSymbol,
518 ParseErrorKind::InvalidBookingMethod("BAD".to_string()),
519 ParseErrorKind::BomInDirectiveBody,
520 ];
521
522 for kind in kinds {
523 let err = ParseError::new(kind, Span::new(0, 1));
524 assert!(!err.label().is_empty());
525 }
526 }
527
528 #[test]
529 fn test_error_messages() {
530 let test_cases = [
532 (ParseErrorKind::UnexpectedChar('$'), "unexpected '$'"),
533 (ParseErrorKind::UnexpectedEof, "unexpected end of file"),
534 (
535 ParseErrorKind::Expected("number".to_string()),
536 "expected number",
537 ),
538 (
539 ParseErrorKind::InvalidDate("2024-13-01".to_string()),
540 "invalid date '2024-13-01'",
541 ),
542 (
543 ParseErrorKind::InvalidNumber("abc".to_string()),
544 "invalid number 'abc'",
545 ),
546 (
547 ParseErrorKind::InvalidAccount("bad".to_string()),
548 "Invalid account 'bad'",
549 ),
550 (
551 ParseErrorKind::InvalidCurrency("???".to_string()),
552 "invalid currency '???'",
553 ),
554 (ParseErrorKind::UnclosedString, "unclosed string literal"),
555 (
556 ParseErrorKind::InvalidEscape('x'),
557 "invalid escape sequence '\\x'",
558 ),
559 (
560 ParseErrorKind::MissingField("date".to_string()),
561 "missing required field: date",
562 ),
563 (ParseErrorKind::IndentationError, "indentation error"),
564 (
565 ParseErrorKind::SyntaxError("bad token".to_string()),
566 "parse error: bad token",
567 ),
568 (ParseErrorKind::MissingNewline, "missing final newline"),
569 (ParseErrorKind::MissingAccount, "expected account name"),
570 (
571 ParseErrorKind::InvalidDateValue("month 13".to_string()),
572 "invalid date: month 13",
573 ),
574 (ParseErrorKind::MissingAmount, "expected amount in posting"),
575 (
576 ParseErrorKind::MissingCurrency,
577 "expected currency after number",
578 ),
579 (
580 ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
581 "must contain ':'",
582 ),
583 (
584 ParseErrorKind::MissingDirective,
585 "expected directive after date",
586 ),
587 (
588 ParseErrorKind::InvalidPoptag("bad".to_string()),
589 "poptag attempted on tag 'bad'",
590 ),
591 (
592 ParseErrorKind::UnclosedPushtag("tag".to_string()),
593 "pushtag 'tag' was never popped",
594 ),
595 (
596 ParseErrorKind::InvalidPopmeta("key".to_string()),
597 "popmeta attempted on key 'key'",
598 ),
599 (
600 ParseErrorKind::UnclosedPushmeta("key".to_string()),
601 "pushmeta 'key' was never popped",
602 ),
603 (
604 ParseErrorKind::DeprecatedPipeSymbol,
605 "Pipe symbol is deprecated",
606 ),
607 (
608 ParseErrorKind::InvalidBookingMethod("BAD".to_string()),
609 "invalid booking method 'BAD'",
610 ),
611 (
617 ParseErrorKind::BomInDirectiveBody,
618 "UTF-8 BOM detected in directive body",
619 ),
620 ];
621
622 for (kind, expected_substring) in test_cases {
623 let msg = format!("{kind}");
624 assert!(
625 msg.contains(expected_substring),
626 "Expected '{expected_substring}' in '{msg}'"
627 );
628 }
629 }
630
631 #[test]
632 fn test_parse_error_is_error_trait() {
633 let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 1));
634 let _: &dyn std::error::Error = &err;
636 }
637}