use crate::ast::*;
use pest::Parser as _;
use pest::iterators::{Pair, Pairs};
use pest::pratt_parser::PrattParser;
use pest_derive::Parser;
use rust_decimal::Decimal;
use std::path::PathBuf;
use std::sync::LazyLock;
#[derive(Parser)]
#[grammar = "grammars/ledger/ledger.pest"]
pub(crate) struct LedgerParser;
pub struct Parser<F: Fn(&str) -> Result<String, Box<dyn std::error::Error>>> {
pub opener: F,
pub base_path: PathBuf,
}
impl<F: Fn(&str) -> Result<String, Box<dyn std::error::Error>>> Parser<F> {
pub fn parse(&mut self, input: &str) -> Result<Journal, Box<dyn std::error::Error>> {
let pairs = LedgerParser::parse(Rule::journal, input)?;
let mut entries = Vec::new();
for pair in pairs.into_iter().next().unwrap().into_inner() {
match pair.as_rule() {
Rule::transaction => {
entries.push(Entry::Transaction(parse_transaction(pair)?));
}
Rule::comment_line => {
entries.push(Entry::Comment(pair.as_str().to_string()));
}
Rule::commodity_directive => {
entries.push(Entry::Directive(parse_commodity_directive(pair)));
}
Rule::account_directive => {
entries.push(Entry::Directive(parse_account_directive(pair)));
}
Rule::alias_directive => {
entries.push(Entry::Directive(parse_alias_directive(pair)));
}
Rule::define_directive => {
entries.push(Entry::Directive(parse_define_directive(pair)));
}
Rule::tag_directive => {
entries.push(Entry::Directive(parse_tag_directive(pair)));
}
Rule::default_directive => {
entries.push(Entry::Directive(parse_default_directive(pair)?));
}
Rule::historical_price => {
entries.push(Entry::HistoricalPrice(parse_historical_price(pair)));
}
Rule::assertion_directive => {
entries.push(Entry::Assertion(parse_assertion_directive(pair)));
}
Rule::include_directive => {
let include_path = self.base_path.join(pair.into_inner().as_str());
let new_input = (self.opener)(include_path.as_os_str().to_str().unwrap())?;
let new_base_path = include_path
.parent()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| self.base_path.clone());
let old_base_path = std::mem::replace(&mut self.base_path, new_base_path);
entries.append(&mut self.parse(&new_input)?.entries);
let _ = std::mem::replace(&mut self.base_path, old_base_path);
}
Rule::budget => {
}
_ => {}
}
}
let journal = Journal { entries };
validate_regexes(&journal)?;
Ok(journal)
}
}
fn validate_regexes(journal: &Journal) -> Result<(), Box<dyn std::error::Error>> {
for entry in &journal.entries {
match entry {
Entry::Directive(Directive::Account { items, .. }) => {
for item in items {
match item {
AccountItem::Assert(e) | AccountItem::Check(e) => {
validate_bool_expr_regexes(e)?;
}
_ => {}
}
}
}
Entry::Directive(Directive::Tag {
asserts, checks, ..
}) => {
for e in asserts.iter().chain(checks.iter()) {
validate_bool_expr_regexes(e)?;
}
}
_ => {}
}
}
Ok(())
}
fn validate_bool_expr_regexes(expr: &BoolExpr) -> Result<(), Box<dyn std::error::Error>> {
if let Some((_, ValueExpr::Regex(pattern))) = &expr.cmp {
regex::Regex::new(pattern).map_err(|e| format!("invalid regex /{pattern}/: {e}"))?;
}
if let Some((_, cont)) = &expr.chain {
validate_bool_expr_regexes(cont)?;
}
Ok(())
}
#[cfg(test)]
pub(crate) fn parse_ledger(input: &str) -> Result<Journal, Box<dyn std::error::Error>> {
Parser {
opener: |_| Ok(String::new()),
base_path: PathBuf::new(),
}
.parse(input)
}
pub struct LedgerFrontend;
impl crate::frontend::Frontend for LedgerFrontend {
fn extensions(&self) -> &'static [&'static str] {
&["ledger"]
}
fn parse(
&self,
input: &str,
base_path: &std::path::Path,
opener: &crate::frontend::Opener,
) -> Result<crate::resolution::HIR, Box<dyn std::error::Error>> {
let ast_journal = Parser {
opener: |path: &str| opener(path),
base_path: base_path.to_path_buf(),
}
.parse(input)?;
Ok(ast_journal.try_into()?)
}
}
fn parse_assertion_directive(pair: Pair<Rule>) -> AssertionDirective {
let mut inner = pair.into_inner();
let date = parse_date(&mut inner.next().unwrap().into_inner());
let op_pair = inner.next().unwrap();
let strict = op_pair.as_str() == "==";
let account = inner.next().unwrap().as_str().trim().to_string();
let amount = parse_expr(inner.next().unwrap());
AssertionDirective {
date,
account,
amount,
strict,
}
}
fn parse_historical_price(pair: Pair<Rule>) -> HistoricalPrice {
let mut inner = pair.into_inner();
let date = parse_date(&mut inner.next().unwrap().into_inner());
let mut time = None;
let mut commodity = String::new();
let mut price_pair = None;
for p in inner {
match p.as_rule() {
Rule::time => time = Some(p.as_str().to_string()),
Rule::commodity => commodity = p.as_str().to_string(),
Rule::value_expr => price_pair = Some(p),
_ => {}
}
}
HistoricalPrice {
date,
time,
commodity,
price: parse_expr(price_pair.expect("historical_price must have a price")),
}
}
fn parse_alias_directive(pair: Pair<Rule>) -> Directive {
let mut pairs = pair.into_inner();
let alias = pairs.next().unwrap().as_str().trim().to_string();
let account = pairs.next().unwrap().as_str().trim().to_string();
Directive::Alias { alias, account }
}
fn parse_define_directive(pair: Pair<Rule>) -> Directive {
let mut pairs = pair.into_inner().peekable();
let name = pairs.next().unwrap().as_str().to_string();
let mut params = Vec::new();
let mut body_pair = None;
for p in pairs {
match p.as_rule() {
Rule::identifier => params.push(p.as_str().to_string()),
Rule::define_body => {
body_pair = Some(p);
}
_ => {}
}
}
let body_pair = body_pair.expect("define_directive must have a define_body");
let inner = body_pair
.into_inner()
.next()
.expect("define_body must have one child");
let body = match inner.as_rule() {
Rule::bool_expr => {
let bool_expr = parse_bool_expr(inner);
if bool_expr.cmp.is_none() && bool_expr.chain.is_none() {
DefineBody::Value(bool_expr.lhs)
} else {
DefineBody::Bool(bool_expr)
}
}
Rule::value_expr => DefineBody::Value(parse_expr(inner)),
r => unreachable!("unexpected rule in define_body: {r:?}"),
};
Directive::Define { name, params, body }
}
fn parse_account_directive(pair: Pair<Rule>) -> Directive {
let mut inner = pair.into_inner();
let name = inner.next().unwrap().as_str().to_string();
let mut notes = Vec::new();
let mut items = Vec::new();
for p in inner {
match p.as_rule() {
Rule::note => notes.push(p.into_inner().as_str().trim().to_string()),
Rule::account_item => {
items.push(parse_account_item(p));
}
Rule::account_assert => {
let expr = parse_bool_expr(p.into_inner().next().unwrap());
items.push(AccountItem::Assert(expr));
}
Rule::account_check => {
let expr = parse_bool_expr(p.into_inner().next().unwrap());
items.push(AccountItem::Check(expr));
}
_ => {}
}
}
Directive::Account { name, notes, items }
}
fn parse_tag_directive(pair: Pair<Rule>) -> Directive {
let mut inner = pair.into_inner();
let name = inner.next().unwrap().as_str().to_string();
let mut asserts = Vec::new();
let mut checks = Vec::new();
for p in inner {
match p.as_rule() {
Rule::note => {
}
Rule::tag_assert => {
let expr = parse_bool_expr(p.into_inner().next().unwrap());
asserts.push(expr);
}
Rule::tag_check => {
let expr = parse_bool_expr(p.into_inner().next().unwrap());
checks.push(expr);
}
_ => {}
}
}
Directive::Tag {
name,
asserts,
checks,
}
}
fn parse_commodity_directive(pair: Pair<Rule>) -> Directive {
let mut inner = pair.into_inner();
let name = inner.next().unwrap().as_str().to_string();
let mut notes = Vec::new();
let mut items = Vec::new();
for p in inner {
match p.as_rule() {
Rule::note => notes.push(p.into_inner().as_str().trim().to_string()),
Rule::commodity_item => {
items.push(parse_commodity_item(p));
}
_ => {}
}
}
Directive::Commodity { name, notes, items }
}
fn parse_default_directive(pair: Pair<Rule>) -> Result<Directive, Box<dyn std::error::Error>> {
let value_expr_pair = pair
.into_inner()
.next()
.expect("default_directive must contain a value_expr");
let format_str = value_expr_pair.as_str().trim().to_string();
let parsed = parse_expr(value_expr_pair);
let commodity = match &parsed {
ValueExpr::Amount {
commodity: Some(c), ..
} => c.clone(),
_ => {
return Err(format!(
"bare `D` directive requires an amount with an explicit commodity symbol \
(e.g. `D $1000.00`); got `{format_str}` which carries no commodity"
)
.into());
}
};
Ok(Directive::Commodity {
name: commodity,
notes: vec![],
items: vec![CommodityItem::Default, CommodityItem::Format(format_str)],
})
}
fn parse_bool_expr(pair: Pair<Rule>) -> BoolExpr {
let mut inner = pair.into_inner().peekable();
let lhs = parse_expr(inner.next().expect("bool_expr must have lhs"));
let cmp = match inner.peek().map(|p| p.as_rule()) {
Some(Rule::cmp_op) => {
let op_pair = inner.next().unwrap();
let op = match op_pair.as_str() {
"==" => CmpOp::Eq,
"!=" => CmpOp::Ne,
"<=" => CmpOp::Le,
">=" => CmpOp::Ge,
"<" => CmpOp::Lt,
">" => CmpOp::Gt,
_ => unreachable!("unknown cmp_op: {}", op_pair.as_str()),
};
let rhs = parse_expr(inner.next().expect("cmp_op must be followed by rhs"));
Some((op, rhs))
}
Some(Rule::regex_cmp_op) => {
let op_pair = inner.next().unwrap();
let op = match op_pair.as_str() {
"=~" => CmpOp::RegexMatch,
"!~" => CmpOp::RegexNotMatch,
_ => unreachable!("unknown regex_cmp_op: {}", op_pair.as_str()),
};
let regex_pair = inner
.next()
.expect("regex_cmp_op must be followed by regex_literal");
let pattern = regex_pair
.into_inner()
.next()
.expect("regex_literal must have regex_body")
.as_str()
.to_string();
Some((op, ValueExpr::Regex(pattern)))
}
_ => None,
};
let chain = if inner.peek().map(|p| p.as_rule()) == Some(Rule::bool_op) {
let op_pair = inner.next().unwrap();
let op = match op_pair.as_str() {
"and" => BoolOp::And,
"or" => BoolOp::Or,
_ => unreachable!("unknown bool_op: {}", op_pair.as_str()),
};
let cont = parse_bool_expr(inner.next().expect("bool_op must be followed by bool_expr"));
Some((op, Box::new(cont)))
} else {
None
};
BoolExpr { lhs, cmp, chain }
}
fn parse_account_item(pair: Pair<Rule>) -> AccountItem {
let mut inner = pair.into_inner();
let key_pair = inner.next().unwrap();
let key = key_pair.as_str();
let mut val = None;
for p in inner {
if p.as_rule() == Rule::account_val {
val = Some(p.as_str().trim().to_string())
}
}
match key {
"alias" => AccountItem::Alias(val.unwrap_or_default()),
"note" => AccountItem::Note(val.unwrap_or_default()),
_ => AccountItem::Unknown(key.to_string(), val),
}
}
fn parse_commodity_item(pair: Pair<Rule>) -> CommodityItem {
let mut inner = pair.into_inner();
let key_pair = inner.next().unwrap();
let key = key_pair.as_str();
let mut val = None;
for p in inner {
if p.as_rule() == Rule::commodity_val {
val = Some(p.as_str().trim().to_string())
}
}
match key {
"alias" => CommodityItem::Alias(val.unwrap_or_default()),
"format" => CommodityItem::Format(val.unwrap_or_default()),
"nomarket" => CommodityItem::NoMarket,
"default" => CommodityItem::Default,
"note" => CommodityItem::Note(val.unwrap_or_default()),
_ => CommodityItem::Unknown(key.to_string(), val),
}
}
fn parse_date(pairs: &mut Pairs<Rule>) -> Date {
let mut year: Option<i32> = None;
let mut p = pairs.next().unwrap();
if let Rule::year = p.as_rule() {
year = Some(p.as_str().parse().unwrap());
p = pairs.next().unwrap();
}
let month = p.as_str().parse().unwrap();
let date = pairs.next().unwrap().as_str().parse().unwrap();
Date { year, month, date }
}
fn parse_transaction(pair: Pair<Rule>) -> Result<Transaction, Box<dyn std::error::Error>> {
let mut inner = pair.into_inner();
let header_pair = inner.next().unwrap();
let mut postings = Vec::new();
let mut notes = Vec::new();
for p in inner {
match p.as_rule() {
Rule::transaction_note => {
if let Some(note_pair) = p.into_inner().next() {
notes.push(note_pair.into_inner().as_str().trim().to_string());
}
}
Rule::posting => {
postings.push(parse_posting(p)?);
}
_ => {}
}
}
let mut header = header_pair.into_inner();
let date = parse_date(&mut header.next().unwrap().into_inner());
let mut secondary_date = None;
let mut state = TransactionState::Uncleared;
let mut code = None;
let mut description = String::new();
for p in header {
match p.as_rule() {
Rule::date => secondary_date = Some(parse_date(&mut p.into_inner())),
Rule::state => state = parse_state(p.as_str()),
Rule::code => {
let s = p.as_str();
code = Some(s[1..s.len() - 1].to_string());
}
Rule::description => description = p.as_str().trim().to_string(),
Rule::note => notes.push(p.as_str().trim().to_string()),
_ => {}
}
}
Ok(Transaction {
date,
secondary_date,
state,
code,
description,
notes,
postings,
})
}
fn parse_posting(pair: Pair<Rule>) -> Result<Posting, Box<dyn std::error::Error>> {
let inner = pair.into_inner();
let mut state = TransactionState::Uncleared;
let mut account = String::new();
let mut kind = PostingKind::Real;
let mut amount = None;
let mut notes = Vec::new();
for p in inner {
match p.as_rule() {
Rule::state => state = parse_state(p.as_str()),
Rule::posting_account => {
let inner_pair = p
.into_inner()
.next()
.expect("posting_account must have one child");
match inner_pair.as_rule() {
Rule::virtual_unbalanced_account => {
kind = PostingKind::VirtualUnbalanced;
account = inner_pair
.into_inner()
.next()
.expect("virtual_unbalanced_account must have virtual_account_inner")
.as_str()
.trim()
.to_string();
}
Rule::virtual_balanced_account => {
kind = PostingKind::VirtualBalanced;
account = inner_pair
.into_inner()
.next()
.expect("virtual_balanced_account must have virtual_account_inner")
.as_str()
.trim()
.to_string();
}
Rule::account => {
account = inner_pair.as_str().trim().to_string();
}
_ => {}
}
}
Rule::amount_logic => amount = Some(parse_amount_logic(p)?),
Rule::note => notes.push(p.as_str().trim().to_string()),
Rule::posting_note => {
if let Some(note_pair) = p.into_inner().next() {
notes.push(note_pair.into_inner().as_str().trim().to_string());
}
}
_ => {}
}
}
Ok(Posting {
account,
amount,
state,
notes,
kind,
})
}
fn parse_amount_logic(pair: Pair<Rule>) -> Result<AmountDetails, Box<dyn std::error::Error>> {
let p = pair.into_inner().next().unwrap();
match p.as_rule() {
Rule::value_logic => {
let inner = p.into_inner();
let mut value = None;
let mut lot_annotation: LotAnnotation = LotAnnotation::default();
let mut has_lot_annotation = false;
let mut lot_pricing = None;
let mut balance_assertion = None;
for p in inner {
match p.as_rule() {
Rule::value_expr => {
value = Some(parse_expr(p));
}
Rule::lot_annotation_or_price => {
let child = p.into_inner().next().unwrap();
match child.as_rule() {
Rule::lot_price => {
let s = child.as_str();
let inner_val = parse_expr(child.into_inner().next().unwrap());
if s.starts_with("@@") {
lot_pricing = Some(LotPricing::Total(inner_val));
} else {
lot_pricing = Some(LotPricing::Unit(inner_val));
}
}
Rule::lot_annotation => {
has_lot_annotation = true;
parse_lot_annotation_into(child, &mut lot_annotation)?;
}
_ => unreachable!(),
}
}
Rule::assertion => {
let inner_expr_pair = p.into_inner().next().unwrap();
balance_assertion = Some(parse_expr(inner_expr_pair));
}
_ => unreachable!(),
}
}
Ok(AmountDetails::Amount {
value: value.unwrap(),
lot_annotation: has_lot_annotation.then_some(lot_annotation),
lot_pricing,
balance_assertion,
})
}
Rule::assertion => {
let inner_expr_pair = p.into_inner().next().unwrap();
Ok(AmountDetails::BalanceAssignment(parse_expr(
inner_expr_pair,
)))
}
_ => unreachable!(),
}
}
fn parse_lot_annotation_into(
pair: Pair<Rule>,
acc: &mut LotAnnotation,
) -> Result<(), Box<dyn std::error::Error>> {
let child = pair.into_inner().next().unwrap();
match child.as_rule() {
Rule::lot_cost => {
if child.as_str().starts_with("{{") {
return Err(
"double-brace `{{total}}` lot syntax is not yet implemented; \
use `{cost}` for per-unit cost or `@@ total` for transient total cost"
.into(),
);
}
let expr_pair = child.into_inner().next().unwrap();
acc.cost = Some(parse_expr(expr_pair));
}
Rule::lot_date => {
let date_pair = child.into_inner().next().unwrap();
let date = parse_date_pair(date_pair);
if let (Some(year), month, day) = (date.year, date.month, date.date) {
acc.date = chrono::NaiveDate::from_ymd_opt(year, month, day);
}
}
Rule::lot_note => {
let note_str = child.into_inner().next().unwrap().as_str().to_string();
acc.note = Some(note_str);
}
_ => unreachable!(),
}
Ok(())
}
fn parse_date_pair(pair: Pair<Rule>) -> Date {
let mut inner = pair.into_inner();
let year: i32 = inner.next().unwrap().as_str().parse().unwrap();
let month: u32 = inner.next().unwrap().as_str().parse().unwrap();
let day: u32 = inner.next().unwrap().as_str().parse().unwrap();
Date {
year: Some(year),
month,
date: day,
}
}
static PRATT_PARSER: LazyLock<PrattParser<Rule>> = LazyLock::new(|| {
use Rule::*;
use pest::pratt_parser::{Assoc::*, Op};
PrattParser::new()
.op(Op::infix(add, Left) | Op::infix(sub, Left))
.op(Op::infix(mul, Left) | Op::infix(div, Left))
.op(Op::prefix(prefix_op))
});
pub(crate) fn parse_expr(pair: Pair<Rule>) -> ValueExpr {
let mut inner = pair.into_inner();
let expr_pair = inner.next().expect("Empty value_expr");
let mut ast = run_pratt(expr_pair.into_inner());
if let Some(comm_pair) = inner.next() {
ast = ValueExpr::Typed {
expr: Box::new(ast),
commodity: comm_pair.as_str().to_string(),
};
}
ast
}
fn run_pratt(pairs: pest::iterators::Pairs<Rule>) -> ValueExpr {
PRATT_PARSER
.map_primary(|pair| match pair.as_rule() {
Rule::term => run_pratt(pair.into_inner()),
Rule::primary => {
let mut inner = pair.into_inner();
let base_pair = inner.next().expect("Primary must have a base");
let mut ast = run_pratt(pest::iterators::Pairs::single(base_pair));
for access in inner {
if access.as_rule() == Rule::access {
let field = access.into_inner().next().unwrap().as_str().to_string();
ast = ValueExpr::Access {
expr: Box::new(ast),
field,
};
}
}
ast
}
Rule::amount => {
let mut inner = pair.into_inner();
let first = inner.next().unwrap();
match first.as_rule() {
Rule::commodity => {
let comm = first.as_str().to_string();
let val_str = inner.next().unwrap().as_str();
ValueExpr::Amount {
value: clean_parse_decimal(val_str),
commodity: Some(comm),
}
}
Rule::number => {
let val = clean_parse_decimal(first.as_str());
let comm = inner.next().map(|c| c.as_str().to_string());
ValueExpr::Amount {
value: val,
commodity: comm,
}
}
_ => unreachable!(),
}
}
Rule::commodity => ValueExpr::Commodity(pair.as_str().to_string()),
Rule::function_call => {
let mut inner = pair.into_inner();
let name = inner.next().unwrap().as_str().to_string();
let args = inner.map(|p| run_pratt(p.into_inner())).collect();
ValueExpr::Function { name, args }
}
Rule::expr => run_pratt(pair.into_inner()),
Rule::string => {
let s = pair.as_str();
ValueExpr::Str(s[1..s.len() - 1].to_string())
}
Rule::bool_expr => ValueExpr::Group(Box::new(parse_bool_expr(pair))),
_ => unreachable!("{:?}", pair.as_rule()),
})
.map_prefix(|op, expr| ValueExpr::Unary {
op: if op.as_str() == "-" { Op::Sub } else { Op::Add },
expr: Box::new(expr),
})
.map_infix(|lhs, op, rhs| {
let op = match op.as_rule() {
Rule::add => Op::Add,
Rule::sub => Op::Sub,
Rule::mul => Op::Mul,
Rule::div => Op::Div,
_ => unreachable!(),
};
ValueExpr::Binary {
lhs: Box::new(lhs),
rhs: Box::new(rhs),
op,
}
})
.parse(pairs)
}
fn clean_parse_decimal(s: &str) -> Decimal {
let cleaned = s.replace(',', "");
cleaned.parse().unwrap_or(Decimal::ZERO)
}
fn parse_state(s: &str) -> TransactionState {
match s {
"*" => TransactionState::Cleared,
"!" => TransactionState::Pending,
_ => TransactionState::Uncleared,
}
}
#[cfg(test)]
mod tests {
use rust_decimal::dec;
use super::*;
#[test]
fn test_simple_transaction() {
let input =
"2023-01-01 * (123) Grocery Store\n Expenses:Food $50.00\n Assets:Checking\n";
let journal = parse_ledger(input).unwrap();
assert_eq!(journal.entries.len(), 1);
if let Entry::Transaction(tx) = &journal.entries[0] {
assert_eq!(tx.description, "Grocery Store");
assert_eq!(tx.code, Some("123".to_string()));
assert!(matches!(tx.state, TransactionState::Cleared));
assert_eq!(tx.postings.len(), 2);
assert_eq!(tx.postings[0].account, "Expenses:Food");
assert_eq!(
tx.postings[0].amount,
Some(AmountDetails::Amount {
value: ValueExpr::Amount {
value: dec!(50.00),
commodity: Some("$".into()),
},
lot_annotation: None,
lot_pricing: None,
balance_assertion: None,
})
);
assert_eq!(tx.postings[1].account, "Assets:Checking");
assert!(tx.postings[1].amount.is_none());
} else {
panic!("Expected a transaction");
}
}
#[test]
fn test_lot_and_assertion() {
let input = "2023-01-01 * Stock Purchase\n Assets:Brokerage 10 AAPL @ $150.00 = $1500.00\n Assets:Checking\n";
let journal = parse_ledger(input).expect("Should parse successfully");
if let Entry::Transaction(ref tx) = journal.entries[0] {
let p = &tx.postings[0];
let details = p.amount.as_ref().expect("Should have amount details");
assert_eq!(
details,
&AmountDetails::Amount {
value: ValueExpr::Amount {
value: dec!(10),
commodity: Some("AAPL".into()),
},
lot_annotation: None,
lot_pricing: Some(LotPricing::Unit(ValueExpr::Amount {
commodity: Some("$".into()),
value: dec!(150.00)
})),
balance_assertion: Some(ValueExpr::Amount {
value: dec!(1500.00),
commodity: Some("$".to_string()),
})
}
);
}
}
#[test]
fn test_notes_and_comments() {
let input = "
; Top level comment
2023-01-01 Transaction with notes
; Header note
Expenses:Rent $1000
; Posting note
Assets:Checking
";
let journal = parse_ledger(input).unwrap();
let tx = journal
.entries
.iter()
.find_map(|e| {
if let Entry::Transaction(t) = e {
Some(t)
} else {
None
}
})
.expect("Transaction not found");
assert_eq!(tx.notes[0], "Header note");
assert_eq!(tx.postings[0].notes[0], "Posting note");
}
#[test]
fn test_invalid_date() {
let input = "23-01-01 * Missing Year Century\n Expenses:Food $10.00\n Assets:Cash\n";
let result = parse_ledger(input);
assert!(result.is_err(), "Should fail due to strict date format");
}
#[test]
fn test_complex_math_and_commas() {
let input = "2023-01-01 * Math Test
Expenses:Food (1,000.00 + 200) * 2 USD
Assets:Cash $-1,234.56
";
let journal = parse_ledger(input).unwrap();
let tx = match &journal.entries[0] {
Entry::Transaction(t) => t,
_ => panic!("Expected transaction"),
};
let p1 = &tx.postings[0];
if let Some(details) = &p1.amount {
assert!(matches!(
details,
AmountDetails::Amount {
value: ValueExpr::Binary { .. },
..
}
));
}
let p2 = &tx.postings[1];
if let Some(details) = &p2.amount {
assert!(matches!(
details,
AmountDetails::Amount {
value: ValueExpr::Binary { .. },
..
}
));
}
}
#[test]
fn test_function_calls() {
let input = "2023-01-01 * Func Test
Expenses:Travel market(100, 2023-01-01)
Assets:Checking
";
let journal = parse_ledger(input).unwrap();
let tx = match &journal.entries[0] {
Entry::Transaction(t) => t,
_ => panic!("Expected transaction"),
};
let p1 = &tx.postings[0];
match &p1.amount {
Some(AmountDetails::Amount {
value: ValueExpr::Function { name, args },
..
}) => {
assert_eq!(name, "market");
assert_eq!(args.len(), 2);
}
_ => panic!("Expected a function call, got {:?}", p1.amount),
}
}
#[test]
fn test_just_math() {
let input = "(100 + 20) * 5";
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
println!("{:?}", expr);
}
#[test]
fn test_math_with_commodity() {
let input = "(100 + 20) USD";
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert!(matches!(expr, ValueExpr::Typed { .. }));
}
#[test]
fn test_comma_number() {
let input = "1,234.56";
let pairs = LedgerParser::parse(Rule::number, input).unwrap();
assert_eq!(clean_parse_decimal(pairs.as_str()), dec!(1234.56));
}
#[test]
fn test_lot_annotation_cost_only() {
let input = "2024-03-01 Buy\n Assets:Brokerage 10 AAPL {$150}\n Assets:Cash\n";
let journal = parse_ledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!("expected transaction")
};
let details = tx.postings[0].amount.as_ref().expect("amount present");
let AmountDetails::Amount { lot_annotation, .. } = details else {
panic!("expected Amount details")
};
let ann = lot_annotation.as_ref().expect("lot annotation present");
assert!(ann.cost.is_some(), "cost should be set");
assert!(ann.date.is_none(), "date should be absent");
assert!(ann.note.is_none(), "note should be absent");
assert!(matches!(
ann.cost.as_ref().unwrap(),
ValueExpr::Amount {
commodity: Some(c),
..
} if c == "$"
));
}
#[test]
fn test_lot_annotation_date_only() {
let input =
"2024-03-01 Buy\n Assets:Brokerage 10 AAPL [2024-01-15]\n Assets:Cash\n";
let journal = parse_ledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!("expected transaction")
};
let details = tx.postings[0].amount.as_ref().expect("amount present");
let AmountDetails::Amount { lot_annotation, .. } = details else {
panic!("expected Amount details")
};
let ann = lot_annotation.as_ref().expect("lot annotation present");
assert!(ann.cost.is_none(), "cost should be absent");
assert_eq!(
ann.date,
chrono::NaiveDate::from_ymd_opt(2024, 1, 15),
"date should be 2024-01-15"
);
assert!(ann.note.is_none(), "note should be absent");
}
#[test]
fn test_lot_annotation_note_only() {
let input =
"2024-03-01 Buy\n Assets:Brokerage 10 AAPL ((BUY-2024-01))\n Assets:Cash\n";
let journal = parse_ledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!("expected transaction")
};
let details = tx.postings[0].amount.as_ref().expect("amount present");
let AmountDetails::Amount { lot_annotation, .. } = details else {
panic!("expected Amount details")
};
let ann = lot_annotation.as_ref().expect("lot annotation present");
assert!(ann.cost.is_none(), "cost should be absent");
assert!(ann.date.is_none(), "date should be absent");
assert_eq!(ann.note.as_deref(), Some("BUY-2024-01"));
}
#[test]
fn test_lot_annotation_combined() {
let input = "2024-03-01 Buy\n Assets:Brokerage 10 AAPL {$150} [2024-03-01] ((BUY-2024-01))\n Assets:Cash\n";
let journal = parse_ledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!("expected transaction")
};
let details = tx.postings[0].amount.as_ref().expect("amount present");
let AmountDetails::Amount { lot_annotation, .. } = details else {
panic!("expected Amount details")
};
let ann = lot_annotation.as_ref().expect("lot annotation present");
assert!(ann.cost.is_some(), "cost should be set");
assert_eq!(
ann.date,
chrono::NaiveDate::from_ymd_opt(2024, 3, 1),
"date should be 2024-03-01"
);
assert_eq!(ann.note.as_deref(), Some("BUY-2024-01"));
}
#[test]
fn test_lot_annotation_double_brace_rejected() {
let input = "2024-03-01 Buy\n Assets:Brokerage 10 AAPL {{$1500}}\n Assets:Cash\n";
let result = parse_ledger(input);
assert!(
result.is_err(),
"double-brace `{{total}}` should be rejected at parse time"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("double-brace"),
"error message should mention double-brace, got: {msg}"
);
}
}
#[cfg(test)]
mod directed_tests {
use super::*;
use rust_decimal::dec;
#[test]
fn test_number_commodity_variants() {
let cases = vec![
("$1000", dec!(1000), Some("$")),
("1000 USD", dec!(1000), Some("USD")),
];
for (input, expected_val, expected_comm) in cases {
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
if let ValueExpr::Amount { value, commodity } = expr {
assert_eq!(value, expected_val);
assert_eq!(commodity, expected_comm.map(|s| s.to_string()));
} else {
panic!("Expected Amount, got {:?}", expr);
}
}
let input = "-1,234.56 BTC";
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert!(matches!(expr, ValueExpr::Unary { op: Op::Sub, .. }));
}
#[test]
fn test_posting_variants() {
let input = "2023-01-01 Transaction
Expenses:NoAmount
Expenses:SimpleAmount $100
Expenses:Expression (100 + 100) USD";
let mut pairs = LedgerParser::parse(Rule::transaction, input).unwrap();
let tx_pair = pairs.next().unwrap();
let tx = parse_transaction(tx_pair).unwrap();
assert_eq!(tx.postings.len(), 3);
}
#[test]
fn test_balance_assignment() {
let input = "2024-12-17 Opening Balance
Assets:Bank:Checking =$21,966.08
Equity:Opening Balances";
let journal = parse_ledger(input).expect("Should parse balance assignment");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!()
};
let p = &tx.postings[0];
assert_eq!(p.account, "Assets:Bank:Checking");
let details = p.amount.as_ref().expect("Should have amount details");
assert_eq!(
*details,
AmountDetails::BalanceAssignment(ValueExpr::Amount {
value: dec!(21966.08),
commodity: Some("$".into())
})
);
}
#[test]
fn test_commodity_directive_block() {
let input = "commodity BTC
; The primary crypto
alias Bitcoin
format 1,000.00000000 BTC
nomarket
default
";
let journal = parse_ledger(input).expect("Should parse commodity directive");
if let Entry::Directive(Directive::Commodity { name, notes, items }) = &journal.entries[0] {
assert_eq!(name, "BTC");
assert_eq!(notes[0], "The primary crypto");
assert_eq!(items.len(), 4);
assert!(matches!(items[0], CommodityItem::Alias(_)));
assert!(matches!(items[1], CommodityItem::Format(_)));
assert!(matches!(items[2], CommodityItem::NoMarket));
assert!(matches!(items[3], CommodityItem::Default));
} else {
panic!("Expected a Commodity Directive");
}
}
#[test]
fn test_commodity_note_parses_to_note_item() {
let input = "commodity $\n note American Dollars\n format $1,000.00\n";
let journal = parse_ledger(input).expect("Should parse commodity with note");
let Entry::Directive(Directive::Commodity { name, items, .. }) = &journal.entries[0] else {
panic!("Expected Commodity directive");
};
assert_eq!(name, "$");
let note_item = items.iter().find(|i| matches!(i, CommodityItem::Note(_)));
assert!(
note_item.is_some(),
"expected CommodityItem::Note, got: {items:?}"
);
let CommodityItem::Note(text) = note_item.unwrap() else {
unreachable!()
};
assert_eq!(text, "American Dollars");
assert!(
!items
.iter()
.any(|i| matches!(i, CommodityItem::Unknown(..))),
"unexpected Unknown item in: {items:?}"
);
}
#[test]
fn test_string_in_function() {
let input = "account(\"Assets:Bank:Checking\")";
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
if let ValueExpr::Function { name, args } = expr {
assert_eq!(name, "account");
match &args[0] {
ValueExpr::Str(s) => assert_eq!(s, "Assets:Bank:Checking"),
_ => panic!("Expected string argument"),
}
} else {
panic!("Expected function call");
}
}
#[test]
fn test_field_access() {
let input = "account(\"Assets:Bank\").total.quantity";
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
if let ValueExpr::Access { expr: inner, field } = expr {
assert_eq!(field, "quantity");
if let ValueExpr::Access {
field: inner_field, ..
} = *inner
{
assert_eq!(inner_field, "total");
} else {
panic!("Expected nested access");
}
} else {
panic!("Expected field access, got {:?}", expr);
}
}
#[test]
fn test_historical_price_with_time() {
let input = "P 2024-06-15 14:30:00 AAPL $182.50\n";
let journal = parse_ledger(input).unwrap();
assert_eq!(journal.entries.len(), 1);
let Entry::HistoricalPrice(ref hp) = journal.entries[0] else {
panic!("Expected HistoricalPrice");
};
assert_eq!(hp.date.year, Some(2024));
assert_eq!(hp.date.month, 6);
assert_eq!(hp.date.date, 15);
assert_eq!(hp.time.as_deref(), Some("14:30:00"));
assert_eq!(hp.commodity, "AAPL");
assert!(matches!(
hp.price,
ValueExpr::Amount {
commodity: Some(ref c),
..
} if c == "$"
));
}
#[test]
fn test_historical_price_without_time() {
let input = "P 2024-01-01 BTC $42000\n";
let journal = parse_ledger(input).unwrap();
let Entry::HistoricalPrice(ref hp) = journal.entries[0] else {
panic!("Expected HistoricalPrice");
};
assert_eq!(hp.date.month, 1);
assert_eq!(hp.date.date, 1);
assert!(hp.time.is_none());
assert_eq!(hp.commodity, "BTC");
}
#[test]
fn test_date_parsing_day_differs_from_month() {
let input = "P 2024-03-17 AAPL $100\n";
let journal = parse_ledger(input).unwrap();
let Entry::HistoricalPrice(ref hp) = journal.entries[0] else {
panic!()
};
assert_eq!(hp.date.month, 3);
assert_eq!(hp.date.date, 17);
}
#[test]
fn test_assertion_directive_weak() {
let input = "2024-01-15 = Assets:Checking $1000.00\n";
let journal = parse_ledger(input).unwrap();
assert_eq!(journal.entries.len(), 1);
let Entry::Assertion(ref a) = journal.entries[0] else {
panic!("expected Assertion, got {:?}", journal.entries[0]);
};
assert_eq!(a.date.year, Some(2024));
assert_eq!(a.date.month, 1);
assert_eq!(a.date.date, 15);
assert_eq!(a.account, "Assets:Checking");
assert!(!a.strict, "= should be non-strict");
assert!(matches!(
a.amount,
ValueExpr::Amount { commodity: Some(ref c), .. } if c == "$"
));
}
#[test]
fn test_assertion_directive_strict() {
let input = "2024-06-30 == Liabilities:CreditCard $-500.00\n";
let journal = parse_ledger(input).unwrap();
assert_eq!(journal.entries.len(), 1);
let Entry::Assertion(ref a) = journal.entries[0] else {
panic!("expected Assertion");
};
assert_eq!(a.account, "Liabilities:CreditCard");
assert!(a.strict, "== should be strict");
}
#[test]
fn test_bool_expr_and_chain_parses() {
let input = "\
account Assets:Savings
assert amount > 0 and amount < 0
";
let journal = parse_ledger(input).unwrap();
assert_eq!(journal.entries.len(), 1);
let Entry::Directive(Directive::Account { items, .. }) = &journal.entries[0] else {
panic!("expected Account directive");
};
let assert_item = items
.iter()
.find(|item| matches!(item, AccountItem::Assert(_)))
.expect("assert item not found");
let AccountItem::Assert(bool_expr) = assert_item else {
unreachable!()
};
assert!(
bool_expr.chain.is_some(),
"bool_expr.chain should be Some(And, ...), got None -- grammar fix may not be applied"
);
assert!(
matches!(bool_expr.chain.as_ref().unwrap().0, BoolOp::And),
"expected BoolOp::And in chain"
);
}
#[test]
fn test_paren_bool_expr_simple() {
let input = "account Assets:Savings\n assert (amount > 0)\n";
let journal = parse_ledger(input).expect("should parse");
let Entry::Directive(Directive::Account { items, .. }) = &journal.entries[0] else {
panic!("expected Account directive");
};
let AccountItem::Assert(expr) = items
.iter()
.find(|i| matches!(i, AccountItem::Assert(_)))
.unwrap()
else {
unreachable!()
};
assert!(
matches!(expr.lhs, ValueExpr::Group(_)),
"expected ValueExpr::Group, got {:?}",
expr.lhs
);
}
#[test]
fn test_paren_bool_expr_or_chain_inside_parens() {
let input = "account Assets:Savings\n assert (amount > 0 or amount < -10)\n";
let journal = parse_ledger(input).expect("should parse");
let Entry::Directive(Directive::Account { items, .. }) = &journal.entries[0] else {
panic!("expected Account directive");
};
assert!(items.iter().any(|i| matches!(i, AccountItem::Assert(_))));
}
#[test]
fn test_paren_bool_nested_parens() {
let input =
"account Test\n assert (amount > 0 and (tag(\"X\") =~ /a/ or tag(\"Y\") =~ /b/))\n";
parse_ledger(input).expect("nested parens should parse");
}
#[test]
fn test_plain_arithmetic_paren_still_works() {
let input = "2024-01-01 Test\n Expenses:Food (100 + 200) USD\n Assets:Cash\n";
let journal = parse_ledger(input).expect("should parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!("expected transaction");
};
let details = tx.postings[0].amount.as_ref().unwrap();
assert!(
matches!(
details,
AmountDetails::Amount {
value: ValueExpr::Typed { .. } | ValueExpr::Binary { .. },
..
}
),
"expected Typed or Binary, got {details:?}"
);
}
#[test]
fn test_define_body_paren_bool_expr() {
let input = "define inRange(x) = (x > 0 and x < 100)\n";
let journal = parse_ledger(input).expect("should parse define with paren bool body");
let Entry::Directive(Directive::Define { name, params, body }) = &journal.entries[0] else {
panic!("expected Define directive");
};
assert_eq!(name, "inRange");
assert_eq!(params, &["x"]);
match body {
DefineBody::Value(ValueExpr::Group(_)) => {}
DefineBody::Bool(_) => {}
other => panic!("unexpected define body: {other:?}"),
}
}
#[test]
fn d_directive_rejects_bare_number() {
let err = parse_ledger("D 1000.00\n").unwrap_err();
assert!(
err.to_string().contains("commodity"),
"error should mention 'commodity', got: {err}"
);
}
#[test]
fn test_issue_89_failing_input() {
let input = "define assetChecker(amt) = (amt > -100.00 or (tag(\"TaxImplication\") !~ /^\\s*$/ and tag(\"Entity\") !~ /^\\s*$/))\n";
parse_ledger(input).expect("issue #89 input should parse");
}
}