pub mod writer;
use crate::ast::*;
use pest::Parser as _;
use pest::iterators::Pair;
use pest_derive::Parser;
use rust_decimal::Decimal;
use std::path::PathBuf;
#[derive(Parser)]
#[grammar = "grammars/hledger/hledger.pest"]
pub(crate) struct HledgerParser;
struct Parser<F: Fn(&str) -> Result<String, Box<dyn std::error::Error>>> {
opener: F,
base_path: PathBuf,
}
impl<F: Fn(&str) -> Result<String, Box<dyn std::error::Error>>> Parser<F> {
fn parse(&mut self, input: &str) -> Result<Journal, Box<dyn std::error::Error>> {
let pairs = HledgerParser::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::block_comment => {
entries.push(Entry::Comment(pair.as_str().to_string()));
}
Rule::historical_price => {
entries.push(Entry::HistoricalPrice(parse_historical_price(pair)));
}
Rule::account_directive => {
entries.push(Entry::Directive(parse_account_directive(pair)));
}
Rule::commodity_directive => {
entries.push(Entry::Directive(parse_commodity_directive(pair)));
}
Rule::default_directive => {
entries.push(Entry::Directive(parse_default_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::periodic_transaction => {
}
Rule::auto_rule => {
let auto_rule = parse_auto_rule(pair)?;
entries.push(Entry::AutoRule(auto_rule));
}
_ => {}
}
}
Ok(Journal { entries })
}
}
fn parse_date(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 date: u32 = inner.next().unwrap().as_str().parse().unwrap();
Date {
year: Some(year),
month,
date,
}
}
fn parse_state(s: &str) -> TransactionState {
match s {
"*" => TransactionState::Cleared,
"!" => TransactionState::Pending,
_ => TransactionState::Uncleared,
}
}
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(header.next().unwrap());
let mut state = TransactionState::Uncleared;
let mut code = None;
let mut description = String::new();
for p in header {
match p.as_rule() {
Rule::status => 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_end().to_string();
}
Rule::note => {
notes.push(p.into_inner().as_str().trim().to_string());
}
_ => {}
}
}
Ok(Transaction {
date,
secondary_date: None,
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::status => 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.into_inner().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()
.find(|p| p.as_rule() == Rule::value_expr)
.expect("assertion must contain a value_expr");
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 mut inner = p.into_inner();
let op_pair = inner
.next()
.expect("assertion must contain an assertion_op");
assert_eq!(op_pair.as_rule(), Rule::assertion_op);
let all_commodities = op_pair.as_str().ends_with('*');
let inner_expr_pair = inner
.find(|p| p.as_rule() == Rule::value_expr)
.expect("assertion must have a value_expr");
let target = parse_expr(inner_expr_pair);
Ok(if all_commodities {
AmountDetails::BalanceAssignmentAllCommodities(target)
} else {
AmountDetails::BalanceAssignment(target)
})
}
_ => unreachable!("unexpected rule in amount_logic: {:?}", p.as_rule()),
}
}
fn parse_lot_annotation_into(pair: Pair<Rule>, acc: &mut LotAnnotation) {
let child = pair.into_inner().next().unwrap();
match child.as_rule() {
Rule::lot_cost => {
if child.as_str().starts_with("{{") {
acc.cost_is_total = true;
}
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 d = parse_date(date_pair);
if let (Some(year), month, day) = (d.year, d.month, d.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!(),
}
}
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(inner.next().unwrap());
let mut commodity = String::new();
let mut price_pair = None;
for p in inner {
match p.as_rule() {
Rule::commodity => commodity = commodity_text(p),
Rule::value_expr => price_pair = Some(p),
_ => {}
}
}
HistoricalPrice {
date,
time: None,
commodity,
price: parse_expr(price_pair.expect("historical_price must have a price")),
}
}
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));
}
_ => {}
}
}
Directive::Account { name, notes, items }
}
fn parse_account_item(pair: Pair<Rule>) -> AccountItem {
let mut inner = pair.into_inner();
let key = inner.next().unwrap().as_str().to_string();
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.as_str() {
"alias" => AccountItem::Alias(val.unwrap_or_default()),
"note" => AccountItem::Note(val.unwrap_or_default()),
_ => AccountItem::Unknown(key, val),
}
}
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 $1,000.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_commodity_directive(pair: Pair<Rule>) -> Directive {
let mut inner = pair.into_inner();
let raw = inner.next().unwrap().as_str().trim().to_string();
let name = derive_commodity_name(&raw);
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));
}
_ => {}
}
}
if !raw.is_empty() {
items.insert(0, CommodityItem::Format(raw));
}
Directive::Commodity { name, notes, items }
}
fn derive_commodity_name(format: &str) -> String {
let sym: String = format
.chars()
.filter(|c| !c.is_ascii_digit() && *c != ',' && *c != '.' && !c.is_whitespace())
.collect();
if sym.is_empty() {
format.trim().to_string()
} else {
sym.trim().to_string()
}
}
fn parse_commodity_item(pair: Pair<Rule>) -> CommodityItem {
let mut inner = pair.into_inner();
let key = inner.next().unwrap().as_str().to_string();
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.as_str() {
"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, val),
}
}
use pest::iterators::Pairs;
use pest::pratt_parser::PrattParser;
use std::sync::LazyLock;
static PRATT: 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))
});
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: Pairs<Rule>) -> ValueExpr {
PRATT
.map_primary(|pair| match pair.as_rule() {
Rule::term => run_pratt(pair.into_inner()),
Rule::primary => {
let base = pair.into_inner().next().expect("primary must have a base");
run_pratt(pest::iterators::Pairs::single(base))
}
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_decimal(val_str);
let value = if sign == "-" { -magnitude } else { magnitude };
ValueExpr::Amount {
value,
commodity: Some(comm),
}
}
Rule::number => {
let val = clean_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::expr => run_pratt(pair.into_inner()),
_ => unreachable!("unexpected primary rule: {:?}", 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_decimal(s: &str) -> Decimal {
let cleaned = s.replace(',', "");
cleaned.parse().unwrap_or(Decimal::ZERO)
}
#[cfg(test)]
pub(crate) fn parse_hledger(input: &str) -> Result<Journal, Box<dyn std::error::Error>> {
Parser {
opener: |_| Ok(String::new()),
base_path: PathBuf::new(),
}
.parse(input)
}
pub struct HledgerFrontend;
pub fn hledger_defaults() -> crate::resolution::ElaborationConfig {
crate::resolution::ElaborationConfig {
tolerance_mode: crate::resolution::ToleranceMode::FractionOfSmallestPrecision(
rust_decimal::Decimal::ONE,
),
balance_mode: crate::resolution::BalanceMode::AtPriceWithSynthesis {
gains_account: "Income:Capital Gains".to_string(),
},
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 HledgerFrontend {
fn extensions(&self) -> &'static [&'static str] {
&["hledger", "journal"]
}
fn elaboration_defaults(&self) -> crate::resolution::ElaborationConfig {
hledger_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)?;
let hir: crate::resolution::HIR = ast_journal.try_into()?;
Ok(hir)
}
fn write_journal(
&self,
hir: &crate::resolution::HIR,
w: &mut dyn std::io::Write,
) -> std::io::Result<()> {
writer::write(hir, w)
}
}
#[cfg(test)]
mod tests {
use rust_decimal::dec;
use super::*;
#[test]
fn simple_cleared_transaction() {
let input = "\
2024-01-15 * Opening Balances
assets:bank:checking $1000.00
equity:opening-balances
";
let journal = parse_hledger(input).expect("parse");
assert_eq!(journal.entries.len(), 1);
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!("expected transaction");
};
assert_eq!(tx.date.year, Some(2024));
assert_eq!(tx.date.month, 1);
assert_eq!(tx.date.date, 15);
assert!(matches!(tx.state, TransactionState::Cleared));
assert_eq!(tx.description, "Opening Balances");
assert_eq!(tx.postings.len(), 2);
assert_eq!(tx.postings[0].account, "assets:bank:checking");
assert!(tx.postings[1].amount.is_none(), "null posting");
}
#[test]
fn pending_with_code_and_comments() {
let input = "\
2024-01-16 ! (INV-42) ACME Corp ; project:website
expenses:consulting $500.00 ; vendor: ACME
assets:bank:checking
";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
assert!(matches!(tx.state, TransactionState::Pending));
assert_eq!(tx.code.as_deref(), Some("INV-42"));
assert_eq!(tx.description, "ACME Corp");
assert!(!tx.notes.is_empty(), "should have note from inline comment");
assert_eq!(tx.postings[0].notes.len(), 1);
assert!(tx.postings[0].notes[0].contains("vendor"));
}
#[test]
fn balance_assertion_single_commodity() {
let input = "\
2024-01-31 * end-of-month check
assets:bank:checking $0.00 = $500.00
expenses:adjustments
";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let p = &tx.postings[0];
assert!(
matches!(
&p.amount,
Some(AmountDetails::Amount {
balance_assertion: Some(_),
..
})
),
"should have balance assertion"
);
}
#[test]
fn balance_assertion_strict() {
let input = "\
2024-02-01 * checked
assets:bank:checking $0.00 == $500.00
expenses:adjustments
";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let p = &tx.postings[0];
assert!(
matches!(
&p.amount,
Some(AmountDetails::Amount {
balance_assertion: Some(_),
..
})
),
"should have strict balance assertion"
);
}
#[test]
fn balance_assignment() {
let input = "\
2024-02-05 * Reset to known balance
assets:bank:checking = $750.00
income:adjustments
";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let p = &tx.postings[0];
assert!(
matches!(&p.amount, Some(AmountDetails::BalanceAssignment(_))),
"should be BalanceAssignment, got {:?}",
p.amount
);
}
#[test]
fn lot_pricing_unit() {
let input = "\
2024-02-10 * Buy euros
assets:eur 100.00 EUR @ $1.10
assets:bank:checking $-110.00
";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let p = &tx.postings[0];
assert!(
matches!(
&p.amount,
Some(AmountDetails::Amount {
lot_pricing: Some(LotPricing::Unit(_)),
..
})
),
"should have unit lot pricing"
);
}
#[test]
fn lot_pricing_total() {
let input = "\
2024-02-11 * Buy stock
assets:brokerage 10 AAPL @@ $1825.00
assets:bank:checking $-1825.00
";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let p = &tx.postings[0];
assert!(
matches!(
&p.amount,
Some(AmountDetails::Amount {
lot_pricing: Some(LotPricing::Total(_)),
..
})
),
"should have total lot pricing"
);
}
#[test]
fn historical_price() {
let input = "P 2024-01-02 EUR $1.10\n";
let journal = parse_hledger(input).expect("parse");
assert_eq!(journal.entries.len(), 1);
let Entry::HistoricalPrice(hp) = &journal.entries[0] else {
panic!("expected HistoricalPrice");
};
assert_eq!(hp.date.year, Some(2024));
assert_eq!(hp.commodity, "EUR");
assert!(matches!(
hp.price,
ValueExpr::Amount {
commodity: Some(ref c),
..
} if c == "$"
));
}
#[test]
fn account_directive_with_note() {
let input = "account assets:bank:checking ; type:A\n";
let journal = parse_hledger(input).expect("parse");
assert_eq!(journal.entries.len(), 1);
let Entry::Directive(Directive::Account { name, notes, .. }) = &journal.entries[0] else {
panic!("expected Account directive");
};
assert_eq!(name, "assets:bank:checking");
assert!(!notes.is_empty(), "should capture the note");
}
#[test]
fn commodity_directive_prefix_symbol() {
let input = "commodity $1,000.00\n";
let journal = parse_hledger(input).expect("parse");
let Entry::Directive(Directive::Commodity { name, items, .. }) = &journal.entries[0] else {
panic!("expected Commodity directive");
};
assert_eq!(name, "$");
assert!(
items.iter().any(|i| matches!(i, CommodityItem::Format(_))),
"should have Format item"
);
}
#[test]
fn commodity_directive_suffix_symbol() {
let input = "commodity 1,000.00 EUR\n";
let journal = parse_hledger(input).expect("parse");
let Entry::Directive(Directive::Commodity { name, .. }) = &journal.entries[0] else {
panic!("expected Commodity directive");
};
assert_eq!(name, "EUR");
}
#[test]
fn date_slash_separator() {
let input = "2024/03/15 Test\n expenses:food $10\n assets:cash\n";
let journal = parse_hledger(input).expect("parse with / separator");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
assert_eq!(tx.date.year, Some(2024));
assert_eq!(tx.date.month, 3);
assert_eq!(tx.date.date, 15);
}
#[test]
fn date_dot_separator() {
let input = "2024.06.01 Test\n expenses:food $10\n assets:cash\n";
let journal = parse_hledger(input).expect("parse with . separator");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
assert_eq!(tx.date.month, 6);
assert_eq!(tx.date.date, 1);
}
#[test]
fn hash_comment_line() {
let input = "# This is a hash comment\n";
let journal = parse_hledger(input).expect("parse");
assert_eq!(journal.entries.len(), 1);
assert!(matches!(journal.entries[0], Entry::Comment(_)));
}
#[test]
fn negative_prefix_amount() {
let input = "2024-01-01 Test\n assets:bank $-110.00\n income:salary\n";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let p = &tx.postings[0];
assert!(p.amount.is_some());
}
#[test]
fn amount_comma_thousands() {
let input = "2024-01-01 Test\n expenses:food $1,234.56\n assets:cash\n";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let AmountDetails::Amount { value, .. } = tx.postings[0].amount.as_ref().unwrap() else {
panic!();
};
assert!(
matches!(value, ValueExpr::Amount { value, .. } if *value == dec!(1234.56)),
"expected 1234.56, got {value:?}"
);
}
#[test]
fn arithmetic_amount() {
let input = "2024-01-01 Test\n expenses:food (100 + 50) USD\n assets:cash\n";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let AmountDetails::Amount { value, .. } = tx.postings[0].amount.as_ref().unwrap() else {
panic!();
};
assert!(
matches!(value, ValueExpr::Typed { .. }),
"expected Typed wrapping an arithmetic expr, got {value:?}"
);
}
#[test]
fn d_directive_prefix_symbol() {
let input = "D $1,000.00\n";
let journal = parse_hledger(input).expect("parse");
let Entry::Directive(Directive::Commodity { name, items, .. }) = &journal.entries[0] else {
panic!("expected Commodity directive");
};
assert_eq!(name, "$");
assert!(
items.iter().any(|i| matches!(i, CommodityItem::Default)),
"should have Default item"
);
assert!(
items.iter().any(|i| matches!(i, CommodityItem::Format(_))),
"should have Format item"
);
}
#[test]
fn d_directive_suffix_symbol() {
let input = "D 1,000.00 USD\n";
let journal = parse_hledger(input).expect("parse");
let Entry::Directive(Directive::Commodity { name, .. }) = &journal.entries[0] else {
panic!("expected Commodity directive");
};
assert_eq!(name, "USD");
}
#[test]
fn d_directive_rejects_bare_number() {
let err = parse_hledger("D 1000.00\n").unwrap_err();
assert!(
err.to_string().contains("commodity"),
"error should mention 'commodity', got: {err}"
);
}
#[test]
fn periodic_transaction_ignored() {
let input = "\
~ monthly Rent
expenses:rent $2000
assets:bank:checking
2024-01-01 Real
expenses:food $10
assets:cash
";
let journal = parse_hledger(input).expect("parse");
let txns: Vec<_> = journal
.entries
.iter()
.filter(|e| matches!(e, Entry::Transaction(_)))
.collect();
assert_eq!(txns.len(), 1);
}
#[test]
fn virtual_unbalanced_posting_parses_to_correct_kind() {
let input = "\
2024-01-15 Setup
Assets:Checking $100
Equity:Opening $-100
(Equity:Reservations) $-25
";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!("expected transaction");
};
let virt = tx
.postings
.iter()
.find(|p| p.account == "Equity:Reservations")
.expect("virtual posting should be present with parens stripped");
assert_eq!(
virt.kind,
PostingKind::VirtualUnbalanced,
"posting kind should be VirtualUnbalanced"
);
}
#[test]
fn virtual_balanced_posting_parses_to_correct_kind() {
let input = "\
2024-01-15 Setup
Assets:Checking $100
[Equity:Reservations] $25
Equity:Opening $-125
";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!("expected transaction");
};
let virt = tx
.postings
.iter()
.find(|p| p.account == "Equity:Reservations")
.expect("virtual posting should be present with brackets stripped");
assert_eq!(
virt.kind,
PostingKind::VirtualBalanced,
"posting kind should be VirtualBalanced"
);
}
#[test]
fn sample_journal_parses_without_error() {
let input = "\
; A small but representative hledger journal
account assets:bank:checking ; type:A
account expenses:groceries
account income:salary
commodity $1,000.00
commodity 1,000.00 EUR
P 2024-01-02 EUR $1.10
P 2024-02-15 AAPL $182.50
2024-01-15 * Opening Balances
assets:bank:checking $1000.00
equity:opening-balances
2024-01-16 ! (INV-42) ACME Corp ; project:website
expenses:consulting $500.00 ; vendor: ACME
assets:bank:checking
2024-01-31 * end-of-month check
assets:bank:checking $0.00 = $500.00
expenses:adjustments
2024-02-01 * checked
assets:bank:checking $0.00 == $500.00
expenses:adjustments
2024-02-05 * Reset to known balance
assets:bank:checking = $750.00
income:adjustments
2024-02-10 * Buy euros
assets:eur 100.00 EUR @ $1.10
assets:bank:checking $-110.00
2024-02-11 * Buy stock
assets:brokerage 10 AAPL @@ $1825.00
assets:bank:checking $-1825.00
";
parse_hledger(input).expect("sample journal should parse without error");
}
#[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_hledger(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");
}
#[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_hledger(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_hledger(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_hledger(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 block_comment_closed_and_unclosed() {
let closed = "\
2024-01-01 * Salary
Assets:Bank $100
Income:Salary
comment
this is ignored
Assets:Fake $999
end comment
2024-01-02 * Coffee
Expenses:Food $5
Assets:Bank
";
let journal = parse_hledger(closed).expect("parse closed block-comment");
let txs: Vec<_> = journal
.entries
.iter()
.filter(|e| matches!(e, Entry::Transaction(_)))
.collect();
assert_eq!(
txs.len(),
2,
"block comment must not consume the second txn"
);
let unclosed = "\
2024-01-01 * Salary
Assets:Bank $100
Income:Salary
comment
trailing notes about the journal
nothing after this matters
";
let journal = parse_hledger(unclosed).expect("parse unclosed block-comment");
let txs: Vec<_> = journal
.entries
.iter()
.filter(|e| matches!(e, Entry::Transaction(_)))
.collect();
assert_eq!(
txs.len(),
1,
"the single transaction before the unclosed block must parse"
);
}
#[test]
fn assertion_op_recognises_star_qualifier() {
let input = "2024-12-31 retain earnings\n Income ==* 0\n Equity:RE\n";
let journal = parse_hledger(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!()
};
assert!(
matches!(
tx.postings[0].amount,
Some(AmountDetails::BalanceAssignmentAllCommodities(_))
),
"expected BalanceAssignmentAllCommodities, got {:?}",
tx.postings[0].amount
);
let input2 = "2024-12-31 retain earnings\n Income =* 0\n Equity:RE\n";
let j2 = parse_hledger(input2).expect("parse =*");
let Entry::Transaction(tx2) = &j2.entries[0] else {
panic!()
};
assert!(matches!(
tx2.postings[0].amount,
Some(AmountDetails::BalanceAssignmentAllCommodities(_))
));
}
#[test]
fn assertion_star_aggregates_subtree() {
let input = "\
2024-01-15 * Salary
Assets:Cash:USD 200.00 USD
Income:Salary -200.00 USD
2024-02-01 * Royalty
Assets:Cash:EUR 30.00 EUR
Income:Royalties -30.00 EUR
2024-12-31 * Retain earnings
Income ==* 0
Equity:Retained-Earnings
";
let ast = parse_hledger(input).expect("parse");
let hir: crate::resolution::HIR = ast.try_into().expect("resolution");
let journal: crate::elaboration::Journal =
crate::elaborate(hir, &hledger_defaults()).expect("elaboration");
let retain = journal
.transactions
.iter()
.find(|t| t.description == "Retain earnings")
.expect("retain earnings tx");
let income = retain
.postings
.iter()
.find(|p| p.account == "Income")
.expect("synthesized Income posting");
assert_eq!(income.amount_in("USD"), Some(dec!(200.00)));
assert_eq!(income.amount_in("EUR"), Some(dec!(30.00)));
let re = retain
.postings
.iter()
.find(|p| p.account == "Equity:Retained-Earnings")
.expect("retained-earnings absorber");
assert_eq!(re.amount_in("USD"), Some(dec!(-200.00)));
assert_eq!(re.amount_in("EUR"), Some(dec!(-30.00)));
}
#[test]
fn account_type_tag_inherits_to_undeclared_descendant() {
let input = "\
account Income ; type:R
2024-01-15 * Salary
Income:Salary -100 USD
Assets:Cash 100 USD
";
let ast = parse_hledger(input).expect("parse");
let hir: crate::resolution::HIR = ast.try_into().expect("resolution");
let journal: crate::elaboration::Journal =
crate::elaborate(hir, &hledger_defaults()).expect("elaboration");
let salary = journal
.accounts
.get("Income:Salary")
.expect("Income:Salary in elaborated accounts");
assert_eq!(
salary.metadata.get("type").map(String::as_str),
Some("R"),
"type:R declared on parent Income must propagate to undeclared Income:Salary; \
got metadata = {:?}",
salary.metadata
);
}
#[test]
fn test_lot_annotation_double_brace_total_form() {
let input = "2024-03-01 Buy\n assets:brokerage 10 AAPL {{$1500}}\n assets:cash\n";
let journal = parse_hledger(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_total, "double-brace form sets cost_is_total");
assert!(ann.cost.is_some(), "cost expression captured");
}
#[test]
fn quoted_commodity_number_first() {
let input = r#"5 "Long Name""#;
let mut pairs = HledgerParser::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 quoted_commodity_commodity_first() {
let input = r#""Long Name" 5"#;
let mut pairs = HledgerParser::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 quoted_commodity_with_colon() {
let input = r#"1 "Plans: Wildthorn Mail""#;
let mut pairs = HledgerParser::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 quoted_commodity_with_apostrophe() {
let input = r#"1 "It's""#;
let mut pairs = HledgerParser::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 quoted_commodity_with_slash() {
let input = r#"1 "a/b""#;
let mut pairs = HledgerParser::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 quoted_commodity_empty_rejected() {
let input = r#""" 5"#;
assert!(
HledgerParser::parse(Rule::value_expr, input).is_err(),
"empty-quoted commodity form must be rejected by the grammar"
);
}
#[test]
fn 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_hledger(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 signed_commodity_first_negative() {
let input = r#""Foo" -1"#;
let mut pairs = HledgerParser::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 signed_commodity_first_explicit_positive() {
let input = r#""Foo" +1"#;
let mut pairs = HledgerParser::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 signed_commodity_first_negative_decimal() {
let input = r#""Foo" -1.5"#;
let mut pairs = HledgerParser::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 bare_commodity_first_negative() {
let input = "USD -1";
let mut pairs = HledgerParser::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 signed_commodity_first_unsigned_regression() {
let input = r#""Foo" 5"#;
let mut pairs = HledgerParser::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 signed_number_first_regression() {
let input = "5 USD";
let mut pairs = HledgerParser::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("USD".into()),
}
);
}
#[test]
fn bare_commodity_regression() {
let input = "100 USD";
let mut pairs = HledgerParser::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()),
}
);
}
}