pub mod ast;
#[cfg(feature = "testing")]
pub mod testing;
pub(crate) mod elaborator;
pub use elaborator::{Amount, ElaborationError, EvaluationError};
pub mod frontend;
pub mod grammars;
pub mod resolution;
pub mod elaboration {
include!(concat!(env!("OUT_DIR"), "/doppio.rs"));
}
mod elaboration_ext;
pub use elaboration::Journal;
pub use frontend::Frontend;
pub use grammars::beancount::BeancountFrontend;
pub use grammars::hledger::HledgerFrontend;
pub use grammars::ledger::LedgerFrontend;
pub fn frontend_for_extension(ext: Option<&str>) -> Box<dyn Frontend> {
let Some(e) = ext else {
return Box::new(LedgerFrontend);
};
if HledgerFrontend.extensions().contains(&e) {
Box::new(HledgerFrontend)
} else if BeancountFrontend.extensions().contains(&e) {
Box::new(BeancountFrontend)
} else if LedgerFrontend.extensions().contains(&e) {
Box::new(LedgerFrontend)
} else {
Box::new(LedgerFrontend)
}
}
pub(crate) fn decimal_to_proto(d: rust_decimal::Decimal) -> elaboration::Decimal {
let mantissa: i128 = d.mantissa();
let scale = d.scale();
let mantissa_low = mantissa as u64;
let mantissa_high = (mantissa >> 64) as i64;
elaboration::Decimal {
mantissa_low,
mantissa_high,
scale,
}
}
pub(crate) fn decimal_from_proto(p: &elaboration::Decimal) -> rust_decimal::Decimal {
let mantissa = ((p.mantissa_high as i128) << 64) | (p.mantissa_low as i128);
rust_decimal::Decimal::from_i128_with_scale(mantissa, p.scale)
}
fn state_to_proto(s: &elaborator::TransactionState) -> i32 {
match s {
elaborator::TransactionState::Uncleared => elaboration::TransactionState::Uncleared as i32,
elaborator::TransactionState::Pending => elaboration::TransactionState::Pending as i32,
elaborator::TransactionState::Cleared => elaboration::TransactionState::Cleared as i32,
}
}
pub(crate) fn posting_kind_to_proto(kind: ast::PostingKind) -> i32 {
match kind {
ast::PostingKind::Real => elaboration::PostingKind::Real as i32,
ast::PostingKind::VirtualUnbalanced => elaboration::PostingKind::VirtualUnbalanced as i32,
ast::PostingKind::VirtualBalanced => elaboration::PostingKind::VirtualBalanced as i32,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compression {
None,
Deflate,
}
impl Compression {
fn as_byte(self) -> u8 {
match self {
Compression::None => 0,
Compression::Deflate => 1,
}
}
fn from_byte(b: u8) -> Option<Self> {
match b {
0 => Some(Compression::None),
1 => Some(Compression::Deflate),
_ => std::option::Option::None,
}
}
}
pub fn write_dop<W: std::io::Write>(
journal: &elaboration::Journal,
writer: &mut W,
compression: Compression,
) -> std::io::Result<()> {
use prost::Message as _;
let encoded = journal.encode_to_vec();
dop_write_header(writer, compression)?;
let payload = match compression {
Compression::None => encoded,
Compression::Deflate => miniz_oxide::deflate::compress_to_vec(&encoded, 6),
};
writer.write_all(&payload)
}
pub fn read_dop<R: std::io::Read>(
reader: &mut R,
path: &std::path::Path,
) -> Result<elaboration::Journal, Box<dyn std::error::Error>> {
use prost::Message as _;
let compression = dop_read_header(reader, path)?;
let mut payload = Vec::new();
reader.read_to_end(&mut payload)?;
let proto_bytes = match compression {
Compression::None => payload,
Compression::Deflate => miniz_oxide::inflate::decompress_to_vec(&payload)
.map_err(|e| format!("{}: deflate decompression failed: {e:?}", path.display()))?,
};
elaboration::Journal::decode(proto_bytes.as_slice())
.map_err(|e| format!("{}: protobuf decode failed: {e}", path.display()).into())
}
#[deprecated(
since = "2.3.0",
note = "use `LedgerFrontend.write_journal(hir, writer)` instead; \
`write_ledger` only handles transactions (no prices or assertions) \
and will be removed in v3.0"
)]
pub fn write_ledger<W>(
entries: impl IntoIterator<Item = resolution::Transaction>,
writer: &mut W,
) -> std::io::Result<()>
where
W: std::io::Write,
{
let mut first = true;
for txn in entries {
if !first {
writeln!(writer)?;
}
first = false;
write!(writer, "{txn}")?;
}
Ok(())
}
pub fn write_journal<F>(
frontend: &F,
hir: &resolution::HIR,
writer: &mut dyn std::io::Write,
) -> std::io::Result<()>
where
F: Frontend,
{
frontend.write_journal(hir, writer)
}
#[cfg(not(target_family = "wasm"))]
pub fn file_opener(pattern: &str) -> Result<String, Box<dyn std::error::Error>> {
use std::io::Read as _;
let mut paths: Vec<_> = glob::glob(pattern)?
.collect::<Result<_, _>>()
.map_err(|e| format!("glob match error for {pattern:?}: {e}"))?;
paths.sort();
let is_glob = pattern.contains(['*', '?', '[']);
if is_glob && paths.is_empty() {
return Err(format!("include glob {pattern:?} matched no files").into());
}
if !is_glob && paths.is_empty() {
return Err(format!("include: file not found: {pattern}").into());
}
let mut buf = String::new();
for path in &paths {
if !buf.is_empty() && !buf.ends_with('\n') {
buf.push('\n');
}
std::fs::File::open(path)
.map_err(|e| format!("include: cannot open {}: {e}", path.display()))?
.read_to_string(&mut buf)
.map_err(|e| format!("include: cannot read {}: {e}", path.display()))?;
}
Ok(buf)
}
pub fn compile<F>(
input: &str,
mut parser: grammars::ledger::Parser<F>,
) -> Result<elaboration::Journal, Box<dyn std::error::Error>>
where
F: Fn(&str) -> Result<String, Box<dyn std::error::Error>>,
{
let output = parser.parse(input)?;
let hir: resolution::HIR = output.try_into()?;
Ok(elaborate(hir, &grammars::ledger::ledger_defaults())?)
}
pub fn elaborate(
hir: resolution::HIR,
config: &resolution::ElaborationConfig,
) -> Result<elaboration::Journal, elaborator::ElaborationError> {
elaborator::elaborate(hir, config)
}
pub fn eval_transaction(
txn: resolution::Transaction,
context: &resolution::Context,
) -> Result<elaboration::Transaction, elaborator::ElaborationError> {
let hir = resolution::HIR {
entries: vec![resolution::ResolutionEntry {
context_id: 0,
data: resolution::Entry::Transaction(txn),
}],
contexts: vec![context.clone()],
..Default::default()
};
let journal = elaborator::elaborate(hir, &grammars::ledger::ledger_defaults())?;
Ok(journal
.transactions
.into_iter()
.next()
.expect("journal should contain exactly one transaction"))
}
pub(crate) const DOP_MAGIC: [u8; 4] = *b"DOP\0";
pub(crate) const DOP_FORMAT_VERSION: u16 = 3;
pub(crate) fn dop_write_header<W: std::io::Write>(
writer: &mut W,
compression: Compression,
) -> std::io::Result<()> {
writer.write_all(&DOP_MAGIC)?;
writer.write_all(&DOP_FORMAT_VERSION.to_le_bytes())?;
writer.write_all(&[compression.as_byte(), 0u8])?;
Ok(())
}
pub(crate) fn dop_read_header<R: std::io::Read>(
reader: &mut R,
path: &std::path::Path,
) -> Result<Compression, Box<dyn std::error::Error>> {
let mut magic = [0u8; 4];
reader.read_exact(&mut magic).map_err(|_| {
format!(
"{}: not a valid .dop file (missing magic header); \
recompile from source with `dop compile`",
path.display()
)
})?;
if magic != DOP_MAGIC {
return Err(format!(
"{}: not a valid .dop file (missing magic header); \
recompile from source with `dop compile`",
path.display()
)
.into());
}
let mut version_bytes = [0u8; 2];
reader.read_exact(&mut version_bytes)?;
let version = u16::from_le_bytes(version_bytes);
if version != DOP_FORMAT_VERSION {
return Err(format!(
"{}: incompatible .dop format version {} \
(this binary supports version {}); \
recompile from source with `dop compile`",
path.display(),
version,
DOP_FORMAT_VERSION,
)
.into());
}
let mut compression_reserved = [0u8; 2];
reader.read_exact(&mut compression_reserved)?;
let compression = Compression::from_byte(compression_reserved[0]).ok_or_else(|| {
format!(
"{}: unknown compression byte {} in .dop header",
path.display(),
compression_reserved[0],
)
})?;
Ok(compression)
}
#[cfg(test)]
#[allow(deprecated)]
mod write_ledger_tests {
use chrono::NaiveDate;
use rust_decimal::Decimal;
use super::*;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn parse_transactions(source: &str) -> Vec<resolution::Transaction> {
let mut p = grammars::ledger::Parser {
opener: |_: &str| Ok(String::new()),
base_path: std::path::PathBuf::new(),
};
let ast_journal = p.parse(&source.to_string()).expect("parse failed");
let hir: resolution::HIR = ast_journal.try_into().expect("resolution failed");
hir.transactions().collect()
}
#[test]
fn write_empty_iterator_produces_no_output() {
let mut out: Vec<u8> = Vec::new();
write_ledger(std::iter::empty::<resolution::Transaction>(), &mut out).unwrap();
assert!(out.is_empty());
}
#[test]
fn write_single_transaction_basic() {
let txn = resolution::Transaction::new(date(2024, 1, 15), "Groceries")
.with_posting(
resolution::Posting::new("Expenses:Food").with_amount((Decimal::from(50u32), "$")),
)
.with_posting(resolution::Posting::new("Assets:Checking"));
let mut out: Vec<u8> = Vec::new();
write_ledger([txn], &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert_eq!(
text,
"2024-01-15 Groceries\n Expenses:Food 50 $\n Assets:Checking\n"
);
}
#[test]
fn multiple_transactions_separated_by_blank_line() {
let txns = vec![
resolution::Transaction::new(date(2024, 1, 1), "First"),
resolution::Transaction::new(date(2024, 1, 2), "Second"),
];
let mut out: Vec<u8> = Vec::new();
write_ledger(txns, &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert_eq!(text, "2024-01-01 First\n\n2024-01-02 Second\n");
}
#[test]
fn round_trip_preserves_date_and_description() {
let original = resolution::Transaction::new(date(2024, 3, 15), "Salary payment")
.with_state(ast::TransactionState::Cleared)
.with_posting(
resolution::Posting::new("Income:Salary")
.with_amount((Decimal::from(5000u32), "USD")),
)
.with_posting(resolution::Posting::new("Assets:Checking"));
let mut out: Vec<u8> = Vec::new();
write_ledger([original], &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
let parsed = parse_transactions(&text);
assert_eq!(parsed.len(), 1);
let roundtripped = &parsed[0];
assert_eq!(roundtripped.date, date(2024, 3, 15));
assert_eq!(roundtripped.description, "Salary payment");
assert!(matches!(roundtripped.state, ast::TransactionState::Cleared));
assert_eq!(roundtripped.postings.len(), 2);
assert_eq!(roundtripped.postings[0].account, "Income:Salary");
assert_eq!(roundtripped.postings[1].account, "Assets:Checking");
}
#[test]
fn round_trip_preserves_metadata_and_tags() {
let original = resolution::Transaction::new(date(2024, 6, 1), "Grant revenue")
.with_tag("income")
.with_comment("Q2 payment")
.with_comment("approved")
.with_metadata("program", "Grant:UW:HARVEST")
.with_metadata("ref", "INV-001")
.with_posting(
resolution::Posting::new("Income:Grants")
.with_amount((Decimal::from(10_000u32), "$")),
)
.with_posting(resolution::Posting::new("Assets:Checking"));
let mut out: Vec<u8> = Vec::new();
write_ledger([original], &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
let parsed = parse_transactions(&text);
assert_eq!(parsed.len(), 1);
let rt = &parsed[0];
assert!(
rt.tags.contains(&"income".to_string()),
"tag 'income' missing from {rt:?}"
);
assert!(
rt.comments.contains(&"Q2 payment".to_string()),
"comment 'Q2 payment' missing from {rt:?}",
);
assert!(
rt.comments.contains(&"approved".to_string()),
"comment 'approved' missing from {rt:?}",
);
assert_eq!(
rt.metadata.get("program").map(String::as_str),
Some("Grant:UW:HARVEST")
);
assert_eq!(rt.metadata.get("ref").map(String::as_str), Some("INV-001"));
}
#[test]
fn round_trip_multiple_transactions() {
let txns = vec![
resolution::Transaction::new(date(2024, 1, 10), "Food")
.with_posting(
resolution::Posting::new("Expenses:Food")
.with_amount((Decimal::from(30u32), "$")),
)
.with_posting(resolution::Posting::new("Assets:Checking")),
resolution::Transaction::new(date(2024, 1, 20), "Rent")
.with_state(ast::TransactionState::Cleared)
.with_posting(
resolution::Posting::new("Expenses:Rent")
.with_amount((Decimal::from(1200u32), "$")),
)
.with_posting(resolution::Posting::new("Assets:Checking")),
];
let mut out: Vec<u8> = Vec::new();
write_ledger(txns, &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
let parsed = parse_transactions(&text);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].description, "Food");
assert_eq!(parsed[0].date, date(2024, 1, 10));
assert_eq!(parsed[1].description, "Rent");
assert_eq!(parsed[1].date, date(2024, 1, 20));
assert!(matches!(parsed[1].state, ast::TransactionState::Cleared));
}
#[test]
fn round_trip_posting_with_metadata() {
let original = resolution::Transaction::new(date(2024, 4, 1), "Payroll")
.with_posting(
resolution::Posting::new("Expenses:Salary")
.with_amount((Decimal::from(3000u32), "$"))
.with_metadata("employee", "alice")
.with_tag("payroll"),
)
.with_posting(resolution::Posting::new("Assets:Bank"));
let mut out: Vec<u8> = Vec::new();
write_ledger([original], &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
let parsed = parse_transactions(&text);
assert_eq!(parsed.len(), 1);
let posting = &parsed[0].postings[0];
assert_eq!(posting.account, "Expenses:Salary");
assert_eq!(
posting.metadata.get("employee").map(String::as_str),
Some("alice")
);
assert!(posting.tags.contains(&"payroll".to_string()));
}
}
#[cfg(test)]
mod eval_transaction_tests {
use chrono::NaiveDate;
use rust_decimal::{Decimal, dec};
use super::*;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
#[test]
fn simple_two_posting_transaction() {
let txn = resolution::Transaction::new(date(2024, 1, 15), "Groceries")
.with_posting(
resolution::Posting::new("Expenses:Food").with_amount((Decimal::from(50u32), "$")),
)
.with_posting(resolution::Posting::new("Assets:Checking"));
let resolved = eval_transaction(txn, &resolution::Context::default()).unwrap();
assert_eq!(resolved.description, "Groceries");
assert_eq!(resolved.postings.len(), 2);
let food = resolved
.postings
.iter()
.find(|p| p.account == "Expenses:Food")
.unwrap();
assert_eq!(food.amount_in("$"), Some(dec!(50)));
let checking = resolved
.postings
.iter()
.find(|p| p.account == "Assets:Checking")
.unwrap();
assert_eq!(checking.amount_in("$"), Some(dec!(-50)));
}
#[test]
fn null_posting_inferred() {
let txn = resolution::Transaction::new(date(2024, 2, 1), "Rent")
.with_posting(
resolution::Posting::new("Expenses:Rent")
.with_amount((Decimal::from(1200u32), "$")),
)
.with_posting(resolution::Posting::new("Assets:Checking"));
let resolved = eval_transaction(txn, &resolution::Context::default()).unwrap();
let checking = resolved
.postings
.iter()
.find(|p| p.account == "Assets:Checking")
.unwrap();
assert_eq!(
checking.amount_in("$"),
Some(dec!(-1200)),
"null posting should be inferred as -$1200"
);
}
#[test]
fn explicit_balanced_amounts() {
let txn = resolution::Transaction::new(date(2024, 3, 1), "Transfer")
.with_posting(
resolution::Posting::new("Assets:Savings")
.with_amount((Decimal::from(500u32), "$")),
)
.with_posting(
resolution::Posting::new("Assets:Checking")
.with_amount((Decimal::from(-500i32), "$")),
);
let resolved = eval_transaction(txn, &resolution::Context::default()).unwrap();
assert_eq!(resolved.postings.len(), 2);
}
#[test]
fn unbalanced_transaction_returns_error() {
let txn = resolution::Transaction::new(date(2024, 4, 1), "Bad")
.with_posting(
resolution::Posting::new("Expenses:Food").with_amount((Decimal::from(100u32), "$")),
)
.with_posting(
resolution::Posting::new("Assets:Checking")
.with_amount((Decimal::from(-50i32), "$")),
);
let result = eval_transaction(txn, &resolution::Context::default());
assert!(
result.is_err(),
"unbalanced transaction should return an error"
);
assert!(matches!(
result.unwrap_err(),
elaborator::ElaborationError::TransactionDoesNotBalance(_)
));
}
#[test]
fn account_alias_resolved_via_context() {
let mut context = resolution::Context::default();
context
.account_aliases
.insert("Checking".into(), "Assets:Checking:Mercury:7920".into());
let txn = resolution::Transaction::new(date(2024, 5, 1), "Deposit")
.with_posting(
resolution::Posting::new("Income:Salary")
.with_amount((Decimal::from(5000u32), "$")),
)
.with_posting(resolution::Posting::new("Checking"));
let resolved = eval_transaction(txn, &context).unwrap();
let checking = resolved
.postings
.iter()
.find(|p| p.account == "Assets:Checking:Mercury:7920")
.expect("alias should resolve to canonical account name");
assert_eq!(checking.amount_in("$"), Some(dec!(-5000)));
}
#[test]
fn default_commodity_from_context() {
let mut context = resolution::Context::default();
context.default_commodity = Some("USD".into());
let bare = ast::ValueExpr::Amount {
value: Decimal::from(25u32),
commodity: None,
};
let txn = resolution::Transaction::new(date(2024, 6, 1), "Bare amount")
.with_posting(resolution::Posting::new("Expenses:Food").with_amount(bare))
.with_posting(resolution::Posting::new("Assets:Cash"));
let resolved = eval_transaction(txn, &context).unwrap();
let food = resolved
.postings
.iter()
.find(|p| p.account == "Expenses:Food")
.unwrap();
assert_eq!(
food.amount_in("USD"),
Some(dec!(25)),
"bare amount should use default commodity from context"
);
}
#[test]
fn resolved_transaction_preserves_fields() {
let txn = resolution::Transaction::new(date(2024, 7, 4), "Independence Day")
.with_state(ast::TransactionState::Cleared)
.with_code("IND-04")
.with_secondary_date(date(2024, 7, 5))
.with_tag("holiday")
.with_metadata("ref", "USA")
.with_posting(
resolution::Posting::new("Expenses:Celebration")
.with_amount((Decimal::from(200u32), "$")),
)
.with_posting(resolution::Posting::new("Assets:Checking"));
let resolved = eval_transaction(txn, &resolution::Context::default()).unwrap();
assert_eq!(resolved.description, "Independence Day");
assert_eq!(
resolved.state,
elaboration::TransactionState::Cleared as i32
);
assert_eq!(resolved.code.as_deref(), Some("IND-04"));
assert!(resolved.secondary_date.is_some());
assert!(resolved.tags.contains(&"holiday".to_string()));
assert_eq!(
resolved.metadata.get("ref").map(String::as_str),
Some("USA")
);
}
#[test]
fn too_many_null_postings_returns_error() {
let txn = resolution::Transaction::new(date(2024, 8, 1), "Ambiguous")
.with_posting(resolution::Posting::new("Expenses:A"))
.with_posting(resolution::Posting::new("Expenses:B"));
let result = eval_transaction(txn, &resolution::Context::default());
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
elaborator::ElaborationError::TooManyNullPostings
));
}
}