// ============================================================
// beancount.pest -- PEG grammar for the Beancount 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 / hledger.pest:
// - Date format is strict ISO `YYYY-MM-DD` (no `/` or `.` separators).
// - Currencies are bare uppercase identifiers (`USD`, `EUR`, `AAPL`),
// not `$`-prefixed.
// - String literals `"..."` are explicit on most directives.
// - Tags `#tag` and links `^link` appear after a transaction's
// description.
// - Comments start with `;` only (Beancount uses `#` for tags).
// - The directive set is keyword-led after the date:
// `<date> open|close|commodity|pad|balance|price|note|document
// |event|query|custom <args...>`.
// - Transactions are flagged with `*` (complete), `!` (flagged),
// or the literal `txn`.
//
// NOT YET SUPPORTED:
// - String escape sequences (`\"`, `\n`, etc) inside quoted strings.
// - Beancount's lot syntax inside `{...}` is captured as raw text;
// the AST adapter (#146) is responsible for parsing the inner
// fields (cost, date, label).
// ============================================================
// --- Top Level ---
journal = ${ (entry | NEWLINE)* ~ EOI }
entry = _{
transaction
| open_directive
| close_directive
| commodity_directive
| pad_directive
| balance_directive
| price_directive
| note_directive
| document_directive
| event_directive
| query_directive
| custom_directive
| include_directive
| option_directive
| plugin_directive
| pushtag_directive
| poptag_directive
| pushmeta_directive
| popmeta_directive
| comment_line
| org_mode_heading
| shebang_line
| org_mode_meta
}
// --- Transactions ---
//
// A transaction header is `<date> <flag> [<payee> <description> | <description>]
// [#tag]* [^link]*`, optionally followed by indented metadata and posting lines.
transaction = ${
txn_header ~ NEWLINE ~
(metadata_line | posting | empty_indented_line)*
}
txn_header = ${
date
~ ws+ ~ flag
~ (ws+ ~ string)?
~ (ws+ ~ string)?
~ (ws+ ~ tag_or_link)*
~ (ws* ~ note)?
}
// Beancount transaction flags. `*` = complete, `!` = flagged for review,
// `txn` = literal keyword form. Single-letter custom flags (P/S/T/C/U/R/M)
// are technically valid but rarely used; not modelled here.
flag = @{ "*" | "!" | "txn" }
tag_or_link = _{ tag | link }
tag = @{ "#" ~ tag_or_link_chars }
link = @{ "^" ~ tag_or_link_chars }
tag_or_link_chars = @{ (ASCII_ALPHANUMERIC | "_" | "-" | ".")+ }
// --- Postings ---
posting = ${
indent+ ~ !NEWLINE ~
(flag ~ ws+)? ~
posting_account ~
(amount_logic)? ~
(ws* ~ note)? ~
(NEWLINE | EOI) ~
posting_meta*
}
posting_account = ${ account }
// Two or more spaces (or a tab) separate the account from the amount.
amount_logic = ${
(ws{2,} | "\t") ~ value_expr ~ (ws* ~ lot_annotation_or_price)*
}
// `{...}` is a Beancount lot specification (cost, date, label).
// `{{...}}` is a total-cost variant. `@`/`@@` are inline price annotations
// (per-unit / total). Try `{{` before `{` and `@@` before `@`.
lot_annotation_or_price = _{ lot_annotation | lot_price }
lot_annotation = ${ ("{{" ~ lot_inner_total ~ "}}") | ("{" ~ lot_inner ~ "}") }
lot_inner = @{ (!"}" ~ ANY)* }
lot_inner_total = @{ (!"}}" ~ ANY)* }
lot_price = ${ ("@@" | "@") ~ ws* ~ value_expr }
posting_meta = _{ metadata_line }
// --- Metadata ---
//
// Indented `key: value` lines under a directive or posting. Beancount
// requires the key to be lowercase-led; we enforce that here so a
// posting like ` Assets:Bank:Checking 100 USD` (uppercase-led) is
// not mis-parsed as a metadata line.
metadata_line = ${
indent+ ~ metadata_key ~ ":" ~ ws* ~ metadata_value ~ (NEWLINE | EOI)
}
metadata_key = @{ (ASCII_ALPHA_LOWER | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")* }
metadata_value = @{ (!NEWLINE ~ ANY)* }
empty_indented_line = _{ indent+ ~ NEWLINE }
// --- Directives ---
//
// All Beancount directives except `include`/`option`/`plugin` lead with
// the date. Each may carry indented metadata lines under it.
open_directive = ${
date ~ ws+ ~ "open" ~ ws+ ~ account
~ (ws+ ~ commodity_list)?
~ (ws+ ~ booking_method)?
~ (ws* ~ note)?
~ (NEWLINE | EOI)
~ metadata_line*
}
// Comma-separated currency list on `open Account USD,EUR`.
commodity_list = ${ commodity ~ (ws* ~ "," ~ ws* ~ commodity)* }
// Booking method on `open` directives: `STRICT`, `NONE`, `FIFO`, `LIFO`,
// `AVERAGE`, etc. Quoted because they appear after the currency list.
booking_method = ${ "\"" ~ booking_method_chars ~ "\"" }
booking_method_chars = @{ (!"\"" ~ ANY)* }
close_directive = ${
date ~ ws+ ~ "close" ~ ws+ ~ account
~ (ws* ~ note)?
~ (NEWLINE | EOI)
~ metadata_line*
}
commodity_directive = ${
date ~ ws+ ~ "commodity" ~ ws+ ~ commodity
~ (ws* ~ note)?
~ (NEWLINE | EOI)
~ metadata_line*
}
pad_directive = ${
date ~ ws+ ~ "pad" ~ ws+ ~ account ~ ws+ ~ account
~ (ws* ~ note)?
~ (NEWLINE | EOI)
~ metadata_line*
}
balance_directive = ${
date ~ ws+ ~ "balance" ~ ws+ ~ account ~ ws+ ~ value_expr
~ (ws* ~ note)?
~ (NEWLINE | EOI)
~ metadata_line*
}
price_directive = ${
date ~ ws+ ~ "price" ~ ws+ ~ commodity ~ ws+ ~ value_expr
~ (ws* ~ note)?
~ (NEWLINE | EOI)
~ metadata_line*
}
note_directive = ${
date ~ ws+ ~ "note" ~ ws+ ~ account ~ ws+ ~ string
~ (ws* ~ note)?
~ (NEWLINE | EOI)
~ metadata_line*
}
document_directive = ${
date ~ ws+ ~ "document" ~ ws+ ~ account ~ ws+ ~ string
~ (ws* ~ note)?
~ (NEWLINE | EOI)
~ metadata_line*
}
event_directive = ${
date ~ ws+ ~ "event" ~ ws+ ~ string ~ ws+ ~ string
~ (ws* ~ note)?
~ (NEWLINE | EOI)
~ metadata_line*
}
query_directive = ${
date ~ ws+ ~ "query" ~ ws+ ~ string ~ ws+ ~ string
~ (ws* ~ note)?
~ (NEWLINE | EOI)
~ metadata_line*
}
// `<date> custom "type-name" <arg> <arg> ...` -- arguments may be strings,
// numbers, accounts, commodities, dates, or booleans.
custom_directive = ${
date ~ ws+ ~ "custom" ~ ws+ ~ string ~ (ws+ ~ custom_arg)*
~ (ws* ~ note)?
~ (NEWLINE | EOI)
~ metadata_line*
}
custom_arg = _{ string | value_expr | account | commodity | date | bool_literal }
bool_literal = @{ "TRUE" | "FALSE" }
include_directive = ${ "include" ~ ws+ ~ string ~ (NEWLINE | EOI) }
option_directive = ${ "option" ~ ws+ ~ string ~ ws+ ~ string ~ (NEWLINE | EOI) }
plugin_directive = ${
"plugin" ~ ws+ ~ string ~ (ws+ ~ string)? ~ (NEWLINE | EOI)
}
// --- pushtag / poptag (lexical tag scoping) ---
//
// `pushtag #foo` adds `#foo` to the active set; every subsequent
// transaction parsed before the matching `poptag #foo` inherits it as
// if it were written on the transaction header. `poptag` removes the
// most-recent matching entry from the active set; mismatched pops are
// silently ignored.
pushtag_directive = ${ "pushtag" ~ ws+ ~ tag ~ (NEWLINE | EOI) }
poptag_directive = ${ "poptag" ~ ws+ ~ tag ~ (NEWLINE | EOI) }
// --- pushmeta / popmeta (lexical metadata scoping) ---
//
// `pushmeta key: value-expr` adds the key/value pair to the active
// metadata stack; every subsequent transaction inherits it. `popmeta
// key:` removes the most-recent value pushed for that key. The
// trailing colon on `popmeta` matches Beancount's own syntax.
pushmeta_directive = ${
"pushmeta" ~ ws+ ~ metadata_key ~ ":" ~ ws* ~ metadata_value ~ (NEWLINE | EOI)
}
popmeta_directive = ${
"popmeta" ~ ws+ ~ metadata_key ~ ":" ~ (NEWLINE | EOI)
}
// --- Atoms ---
date = ${ year ~ "-" ~ monthdate ~ "-" ~ monthdate }
year = @{ ASCII_DIGIT{4} }
monthdate = @{ ASCII_DIGIT{2} }
// String literal: double-quoted form with backslash escape sequences.
// The grammar accepts any byte after a backslash so the inner text
// matches even malformed escapes; the AST adapter is responsible for
// interpreting the recognised sequences (\\, \", \n, \t, \r) and
// passing through unknown escapes verbatim with the leading backslash
// preserved.
string = ${ "\"" ~ string_inner ~ "\"" }
string_inner = @{ (escape_seq | (!"\"" ~ !"\\" ~ ANY))* }
escape_seq = @{ "\\" ~ ANY }
// Account: colon-separated segments. Beancount accepts segments
// starting with an uppercase letter or digit; we accept either case
// to be permissive. Stops at whitespace, semicolon, or newline.
account = @{ account_segment ~ (":" ~ account_segment)+ }
account_segment = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")* }
indent = _{ " " | "\t" }
ws = _{ " " | "\t" }
note = ${ ";" ~ note_str }
note_str = ${ (!NEWLINE ~ ANY)* }
comment_line = @{ ";" ~ (!NEWLINE ~ ANY)* }
// Org-mode outline headings: `*`, `**`, `***`, ... at column 0,
// followed by free-form heading text. Beancount silently accepts
// these so journals can be edited as Org-mode outlines (and the
// outline structure is preserved for any tool that reads it).
// Single `*` followed by a transaction header would be ambiguous
// only if the next token were a date-like token at column 1; the
// transaction header rule starts with a digit, so a top-level entry
// beginning with `*` cannot be a transaction (transactions are
// `<date> <flag> ...`, not `<flag> <date>`).
org_mode_heading = @{ "*" ~ (!NEWLINE ~ ANY)* }
// Shebang: `#!/usr/bin/env bean-web` etc. Beancount accepts these
// at the top of a file (a journal that is also runnable). We accept
// them anywhere a top-level entry can appear.
shebang_line = @{ "#!" ~ (!NEWLINE ~ ANY)* }
// Org-mode file-level startup directives like `#+STARTUP: showall`
// or `#+TITLE: My Journal`. The file-level analogue of the `*`
// outline headings -- semantically a no-op, useful only when the
// journal is edited in Emacs Org-mode.
org_mode_meta = @{ "#+" ~ (!NEWLINE ~ ANY)* }
identifier = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")* }
// --- Value expressions ---
//
// Beancount amounts are `<number> <currency>`. Bare numbers and infix
// arithmetic via parentheses are also accepted (Beancount itself
// permits expression-level arithmetic on totals).
value_expr = ${ expr ~ (ws+ ~ commodity)? }
expr = ${ term ~ (ws* ~ infix_op ~ ws* ~ term)* }
term = ${ (prefix_op ~ ws*)? ~ primary }
primary = { base_primary }
base_primary = _{
"(" ~ expr ~ ")"
| amount
}
amount = ${
(number ~ ws+ ~ commodity)
| number
}
number = @{
"-"? ~ ASCII_DIGIT+ ~ ("," ~ ASCII_DIGIT{3})* ~ ("." ~ ASCII_DIGIT+)?
| "-"? ~ "." ~ ASCII_DIGIT+
}
// Beancount currency: must start with an uppercase letter; subsequent
// characters may be uppercase, digits, or any of `'._-`. Length 1+
// (Beancount itself enforces 2..24, but we don't need to here).
commodity = @{ ASCII_ALPHA_UPPER ~ (ASCII_ALPHA_UPPER | ASCII_DIGIT | "'" | "." | "_" | "-")* }
infix_op = _{ add | sub | mul | div }
add = { "+" }
sub = { "-" }
mul = { "*" }
div = { "/" }
prefix_op = { "-" | "+" }