// ============================================================
// 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
| c_directive
| comment_line
| budget
| auto_rule
| apply_tag_directive
| end_tag_directive
}
// --- 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)*
}
// Automated posting rules ("= QUERY\n POSTINGS…"). The query is a
// regex / value-expression that ledger-cli matches against subsequent
// transactions; matched postings synthesise additional postings from
// the rule body. See elaborator.rs for the application semantics (#249).
//
// Multiplier semantics: a body posting amount that is a bare commodity-less
// number (e.g. `0.10`) is interpreted as a multiplier of the matched
// posting's amount in its commodity. The explicit `*N` prefix form
// (e.g. `*-1`, `* 0.5`) is also accepted and lowers to the same bare-number
// multiplier representation — no new semantic path is required in the
// elaborator. Body postings with an explicit commodity symbol (e.g. `$10.00`)
// are taken as literal amounts. See `auto_multiplier` below and #254.
auto_rule = ${
"=" ~ ws+ ~ rule_query ~ NEWLINE ~
(transaction_note | posting | empty_indented_line)*
}
rule_query = @{ (!(NEWLINE | ";") ~ ANY)+ }
// `apply tag <key>[: <value>]` opens a tag scope; `end tag` (or
// `end apply tag`) closes it. ledger-cli appends the active tag
// stack to every transaction declared between the two markers
// (#222). The Parser threads the stack through recursive include
// resolution; transaction parsing reads it and appends a note per
// active tag.
apply_tag_directive = ${ "apply" ~ ws+ ~ "tag" ~ ws+ ~ apply_tag_body ~ (NEWLINE | EOI) }
apply_tag_body = @{ (!NEWLINE ~ ANY)+ }
end_tag_directive = ${ "end" ~ ws+ ~ ("apply" ~ ws+)? ~ "tag" ~ ws* ~ (NEWLINE | EOI) }
// 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.
//
// `ws*` immediately before `(NEWLINE | EOI)` tolerates trailing whitespace
// after the last meaningful token on the line. Real-world journals
// (e.g. ledger-cli's own `test/input/standard.dat`) pad account names to
// a fixed column with trailing spaces, including on null postings; the
// whitespace before EOL is parser-meaningless. See #247.
posting = ${
indent+ ~ !NEWLINE ~ (state ~ ws+)? ~ posting_account ~ (amount_logic)? ~ (ws* ~ note)? ~ ws* ~ (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").
//
// `auto_multiplier` is tried first so that the `*` prefix in automated-rule
// body postings (e.g. `*-1`, `* 0.5`) is consumed before `value_logic`'s
// expression parser (which also handles infix `*`). Outside auto-rule bodies
// this is harmless: regular postings don't start with `*` anyway.
amount_logic = ${
(ws{2,} | "\t") ~ (
auto_multiplier
| value_logic
| assertion
)
}
// Explicit `*N` multiplier form used in automated-rule body postings.
// Matches `*<number>` or `* <number>` with an optional leading sign on the
// number (e.g. `*-1`, `*0.5`, `* 0.12`). The `*` is consumed here and the
// numeric part is lowered to the same bare-number `ValueExpr::Amount` as a
// plain bare decimal — the elaborator's `is_bare_number_expr` treats both
// forms identically (#254).
auto_multiplier = ${ "*" ~ ws* ~ (prefix_op ~ ws*)? ~ number }
// 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) }
// --- Commodity Conversion Directive ---
// `C N1 X = N2 Y` — declares a fixed exchange rate between two commodities.
// The LHS commodity (X) is canonical; the RHS (Y) is the alias. Amounts in Y
// are divided by (N1 * N2) to obtain their X-equivalent. Each amount is a
// concrete `<number><commodity>` pair — no arithmetic in the C directive.
//
// Example: C 1.00s = 100c (1 silver = 100 copper; canonical is s)
// C 1.00G = 100s (1 gold = 100 silver; canonical is G)
//
// ledger-cli only — hledger has no equivalent directive at the grammar level.
c_directive = ${
"C" ~ ws+ ~ c_amount ~ ws* ~ "=" ~ ws* ~ c_amount ~ (NEWLINE | EOI)
}
// A bare concrete amount inside a `C` directive: digits (with optional decimal
// point), immediately followed by the commodity symbol (no space allowed).
// Both `100c` and `1.00s` are valid; symbol-first like `$1` is NOT used by
// ledger-cli's C directive in practice.
c_amount = ${ c_number ~ ws* ~ commodity }
c_number = @{ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT*)? }
// 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* }
// Ordering note: `amount` is tried before `string` so that commodity-first
// quoted amounts like `"Long Name" 5` are consumed by the `amount` rule
// (commodity ~ ws* ~ number) rather than by `string`. A quoted string that
// is *not* followed by a number still falls through to `string` because the
// `amount` alternatives all require a numeric component. `function_call` is
// first because it starts with an identifier, not a quote.
base_primary = _{
function_call
| amount
| string
| "(" ~ expr ~ ")"
| "(" ~ bool_expr ~ ")"
| 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)
// `"Foo" -1` — quoted commodity before a signed number (ledger-cli
// accepts optional whitespace between sign and number)
// 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* ~ prefix_op? ~ ws* ~ number)
| (number ~ ws+ ~ !bool_op ~ commodity)
| (number ~ 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 ($, €, £, ¥), a
// double-quoted opaque name (e.g. "Plans: Wildthorn Mail"), or an
// identifier-style name (USD, BTC, AAPL, …). The quoted alternative is
// listed second so that a leading `"` is always consumed by it rather than
// by the symbol alternative; the identifier alternative is last so that bare
// names still work. Escape sequences are not supported inside quoted names
// (ledger-cli does not support them either); embedded quotes are disallowed.
commodity = @{
symbol+
| "\"" ~ (!"\"" ~ ANY)+ ~ "\""
| (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 = { "-" | "+" }