doppio 1.0.0

A typed compiler pipeline for plain-text Ledger accounting -- parse, resolve, and elaborate .ledger files with a library API built for programmatic use.
Documentation
// ============================================================
// ledger.pest — PEG grammar for the Ledger plain-text accounting format
//
// Parsing is handled by pest (https://pest.rs/). This file defines the
// *structure* of the source language. Operator precedence for value
// expressions is NOT encoded here — it is applied by a Pratt parser in
// parser.rs after the grammar has produced a flat token stream.
//
// Rule naming conventions (pest):
//   UPPERCASE  — built-in rules (NEWLINE, EOI, ANY, ASCII_DIGIT, …)
//   _{ … }    — silent rule: matched but not included in the pair tree
//   @{ … }    — atomic rule: inner tokens are NOT individually paired
//   ${ … }    — compound-atomic: inner named rules ARE paired, but
//               implicit whitespace skipping is disabled
// ============================================================

// --- Top Level ---

// The entire file is a sequence of entries separated by blank lines.
// A compound-atomic ($) root rule is used so that leading/trailing
// whitespace is preserved exactly as written in the source.
journal = ${ (entry | NEWLINE)* ~ EOI }

// An entry is one of: transaction, directive, comment, or budget.
// Silent (_) so the match is transparent — callers see the inner rule.
entry = _{
    assertion_directive
    | transaction
    | directive
    | historical_price
    | comment_line
    | budget
}

// --- Transactions ---

// A transaction is a header line followed by zero or more postings/notes.
// The compound-atomic modifier ($) is critical: it prevents the grammar
// from silently swallowing the indentation that separates postings.
transaction = ${
    header ~ NEWLINE ~
    (transaction_note | posting | empty_indented_line)*
}

// Budget entries (e.g. "~ monthly ...") share the same posting structure
// but are not yet elaborated by the compiler.
budget = ${
    "~" ~ ws+ ~ ("yearly" | "monthly" | "weekly") ~ NEWLINE ~
    (transaction_note | posting | empty_indented_line)*
}

// The header line: date, optional secondary date, state, code, description,
// and an optional trailing note.
header = ${
    date ~ (ws* ~ "=" ~ ws* ~ date)?
    ~ (ws+ ~ state)?
    ~ (ws+ ~ code)?
    ~ (ws+ ~ description)
    ~ (ws* ~ note)?
}

// A note line that is part of the transaction header (indented, starts with ;)
transaction_note = ${ indent+ ~ note ~ (NEWLINE | EOI) }

// A blank indented line allowed between postings — consumed silently.
empty_indented_line = _{ indent+ ~ NEWLINE }

// --- Postings ---

// A posting is an indented account name, optionally followed by an amount
// and/or a trailing note, plus zero or more indented note lines.
//
// The !NEWLINE lookahead prevents a truly empty indented line from being
// matched as a posting with an empty account name.
posting = ${
    indent+ ~ !NEWLINE ~ (state ~ ws+)? ~ posting_account ~ (amount_logic)? ~ (ws* ~ note)? ~ (NEWLINE | EOI) ~
    posting_note*
}

// The account portion of a posting, which may be a bare account name or a
// virtual-posting marker wrapping an account name.
posting_account = ${
    virtual_unbalanced_account
    | virtual_balanced_account
    | account
}

// `(Account)` — virtual unbalanced: the posting is excluded from the
// transaction's balance check. The parens are part of the syntax, not the
// account name; the inner name is captured by `virtual_account_inner`.
virtual_unbalanced_account = ${ "(" ~ virtual_account_inner ~ ")" }

// `[Account]` — virtual balanced: the posting participates in the balance
// check but is flagged so reports can include/exclude it via `--real`.
virtual_balanced_account = ${ "[" ~ virtual_account_inner ~ "]" }

// The bare account name inside a virtual posting marker. The exclusion set
// matches `account` but also stops at `)` and `]` so the closing marker is
// never consumed into the name.
virtual_account_inner = @{ (!("  " | ";" | "\t" | "=" | NEWLINE | ")" | "]") ~ ANY)+ }

// A note line indented beneath a posting.
posting_note = ${ indent+ ~ note ~ (NEWLINE | EOI) }

// The amount field of a posting.
//
// Two or more spaces (or a tab) are REQUIRED before the amount. This is the
// canonical Ledger convention: it unambiguously separates the account name
// from the amount, since account names may contain single spaces (e.g.
// "Equity:Opening Balances").
amount_logic = ${
    (ws{2,} | "\t") ~ (
        value_logic
        | assertion
    )
}

// A value expression, optionally followed by lot annotations and/or lot pricing
// and/or a balance assertion check.
//
// Lot annotations may appear in any order between the value and the optional
// `@`/`@@` price. Multiple annotations of different kinds are allowed;
// duplicate kinds take the last value (matching ledger-cli behaviour).
value_logic = ${ value_expr ~ (ws* ~ lot_annotation_or_price)* ~ (ws* ~ assertion)? }
lot_annotation_or_price = ${ lot_annotation | lot_price }
lot_annotation = ${ lot_cost | lot_date | lot_note }
// Double-brace `{{expr}}` means total cost; single-brace `{expr}` means per-unit
// cost. Try `{{` before `{` so the longer token wins.
lot_cost = ${ ("{{" ~ ws* ~ value_expr ~ ws* ~ "}}") | ("{" ~ ws* ~ value_expr ~ ws* ~ "}") }
// `[date]` — lot acquisition date. Only valid after a posting amount, so there
// is no ambiguity with `[Account]` virtual balanced postings (those appear in
// account position, not after an amount).
lot_date = ${ "[" ~ date ~ "]" }
// `((note))` — free-form lot note. Try `((` before `(` to avoid partial matches.
lot_note = ${ "((" ~ lot_note_inner ~ "))" }
lot_note_inner = @{ (!"))" ~ ANY)* }

// A balance check or balance assignment: `= target` or `== target`.
// Used in two ways within `amount_logic`:
//   - After `value_logic`: a post-posting balance assertion (becomes `balance_assertion`
//     in `AmountDetails::Amount`).
//   - Standalone (no preceding amount): parsed by the parser as
//     `AmountDetails::BalanceAssignment`; the posting amount is inferred to bring
//     the account to the target balance.
assertion = ${ "="{1, 2} ~ ws* ~ value_expr }

// --- Atoms ---

// A full date: YYYY/MM/DD or YYYY-MM-DD. The four-digit year is mandatory.
date = ${ year ~ ("/" | "-") ~ monthdate ~ ("/" | "-") ~ monthdate }
year = @{ ASCII_DIGIT{4} }
monthdate = @{ ASCII_DIGIT{1,2} }

// Cleared (*) or pending (!) state marker.
state = { "*" | "!" }

// An optional reference code in parentheses, e.g. "(INV-042)".
code = { "(" ~ (!")" ~ ANY)* ~ ")" }

// Everything after the header fields up to a semicolon or newline.
description = { (!(";" | NEWLINE) ~ ANY)* }

// An account name: any characters that are not a double-space, semicolon,
// tab, equals sign, or newline. The double-space exclusion is what
// makes the "two-space rule" for amounts work — the account name stops
// as soon as two consecutive spaces are encountered.
account = @{ (!("  " | ";" | "\t" | "=" | NEWLINE) ~ ANY)+ }

// Lot pricing: "@ unit_price" or "@@ total_price".
lot_price = ${ ("@@" | "@") ~ ws* ~ value_expr }

// Whitespace helpers (silent — not included in the pair tree).
indent = _{ " " | "\t" }
ws = _{ " " | "\t" }

// A semicolon-prefixed note. The note_str captures everything up to EOL.
note = ${ ";" ~ note_str }
note_str = ${ (!NEWLINE ~ ANY)* }

// A full-line comment. Ledger accepts several comment characters.
comment_line = @{ (";" | "#" | "*" | "%" | "|") ~ (!NEWLINE ~ ANY)* }

// --- Historical Prices ---

// A price directive: "P date [HH:MM[:SS]] commodity price".
// Records the market price of a commodity at a point in time.
// The time component is optional; when absent the price applies to the whole day.
historical_price = ${ "P" ~ ws+ ~ date ~ ws+ ~ (time ~ ws+)? ~ commodity ~ ws+ ~ value_expr ~ (NEWLINE | EOI) }

// An optional wall-clock time: HH:MM or HH:MM:SS.
time = ${ ASCII_DIGIT{2} ~ ":" ~ ASCII_DIGIT{2} ~ (":" ~ ASCII_DIGIT{2})? }

// --- Standalone Balance Assertion Directives ---

// A standalone balance assertion: `date = account  amount` (weak) or
// `date == account  amount` (strict). The `=`/`==` marker takes the place
// of the cleared-state field that appears in transaction headers.
//
// Two or more spaces (or a tab) separate the account from the amount,
// matching the same convention used for posting amounts.
assertion_directive = ${
    date ~ ws+ ~ assertion_op ~ ws+ ~ account ~ (ws{2,} | "\t") ~ value_expr ~ (NEWLINE | EOI)
}

// `==` must be tried before `=` so that the longer token wins.
assertion_op = @{ "==" | "=" }

// --- Directives ---

// The optional leading "!" is allowed by some Ledger implementations.
// define and tag directives are parsed but not yet elaborated.
directive = _{ "!"? ~ (commodity_directive | account_directive | alias_directive | include_directive | define_directive | tag_directive | default_directive) }

// A bare `D` directive: declares the default commodity AND its display format
// in a single line. `D $1000.00` is equivalent to a `commodity $` block with
// `default` and `format $1,000.00` sub-directives. The amount text supplies
// both the commodity symbol and the format string.
default_directive = ${ "D" ~ ws+ ~ value_expr ~ (NEWLINE | EOI) }

tag_directive = ${
    "tag" ~ ws+ ~ identifier ~ (ws* ~ note)? ~ NEWLINE ~
    ((indent+ ~ note ~ NEWLINE) | tag_assert | tag_check)*
}

// assert / check sub-directives for tag blocks. Separate rules from
// account_assert / account_check so parent context can be determined
// unambiguously by the parser, even though the shape is identical.
tag_assert = ${ indent+ ~ "assert" ~ ws+ ~ bool_expr ~ (NEWLINE | EOI) }
tag_check  = ${ indent+ ~ "check"  ~ ws+ ~ bool_expr ~ (NEWLINE | EOI) }

// A commodity block: declares properties such as aliases, display format,
// and whether market prices are tracked.
commodity_directive = ${
    "commodity" ~ ws+ ~ commodity ~ (ws* ~ note)? ~ NEWLINE ~
    ((indent+ ~ note ~ NEWLINE) | commodity_item)*
}

commodity_item = ${
    indent+ ~ commodity_key ~ (ws+ ~ commodity_val)? ~ (ws* ~ note)? ~ (NEWLINE | EOI)
}

// Known commodity keys are enumerated first; anything else is "identifier".
commodity_key = @{ "alias" | "format" | "nomarket" | "default" | identifier }
commodity_val = @{ (!NEWLINE ~ ANY)* }

// An account block: declares an account and its properties (alias, note).
// account_assert / account_check are tried before account_item so the
// "assert" / "check" keywords are not swallowed as unknown keys.
account_directive = ${
    "account" ~ ws+ ~ account ~ (ws* ~ note)? ~ NEWLINE ~
    ((indent+ ~ note ~ NEWLINE) | account_assert | account_check | account_item)*
}

// assert / check take a full boolean expression rather than a raw string.
account_assert = ${ indent+ ~ "assert" ~ ws+ ~ bool_expr ~ (NEWLINE | EOI) }
account_check  = ${ indent+ ~ "check"  ~ ws+ ~ bool_expr ~ (NEWLINE | EOI) }

account_item = ${
    indent+ ~ account_key ~ (ws+ ~ account_val)? ~ (ws* ~ note)? ~ (NEWLINE | EOI)
}
account_key = @{ "note" | "alias" | identifier }
account_val = @{ (!NEWLINE ~ ANY)* }

// A top-level "alias short = long" shorthand for account names.
alias_directive = ${ "alias" ~ ws+ ~ account ~ ws* ~ "=" ~ ws* ~ account ~ (ws+ ~ note)? ~ (NEWLINE | EOI) }

// A define directive: "define name[(p1, p2, ...)] = body"
// Associates a name with a value or boolean expression, optionally parameterized.
// The body is tried as a bool_expr first (to support comparisons and logical chains),
// falling back to value_expr for plain arithmetic/amount aliases.
define_directive = ${
    "define" ~ ws+ ~ identifier ~
    (ws* ~ "(" ~ ws* ~ identifier ~ (ws* ~ "," ~ ws* ~ identifier)* ~ ws* ~ ")")? ~
    ws* ~ "=" ~ ws* ~ define_body ~ (NEWLINE | EOI)
}

// A define body is either a full boolean expression (supports comparisons and
// logical chains) or a plain value expression. bool_expr is tried first because
// it is strictly more expressive; value_expr handles plain amounts and arithmetic.
define_body = ${ bool_expr | value_expr }

// Include another file by path (glob patterns are resolved in parser.rs).
include_directive = ${ "include" ~ ws+ ~ filename ~ (NEWLINE | EOI) }
filename = @{ (!NEWLINE ~ ANY)+ }

// --- Expressions (Explicit Whitespace) ---
//
// These rules define the *syntax* of value expressions. Operator PRECEDENCE
// is NOT handled here — the grammar produces a flat left-to-right token
// stream that is then processed by a Pratt parser in parser.rs, which
// applies the correct precedence (* and / bind tighter than + and -).

// A boolean expression used in account assert/check directives.
// Grammar: value_expr (cmp_op value_expr)? (bool_op bool_expr)?
// This is kept intentionally simple: no grouping, left-to-right, since
// the Pratt parser does not yet handle boolean / comparison operators.
// TODO(#74-followup): wire cmp_op/bool_op into the Pratt parser so that
// complex expressions like "a > 0 and b < 10" parse correctly under full
// precedence rules.
// `cmp_op` separators are punctuation so they take ws*; `bool_op` is alphabetic
// (`and`/`or`) and must be space-separated to avoid `xand` parsing as `x and`.
// `bool_expr` supports both ordinary comparisons (cmp_op) and regex matches
// (regex_cmp_op). The RHS of a regex comparison must be a regex_literal;
// ordinary comparisons accept any value_expr.
bool_expr = ${
    value_expr ~ (
        (ws* ~ regex_cmp_op ~ ws* ~ regex_literal)
        | (ws* ~ cmp_op ~ ws* ~ value_expr)
    )? ~ (ws+ ~ bool_op ~ ws+ ~ bool_expr)?
}
cmp_op = @{ "==" | "!=" | "<=" | ">=" | "<" | ">" }
// Regex match operators: must be tried before cmp_op to avoid `=` prefix ambiguity.
regex_cmp_op = @{ "=~" | "!~" }
bool_op = @{ "and" | "or" }

// A regex literal: /pattern/ where \/ is an escaped slash inside the pattern.
// compound-atomic ($) so that regex_body is named and visible to the parser.
regex_literal = ${ "/" ~ regex_body ~ "/" }
// regex_body captures the raw pattern text between the delimiters.
// Allows backslash-escapes (including \/) and any non-slash character.
regex_body = @{ (("\\" ~ ANY) | (!"/" ~ ANY))* }

// A value_expr is an expression optionally followed by a commodity annotation.
// The trailing commodity form "(1 + 2) USD" creates a ValueExpr::Typed node.
// The negative lookahead `!bool_op` prevents `and`/`or` from being consumed as
// a commodity, which would silently drop the boolean chain in `bool_expr`.
value_expr = ${ expr ~ (ws+ ~ !bool_op ~ commodity)? }

// An expression is one or more terms joined by infix operators.
expr = ${ term ~ (ws* ~ infix_op ~ ws* ~ term)* }

// A term is an optional prefix operator followed by a primary atom.
term = ${ (prefix_op ~ ws*)? ~ primary }

// A primary is a base atom optionally followed by dot-access chains.
// base_primary is silent, so the parser sees the child rule directly.
primary = { base_primary ~ access* }

base_primary = _{
    function_call
    | string
    | "(" ~ bool_expr ~ ")"
    | "(" ~ expr ~ ")"
    | amount
    | commodity
}

// A dot-accessor: ".fieldname"
access = ${ "." ~ identifier }

// A double-quoted string literal.
// Atomic string: matches anything between double quotes
string = ${ "\"" ~ (!"\"" ~ ANY)* ~ "\"" }

// A function call: name(arg1, arg2, …). Arguments are full expr trees.
function_call = ${ identifier ~ ws* ~ "(" ~ ws* ~ (expr ~ (ws* ~ "," ~ ws* ~ expr)*)? ~ ws* ~ ")" }

// An amount literal: commodity-prefixed, number-first, or bare number.
//   "$100"      — symbol before number (no space required)
//   "100 USD"   — identifier commodity after number (space required)
//   "100"       — bare number (commodity filled in from context later)
// The negative lookahead `!bool_op` in the number-first alternative prevents
// `and`/`or` from being consumed as a commodity (e.g., `0 and` in `amount > 0 and`).
amount = ${
    (commodity ~ ws* ~ number)
    | (number ~ ws+ ~ !bool_op ~ commodity)
    | number
}

// Numbers may use comma thousands-separators ("1,234.56") or start with a
// decimal point (".5"). Commas are stripped in parser.rs before parsing.
// Comma followed by 3 digits to distinguish from function arguments
number = @{
    ASCII_DIGIT+ ~ ("," ~ ASCII_DIGIT{3})* ~ ("." ~ ASCII_DIGIT+)?
    | "." ~ ASCII_DIGIT+
}

// A commodity is either one or more currency symbols ($, €, £, ¥) or
// an identifier-style name (USD, BTC, AAPL, …).
commodity = @{
    symbol+
    | (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "." | ":" | "#")*
}

// Currency symbols treated as single-character commodities.
symbol = _{ "$" | "€" | "£" | "¥" }

// A plain identifier: letter or underscore, then alphanumeric or underscore.
identifier = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* }

// Infix and prefix operators. These rules are named (not silent) so that
// the Pratt parser in parser.rs can match on their Rule variants.
// Operators must be visible to Pratt
infix_op = _{ add | sub | mul | div }
    add = { "+" }
    sub = { "-" }
    mul = { "*" }
    div = { "/" }
prefix_op = { "-" | "+" }