# doppio
**A typed compiler pipeline and CLI for [Ledger](https://ledger-cli.org/) plain-text accounting — built to be embedded.**
doppio parses `.ledger` files through a four-stage compiler (parse → resolution → elaboration → binary serialization), enforces double-entry balance and balance assertions, and exposes every stage as a first-class Rust library API. Use the `dop` CLI to query your journals directly, or embed the library to build importers, reporting tools, and accounting applications on a correct, type-safe foundation.
## Why doppio?
Most Ledger tooling treats the format as a parsing problem. doppio treats it as a compilation problem: source text goes in, a fully elaborated, validated journal comes out — along with a compact binary (`.dop`) for fast repeated queries without re-parsing.
**For library users:** Construct transactions programmatically with a fluent builder API, run them through elaboration to validate balance, and serialize back to Ledger source text or the binary format. The library exposes the full pipeline at each stage (`ast`, `resolution`, `elaboration`) so you work at the right level of abstraction.
**For CLI users:** Compile once to `.dop`, then query balance sheets and posting registers in milliseconds. Accepts both raw `.ledger` files and pre-compiled `.dop` files interchangeably.
## Quick start
**CLI:**
```sh
cargo install doppio
dop compile --output my-journal.dop my-journal.ledger
dop balance my-journal.dop
dop register my-journal.dop Expenses
```
**Library:**
```rust
use doppio::resolution::{Context, Transaction, Posting};
use chrono::NaiveDate;
use rust_decimal::Decimal;
// Build a transaction programmatically
let txn = Transaction::new(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), "Groceries")
.with_posting(Posting::new("Expenses:Food").with_amount((Decimal::from(50u32), "$")))
.with_posting(Posting::new("Assets:Checking"));
// Validate and elaborate it (balance is checked, null posting inferred)
let resolved = doppio::eval_transaction(txn, &Context::default())?;
// Or compile a full journal from source. The opener returns
// Result<String, Box<dyn Error>> so it can surface I/O failures.
let journal = doppio::compile(&source_text, doppio::parser::Parser {
opener: doppio::file_opener, // built-in glob-aware opener
base_path: std::path::PathBuf::from("."),
})?;
for txn in &journal.transactions {
println!("{}: {}", txn.date, txn.description);
}
```
## CLI reference
### `compile` — pre-process a journal file
```
dop compile --output my-journal.dop my-journal.ledger
```
Parses the source file, runs it through the full compilation pipeline, and writes the result as a postcard-serialized, XZ-compressed `.dop` file. Use this for large journals to avoid re-parsing on every query.
### `balance` — account balances
```
dop balance my-journal.ledger
dop balance my-journal.dop --depth 2 --begin 2024-01-01 --cleared
dop balance my-journal.dop --pattern "^Expenses" --format json
```
Prints account balances grouped by commodity. Flags: `--depth N` (truncate hierarchy), `--flat` (single-line output), `--begin`/`--end` (date range), `--cleared` (cleared transactions only), `--tag KEY` (transactions tagged with `KEY`), `--pattern REGEX` (filter accounts), `--format text|json|csv`.
### `register` — posting register
```
dop register my-journal.ledger
dop register my-journal.dop Expenses --format csv
```
Lists individual postings with running totals per commodity, optionally filtered to accounts matching a regex pattern. Flags: `--begin`/`--end` (date range), `--cleared`, `--tag KEY`, `--format text|json|csv`.
### `print` — re-emit canonical Ledger source
```
dop print my-journal.ledger
```
Parses and re-emits the journal in canonical Ledger source format — useful for normalizing formatting or verifying round-trip fidelity.
### `stats` — journal summary
```
dop stats my-journal.ledger
```
Prints transaction count, account count, commodity count, and date range.
### `accounts` — list account names
```
dop accounts my-journal.ledger
```
Lists all account names found in the journal.
## Library API
The library exposes four modules corresponding to the pipeline stages, plus top-level entry points:
| `compile(source, parser)` | Full pipeline: source text → elaborated `Journal` |
| `eval_transaction(txn, ctx)` | Elaborate a single `resolution::Transaction` — validate balance, infer null posting, apply aliases |
| `write_ledger(txns, writer)` | Serialize `resolution::Transaction` values to canonical Ledger source text |
| `dop_write_header` / `dop_read_header` | Portable `.dop` header I/O with clear version-mismatch errors |
The `resolution::Transaction` and `resolution::Posting` builder APIs are the intended construction layer for programmatic use:
```rust
doppio::resolution::Transaction::new(date, "Payee")
.with_state(doppio::ast::TransactionState::Cleared)
.with_metadata("import_id", &bank_transaction_id)
.with_posting(
doppio::resolution::Posting::new("Assets:Checking")
.with_amount((amount, "USD"))
)
.with_posting(doppio::resolution::Posting::new("Expenses:Food"))
```
Full API documentation:
```
cargo doc --no-deps --open
```
## Supported Ledger features
doppio supports the subset of ledger-cli syntax needed for typical day-to-day
plain-text accounting, including the patterns used by real downstream books.
At a glance:
| Transactions, postings, balance assertions/assignments | Supported |
| Directives — `include` (incl. globs), `account`, `commodity`, `alias`, `define` (with parameters), `tag` (with `assert`/`check`), `P` historical price | Supported |
| Expressions — arithmetic, comparisons, regex `=~`/`!~`, `tag()`, parameterised function calls | Supported |
| CLI — `compile`, `balance`, `register`, `print`, `stats`, `accounts`, `commodities`; text / JSON / CSV output | Supported |
| Library API — `compile`, `eval_transaction`, `write_ledger`, `.dop` binary format | Supported |
| Budgets (`~`), automated transactions (`= payee expr`), Lisp-style scripting | Not supported |
See [`docs/SUPPORTED_FEATURES.md`](./docs/SUPPORTED_FEATURES.md) for the full
matrix with notes on partial support and known limitations.
## Pipeline
doppio processes source text through four sequential stages:
```
.ledger text
│
▼
┌─────────┐
│ parse │ pest PEG grammar → ast::Journal
└─────────┘
│ unresolved dates, aliases, raw ValueExpr amounts
▼
┌────────────┐
│ resolution │ ast::Journal → resolution::HIR
└────────────┘
│ dates normalized, aliases indexed, notes → tags/metadata
▼
┌─────────────┐
│ elaboration │ resolution::HIR → elaboration::Journal
└─────────────┘
│ amounts evaluated, transactions balanced, accounts registered
▼
┌──────────────┐
│ serialization│ postcard + XZ → .dop
└──────────────┘
```
### Stage details
**Parse** (`src/parser.rs`, `src/ledger.pest`): A [pest](https://pest.rs/) PEG grammar tokenizes the source into an `ast::Journal` containing transactions, directives, and comments. Amount expressions are kept as unevaluated `ValueExpr` trees. `include` directives are resolved recursively here.
**Resolution** (`src/resolution.rs`): Converts `ast::Journal` to a Higher-level Intermediate Representation (`HIR`). Dates are resolved to `NaiveDate` (a full year is required). Commodity and account aliases are accumulated into a versioned `Context` stack so each transaction sees the aliases that were in effect when it was defined. Structured metadata and tags are extracted from freeform notes.
**Elaboration** (`src/elaboration.rs`): Converts `HIR` to the final `elaboration::Journal`. `ValueExpr` trees are evaluated to `(Decimal, commodity)` pairs, commodity aliases are applied, and each transaction is balanced — if exactly one posting has no explicit amount, its value is inferred as the negation of the sum of the rest. Balance assertions (`= amount`) and balance assignments (`=amount`) are checked or applied at this stage.
**Serialization**: The `Journal` implements `serde::Serialize`/`Deserialize`. The `compile` command writes it through [postcard](https://github.com/jamesmunns/postcard) into an XZ-compressed stream; the `balance` and `register` commands decompress and deserialize it in the reverse direction.
## Build from source
```
cargo build --release
```
The resulting binary is `target/release/dop`.
## Changelog
See [CHANGELOG.md](./CHANGELOG.md) for release notes.