// ============================================================
// hledger.pest — PEG grammar for the hledger journal format.
//
// Conventions from pest (https://pest.rs/):
// 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
//
// Key differences from ledger.pest:
// - Date separators include `.` and `/` in addition to `-`
// - Comment lines also start with `#` (in addition to `;`)
// - `description` is optional (hledger allows bare `DATE *` lines)
// - `account` directives may have a type comment on the header line
// - `commodity` directive accepts a commodity-format string, not just
// a bare commodity name
// - Automated posting rule bodies accept both bare decimal multipliers
// (e.g. `0.10`) and explicit `*N` prefix multipliers (e.g. `*-1`,
// `* 0.5`); see `auto_multiplier` rule below (#254)
//
// NOT YET SUPPORTED (stubbed / ignored):
// - Full hledger date inference from a preceding `Y year` directive.
// - Directive-attached tags (metadata on the same line as a directive).
// ============================================================
// --- Top Level ---
journal = ${ (entry | NEWLINE)* ~ EOI }
entry = _{
transaction
| periodic_transaction
| auto_rule
| historical_price
| account_directive
| commodity_directive
| default_directive
| include_directive
| block_comment
| comment_line
}
// --- Transactions ---
// A transaction header followed by optional indented notes and postings.
transaction = ${
header ~ NEWLINE ~
(transaction_note | posting | empty_indented_line)*
}
// hledger header:
// DATE [STATUS] [CODE] [DESCRIPTION] [; COMMENT]
//
// Description is optional in hledger (bare `2024-01-01 *` is valid).
header = ${
date
~ (ws+ ~ status)?
~ (ws+ ~ code)?
~ (ws+ ~ description)?
~ (ws* ~ note)?
}
transaction_note = ${ indent+ ~ note ~ (NEWLINE | EOI) }
empty_indented_line = _{ indent+ ~ NEWLINE }
// --- Periodic transactions ---
//
// `~ PERIOD_EXPR\n POSTINGS…`
// The period expression is the rest of the header line; it is stored as a raw
// string and not elaborated (same as the ledger frontend budget entries).
periodic_transaction = ${
"~" ~ ws+ ~ period_expr ~ NEWLINE ~
(transaction_note | posting | empty_indented_line)*
}
period_expr = @{ (!(NEWLINE | ";") ~ ANY)+ }
// --- Automated posting rules ---
//
// `= QUERY\n POSTINGS…`
//
// 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)+ }
// --- Postings ---
posting = ${
indent+ ~ !NEWLINE ~
(status ~ ws+)? ~
posting_account ~
(amount_logic)? ~
(ws* ~ note)? ~
(NEWLINE | EOI) ~
posting_note*
}
posting_note = ${ indent+ ~ note ~ (NEWLINE | EOI) }
// The account portion of a posting: bare name or a virtual-posting marker.
posting_account = ${
virtual_unbalanced_account
| virtual_balanced_account
| account
}
// `(Account)` — virtual unbalanced posting.
virtual_unbalanced_account = ${ "(" ~ virtual_account_inner ~ ")" }
// `[Account]` — virtual balanced posting.
virtual_balanced_account = ${ "[" ~ virtual_account_inner ~ "]" }
// Bare account name inside a virtual posting marker (parens/brackets stripped).
virtual_account_inner = @{ (!(" " | ";" | "\t" | "=" | NEWLINE | ")" | "]") ~ ANY)+ }
// The amount field of a posting.
// Two or more spaces (or a tab) separate the account from the amount —
// identical to the ledger-cli convention so that account names containing a
// single space are parsed correctly.
//
// `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. Outside auto-rule bodies this is harmless: regular
// postings don't start with `*`.
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`). Lowers 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. Mirrors the ledger-cli grammar.
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.
lot_date = ${ "[" ~ date ~ "]" }
// `((note))` — free-form lot note.
lot_note = ${ "((" ~ lot_note_inner ~ "))" }
lot_note_inner = @{ (!"))" ~ ANY)* }
// hledger uses `==` for "strict" balance assertions and `=` for "weak"
// assertions; an optional trailing `*` qualifier ("== *" / "= *") makes
// the assertion span EVERY commodity the account holds rather than just
// the one written. The combined form `==* 0` is the standard
// fiscal-year retained-earnings idiom: assert that the account has
// zero across all commodities and synthesize whatever postings are
// needed to make that true. The longer token must be matched first
// (the `==` `*` ordering is enforced by the rule below).
assertion = ${ assertion_op ~ ws* ~ value_expr }
assertion_op = @{ ("==" | "=") ~ "*"? }
// --- Atoms ---
// hledger accepts YYYY-MM-DD, YYYY/MM/DD, and YYYY.MM.DD.
date = ${ year ~ date_sep ~ monthdate ~ date_sep ~ monthdate }
year = @{ ASCII_DIGIT{4} }
monthdate = @{ ASCII_DIGIT{1,2} }
date_sep = _{ "-" | "/" | "." }
// Cleared (*) or pending (!) state marker.
status = { "*" | "!" }
// An optional reference code in parentheses, e.g. "(INV-042)".
code = { "(" ~ (!")" ~ ANY)* ~ ")" }
// Everything after the header fields up to a semicolon or newline.
// hledger trims trailing whitespace from the description at display time.
description = @{ (!(";" | NEWLINE) ~ ANY)+ }
// An account name: stops at double-space, semicolon, tab, equals, or newline.
account = @{ (!(" " | ";" | "\t" | "=" | NEWLINE) ~ ANY)+ }
// Lot pricing: `@ unit_price` or `@@ total_price`.
lot_price = ${ ("@@" | "@") ~ ws* ~ value_expr }
indent = _{ " " | "\t" }
ws = _{ " " | "\t" }
// hledger comments start with `;` or `#`.
note = ${ ";" ~ note_str }
note_str = ${ (!NEWLINE ~ ANY)* }
comment_line = @{ (";" | "#") ~ (!NEWLINE ~ ANY)* }
// `comment` ... `end comment` block-comment form. hledger ignores
// every line in between; we capture the inner text verbatim and
// pass it through as Entry::Comment so resolution discards it like
// any other comment. The opening `comment` must appear on its own
// line; an unclosed block (no matching `end comment`) is implicitly
// terminated by EOF, matching hledger's behaviour.
//
// The body lookahead requires `end comment` to be followed by
// NEWLINE or EOI so that a literal `end comment` *substring* inside
// the body's prose (e.g. inside backticks) does not prematurely
// terminate the block.
block_comment = ${
"comment" ~ NEWLINE ~
block_comment_body ~
(("end comment" ~ (NEWLINE | EOI)) | EOI)
}
block_comment_body = @{ (!("end comment" ~ (NEWLINE | EOI)) ~ ANY)* }
// --- Historical prices ---
historical_price = ${
"P" ~ ws+ ~ date ~ ws+ ~ commodity ~ ws+ ~ value_expr ~ (NEWLINE | EOI)
}
// --- Account directive ---
//
// hledger's account directive optionally has indented sub-directives
// (note, alias, type, …) which are treated as key/value account items,
// mirroring the structure of the ledger-cli account block.
account_directive = ${
"account" ~ ws+ ~ account ~ (ws* ~ note)? ~ NEWLINE ~
((indent+ ~ note ~ NEWLINE) | account_item)*
}
account_item = ${
indent+ ~ account_key ~ (ws+ ~ account_val)? ~ (ws* ~ note)? ~ (NEWLINE | EOI)
}
account_key = @{ "note" | "alias" | "type" | identifier }
account_val = @{ (!NEWLINE ~ ANY)* }
// --- Commodity directive ---
//
// hledger accepts:
// commodity SYMBOL (bare symbol)
// commodity FORMAT_STRING (e.g. `commodity $1,000.00`)
//
// Both forms are captured as a raw format string; parsing the format is done
// in the Rust layer by inspecting the string.
commodity_directive = ${
"commodity" ~ ws+ ~ commodity_format ~ (ws* ~ note)? ~ (NEWLINE | EOI) ~
((indent+ ~ note ~ NEWLINE) | commodity_item)*
}
// A commodity format string is the rest of the line up to a comment.
commodity_format = @{ (!(";" | NEWLINE) ~ ANY)+ }
commodity_item = ${
indent+ ~ commodity_key ~ (ws+ ~ commodity_val)? ~ (ws* ~ note)? ~ (NEWLINE | EOI)
}
commodity_key = @{ "alias" | "format" | "nomarket" | "default" | "note" | identifier }
commodity_val = @{ (!NEWLINE ~ ANY)* }
// --- Default commodity directive ---
//
// hledger supports the same `D <amount>` form as ledger-cli:
// D $1,000.00
// This declares the default commodity and its display format simultaneously.
// It is lowered to the same `Directive::Commodity` representation as a
// full `commodity` block with `default` and `format` sub-directives.
default_directive = ${ "D" ~ ws+ ~ value_expr ~ (NEWLINE | EOI) }
// --- Include directive ---
include_directive = ${ "include" ~ ws+ ~ filename ~ (NEWLINE | EOI) }
filename = @{ (!NEWLINE ~ ANY)+ }
// --- Value expressions ---
//
// Identical in shape to ledger.pest. hledger's posting-amount syntax is a
// subset of ledger-cli's (no `define`, no boolean-expressions in directives),
// so the same Pratt-parsed expression grammar is fully sufficient.
value_expr = ${ expr ~ (ws+ ~ commodity)? }
expr = ${ term ~ (ws* ~ infix_op ~ ws* ~ term)* }
term = ${ (prefix_op ~ ws*)? ~ primary }
primary = { base_primary }
base_primary = _{
"(" ~ expr ~ ")"
| amount
| commodity
}
// 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; `prefix_op?`
// allows both `"Foo" -1` and `"Foo" - 1` (permissive whitespace).
amount = ${
(commodity ~ ws* ~ prefix_op? ~ ws* ~ number)
| (number ~ ws+ ~ commodity)
| number
}
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
// (hledger does not support them either); embedded quotes are disallowed.
commodity = @{
symbol+
| "\"" ~ (!"\"" ~ ANY)+ ~ "\""
| (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "." | ":" | "#")*
}
symbol = _{ "$" | "€" | "£" | "¥" }
// A plain identifier: letter or underscore, then alphanumeric or underscore.
identifier = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* }
infix_op = _{ add | sub | mul | div }
add = { "+" }
sub = { "-" }
mul = { "*" }
div = { "/" }
prefix_op = { "-" | "+" }