pub mod writer;
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 mut active_tags: Vec<(String, Option<String>)> = Vec::new();
let journal = self.parse_inner(input, &mut active_tags)?;
validate_regexes(&journal)?;
Ok(journal)
}
fn parse_inner(
&mut self,
input: &str,
active_tags: &mut Vec<(String, Option<String>)>,
) -> 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 => {
let mut tx = parse_transaction(pair)?;
for (key, value) in active_tags.iter() {
let note = match value {
Some(v) => format!("{key}: {v}"),
None => format!(":{key}:"),
};
tx.notes.push(note);
}
entries.push(Entry::Transaction(tx));
}
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_inner(&new_input, active_tags)?.entries);
let _ = std::mem::replace(&mut self.base_path, old_base_path);
}
Rule::budget => {
}
Rule::auto_rule => {
let auto_rule = parse_auto_rule(pair)?;
entries.push(Entry::AutoRule(auto_rule));
}
Rule::c_directive => {
entries.push(parse_c_directive(pair)?);
}
Rule::apply_tag_directive => {
let body = pair
.into_inner()
.find(|p| p.as_rule() == Rule::apply_tag_body)
.map(|p| p.as_str().trim().to_string())
.unwrap_or_default();
let (key, value) = match body.split_once(':') {
Some((k, v)) => (k.trim().to_string(), Some(v.trim().to_string())),
None => (body, None),
};
active_tags.push((key, value));
}
Rule::end_tag_directive => {
active_tags.pop();
}
_ => {}
}
}
Ok(Journal { entries })
}
}
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;
pub fn ledger_defaults() -> crate::resolution::ElaborationConfig {
crate::resolution::ElaborationConfig {
tolerance_mode: crate::resolution::ToleranceMode::FractionOfSmallestPrecision(
rust_decimal::Decimal::ONE,
),
balance_mode: crate::resolution::BalanceMode::CostBasis,
assertion_scope: crate::resolution::AssertionScope::Direct,
lot_validation_mode: crate::resolution::LotValidationMode::Permissive,
default_booking_method: crate::resolution::BookingMethod::None,
infer_implicit_total_cost: true,
}
}
impl crate::frontend::Frontend for LedgerFrontend {
fn extensions(&self) -> &'static [&'static str] {
&["ledger"]
}
fn elaboration_defaults(&self) -> crate::resolution::ElaborationConfig {
ledger_defaults()
}
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 write_journal(
&self,
hir: &crate::resolution::HIR,
w: &mut dyn std::io::Write,
) -> std::io::Result<()> {
writer::write(hir, w)
}
}
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 commodity_text(pair: Pair<'_, Rule>) -> String {
let s = pair.as_str();
if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
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 = commodity_text(p),
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_c_directive(pair: Pair<Rule>) -> Result<Entry, Box<dyn std::error::Error>> {
let mut amounts = pair.into_inner().filter(|p| p.as_rule() == Rule::c_amount);
let lhs = parse_c_amount(amounts.next().ok_or("c_directive: missing LHS amount")?)?;
let rhs = parse_c_amount(amounts.next().ok_or("c_directive: missing RHS amount")?)?;
Ok(Entry::CommodityConversion { lhs, rhs })
}
fn parse_c_amount(pair: Pair<Rule>) -> Result<CommodityAmount, Box<dyn std::error::Error>> {
let mut inner = pair.into_inner();
let number_str = inner.next().ok_or("c_amount: missing c_number")?.as_str();
let commodity = inner
.next()
.ok_or("c_amount: missing commodity")?
.as_str()
.to_string();
let value = number_str
.parse::<Decimal>()
.map_err(|e| format!("c_amount: invalid number `{number_str}`: {e}"))?;
Ok(CommodityAmount { value, commodity })
}
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_auto_rule(pair: Pair<Rule>) -> Result<AutoRule, Box<dyn std::error::Error>> {
let mut query = String::new();
let mut postings = Vec::new();
for child in pair.into_inner() {
match child.as_rule() {
Rule::rule_query => {
query = child.as_str().trim().to_string();
}
Rule::posting => {
postings.push(parse_posting(child)?);
}
_ => {}
}
}
Ok(AutoRule { query, 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::auto_multiplier => {
let mut inner = p.into_inner();
let first = inner
.next()
.expect("auto_multiplier must have at least a number");
let (sign, number_str) = if first.as_rule() == Rule::prefix_op {
let s = first.as_str();
let n = inner
.next()
.expect("auto_multiplier prefix_op must be followed by number");
(s, n.as_str())
} else {
("", first.as_str())
};
let cleaned = format!("{sign}{}", number_str.replace(',', ""));
let value: Decimal = cleaned.parse().unwrap_or(Decimal::ZERO);
Ok(AmountDetails::Amount {
value: ValueExpr::Amount {
value,
commodity: None,
},
lot_annotation: None,
lot_pricing: None,
balance_assertion: None,
})
}
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: commodity_text(comm_pair),
};
}
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 = commodity_text(first);
let next = inner.next().unwrap();
let (sign, val_str) = if next.as_rule() == Rule::prefix_op {
let sign = next.as_str();
let num_str = inner.next().unwrap().as_str();
(sign, num_str)
} else {
("+", next.as_str())
};
let magnitude = clean_parse_decimal(val_str);
let value = if sign == "-" { -magnitude } else { magnitude };
ValueExpr::Amount {
value,
commodity: Some(comm),
}
}
Rule::number => {
let val = clean_parse_decimal(first.as_str());
let comm = inner.next().map(commodity_text);
ValueExpr::Amount {
value: val,
commodity: comm,
}
}
_ => unreachable!(),
}
}
Rule::commodity => ValueExpr::Commodity(commodity_text(pair)),
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::Amount { .. },
..
}
));
}
}
#[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_auto_rule_parse_and_emit() {
let input = "\
= /^Income/
(Liabilities:Tithe) 0.12
2024-01-01 * Salary
Income:Salary $-1000.00
Assets:Checking $1000.00
";
let journal = parse_ledger(input).expect("parse must accept auto-rule");
let auto_rules: Vec<_> = journal
.entries
.iter()
.filter(|e| matches!(e, Entry::AutoRule(_)))
.collect();
assert_eq!(auto_rules.len(), 1, "exactly one auto-rule expected");
let transactions: Vec<_> = journal
.entries
.iter()
.filter(|e| matches!(e, Entry::Transaction(_)))
.collect();
assert_eq!(transactions.len(), 1, "exactly one transaction expected");
if let Entry::AutoRule(rule) = auto_rules[0] {
assert_eq!(rule.query, "/^Income/");
assert_eq!(rule.postings.len(), 1);
} else {
panic!("expected Entry::AutoRule");
}
}
#[test]
fn test_apply_tag_propagates_metadata() {
let input = "\
apply tag hastag: true
2024-01-15 * Bookstore
Expenses:Books $20.00
Liabilities:MasterCard
end tag
2024-01-20 * Sale
Assets:Cash $30.00
Income:Sales
";
let journal = parse_ledger(input).expect("parse must accept apply-tag block");
let txs: Vec<&Transaction> = journal
.entries
.iter()
.filter_map(|e| match e {
Entry::Transaction(t) => Some(t),
_ => None,
})
.collect();
assert_eq!(txs.len(), 2);
assert!(
txs[0].notes.iter().any(|n| n == "hastag: true"),
"inside-block transaction should carry `hastag: true` note; got {:?}",
txs[0].notes
);
assert!(
txs[1].notes.iter().all(|n| n != "hastag: true"),
"outside-block transaction must not carry the popped tag; got {:?}",
txs[1].notes
);
}
#[test]
fn test_apply_tag_nested_blocks_propagate_full_stack() {
let input = "\
apply tag outer: 1
apply tag inner: 2
2024-01-15 * Inside both
Expenses:Books $20.00
Liabilities:MasterCard
end tag
2024-01-16 * Inside outer only
Expenses:Food $10.00
Assets:Cash
end tag
";
let journal = parse_ledger(input).expect("parse must accept nested apply-tag block");
let txs: Vec<&Transaction> = journal
.entries
.iter()
.filter_map(|e| match e {
Entry::Transaction(t) => Some(t),
_ => None,
})
.collect();
assert_eq!(txs.len(), 2);
assert!(
txs[0].notes.iter().any(|n| n == "outer: 1")
&& txs[0].notes.iter().any(|n| n == "inner: 2"),
"inner transaction inherits both stacked tags; got {:?}",
txs[0].notes
);
assert!(
txs[1].notes.iter().any(|n| n == "outer: 1")
&& txs[1].notes.iter().all(|n| n != "inner: 2"),
"after popping inner, only outer remains; got {:?}",
txs[1].notes
);
}
#[test]
fn test_apply_tag_bare_key_propagates_as_tag_note() {
let input = "\
apply tag urgent
2024-01-15 * Test
Assets:Cash $10.00
Income:Random
end apply tag
";
let journal = parse_ledger(input).expect("parse must accept bare apply-tag");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!("expected Transaction");
};
assert!(
tx.notes.iter().any(|n| n == ":urgent:"),
"bare apply-tag should land as `:urgent:` tag-note; got {:?}",
tx.notes
);
}
#[test]
fn test_end_apply_tag_alternate_closer() {
let input = "\
apply tag foo
2024-01-15 * Inside
Assets:Cash $10.00
Income:Random
end apply tag
2024-01-16 * After-close
Assets:Cash $5.00
Income:Other
";
let journal = parse_ledger(input).expect("parse must accept `end apply tag` closer");
let txs: Vec<&Transaction> = journal
.entries
.iter()
.filter_map(|e| match e {
Entry::Transaction(t) => Some(t),
_ => None,
})
.collect();
assert!(
txs[0].notes.iter().any(|n| n == ":foo:"),
"inside-block transaction inherits `:foo:`; got {:?}",
txs[0].notes
);
assert!(
txs[1].notes.iter().all(|n| n != ":foo:"),
"after `end apply tag`, the tag is popped; got {:?}",
txs[1].notes
);
}
#[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_paren_amount_no_arithmetic() {
let input = "2024-01-01 Test\n Expenses:Items (54G)\n Assets:Bar\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().expect("amount present");
assert!(
!matches!(
details,
AmountDetails::Amount {
value: ValueExpr::Group(_),
..
}
),
"expected non-Group amount, got {details:?}"
);
assert!(
matches!(
details,
AmountDetails::Amount {
value: ValueExpr::Amount { .. },
..
}
),
"expected Amount variant, got {details:?}"
);
}
#[test]
fn test_paren_amount_multiply() {
let input = "2024-01-01 Test\n Expenses:Items (9G * 6)\n Assets:Bar\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().expect("amount present");
assert!(
!matches!(
details,
AmountDetails::Amount {
value: ValueExpr::Group(_),
..
}
),
"expected non-Group amount, got {details:?}"
);
assert!(
matches!(
details,
AmountDetails::Amount {
value: ValueExpr::Binary { .. },
..
}
),
"expected Binary expr, got {details:?}"
);
}
#[test]
fn test_paren_amount_add() {
let input = "2024-01-01 Test\n Expenses:Items (27G + 27G)\n Assets:Bar\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().expect("amount present");
assert!(
matches!(
details,
AmountDetails::Amount {
value: ValueExpr::Binary { op: Op::Add, .. },
..
}
),
"expected Binary(Add), got {details:?}"
);
}
#[test]
fn test_paren_amount_divide() {
let input = "2024-01-01 Test\n Expenses:Items (108G / 2)\n Assets:Bar\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().expect("amount present");
assert!(
matches!(
details,
AmountDetails::Amount {
value: ValueExpr::Binary { op: Op::Div, .. },
..
}
),
"expected Binary(Div), got {details:?}"
);
}
#[test]
fn test_paren_zero_stays_zero() {
let input = "2024-01-01 Test\n Expenses:Items (0)\n Assets:Bar\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().expect("amount present");
assert!(
!matches!(
details,
AmountDetails::Amount {
value: ValueExpr::Group(_),
..
}
),
"expected non-Group for (0), got {details:?}"
);
}
#[test]
fn test_paren_bool_expr_in_assert_still_works() {
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 Group for (amount > 0), got {:?}",
expr.lhs
);
if let ValueExpr::Group(inner) = &expr.lhs {
assert!(
inner.cmp.is_some(),
"expected cmp in inner BoolExpr, got None"
);
}
}
#[test]
fn test_paren_bool_expr_and_in_assert_still_works() {
let input = "account Assets:Savings\n assert (amount > 0 and 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");
};
let AccountItem::Assert(expr) = items
.iter()
.find(|i| matches!(i, AccountItem::Assert(_)))
.unwrap()
else {
unreachable!()
};
assert!(
matches!(expr.lhs, ValueExpr::Group(_)),
"expected Group for (amount > 0 and amount < 10), got {:?}",
expr.lhs
);
if let ValueExpr::Group(inner) = &expr.lhs {
assert!(
inner.chain.is_some(),
"expected bool chain in Group, got None"
);
}
}
#[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");
}
#[test]
fn test_issue_247_null_posting_with_trailing_whitespace() {
let input = concat!(
"2002/01/01 * Test\n",
" Assets:Checking $100.00\n",
" Equity:Opening \n",
);
parse_ledger(input).expect("trailing whitespace on null posting should parse");
}
#[test]
fn test_issue_247_amount_posting_with_trailing_whitespace() {
let input = concat!(
"2002/01/01 * Test\n",
" Assets:Checking $100.00 \n",
" Equity:Opening $-100.00\n",
);
parse_ledger(input).expect("trailing whitespace on amount posting should parse");
}
#[test]
fn test_quoted_commodity_number_first() {
let input = r#"5 "Long Name""#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(5),
commodity: Some("Long Name".into()),
}
);
}
#[test]
fn test_quoted_commodity_commodity_first() {
let input = r#""Long Name" 5"#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(5),
commodity: Some("Long Name".into()),
}
);
}
#[test]
fn test_quoted_commodity_with_colon() {
let input = r#"1 "Plans: Wildthorn Mail""#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(1),
commodity: Some("Plans: Wildthorn Mail".into()),
}
);
}
#[test]
fn test_quoted_commodity_with_apostrophe() {
let input = r#"1 "It's""#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(1),
commodity: Some("It's".into()),
}
);
}
#[test]
fn test_quoted_commodity_leading_dollar() {
let input = r#"1 "$pecial""#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(1),
commodity: Some("$pecial".into()),
}
);
}
#[test]
fn test_quoted_commodity_with_slash() {
let input = r#"1 "a/b""#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(1),
commodity: Some("a/b".into()),
}
);
}
#[test]
fn test_quoted_commodity_empty_not_treated_as_commodity() {
let input = r#""" 5"#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert!(
!matches!(
&expr,
ValueExpr::Amount {
commodity: Some(c),
..
} if c.is_empty()
),
"empty commodity name must never appear in an Amount; got: {expr:?}"
);
}
#[test]
fn test_quoted_commodity_in_full_transaction() {
let input = concat!(
"2024-01-01 * Test\n",
" Assets:Items 1 \"Plans: Wildthorn Mail\" @ $125\n",
" Equity\n",
);
let journal = parse_ledger(input).expect("quoted commodity transaction should 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 { value, .. } = details else {
panic!("expected Amount variant");
};
assert!(
matches!(
value,
ValueExpr::Amount {
commodity: Some(c),
..
} if c == "Plans: Wildthorn Mail"
),
"commodity should be unquoted; got: {value:?}"
);
}
#[test]
fn test_signed_commodity_first_negative() {
let input = r#""Foo" -1"#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(-1),
commodity: Some("Foo".into()),
}
);
}
#[test]
fn test_signed_commodity_first_explicit_positive() {
let input = r#""Foo" +1"#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(1),
commodity: Some("Foo".into()),
}
);
}
#[test]
fn test_signed_commodity_first_negative_decimal() {
let input = r#""Foo" -1.5"#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(-1.5),
commodity: Some("Foo".into()),
}
);
}
#[test]
fn test_bare_commodity_first_negative() {
let input = "USD -1";
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(-1),
commodity: Some("USD".into()),
}
);
}
#[test]
fn test_signed_commodity_first_unsigned_regression() {
let input = r#""Foo" 5"#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(5),
commodity: Some("Foo".into()),
}
);
}
#[test]
fn test_signed_number_first_regression() {
let input = "-1 USD";
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Unary {
op: crate::ast::Op::Sub,
expr: Box::new(ValueExpr::Amount {
value: rust_decimal::dec!(1),
commodity: Some("USD".into()),
}),
}
);
}
#[test]
fn test_signed_commodity_first_with_lot() {
let input = r#""Beaststalker's Belt" -1"#;
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(-1),
commodity: Some("Beaststalker's Belt".into()),
}
);
}
#[test]
fn test_wow_dat_posting_shape() {
let input = concat!(
"2006/03/16 Auction House\n",
" Assets:Tajer 1195768c\n",
" Assets:Tajer:Items \"Beaststalker's Belt\" -1 {65G} @ 1195768c\n",
" Income:Brokering -545768c\n",
);
let journal = parse_ledger(input).expect("wow.dat posting shape should parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!("expected transaction");
};
let details = tx.postings[1].amount.as_ref().expect("amount present");
let AmountDetails::Amount { value, .. } = details else {
panic!("expected Amount variant");
};
assert!(
matches!(
value,
ValueExpr::Amount {
value: v,
commodity: Some(c),
} if *v == rust_decimal::dec!(-1) && c == "Beaststalker's Belt"
),
"expected Amount(-1, \"Beaststalker's Belt\"); got: {value:?}"
);
}
#[test]
fn test_bare_commodity_regression() {
let input = "100 USD";
let mut pairs = LedgerParser::parse(Rule::value_expr, input).unwrap();
let expr = parse_expr(pairs.next().unwrap());
assert_eq!(
expr,
ValueExpr::Amount {
value: rust_decimal::dec!(100),
commodity: Some("USD".into()),
}
);
}
}