use crate::Span;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
pub kind: ParseErrorKind,
pub span: Span,
pub context: Option<String>,
pub hint: Option<String>,
}
impl ParseError {
#[must_use]
pub const fn new(kind: ParseErrorKind, span: Span) -> Self {
Self {
kind,
span,
context: None,
hint: None,
}
}
#[must_use]
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
#[must_use]
pub const fn span(&self) -> (usize, usize) {
(self.span.start, self.span.end)
}
#[must_use]
pub const fn kind_code(&self) -> u32 {
match &self.kind {
ParseErrorKind::UnexpectedChar(_) => 1,
ParseErrorKind::UnexpectedEof => 2,
ParseErrorKind::Expected(_) => 3,
ParseErrorKind::InvalidDate(_) => 4,
ParseErrorKind::InvalidNumber(_) => 5,
ParseErrorKind::InvalidAccount(_) => 6,
ParseErrorKind::InvalidCurrency(_) => 7,
ParseErrorKind::UnclosedString => 8,
ParseErrorKind::InvalidEscape(_) => 9,
ParseErrorKind::MissingField(_) => 10,
ParseErrorKind::IndentationError => 11,
ParseErrorKind::SyntaxError(_) => 12,
ParseErrorKind::MissingNewline => 13,
ParseErrorKind::MissingAccount => 14,
ParseErrorKind::InvalidDateValue(_) => 15,
ParseErrorKind::MissingAmount => 16,
ParseErrorKind::MissingCurrency => 17,
ParseErrorKind::InvalidAccountFormat(_) => 18,
ParseErrorKind::MissingDirective => 19,
ParseErrorKind::InvalidPoptag(_) => 20,
ParseErrorKind::UnclosedPushtag(_) => 21,
ParseErrorKind::InvalidPopmeta(_) => 22,
ParseErrorKind::UnclosedPushmeta(_) => 23,
ParseErrorKind::DeprecatedPipeSymbol => 24,
ParseErrorKind::InvalidBookingMethod(_) => 25,
}
}
#[must_use]
pub fn message(&self) -> String {
format!("{}", self.kind)
}
#[must_use]
pub const fn label(&self) -> &str {
match &self.kind {
ParseErrorKind::UnexpectedChar(_) => "unexpected character",
ParseErrorKind::UnexpectedEof => "unexpected end of file",
ParseErrorKind::Expected(_) => "expected different token",
ParseErrorKind::InvalidDate(_) => "invalid date",
ParseErrorKind::InvalidNumber(_) => "invalid number",
ParseErrorKind::InvalidAccount(_) => "invalid account",
ParseErrorKind::InvalidCurrency(_) => "invalid currency",
ParseErrorKind::UnclosedString => "unclosed string",
ParseErrorKind::InvalidEscape(_) => "invalid escape",
ParseErrorKind::MissingField(_) => "missing field",
ParseErrorKind::IndentationError => "indentation error",
ParseErrorKind::SyntaxError(_) => "parse error",
ParseErrorKind::MissingNewline => "syntax error",
ParseErrorKind::MissingAccount => "expected account name",
ParseErrorKind::InvalidDateValue(_) => "invalid date value",
ParseErrorKind::MissingAmount => "expected amount",
ParseErrorKind::MissingCurrency => "expected currency",
ParseErrorKind::InvalidAccountFormat(_) => "invalid account format",
ParseErrorKind::MissingDirective => "expected directive",
ParseErrorKind::InvalidPoptag(_) => "invalid poptag",
ParseErrorKind::UnclosedPushtag(_) => "unclosed pushtag",
ParseErrorKind::InvalidPopmeta(_) => "invalid popmeta",
ParseErrorKind::UnclosedPushmeta(_) => "unclosed pushmeta",
ParseErrorKind::DeprecatedPipeSymbol => "deprecated pipe symbol",
ParseErrorKind::InvalidBookingMethod(_) => "invalid booking method",
}
}
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.kind)?;
if let Some(ctx) = &self.context {
write!(f, " ({ctx})")?;
}
Ok(())
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseErrorKind {
UnexpectedChar(char),
UnexpectedEof,
Expected(String),
InvalidDate(String),
InvalidNumber(String),
InvalidAccount(String),
InvalidCurrency(String),
UnclosedString,
InvalidEscape(char),
MissingField(String),
IndentationError,
SyntaxError(String),
MissingNewline,
MissingAccount,
InvalidDateValue(String),
MissingAmount,
MissingCurrency,
InvalidAccountFormat(String),
MissingDirective,
InvalidPoptag(String),
UnclosedPushtag(String),
InvalidPopmeta(String),
UnclosedPushmeta(String),
DeprecatedPipeSymbol,
InvalidBookingMethod(String),
}
impl fmt::Display for ParseErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnexpectedChar(c) => write!(f, "syntax error: unexpected '{c}'"),
Self::UnexpectedEof => write!(f, "unexpected end of file"),
Self::Expected(what) => write!(f, "expected {what}"),
Self::InvalidDate(s) => write!(f, "invalid date '{s}'"),
Self::InvalidNumber(s) => write!(f, "invalid number '{s}'"),
Self::InvalidAccount(s) => write!(f, "Invalid account '{s}'"),
Self::InvalidCurrency(s) => write!(f, "invalid currency '{s}'"),
Self::UnclosedString => write!(f, "unclosed string literal"),
Self::InvalidEscape(c) => write!(f, "invalid escape sequence '\\{c}'"),
Self::MissingField(field) => write!(f, "missing required field: {field}"),
Self::IndentationError => write!(f, "indentation error"),
Self::SyntaxError(msg) => write!(f, "parse error: {msg}"),
Self::MissingNewline => write!(f, "syntax error: missing final newline"),
Self::MissingAccount => write!(f, "expected account name"),
Self::InvalidDateValue(msg) => write!(f, "invalid date: {msg}"),
Self::MissingAmount => write!(f, "expected amount in posting"),
Self::MissingCurrency => write!(f, "expected currency after number"),
Self::InvalidAccountFormat(s) => {
write!(f, "invalid account '{s}': must contain ':'")
}
Self::MissingDirective => write!(f, "expected directive after date"),
Self::InvalidPoptag(tag) => {
write!(f, "poptag attempted on tag '{tag}' which was never pushed")
}
Self::UnclosedPushtag(tag) => {
write!(f, "pushtag '{tag}' was never popped")
}
Self::InvalidPopmeta(key) => {
write!(f, "popmeta attempted on key '{key}' which was never pushed")
}
Self::UnclosedPushmeta(key) => {
write!(f, "pushmeta '{key}' was never popped")
}
Self::DeprecatedPipeSymbol => {
write!(f, "Pipe symbol is deprecated")
}
Self::InvalidBookingMethod(m) => {
write!(
f,
"invalid booking method '{m}': must be one of FIFO, STRICT, STRICT_WITH_SIZE, LIFO, HIFO, NONE, AVERAGE"
)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_error_new() {
let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5));
assert_eq!(err.span(), (0, 5));
assert!(err.context.is_none());
assert!(err.hint.is_none());
}
#[test]
fn test_parse_error_with_context() {
let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
.with_context("in transaction");
assert_eq!(err.context, Some("in transaction".to_string()));
}
#[test]
fn test_parse_error_with_hint() {
let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
.with_hint("add more input");
assert_eq!(err.hint, Some("add more input".to_string()));
}
#[test]
fn test_parse_error_display_with_context() {
let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
.with_context("parsing header");
let display = format!("{err}");
assert!(display.contains("unexpected end of file"));
assert!(display.contains("parsing header"));
}
#[test]
fn test_kind_codes() {
let kinds = [
(ParseErrorKind::UnexpectedChar('x'), 1),
(ParseErrorKind::UnexpectedEof, 2),
(ParseErrorKind::Expected("foo".to_string()), 3),
(ParseErrorKind::InvalidDate("bad".to_string()), 4),
(ParseErrorKind::InvalidNumber("nan".to_string()), 5),
(ParseErrorKind::InvalidAccount("bad".to_string()), 6),
(ParseErrorKind::InvalidCurrency("???".to_string()), 7),
(ParseErrorKind::UnclosedString, 8),
(ParseErrorKind::InvalidEscape('n'), 9),
(ParseErrorKind::MissingField("name".to_string()), 10),
(ParseErrorKind::IndentationError, 11),
(ParseErrorKind::SyntaxError("oops".to_string()), 12),
(ParseErrorKind::MissingNewline, 13),
(ParseErrorKind::MissingAccount, 14),
(ParseErrorKind::InvalidDateValue("month 13".to_string()), 15),
(ParseErrorKind::MissingAmount, 16),
(ParseErrorKind::MissingCurrency, 17),
(
ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
18,
),
(ParseErrorKind::MissingDirective, 19),
(ParseErrorKind::InvalidPoptag("bad".to_string()), 20),
(ParseErrorKind::UnclosedPushtag("tag".to_string()), 21),
(ParseErrorKind::InvalidPopmeta("key".to_string()), 22),
(ParseErrorKind::UnclosedPushmeta("key".to_string()), 23),
(ParseErrorKind::DeprecatedPipeSymbol, 24),
(ParseErrorKind::InvalidBookingMethod("BAD".to_string()), 25),
];
for (kind, expected_code) in kinds {
let err = ParseError::new(kind, Span::new(0, 1));
assert_eq!(err.kind_code(), expected_code);
}
}
#[test]
fn test_error_labels() {
let kinds = [
ParseErrorKind::UnexpectedChar('x'),
ParseErrorKind::UnexpectedEof,
ParseErrorKind::Expected("foo".to_string()),
ParseErrorKind::InvalidDate("bad".to_string()),
ParseErrorKind::InvalidNumber("nan".to_string()),
ParseErrorKind::InvalidAccount("bad".to_string()),
ParseErrorKind::InvalidCurrency("???".to_string()),
ParseErrorKind::UnclosedString,
ParseErrorKind::InvalidEscape('n'),
ParseErrorKind::MissingField("name".to_string()),
ParseErrorKind::IndentationError,
ParseErrorKind::SyntaxError("oops".to_string()),
ParseErrorKind::MissingNewline,
ParseErrorKind::MissingAccount,
ParseErrorKind::InvalidDateValue("month 13".to_string()),
ParseErrorKind::MissingAmount,
ParseErrorKind::MissingCurrency,
ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
ParseErrorKind::MissingDirective,
ParseErrorKind::InvalidPoptag("bad".to_string()),
ParseErrorKind::UnclosedPushtag("tag".to_string()),
ParseErrorKind::InvalidPopmeta("key".to_string()),
ParseErrorKind::UnclosedPushmeta("key".to_string()),
ParseErrorKind::DeprecatedPipeSymbol,
ParseErrorKind::InvalidBookingMethod("BAD".to_string()),
];
for kind in kinds {
let err = ParseError::new(kind, Span::new(0, 1));
assert!(!err.label().is_empty());
}
}
#[test]
fn test_error_messages() {
let test_cases = [
(ParseErrorKind::UnexpectedChar('$'), "unexpected '$'"),
(ParseErrorKind::UnexpectedEof, "unexpected end of file"),
(
ParseErrorKind::Expected("number".to_string()),
"expected number",
),
(
ParseErrorKind::InvalidDate("2024-13-01".to_string()),
"invalid date '2024-13-01'",
),
(
ParseErrorKind::InvalidNumber("abc".to_string()),
"invalid number 'abc'",
),
(
ParseErrorKind::InvalidAccount("bad".to_string()),
"Invalid account 'bad'",
),
(
ParseErrorKind::InvalidCurrency("???".to_string()),
"invalid currency '???'",
),
(ParseErrorKind::UnclosedString, "unclosed string literal"),
(
ParseErrorKind::InvalidEscape('x'),
"invalid escape sequence '\\x'",
),
(
ParseErrorKind::MissingField("date".to_string()),
"missing required field: date",
),
(ParseErrorKind::IndentationError, "indentation error"),
(
ParseErrorKind::SyntaxError("bad token".to_string()),
"parse error: bad token",
),
(ParseErrorKind::MissingNewline, "missing final newline"),
(ParseErrorKind::MissingAccount, "expected account name"),
(
ParseErrorKind::InvalidDateValue("month 13".to_string()),
"invalid date: month 13",
),
(ParseErrorKind::MissingAmount, "expected amount in posting"),
(
ParseErrorKind::MissingCurrency,
"expected currency after number",
),
(
ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
"must contain ':'",
),
(
ParseErrorKind::MissingDirective,
"expected directive after date",
),
(
ParseErrorKind::InvalidPoptag("bad".to_string()),
"poptag attempted on tag 'bad'",
),
(
ParseErrorKind::UnclosedPushtag("tag".to_string()),
"pushtag 'tag' was never popped",
),
(
ParseErrorKind::InvalidPopmeta("key".to_string()),
"popmeta attempted on key 'key'",
),
(
ParseErrorKind::UnclosedPushmeta("key".to_string()),
"pushmeta 'key' was never popped",
),
(
ParseErrorKind::DeprecatedPipeSymbol,
"Pipe symbol is deprecated",
),
(
ParseErrorKind::InvalidBookingMethod("BAD".to_string()),
"invalid booking method 'BAD'",
),
];
for (kind, expected_substring) in test_cases {
let msg = format!("{kind}");
assert!(
msg.contains(expected_substring),
"Expected '{expected_substring}' in '{msg}'"
);
}
}
#[test]
fn test_parse_error_is_error_trait() {
let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 1));
let _: &dyn std::error::Error = &err;
}
}