use crate::data::{ AccountName, DataError, Datestamp, Decimal, SourceFile, Span, Spanned, UnitName, spec::SpecRoot, }; use super::{Change, Transaction, LedgerEntry}; use chumsky::{prelude::*, text::inline_whitespace}; type InputWithContext<'a> = chumsky::input::WithContext; fn ledger_parser<'a>() -> impl Parser< 'a, InputWithContext<'a>, Vec, chumsky::extra::Full< chumsky::error::Rich<'a, char, Span>, chumsky::extra::SimpleState<&'a SpecRoot>, (), >, > { let int = chumsky::text::digits(10) .to_slice() .map(|v: &str| v.parse::().unwrap()); let datestamp = group((int, just('-').ignored(), int, just('-').ignored(), int)) .map(|(y, _, m, _, d)| Datestamp { year: y as u16, month: m as u8, day: d as u8 }); let mark = |m| just(m).padded_by(inline_whitespace()); let decimal_digit = one_of("0123456789.,"); let decimal_digits = decimal_digit .or(just(' ').repeated().ignore_then(decimal_digit)) .repeated(); let decimal = choice((just('-').ignored(), just('+').ignored(), empty())) .then(decimal_digits) .to_slice() .try_map(|s: &str, span| { Ok(Spanned::new( Decimal::from_str_exact(s.trim()).map_err(|e| { Rich::custom( span, format!("Failed to parse '{s}' as a decimal number: {e}"), ) })?, span, )) }); let change = group(( mark('-'), none_of(": \n\t").repeated().to_slice().map_with(|v, e| Spanned(AccountName::new(v), e.span())), mark(':'), decimal, choice(( mark('=').ignore_then(decimal).map(Some), empty().map(|_| None) )), choice(( inline_whitespace() .at_least(1) .ignore_then(chumsky::text::ident()) .then_ignore(inline_whitespace()) .map_with(|u, e| Some(Spanned(UnitName::new(u), e.span()))), inline_whitespace().map(|_| None), )) .then_ignore(chumsky::text::newline()), )) .try_map_with(|(_, acc, _, amount, balance, unit, ), e| { let span = e.span(); let spec: &mut chumsky::extra::SimpleState<&SpecRoot> = e.state(); let Some(acc_spec) = spec.accounts.get(acc.as_ref()) else { return Err(chumsky::error::Rich::custom(acc.span(), "no such account")); }; let unit = match (unit, acc_spec.unit) { (None, None) => { return Err(chumsky::error::Rich::custom(span, "account does not have a unit specified, so all transactions must specify units")) } (Some(unit), None) => unit, (None, Some(unit)) => Spanned(unit, amount.span().to_end()), (Some(unit1), Some(unit2)) => { if *unit1 != unit2 { return Err(chumsky::error::Rich::custom(span, "unit mismatch between account and transaction")) } else { unit1 } } }; if !spec.units.contains_key(&unit) { return Err(chumsky::error::Rich::custom(unit.span(), format!("no such unit '{unit}' found"))) } Ok(Spanned::new(Change { account: acc, amount, balance, unit, }, span)) }); let annotation = mark('[') .ignore_then(none_of("]\n\t").repeated().to_slice()) .then_ignore(mark(']')) .map(|v: &str| String::from(v.trim())); let transaction = group(( chumsky::text::whitespace(), datestamp, mark(':'), inline_whitespace(), chumsky::primitive::none_of("\n") .repeated() .collect::(), chumsky::text::newline(), choice(( annotation .repeated() .collect() .then_ignore(chumsky::text::newline()), empty().map(|_| vec![]), )), change.repeated().at_least(1).collect(), chumsky::text::whitespace(), )) .map_with( |(_, datestamp, _, _, title, _, annotations, changes, _), e| { LedgerEntry::Transaction(Spanned::new( Transaction { datestamp, title: (!title.is_empty()).then_some(title), annotations, changes, }, e.span(), )) }, ); let comment = mark('#').ignore_then(none_of("\n").repeated()).padded().to_slice().map_with(|s: &str, e| LedgerEntry::Comment(Spanned::new(s.into(), e.span()))); (transaction.or(comment)).repeated().collect() } pub fn parse_ledger( source: SourceFile, spec: &SpecRoot, data: &str, ) -> Result, DataError> { let parser = ledger_parser(); let (presult, errors) = parser .parse_with_state( data.with_context(source), &mut chumsky::extra::SimpleState(spec), ) .into_output_errors(); if let Some(e) = errors.first() { // let span = e.span().start()..e.span().end(); let span = *e.span(); let report = ariadne::Report::build(ariadne::ReportKind::Error, span) .with_label(ariadne::Label::new(span).with_message(e.reason())) .finish(); Err(report.into()) } else { Ok(presult.unwrap()) } }