rustledger_parser/cst/parser.rs
1//! CST builders: phase 1 flat ([`parse_flat`]) + phase 2.1-2.4
2//! structured ([`parse_structured`]).
3//!
4//! Both walk the lossless token stream and emit a `GreenNode` whose
5//! `text()` is byte-identical to the input source. They differ in
6//! what they wrap:
7//!
8//! - [`parse_flat`] (phase 1) puts every token as a direct child of
9//! a single `SOURCE_FILE` node. Useful for round-trip-only tests
10//! and the kind-sequence corpus baseline.
11//! - [`parse_structured`] recognizes:
12//! - **Phase 2.1a**: 14 single-line directive shapes —
13//! `OPEN`/`CLOSE`/`BALANCE`/`PAD`/`EVENT`/`QUERY`/`NOTE`/
14//! `DOCUMENT`/`PRICE`/`COMMODITY` (dated) +
15//! `PUSHTAG`/`POPTAG`/`PUSHMETA`/`POPMETA` (top-level keyword).
16//! - **Phase 2.1b**: `TRANSACTION` — DATE + `STAR` / `PENDING_KW`
17//! (`!`) / `FLAG` / `TXN_KW`, multi-line scope through the last
18//! indented sub-line (postings, metadata, indented comments).
19//!
20//! Each wraps in its specific node kind per the Directive-
21//! Terminator Rule (see [`crate::cst::trivia`]).
22//!
23//! - **Phase 2.3**: edge directives —
24//! `OPTION_DIRECTIVE` / `INCLUDE_DIRECTIVE` /
25//! `PLUGIN_DIRECTIVE` (top-level keyword) +
26//! `CUSTOM_DIRECTIVE` (dated with arbitrary trailing value
27//! list). Body / metadata shape is identical to PR 2.1a's
28//! dated and standalone-keyword directives — only the header
29//! keyword recognition is new.
30//!
31//! - **Phase 2.4**: error recovery — unrecognized / malformed
32//! top-level lines are wrapped in `ERROR_NODE` (terminated by
33//! NEWLINE or EOF per rule 5). Same trivia attachment policy
34//! as recognized directives (rule 2): pending leading trivia
35//! attaches inside the `ERROR_NODE` when it's not the very
36//! first content in the file. AMOUNT now also wraps full
37//! arithmetic expressions (`[sign] (NUMBER | PAREN_EXPR)
38//! ([WS] op [WS] (NUMBER | PAREN_EXPR))* [WS CURRENCY]`),
39//! closing the deferred 2.2c.1 divergence with Python
40//! beancount on `10+5 USD`-shape amounts.
41//!
42//! Phase 2.2a adds `META_ENTRY` sub-node structure around indented
43//! `WS META_KEY ... (NEWLINE | EOF)` sub-lines inside any directive
44//! or transaction (per rule 5 of `cst::trivia`, an unterminated
45//! final sub-line at EOF still gets wrapped). Phase 2.2b adds
46//! `POSTING` sub-node structure around each `WS [(FLAG | STAR |
47//! PENDING_KW | HASH | single-char CURRENCY) WS] ACCOUNT ...`
48//! posting line inside `TRANSACTION` (the flag arm mirrors
49//! `parse_flag` in the legacy AST parser and `identify_directive`'s
50//! transaction-trigger arm; single-char `CURRENCY` covers letters
51//! like `T`/`V`/`F`/`X` that win the lexer's priority-3 Currency-
52//! vs-Flag tie-break). Posting-attached metadata (`META_ENTRY` sub-
53//! lines following the posting, indented `>=` the posting) becomes a
54//! child of that `POSTING`. Phase 2.2c adds `AMOUNT` / `COST_SPEC` /
55//! `PRICE_ANNOTATION` inside `POSTING`. Phase 5 deletes
56//! `parse_flat` once `parse_structured` covers every byte in
57//! every corpus file.
58
59use std::ops::Range;
60
61use rowan::GreenNodeBuilder;
62
63use crate::cst::lossless_tokens::lossless_kind_tokens;
64use crate::cst::syntax_kind::{SyntaxKind, SyntaxNode};
65
66/// Parse `source` to a flat lossless CST.
67///
68/// The returned node's text serialization equals `source` byte-for-
69/// byte for every UTF-8 input. Every token is a direct child of
70/// `SOURCE_FILE`; no structural directive wrapping.
71#[must_use]
72pub fn parse_flat(source: &str) -> SyntaxNode {
73 let mut builder = GreenNodeBuilder::new();
74 builder.start_node(SyntaxKind::SOURCE_FILE.into());
75 for (kind, range) in lossless_kind_tokens(source) {
76 builder.token(kind.into(), &source[range]);
77 }
78 builder.finish_node();
79 SyntaxNode::new_root(builder.finish())
80}
81
82/// Parse `source` to a structured lossless CST.
83///
84/// Recognizes the 14 single-line directive shapes (PR 2.1a) plus
85/// `TRANSACTION` (PR 2.1b) plus the 4 edge directives `OPTION` /
86/// `INCLUDE` / `PLUGIN` / `CUSTOM` (PR 2.3), and wraps each in its
87/// specific node kind. Trivia attaches per the Directive-
88/// Terminator Rule.
89///
90/// Unrecognized / malformed top-level lines are wrapped in an
91/// `ERROR_NODE` (PR 2.4) — same trivia attachment policy as
92/// recognized directives and the same rule-5 unterminated-at-EOF
93/// behavior. Round-trip byte-identical for every UTF-8 input.
94#[must_use]
95pub fn parse_structured(source: &str) -> SyntaxNode {
96 let tokens: Vec<(SyntaxKind, Range<usize>)> = lossless_kind_tokens(source);
97 let mut builder = GreenNodeBuilder::new();
98 builder.start_node(SyntaxKind::SOURCE_FILE.into());
99
100 let mut pending_leading: Vec<(SyntaxKind, Range<usize>)> = Vec::new();
101 let mut seen_first_content = false;
102 let mut i = 0;
103
104 while i < tokens.len() {
105 let (kind, ref range) = tokens[i];
106 if kind.is_trivia() {
107 pending_leading.push((kind, range.clone()));
108 i += 1;
109 continue;
110 }
111
112 // Non-trivia at the top level. Identify what kind of line
113 // starts here. Both branches share the same trivia-
114 // attachment + node-emission shape: drain pending trivia
115 // around `start_node(kind)` per rule 2 (the FIRST
116 // non-trivia content's pending trivia attaches under
117 // SOURCE_FILE; subsequent runs attach INSIDE the new
118 // node), emit the body, then `finish_node()`.
119 let node_kind = identify_directive(&tokens, i).unwrap_or(SyntaxKind::ERROR_NODE);
120 if seen_first_content {
121 builder.start_node(node_kind.into());
122 emit_tokens(&mut builder, source, std::mem::take(&mut pending_leading));
123 } else {
124 emit_tokens(&mut builder, source, std::mem::take(&mut pending_leading));
125 builder.start_node(node_kind.into());
126 }
127 seen_first_content = true;
128 i = match node_kind {
129 SyntaxKind::TRANSACTION => emit_transaction_body(&mut builder, source, &tokens, i),
130 SyntaxKind::ERROR_NODE => emit_through_terminator(&mut builder, source, &tokens, i),
131 // Recognized directive (PR 2.1a / 2.3 single-line shapes):
132 // header + optional indented META_ENTRY sub-lines.
133 _ => emit_directive_body(&mut builder, source, &tokens, i),
134 };
135 builder.finish_node();
136 }
137
138 // File-trailing trivia: drain any pending under SOURCE_FILE.
139 emit_tokens(&mut builder, source, std::mem::take(&mut pending_leading));
140
141 builder.finish_node();
142 SyntaxNode::new_root(builder.finish())
143}
144
145/// Emit a sequence of `(kind, range)` tokens into the builder.
146fn emit_tokens(
147 builder: &mut GreenNodeBuilder<'_>,
148 source: &str,
149 tokens: impl IntoIterator<Item = (SyntaxKind, Range<usize>)>,
150) {
151 for (kind, range) in tokens {
152 builder.token(kind.into(), &source[range]);
153 }
154}
155
156/// Consume `tokens[i..]` into `builder` up to and including the
157/// next `NEWLINE` token (or EOF). Returns the new index `i`.
158fn emit_through_terminator(
159 builder: &mut GreenNodeBuilder<'_>,
160 source: &str,
161 tokens: &[(SyntaxKind, Range<usize>)],
162 mut i: usize,
163) -> usize {
164 while i < tokens.len() {
165 let (kind, ref range) = tokens[i];
166 builder.token(kind.into(), &source[range.clone()]);
167 i += 1;
168 if kind == SyntaxKind::NEWLINE {
169 break;
170 }
171 }
172 i
173}
174
175/// Consume one indented sub-line of a directive or transaction
176/// body, wrapping it in a `META_ENTRY` node iff it's metadata
177/// (i.e., starts `WS META_KEY ...`).
178///
179/// Phase 2.2a structural wrapping: each metadata sub-line becomes
180/// its own `META_ENTRY` node containing the indent `WHITESPACE`,
181/// the `META_KEY`, the rest of the line's content tokens, and —
182/// when present — the terminator `NEWLINE`. An UNTERMINATED final
183/// metadata sub-line at EOF (per rule 5 of `cst::trivia`) is still
184/// wrapped: its `META_ENTRY` simply ends at the last content token
185/// with no `NEWLINE` child. Token kinds inside the `META_ENTRY`
186/// stay flat — phase 3's typed-AST surface will expose `key()` and
187/// `value()` accessors that walk these children. Indented
188/// `;`-comments flow through as flat children, NOT wrapped in
189/// `META_ENTRY`. POSTING lines are recognized earlier in
190/// `emit_transaction_body` and never reach this helper.
191fn emit_body_sub_line(
192 builder: &mut GreenNodeBuilder<'_>,
193 source: &str,
194 tokens: &[(SyntaxKind, Range<usize>)],
195 i: usize,
196) -> usize {
197 if starts_meta_sub_line(tokens, i) {
198 builder.start_node(SyntaxKind::META_ENTRY.into());
199 let next = emit_through_terminator(builder, source, tokens, i);
200 builder.finish_node();
201 next
202 } else {
203 emit_through_terminator(builder, source, tokens, i)
204 }
205}
206
207/// Returns true iff `tokens[i..]` starts an indented `WS META_KEY ...`
208/// metadata sub-line.
209///
210/// **Single source of truth** for the `WS + META_KEY` recognition
211/// pattern. Used by both `emit_body_sub_line` (decides whether to
212/// open a `META_ENTRY` node around the sub-line) and
213/// `is_indented_directive_continuation`'s `META_KEY` arm (decides
214/// whether the directive body should keep consuming). Routing both
215/// call sites through one helper prevents the predicate-pair drift
216/// hazard where one widens (e.g. admits a different indent token)
217/// without the other and the parser starts consuming sub-lines
218/// without wrapping them, or wrapping sub-lines that the body loop
219/// never reaches.
220fn starts_meta_sub_line(tokens: &[(SyntaxKind, Range<usize>)], i: usize) -> bool {
221 matches!(tokens.get(i), Some((SyntaxKind::WHITESPACE, _)))
222 && matches!(tokens.get(i + 1), Some((SyntaxKind::META_KEY, _)))
223}
224
225/// Consume the header line through its terminator NEWLINE, then
226/// keep consuming any indented metadata sub-lines OR indented
227/// `;`/`%` comment lines that follow at the same logical block.
228///
229/// The Directive-Terminator Rule (see `cst::trivia`) declares that
230/// a directive carrying metadata spans multiple lines: its last
231/// content token is the last content token of its LAST sub-line,
232/// not the header. Stopping at the header NEWLINE would orphan
233/// metadata under `SOURCE_FILE` and silently violate the rule. PR
234/// 2.1a wraps the full multi-line span; PR 2.2 will introduce a
235/// `META_ENTRY` sub-node around each `WHITESPACE META_KEY ...
236/// NEWLINE` run inside.
237///
238/// A continuation sub-line is recognized as `WHITESPACE` (the
239/// indent) followed by either:
240/// - `META_KEY` — the standard metadata sub-line, or
241/// - any comment-class trivia token (per [`is_comment_token`]: `;`,
242/// `%`, `#!`, `#+`) — an indented documentation comment between
243/// metadata entries (a common Beancount idiom; keeping it inside
244/// the directive prevents subsequent metadata from getting
245/// orphaned to `SOURCE_FILE`).
246///
247/// Anything else — a blank line, a non-indented top-level token,
248/// EOF — terminates the directive. Blank-line separated metadata
249/// blocks are currently a known limitation: a `\n` between two
250/// metadata entries closes the directive and orphans the second
251/// entry. PR 2.2's grammar will likely subsume this when it
252/// introduces `META_ENTRY` structure.
253fn emit_directive_body(
254 builder: &mut GreenNodeBuilder<'_>,
255 source: &str,
256 tokens: &[(SyntaxKind, Range<usize>)],
257 mut i: usize,
258) -> usize {
259 i = emit_through_terminator(builder, source, tokens, i);
260 // PROSPECTIVELY scan the upcoming indented-content block for
261 // any `WS META_KEY`. If the block contains metadata, any
262 // indented comments anywhere in it — including BEFORE the
263 // first META_KEY (the "doc-comment-for-the-following-field"
264 // idiom) — are continuations that belong inside the directive.
265 // If the block contains NO metadata, an indented comment is
266 // inter-directive trivia (rule 2) or file-trailing (rule 4)
267 // and must not be absorbed. Per-line bookkeeping was tried in
268 // v4 but couldn't see the META_KEY that came AFTER a leading
269 // comment, so a comment-before-first-metadata silently closed
270 // the directive and orphaned the metadata.
271 let block_has_meta = upcoming_indented_block_has_meta(tokens, i);
272 while is_indented_directive_continuation(tokens, i, block_has_meta) {
273 i = emit_body_sub_line(builder, source, tokens, i);
274 }
275 i
276}
277
278/// Consume the transaction header through its terminator NEWLINE,
279/// then keep consuming ANY indented sub-line (postings, metadata,
280/// indented comments — any line starting with `WHITESPACE`
281/// followed by a non-`NEWLINE` token).
282///
283/// **Phase 2.2b attributes metadata by indent depth.** Beancount
284/// distinguishes TRANSACTION-level metadata (at the transaction's
285/// standard indent, typically two spaces, before any posting OR
286/// interspersed between postings at that same indent) from
287/// POSTING-attached metadata (at a DEEPER indent following a
288/// posting line). The transaction-level case stays a direct child
289/// of `TRANSACTION`; the posting-attached case becomes a child of
290/// the preceding `POSTING` node.
291///
292/// State machine: walk the body lines while tracking the indent
293/// width of the most-recently-opened `POSTING` (if any). For each
294/// sub-line:
295///
296/// - **Posting line** (`WS [(FLAG | STAR | PENDING_KW | HASH |
297/// single-char CURRENCY) WS] ACCOUNT ...`, full flag set per
298/// [`starts_posting_sub_line`]):
299/// close the open POSTING if any, then open a new POSTING and
300/// consume the line. **Sibling POSTING indents are not required
301/// to be uniform**: a transaction with postings at different
302/// indent depths produces sibling POSTING nodes whose
303/// `open_posting_indent` reflects each one's own header indent.
304/// Subsequent metadata then attributes against the
305/// most-recently-opened POSTING's indent, which means
306/// metadata can attribute differently depending on which
307/// posting precedes it. Beancount's grammar uses uniform
308/// indentation by convention, so this is a defensive (not
309/// primary) shape; pinned by
310/// `postings_at_increasing_indents_produce_siblings_and_meta_attributes_to_latest`.
311/// - **Metadata sub-line** (`WS META_KEY ...`): if a POSTING is
312/// open AND this line's indent is `>=` the POSTING's indent, emit
313/// the `META_ENTRY` INSIDE the POSTING. Otherwise (no open POSTING,
314/// or strictly shallower indent), close any open POSTING and emit
315/// the `META_ENTRY` at TRANSACTION level. The `>=` (not `>`) match
316/// mirrors Beancount, which attributes metadata to the preceding
317/// posting by POSITION, so same-indent `key: value` is posting
318/// metadata.
319/// - **Indented comment line** (`WS COMMENT` / `WS PERCENT_COMMENT`):
320/// apply the same indent-attribution rule as metadata. If the
321/// comment is strictly more indented than the open POSTING, it
322/// stays INSIDE the POSTING (preserving the doc-comment-for-
323/// following-posting-metadata idiom — a deeper-indented `; doc`
324/// followed by deeper-indented `key: value` should both belong
325/// to the same posting). Otherwise close any open POSTING and
326/// emit the comment flat at TRANSACTION level (matches the
327/// `posting_with_indented_comment_between_postings_terminates_posting`
328/// test, where the comment is at the SAME indent as the postings
329/// and is therefore transaction-level inter-posting trivia).
330/// - **Any other indented content** (`WS STRING`, `WS NUMBER`,
331/// unrecognized shape): close any open POSTING and emit the line
332/// flat at TRANSACTION level. We don't know what to do with it
333/// structurally; flat-passthrough preserves bytes.
334///
335/// Indent width is measured as the BYTE LENGTH of the leading
336/// `WHITESPACE` token — sufficient when the source uses uniform
337/// spaces (the standard Beancount convention). **Known divergence
338/// from the legacy AST parser**: the legacy lexer's `Indent(N)` /
339/// `DeepIndent(N)` variants (`logos_lexer.rs:615-616`) count tabs
340/// as 4 spaces, so a tab-indented posting followed by space-
341/// indented metadata is compared by VISUAL columns there but by
342/// BYTE COUNT here. The two paths can disagree on mixed-indent
343/// files. No test corpus file currently triggers the divergence in
344/// posting-attached-metadata position; if one shows up, switching
345/// `indent_width` to a column-aware count is the fix.
346///
347/// Compared with `emit_directive_body` (which only continues on
348/// `WS META_KEY` and gated `WS COMMENT`), transactions have a
349/// looser body shape. PR 2.2c will introduce `AMOUNT` /
350/// `COST_SPEC` / `PRICE_ANNOTATION` sub-nodes INSIDE `POSTING`;
351/// for now the POSTING's content tokens (account, amount,
352/// currency, etc.) stay flat children of POSTING.
353///
354/// Termination: a blank line (NEWLINE alone, or WHITESPACE then
355/// NEWLINE), any non-indented top-level token, or EOF. Any open
356/// POSTING is closed before returning.
357fn emit_transaction_body(
358 builder: &mut GreenNodeBuilder<'_>,
359 source: &str,
360 tokens: &[(SyntaxKind, Range<usize>)],
361 mut i: usize,
362) -> usize {
363 i = emit_through_terminator(builder, source, tokens, i);
364
365 let mut open_posting_indent: Option<usize> = None;
366
367 while is_indented_transaction_body_line(tokens, i) {
368 let sub_line_indent = indent_width(tokens, i);
369
370 if starts_posting_sub_line(tokens, i) {
371 if open_posting_indent.is_some() {
372 builder.finish_node();
373 }
374 builder.start_node(SyntaxKind::POSTING.into());
375 open_posting_indent = Some(sub_line_indent);
376 i = emit_posting_line(builder, source, tokens, i);
377 } else if starts_meta_sub_line(tokens, i) {
378 // Beancount attributes metadata by POSITION: a `key: value`
379 // line following a posting attaches to that posting, even
380 // at the SAME indent (`attach_on_equal = true`).
381 close_open_posting_unless_attached(
382 builder,
383 &mut open_posting_indent,
384 sub_line_indent,
385 true,
386 );
387 i = emit_body_sub_line(builder, source, tokens, i);
388 } else if starts_indented_comment(tokens, i) {
389 // Comments use the STRICT (`>`) rule: deeper-indented
390 // comments stay INSIDE the open POSTING; same-or-shallower
391 // comments close the POSTING and emit flat at TRANSACTION
392 // level. Comments are AST-invisible, so this only affects
393 // formatter emission placement.
394 close_open_posting_unless_attached(
395 builder,
396 &mut open_posting_indent,
397 sub_line_indent,
398 false,
399 );
400 i = emit_through_terminator(builder, source, tokens, i);
401 } else {
402 // Catch-all: any other indented content (e.g., `WS
403 // STRING`, `WS NUMBER`, or unrecognized shapes that
404 // future error-recovery work might surface). Close any
405 // open POSTING and emit flat at TRANSACTION level. PR
406 // 2.2c (AMOUNT / COST_SPEC / PRICE_ANNOTATION) lives
407 // INSIDE a `POSTING` and reaches the parser through
408 // `starts_posting_sub_line`, never this branch — but
409 // if a future continuation form (e.g., multi-line
410 // postings) gets added, this branch is where it would
411 // need to be teased apart from genuine other content.
412 if open_posting_indent.is_some() {
413 builder.finish_node();
414 open_posting_indent = None;
415 }
416 i = emit_through_terminator(builder, source, tokens, i);
417 }
418 }
419
420 if open_posting_indent.is_some() {
421 builder.finish_node();
422 }
423
424 i
425}
426
427/// Consume a posting sub-line through its terminator NEWLINE (or
428/// EOF), wrapping the `AMOUNT`, `COST_SPEC`, and `PRICE_ANNOTATION`
429/// sub-structures inside the already-open `POSTING` node.
430///
431/// Preconditions: the caller has opened a `POSTING` node and is
432/// positioned at the first token of the posting line (`WS`).
433/// `starts_posting_sub_line(tokens, i)` must hold.
434///
435/// Body shape (after the `WS [(flag) WS] ACCOUNT` prefix):
436///
437/// - `AMOUNT` is the units amount: `[(MINUS | PLUS)] NUMBER
438/// [WS CURRENCY]`, or a bare `CURRENCY`. Mirrors the legacy AST
439/// `parse_incomplete_amount`: NUMBER + optional CURRENCY, or
440/// CURRENCY alone. Wrapping skips intervening `WHITESPACE`
441/// between AMOUNT and CURRENCY so the sub-node owns both.
442/// - `COST_SPEC` is a bracketed cost annotation, opened by
443/// `L_BRACE` (per-unit), `L_BRACE_HASH` (per-unit + total), or
444/// `L_DOUBLE_BRACE` (total-only), and closed by the matching
445/// `R_BRACE` / `R_DOUBLE_BRACE`. Contents stay flat children;
446/// phase 3 typed-AST will surface accessors. Per rule 5 of
447/// `cst::trivia`, an unclosed brace at EOF still gets wrapped
448/// (the `COST_SPEC` simply has no matching close-brace child).
449/// - `PRICE_ANNOTATION` is opened by `AT` (per-unit price) or
450/// `AT_AT` (total price). Its trailing amount is recursively
451/// wrapped in `AMOUNT` so the structure mirrors the units-amount
452/// case: `PRICE_ANNOTATION(AT [WS AMOUNT])`. The typed-AST
453/// decodes per-unit-vs-total by the opener token kind, then
454/// walks the `AMOUNT` child for the number/currency.
455///
456/// Canonical order on a well-formed posting line is `ACCOUNT
457/// [AMOUNT] [COST_SPEC] [PRICE_ANNOTATION]`. The state machine
458/// here is order-independent at the recognition level (each sub-
459/// structure wraps when its opener token is encountered), so a
460/// malformed posting with reordered or duplicated sub-structures
461/// still round-trips byte-identically — duplicates each get their
462/// own wrapper.
463///
464/// Trailing tokens (`WHITESPACE`, `COMMENT`, `PERCENT_COMMENT`,
465/// `NEWLINE`) that follow the last recognized sub-structure stay
466/// as flat children of `POSTING`.
467fn emit_posting_line(
468 builder: &mut GreenNodeBuilder<'_>,
469 source: &str,
470 tokens: &[(SyntaxKind, Range<usize>)],
471 mut i: usize,
472) -> usize {
473 // Emit the indent `WHITESPACE`.
474 if let Some((SyntaxKind::WHITESPACE, range)) = tokens.get(i) {
475 builder.token(SyntaxKind::WHITESPACE.into(), &source[range.clone()]);
476 i += 1;
477 }
478
479 // Optional flag (`FLAG` / `STAR` / `PENDING_KW` / `HASH` /
480 // single-char `CURRENCY`) + separating `WHITESPACE`. Mirrors
481 // `starts_posting_sub_line`'s flag arm.
482 let next = tokens.get(i).map(|(k, _)| *k);
483 let is_flag = match next {
484 Some(SyntaxKind::FLAG | SyntaxKind::STAR | SyntaxKind::PENDING_KW | SyntaxKind::HASH) => {
485 true
486 }
487 Some(SyntaxKind::CURRENCY) => tokens[i].1.len() == 1,
488 _ => false,
489 };
490 if is_flag {
491 // Emit flag + WHITESPACE pair.
492 if let Some((kind, range)) = tokens.get(i) {
493 builder.token((*kind).into(), &source[range.clone()]);
494 i += 1;
495 }
496 if let Some((SyntaxKind::WHITESPACE, range)) = tokens.get(i) {
497 builder.token(SyntaxKind::WHITESPACE.into(), &source[range.clone()]);
498 i += 1;
499 }
500 }
501
502 // Emit the required ACCOUNT.
503 if let Some((SyntaxKind::ACCOUNT, range)) = tokens.get(i) {
504 builder.token(SyntaxKind::ACCOUNT.into(), &source[range.clone()]);
505 i += 1;
506 }
507
508 // Scan post-ACCOUNT tokens, wrapping AMOUNT / COST_SPEC /
509 // PRICE_ANNOTATION as openers appear. Anything else flows as
510 // flat children of POSTING.
511 while i < tokens.len() {
512 let (kind, range) = (tokens[i].0, tokens[i].1.clone());
513 if kind == SyntaxKind::NEWLINE {
514 builder.token(kind.into(), &source[range]);
515 i += 1;
516 break;
517 }
518 if starts_amount(tokens, i) {
519 i = emit_amount(builder, source, tokens, i);
520 continue;
521 }
522 if matches!(
523 kind,
524 SyntaxKind::L_BRACE | SyntaxKind::L_BRACE_HASH | SyntaxKind::L_DOUBLE_BRACE,
525 ) {
526 i = emit_cost_spec(builder, source, tokens, i);
527 continue;
528 }
529 if matches!(kind, SyntaxKind::AT | SyntaxKind::AT_AT) {
530 i = emit_price_annotation(builder, source, tokens, i);
531 continue;
532 }
533 // Flat passthrough (WHITESPACE, COMMENT, PERCENT_COMMENT,
534 // anything else).
535 builder.token(kind.into(), &source[range]);
536 i += 1;
537 }
538
539 i
540}
541
542/// Returns true iff `tokens[i..]` starts an AMOUNT-shape token
543/// run: an arithmetic-expression operand (`NUMBER`, `L_PAREN`, or
544/// signed variants), or a bare `CURRENCY`. Used by
545/// `emit_posting_line` to gate whether to open an `AMOUNT` wrapper.
546fn starts_amount(tokens: &[(SyntaxKind, Range<usize>)], i: usize) -> bool {
547 match tokens.get(i).map(|(k, _)| *k) {
548 Some(SyntaxKind::NUMBER | SyntaxKind::CURRENCY | SyntaxKind::L_PAREN) => true,
549 Some(SyntaxKind::MINUS | SyntaxKind::PLUS) => {
550 // Look PAST whitespace: beancount allows a space between a unary
551 // sign and its operand (`- 7.5 USD`, pinned by the lima fixture
552 // `Arithmetic.NumberExprNegative`). Requiring the operand to be
553 // adjacent left the sign outside the `AMOUNT` node as a flat
554 // `POSTING` child, where nothing reads it — so `- 7.5 USD` booked
555 // as **+7.5**, silently, and the user saw only a downstream
556 // "does not balance". Found while fixing the same silent drop for
557 // `-,123.00` (issue #1892 discussion).
558 let mut j = i + 1;
559 while matches!(tokens.get(j).map(|(k, _)| *k), Some(SyntaxKind::WHITESPACE)) {
560 j += 1;
561 }
562 matches!(
563 tokens.get(j).map(|(k, _)| *k),
564 Some(SyntaxKind::NUMBER | SyntaxKind::L_PAREN),
565 )
566 }
567 _ => false,
568 }
569}
570
571/// Returns true iff `tokens[i]` is an arithmetic operator
572/// (`PLUS` / `MINUS` / `STAR` / `SLASH`).
573const fn is_arith_op(kind: SyntaxKind) -> bool {
574 matches!(
575 kind,
576 SyntaxKind::PLUS | SyntaxKind::MINUS | SyntaxKind::STAR | SyntaxKind::SLASH,
577 )
578}
579
580/// Emit an `AMOUNT` node containing the units amount.
581///
582/// Recognizes Python beancount's `parse_expr` grammar shape:
583/// `[sign] operand ([WS] op [WS] [sign] operand)* [WS CURRENCY]`,
584/// where `operand` is `NUMBER` or a parenthesized sub-expression
585/// `L_PAREN expr R_PAREN`. Also accepts a bare `CURRENCY`
586/// (currency-only amount). Closes the PR 2.2c.1 deferred
587/// divergence: `bean-check` accepts `10+5 USD`, `-10+5 USD`, and
588/// `-(10+5) USD`; this helper now wraps them as a single `AMOUNT`
589/// node containing the full expression tokens flat (sign + operands
590/// + operators + currency).
591///
592/// Stops at the first token that doesn't fit the grammar (e.g.,
593/// `L_BRACE` cost-spec opener, `AT` price opener, `NEWLINE`,
594/// `COMMENT`, etc.). Returns the new index.
595fn emit_amount(
596 builder: &mut GreenNodeBuilder<'_>,
597 source: &str,
598 tokens: &[(SyntaxKind, Range<usize>)],
599 mut i: usize,
600) -> usize {
601 builder.start_node(SyntaxKind::AMOUNT.into());
602
603 // Currency-only amount: bare `CURRENCY` and nothing more.
604 if matches!(tokens.get(i).map(|(k, _)| *k), Some(SyntaxKind::CURRENCY))
605 && !starts_amount_operand(tokens, i + 1)
606 {
607 let range = tokens[i].1.clone();
608 builder.token(SyntaxKind::CURRENCY.into(), &source[range]);
609 i += 1;
610 builder.finish_node();
611 return i;
612 }
613
614 // Optional leading sign, plus any whitespace between it and its operand.
615 //
616 // The gap must be consumed INSIDE the node: leaving it outside closed the
617 // `AMOUNT` right after the sign, so `- 7.5 USD` produced two sibling
618 // amounts (`AMOUNT(MINUS)` and `AMOUNT(NUMBER CURRENCY)`) and the sign was
619 // never read — the posting booked as **+7.5**. `starts_amount` has already
620 // confirmed an operand follows, so this cannot swallow a trailing space.
621 if matches!(
622 tokens.get(i).map(|(k, _)| *k),
623 Some(SyntaxKind::MINUS | SyntaxKind::PLUS),
624 ) {
625 let (kind, range) = (tokens[i].0, tokens[i].1.clone());
626 builder.token(kind.into(), &source[range]);
627 i += 1;
628 while matches!(tokens.get(i).map(|(k, _)| *k), Some(SyntaxKind::WHITESPACE)) {
629 let range = tokens[i].1.clone();
630 builder.token(SyntaxKind::WHITESPACE.into(), &source[range]);
631 i += 1;
632 }
633 }
634
635 // First operand.
636 i = emit_amount_operand(builder, source, tokens, i);
637
638 // Tail: zero or more `[WS] op [WS] [sign] operand` runs. Each
639 // iteration commits the WS / op / WS / sign tokens BEFORE
640 // dispatching the operand emission. Lookahead-only: do NOT
641 // consume any token until the full op-operand prefix is
642 // confirmed, so a trailing single WHITESPACE before CURRENCY
643 // (the canonical `100 USD` shape) isn't accidentally consumed
644 // as a leading op-prefix.
645 loop {
646 let mut j = i;
647 if matches!(tokens.get(j).map(|(k, _)| *k), Some(SyntaxKind::WHITESPACE)) {
648 j += 1;
649 }
650 let Some((op_kind, _)) = tokens.get(j) else {
651 break;
652 };
653 if !is_arith_op(*op_kind) {
654 break;
655 }
656 let op_kind = *op_kind;
657 j += 1;
658 if matches!(tokens.get(j).map(|(k, _)| *k), Some(SyntaxKind::WHITESPACE)) {
659 j += 1;
660 }
661 // Optional sign before next operand.
662 let signed = matches!(
663 tokens.get(j).map(|(k, _)| *k),
664 Some(SyntaxKind::MINUS | SyntaxKind::PLUS),
665 );
666 let operand_start = if signed { j + 1 } else { j };
667 if !starts_amount_operand(tokens, operand_start) {
668 break;
669 }
670 // Commit tokens [i..j) (WS? op WS?) into AMOUNT.
671 while i < j {
672 let (kind, range) = (tokens[i].0, tokens[i].1.clone());
673 // Sanity: the only non-op tokens we should be committing
674 // here are WHITESPACE. The op token itself was already
675 // verified.
676 debug_assert!(
677 kind == SyntaxKind::WHITESPACE || kind == op_kind || is_arith_op(kind),
678 "unexpected token kind {kind:?} during op-prefix commit",
679 );
680 builder.token(kind.into(), &source[range]);
681 i += 1;
682 }
683 if signed {
684 let (kind, range) = (tokens[i].0, tokens[i].1.clone());
685 builder.token(kind.into(), &source[range]);
686 i += 1;
687 }
688 i = emit_amount_operand(builder, source, tokens, i);
689 }
690
691 // Optional trailing CURRENCY, either directly adjacent (`100USD`,
692 // `(10+5)USD`) or separated by WHITESPACE (`100 USD`).
693 if matches!(tokens.get(i).map(|(k, _)| *k), Some(SyntaxKind::WHITESPACE))
694 && matches!(
695 tokens.get(i + 1).map(|(k, _)| *k),
696 Some(SyntaxKind::CURRENCY),
697 )
698 {
699 let ws_range = tokens[i].1.clone();
700 builder.token(SyntaxKind::WHITESPACE.into(), &source[ws_range]);
701 i += 1;
702 let cur_range = tokens[i].1.clone();
703 builder.token(SyntaxKind::CURRENCY.into(), &source[cur_range]);
704 i += 1;
705 } else if matches!(tokens.get(i).map(|(k, _)| *k), Some(SyntaxKind::CURRENCY)) {
706 let cur_range = tokens[i].1.clone();
707 builder.token(SyntaxKind::CURRENCY.into(), &source[cur_range]);
708 i += 1;
709 }
710
711 builder.finish_node();
712 i
713}
714
715/// Returns true iff `tokens[i]` starts an arithmetic-expression
716/// operand (a bare `NUMBER` or a parenthesized sub-expression
717/// opener `L_PAREN`). Used by `emit_amount` to gate operand
718/// emission inside the op-loop tail.
719fn starts_amount_operand(tokens: &[(SyntaxKind, Range<usize>)], i: usize) -> bool {
720 matches!(
721 tokens.get(i).map(|(k, _)| *k),
722 Some(SyntaxKind::NUMBER | SyntaxKind::L_PAREN),
723 )
724}
725
726/// Emit one operand of an arithmetic expression: either a bare
727/// `NUMBER` or a parenthesized `L_PAREN expr R_PAREN` sub-
728/// expression. The sub-expression's content tokens stay flat
729/// children of the surrounding `AMOUNT` node (no separate
730/// `EXPR` / `PAREN_GROUP` wrapping for now). Per rule 5, an
731/// unclosed paren at EOF or NEWLINE stops without emitting a
732/// closing paren — round-trip preserves bytes.
733fn emit_amount_operand(
734 builder: &mut GreenNodeBuilder<'_>,
735 source: &str,
736 tokens: &[(SyntaxKind, Range<usize>)],
737 mut i: usize,
738) -> usize {
739 match tokens.get(i).map(|(k, _)| *k) {
740 Some(SyntaxKind::NUMBER) => {
741 let range = tokens[i].1.clone();
742 builder.token(SyntaxKind::NUMBER.into(), &source[range]);
743 i += 1;
744 }
745 Some(SyntaxKind::L_PAREN) => {
746 // Emit opener.
747 let range = tokens[i].1.clone();
748 builder.token(SyntaxKind::L_PAREN.into(), &source[range]);
749 i += 1;
750 // Consume balanced content until matching R_PAREN.
751 // Track nesting depth so `((1+2))` works. Stop at
752 // NEWLINE / EOF (rule 5 unterminated case).
753 let mut depth = 1usize;
754 while depth > 0 {
755 let Some((kind, range)) = tokens.get(i) else {
756 break;
757 };
758 let (kind, range) = (*kind, range.clone());
759 if kind == SyntaxKind::NEWLINE {
760 break;
761 }
762 builder.token(kind.into(), &source[range]);
763 i += 1;
764 match kind {
765 SyntaxKind::L_PAREN => depth += 1,
766 SyntaxKind::R_PAREN => depth -= 1,
767 _ => {}
768 }
769 }
770 }
771 _ => {}
772 }
773 i
774}
775
776/// Emit a `COST_SPEC` node spanning `L_BRACE` / `L_BRACE_HASH` /
777/// `L_DOUBLE_BRACE` ... matching `R_BRACE` / `R_DOUBLE_BRACE`. Per
778/// rule 5 (unterminated final directive), an unclosed brace at
779/// EOF or hitting a NEWLINE still gets wrapped — the `COST_SPEC`
780/// simply has no matching close-brace child. Contents stay flat
781/// children of `COST_SPEC`.
782fn emit_cost_spec(
783 builder: &mut GreenNodeBuilder<'_>,
784 source: &str,
785 tokens: &[(SyntaxKind, Range<usize>)],
786 mut i: usize,
787) -> usize {
788 builder.start_node(SyntaxKind::COST_SPEC.into());
789
790 // Emit opening brace token.
791 if let Some((kind, range)) = tokens.get(i) {
792 builder.token((*kind).into(), &source[range.clone()]);
793 i += 1;
794 }
795
796 // Emit content tokens up to and including the matching close
797 // brace, or until NEWLINE / EOF (unclosed-brace case).
798 while i < tokens.len() {
799 let (kind, range) = (tokens[i].0, tokens[i].1.clone());
800 if kind == SyntaxKind::NEWLINE {
801 // Unclosed brace: stop BEFORE the NEWLINE so the
802 // NEWLINE remains a sibling of COST_SPEC (the
803 // posting-line terminator), not a child.
804 break;
805 }
806 builder.token(kind.into(), &source[range]);
807 i += 1;
808 if matches!(kind, SyntaxKind::R_BRACE | SyntaxKind::R_DOUBLE_BRACE) {
809 break;
810 }
811 }
812
813 builder.finish_node();
814 i
815}
816
817/// Emit a `PRICE_ANNOTATION` node opened by `AT` or `AT_AT`,
818/// optionally followed by `WS` and a nested `AMOUNT`. The nested
819/// `AMOUNT` mirrors the units-amount wrapping above; the typed-AST
820/// decodes per-unit-vs-total by inspecting the opener token kind
821/// (`AT` vs `AT_AT`) and walks the `AMOUNT` child for the number
822/// and currency. Avoids absorbing a trailing-only `WHITESPACE`
823/// before a comment or `NEWLINE` (only swallows WS that precedes
824/// an actual amount start).
825fn emit_price_annotation(
826 builder: &mut GreenNodeBuilder<'_>,
827 source: &str,
828 tokens: &[(SyntaxKind, Range<usize>)],
829 mut i: usize,
830) -> usize {
831 builder.start_node(SyntaxKind::PRICE_ANNOTATION.into());
832
833 // Emit the `AT` / `AT_AT` opener.
834 if let Some((kind, range)) = tokens.get(i) {
835 builder.token((*kind).into(), &source[range.clone()]);
836 i += 1;
837 }
838
839 // Optional intervening WHITESPACE, but only if an amount
840 // follows; trailing-only WS belongs as a sibling of
841 // PRICE_ANNOTATION, not a child.
842 let ws_then_amount = matches!(tokens.get(i).map(|(k, _)| *k), Some(SyntaxKind::WHITESPACE),)
843 && starts_amount(tokens, i + 1);
844 if ws_then_amount {
845 let ws_range = tokens[i].1.clone();
846 builder.token(SyntaxKind::WHITESPACE.into(), &source[ws_range]);
847 i += 1;
848 }
849 if starts_amount(tokens, i) {
850 i = emit_amount(builder, source, tokens, i);
851 }
852
853 builder.finish_node();
854 i
855}
856
857/// Close any currently-open POSTING node IF the next sub-line at
858/// `sub_line_indent` should NOT be attached to it. Shared between the
859/// `META_ENTRY` and indented-comment branches of
860/// `emit_transaction_body`, which differ ONLY in their same-indent
861/// tie-break (`attach_on_equal`).
862///
863/// `attach_on_equal` selects the attachment threshold:
864///
865/// - **Metadata (`true`)**: a `key: value` sub-line attaches when it
866/// is indented `>=` the open POSTING. This matches Beancount, whose
867/// grammar attributes metadata by POSITION (any `key_value` line
868/// following a posting, before the next posting, attaches to that
869/// posting) rather than by relative indent — so the common
870/// `key: value` at the SAME column as the posting (e.g. the
871/// `effective_date:` idiom) is posting metadata, not transaction
872/// metadata. Pinned by
873/// `same_indent_metadata_attaches_to_preceding_posting`.
874/// - **Indented comment (`false`)**: a `; doc` / `% doc` sub-line
875/// attaches only when STRICTLY more indented (`>`). A same-indent
876/// comment closes the POSTING and emits as transaction-level
877/// inter-posting trivia. Comments are AST-invisible, so this
878/// threshold only affects CST/formatter emission placement; it is
879/// pinned by
880/// `posting_with_indented_comment_between_postings_terminates_posting`
881/// and must stay strict to preserve that formatter contract.
882///
883/// A sub-line below the attachment threshold closes the POSTING.
884/// Called with `open_posting_indent = None` is a no-op (no POSTING to
885/// close).
886fn close_open_posting_unless_attached(
887 builder: &mut GreenNodeBuilder<'_>,
888 open_posting_indent: &mut Option<usize>,
889 sub_line_indent: usize,
890 attach_on_equal: bool,
891) {
892 let attach = open_posting_indent.is_some_and(|p_indent| {
893 if attach_on_equal {
894 sub_line_indent >= p_indent
895 } else {
896 sub_line_indent > p_indent
897 }
898 });
899 if !attach && open_posting_indent.is_some() {
900 builder.finish_node();
901 *open_posting_indent = None;
902 }
903}
904
905/// Returns true iff `tokens[i..]` starts a posting sub-line:
906/// `WHITESPACE` (the indent) followed by `ACCOUNT`, or by an
907/// optional flag (`FLAG` / `STAR` / `PENDING_KW` / `HASH` /
908/// single-char `CURRENCY`) plus another `WHITESPACE` then
909/// `ACCOUNT`. Mirrors the legacy AST parser's `parse_posting` shape
910/// (`parser.rs:866-880`): indent, optional flag, then a required
911/// account. The flag set MUST stay in sync with `parse_flag` in the
912/// legacy parser (`Token::Star | Pending | Flag(_) | Hash` plus
913/// single-char `Currency`) and with `identify_directive`'s
914/// transaction-trigger arm above; drift would silently leave
915/// HASH-flagged or single-char-CURRENCY-flagged posting lines flat
916/// under `TRANSACTION` instead of wrapped in `POSTING`. The single-
917/// char `CURRENCY`-as-flag arm exists because the lexer's priority-3
918/// Currency-vs-Flag tie-break makes letters like `T`/`V`/`F`/`X`
919/// tokenize as `CURRENCY`, but they still function as posting flags
920/// by Beancount convention.
921fn starts_posting_sub_line(tokens: &[(SyntaxKind, Range<usize>)], i: usize) -> bool {
922 if !matches!(tokens.get(i), Some((SyntaxKind::WHITESPACE, _))) {
923 return false;
924 }
925 if matches!(tokens.get(i + 1), Some((SyntaxKind::ACCOUNT, _))) {
926 return true;
927 }
928 let has_flag = match tokens.get(i + 1) {
929 Some((
930 SyntaxKind::FLAG | SyntaxKind::STAR | SyntaxKind::PENDING_KW | SyntaxKind::HASH,
931 _,
932 )) => true,
933 Some((SyntaxKind::CURRENCY, range)) => range.len() == 1,
934 _ => false,
935 };
936 if !has_flag {
937 return false;
938 }
939 matches!(tokens.get(i + 2), Some((SyntaxKind::WHITESPACE, _)))
940 && matches!(tokens.get(i + 3), Some((SyntaxKind::ACCOUNT, _)))
941}
942
943/// Byte length of the leading `WHITESPACE` token at `tokens[i]`,
944/// or 0 if there is no leading whitespace. Used by
945/// `emit_transaction_body` to decide whether a metadata or
946/// comment sub-line's indent is strictly deeper than the
947/// surrounding POSTING's indent (the posting-attached-metadata /
948/// posting-attached-comment rule).
949///
950/// **Known divergence from the legacy AST parser**: the legacy
951/// lexer's `Indent(N)` / `DeepIndent(N)` variants
952/// (`logos_lexer.rs:615-616`) count tabs as 4 spaces, but this
953/// helper returns raw bytes. Mixed tab+space indentation can
954/// therefore produce different attribution between the two paths.
955/// Acceptable for now because (a) Beancount idiom is uniform
956/// spaces, (b) no corpus file currently triggers the divergence in
957/// posting-attached-metadata position, and (c) the CST round-trip
958/// is byte-identical regardless of how `indent_width` classifies.
959/// If a file shows up, switch to a column-aware count.
960fn indent_width(tokens: &[(SyntaxKind, Range<usize>)], i: usize) -> usize {
961 match tokens.get(i) {
962 Some((SyntaxKind::WHITESPACE, range)) => range.len(),
963 _ => 0,
964 }
965}
966
967/// Returns true iff `kind` is one of the four comment-class trivia
968/// token kinds: `COMMENT` (`;`), `PERCENT_COMMENT` (`%`), `SHEBANG`
969/// (`#!`), or `EMACS_DIRECTIVE` (`#+`). Mirrors the comment subset
970/// of `SyntaxKind::is_trivia()` and is the single source of truth
971/// for the three call sites that need to decide whether a token
972/// "is a comment" for body-continuation / indent-attribution
973/// purposes (`starts_indented_comment`,
974/// `upcoming_indented_block_has_meta`,
975/// `is_indented_directive_continuation`). A new comment-class
976/// token would otherwise require three coordinated edits;
977/// `is_comment_token_covers_all_comment_class_trivia` in this
978/// module's tests asserts membership stays in sync with `is_trivia`.
979///
980/// **Known CST/AST divergence**: The legacy AST parser's
981/// `parse_posting_metadata` / `parse_transaction_directive` paths
982/// in `crates/rustledger-parser/src/parser.rs` only treat
983/// `Token::Comment` and `Token::PercentComment` as in-body trivia
984/// for transaction / directive bodies. `Token::Shebang` and
985/// `Token::EmacsDirective` are processed only at top level
986/// (`parse_directive` dispatch). So a deeper-indented `#+STARTUP:
987/// overview` between two postings is INSIDE the POSTING for the
988/// CST but TERMINATES the transaction for the AST. Phase-isolated
989/// in practice: the loader, LSP, validator, query, booking, and
990/// CLI all run through the AST path; the only current
991/// `parse_structured` consumers are this crate's corpus baseline
992/// test and `examples/dump_top_level_directives.rs`. Phase 5
993/// deletes `parse_flat` and the AST; that reconciliation should
994/// adopt the CST behavior (consistent with `is_trivia()`'s
995/// classification of all four comment-class tokens) rather than
996/// the AST behavior (an indented comment-class line silently
997/// terminating the directive is the surprising outcome).
998const fn is_comment_token(kind: SyntaxKind) -> bool {
999 matches!(
1000 kind,
1001 SyntaxKind::COMMENT
1002 | SyntaxKind::PERCENT_COMMENT
1003 | SyntaxKind::SHEBANG
1004 | SyntaxKind::EMACS_DIRECTIVE,
1005 )
1006}
1007
1008/// Returns true iff `tokens[i..]` starts an indented comment line:
1009/// `WHITESPACE` (the indent) followed by a comment-class token (per
1010/// [`is_comment_token`]). Used by `emit_transaction_body` to apply
1011/// the same indent-attribution rule to comments that it applies to
1012/// metadata.
1013fn starts_indented_comment(tokens: &[(SyntaxKind, Range<usize>)], i: usize) -> bool {
1014 matches!(tokens.get(i), Some((SyntaxKind::WHITESPACE, _)))
1015 && matches!(tokens.get(i + 1), Some((k, _)) if is_comment_token(*k))
1016}
1017
1018/// Returns true iff `tokens[i..]` starts an indented line with
1019/// actual content: `WHITESPACE` followed by ANY non-`NEWLINE`
1020/// token. A blank line (`NEWLINE` alone, or `WHITESPACE NEWLINE`)
1021/// or EOF terminates the transaction body.
1022///
1023/// **Deliberate divergence from rule 4 of `cst::trivia`:** unlike
1024/// the single-line-directive body, a TRANSACTION body absorbs an
1025/// indented trailing `;`-comment AT EOF (file-trailing-ish) into
1026/// the directive. Rationale: documentation comments interleaved
1027/// with postings are a Beancount idiom, and forcing the body to
1028/// "back-track" the last comment if it's trailing would require
1029/// look-ahead the per-line predicate can't do without extra state.
1030/// Pinned by `transaction_trailing_indented_comment_at_eof_stays_inside`.
1031fn is_indented_transaction_body_line(tokens: &[(SyntaxKind, Range<usize>)], i: usize) -> bool {
1032 if !matches!(tokens.get(i), Some((SyntaxKind::WHITESPACE, _))) {
1033 return false;
1034 }
1035 !matches!(tokens.get(i + 1), Some((SyntaxKind::NEWLINE, _)) | None)
1036}
1037
1038/// Scan forward through any indented `WS META_KEY` sub-lines or
1039/// `WS <comment>` sub-lines (per [`is_comment_token`]) starting at
1040/// `tokens[i..]`, returning `true` iff at least one of them is a
1041/// metadata (`WS META_KEY`) sub-line. Stops at the first line that
1042/// is neither metadata nor an indented comment (blank line,
1043/// non-indented top-level content, EOF).
1044fn upcoming_indented_block_has_meta(tokens: &[(SyntaxKind, Range<usize>)], mut i: usize) -> bool {
1045 loop {
1046 let head = tokens.get(i).map(|(k, _)| *k);
1047 let next = tokens.get(i + 1).map(|(k, _)| *k);
1048 match (head, next) {
1049 (Some(SyntaxKind::WHITESPACE), Some(SyntaxKind::META_KEY)) => return true,
1050 (Some(SyntaxKind::WHITESPACE), Some(k)) if is_comment_token(k) => {
1051 // Skip past this indented-comment line.
1052 while i < tokens.len() && tokens[i].0 != SyntaxKind::NEWLINE {
1053 i += 1;
1054 }
1055 if i >= tokens.len() {
1056 return false;
1057 }
1058 i += 1; // past the NEWLINE
1059 }
1060 _ => return false,
1061 }
1062 }
1063}
1064
1065/// Returns true iff `tokens[i..]` starts an indented line that
1066/// CONTINUES the current multi-line directive: `WHITESPACE` (the
1067/// indent) followed by content that visually "belongs to" the
1068/// metadata block.
1069///
1070/// Recognizes:
1071/// - `WS META_KEY` — always a continuation regardless of context.
1072/// - `WS <comment>` (per [`is_comment_token`]) — a continuation iff
1073/// the surrounding indented block contains ANY `WS META_KEY` (the
1074/// `block_has_meta` argument). This prevents absorbing indented
1075/// comments that follow a header-only directive (rule 2 / rule
1076/// 4 cases) while still keeping documentation comments BEFORE
1077/// the first metadata entry inside the directive.
1078///
1079/// All other shapes (blank `\n`, non-indented content, EOF)
1080/// terminate the directive.
1081fn is_indented_directive_continuation(
1082 tokens: &[(SyntaxKind, Range<usize>)],
1083 i: usize,
1084 block_has_meta: bool,
1085) -> bool {
1086 // The META_KEY arm routes through `starts_meta_sub_line` so the
1087 // continuation predicate and the wrapping predicate
1088 // (`emit_body_sub_line`) cannot drift.
1089 if starts_meta_sub_line(tokens, i) {
1090 return true;
1091 }
1092 if !matches!(tokens.get(i), Some((SyntaxKind::WHITESPACE, _))) {
1093 return false;
1094 }
1095 match tokens.get(i + 1) {
1096 Some((k, _)) if is_comment_token(*k) => block_has_meta,
1097 _ => false,
1098 }
1099}
1100
1101/// Given the token slice and the index of a non-trivia token,
1102/// decide whether it starts a recognized top-level directive of
1103/// any kind. Returns the directive `SyntaxKind` if yes, `None`
1104/// otherwise (random content that doesn't fit a known shape — the
1105/// caller wraps such content in an `ERROR_NODE` per PR 2.4).
1106///
1107/// Beancount directive line shapes recognized here:
1108///
1109/// - `DATE WHITESPACE <KEYWORD> ...`: OPEN / CLOSE / BALANCE / PAD
1110/// / EVENT / QUERY / NOTE / DOCUMENT / PRICE / COMMODITY (PR
1111/// 2.1a) + CUSTOM (PR 2.3)
1112/// - `DATE WHITESPACE <txn-trigger> ...`: TRANSACTION (PR 2.1b),
1113/// where `<txn-trigger>` is one of `STAR` / `PENDING_KW` (`!`)
1114/// / `FLAG` / `HASH` / `TXN_KW` / `STRING` ("implied" txn form
1115/// with no explicit flag) / single-char `CURRENCY` (ticker
1116/// letters). Mirrors `parse_dated_directive` in the legacy AST
1117/// parser at parser.rs:1707-1715.
1118/// - `<KEYWORD> ...` (no leading date): PUSHTAG / POPTAG /
1119/// PUSHMETA / POPMETA (PR 2.1a) + OPTION / INCLUDE / PLUGIN
1120/// (PR 2.3)
1121fn identify_directive(tokens: &[(SyntaxKind, Range<usize>)], i: usize) -> Option<SyntaxKind> {
1122 let (head, _) = tokens.get(i)?;
1123 match *head {
1124 // Top-level keyword directives — no leading date.
1125 SyntaxKind::PUSHTAG_KW => Some(SyntaxKind::PUSHTAG_DIRECTIVE),
1126 SyntaxKind::POPTAG_KW => Some(SyntaxKind::POPTAG_DIRECTIVE),
1127 SyntaxKind::PUSHMETA_KW => Some(SyntaxKind::PUSHMETA_DIRECTIVE),
1128 SyntaxKind::POPMETA_KW => Some(SyntaxKind::POPMETA_DIRECTIVE),
1129
1130 // Phase 2.3: edge directives (option / include / plugin).
1131 // These are top-level keyword directives — like
1132 // pushtag/poptag/pushmeta/popmeta above — so the same
1133 // single-line directive body shape applies. Their full
1134 // header is consumed by `emit_through_terminator`; trailing
1135 // indented metadata lines (a rare but legal Beancount idiom
1136 // for option / include / plugin) are absorbed by
1137 // `emit_directive_body`'s look-ahead, same as the other
1138 // top-level-keyword directives.
1139 SyntaxKind::OPTION_KW => Some(SyntaxKind::OPTION_DIRECTIVE),
1140 SyntaxKind::INCLUDE_KW => Some(SyntaxKind::INCLUDE_DIRECTIVE),
1141 SyntaxKind::PLUGIN_KW => Some(SyntaxKind::PLUGIN_DIRECTIVE),
1142
1143 // Dated directives — peek past SAME-LINE whitespace for the
1144 // keyword. Only WHITESPACE separates content tokens within a
1145 // directive's header line; a NEWLINE means we crossed into
1146 // the next line and the DATE/keyword pair is NOT a single
1147 // directive. Skipping `is_trivia()` (which includes NEWLINE
1148 // and COMMENT) would wrongly identify malformed `DATE\nopen ...`
1149 // as OPEN_DIRECTIVE while `emit_through_terminator` only
1150 // captures the first line, leaving the keyword orphaned.
1151 SyntaxKind::DATE => {
1152 let mut j = i + 1;
1153 while j < tokens.len() && tokens[j].0 == SyntaxKind::WHITESPACE {
1154 j += 1;
1155 }
1156 let (next, _) = tokens.get(j)?;
1157 match *next {
1158 SyntaxKind::OPEN_KW => Some(SyntaxKind::OPEN_DIRECTIVE),
1159 SyntaxKind::CLOSE_KW => Some(SyntaxKind::CLOSE_DIRECTIVE),
1160 SyntaxKind::BALANCE_KW => Some(SyntaxKind::BALANCE_DIRECTIVE),
1161 SyntaxKind::PAD_KW => Some(SyntaxKind::PAD_DIRECTIVE),
1162 SyntaxKind::EVENT_KW => Some(SyntaxKind::EVENT_DIRECTIVE),
1163 SyntaxKind::QUERY_KW => Some(SyntaxKind::QUERY_DIRECTIVE),
1164 SyntaxKind::NOTE_KW => Some(SyntaxKind::NOTE_DIRECTIVE),
1165 SyntaxKind::DOCUMENT_KW => Some(SyntaxKind::DOCUMENT_DIRECTIVE),
1166 SyntaxKind::PRICE_KW => Some(SyntaxKind::PRICE_DIRECTIVE),
1167 SyntaxKind::COMMODITY_KW => Some(SyntaxKind::COMMODITY_DIRECTIVE),
1168 // Phase 2.3: CUSTOM is a dated directive with a
1169 // type-name STRING followed by an arbitrary value
1170 // list (STRING / ACCOUNT / amount / DATE / CURRENCY
1171 // / BOOL_TRUE / BOOL_FALSE). The header consumption
1172 // is identical to the other dated single-line
1173 // directives; only the value list is open-ended,
1174 // which is fine for the CST since the trailing
1175 // tokens stay flat.
1176 SyntaxKind::CUSTOM_KW => Some(SyntaxKind::CUSTOM_DIRECTIVE),
1177 // Transaction triggers after the DATE. Beancount
1178 // accepts:
1179 // - `*` (STAR) for completed transactions
1180 // - `!` (PENDING_KW) for incomplete/warning
1181 // - letter flags P/S/T/C/U/R/M/?/& (FLAG)
1182 // - `#` (HASH) promoted to a flag in this position
1183 // (cf. `Token::is_txn_flag` and the AST parser's
1184 // `parse_flag` accepting Hash)
1185 // - the explicit `txn` keyword (TXN_KW)
1186 // - a bare STRING ("implied transaction": the AST
1187 // parser at parser.rs:1713 dispatches
1188 // `Token::String(_)` to `parse_transaction_directive`
1189 // with an implied `*` flag; common shorthand
1190 // form in real ledgers like
1191 // `2024-01-15 "Coffee"`)
1192 SyntaxKind::STAR
1193 | SyntaxKind::PENDING_KW
1194 | SyntaxKind::FLAG
1195 | SyntaxKind::HASH
1196 | SyntaxKind::TXN_KW
1197 | SyntaxKind::STRING => Some(SyntaxKind::TRANSACTION),
1198 // Single-character CURRENCY: NYSE/NASDAQ-style
1199 // ticker letters (T, V, F, X, ...) double as
1200 // transaction flags. The lexer prioritizes
1201 // CURRENCY over FLAG for single uppercase letters
1202 // (logos_lexer Currency priority 3); the AST parser
1203 // (`parse_flag` arm `Token::Currency(s) if s.len() == 1`)
1204 // mirrors this. We do the same to stay consistent
1205 // with the established lexer/parser contract.
1206 SyntaxKind::CURRENCY if tokens[j].1.len() == 1 => Some(SyntaxKind::TRANSACTION),
1207 // Anything else: unknown shape.
1208 _ => None,
1209 }
1210 }
1211 _ => None,
1212 }
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217 use super::*;
1218
1219 fn assert_round_trips(source: &str) {
1220 let tree = parse_flat(source);
1221 assert_eq!(tree.text().to_string(), source);
1222 let structured = parse_structured(source);
1223 assert_eq!(structured.text().to_string(), source);
1224 }
1225
1226 /// Drift guard: `is_comment_token` and `is_trivia` must agree on
1227 /// what counts as comment-class trivia. Enforces two invariants:
1228 ///
1229 /// 1. `is_trivia() ⊆ is_comment_token ∪ non_comment_trivia`:
1230 /// every trivia kind is either a comment or in the explicit
1231 /// whitespace-class allow-list. Catches a new lexer-level
1232 /// addition to `is_trivia()` that's silently forgotten in
1233 /// `is_comment_token`.
1234 /// 2. `is_comment_token ⊆ is_trivia()`: every kind
1235 /// `is_comment_token` says yes to is actually trivia. Catches
1236 /// a future edit to `is_comment_token`'s match arm that
1237 /// accidentally pulls in a non-trivia content token,
1238 /// silently extending indent-attribution to real content
1239 /// inside POSTING / directive bodies.
1240 ///
1241 /// On failure (1), if the new trivia kind is neither comment-
1242 /// class nor whitespace-class (e.g., some future
1243 /// `SECTION_HEADER` that should NOT be absorbed as a
1244 /// continuation), don't reflexively add it to either set —
1245 /// revisit whether the body-continuation predicates need a
1246 /// different abstraction (`is_body_continuation_trivia` or
1247 /// similar) and propagate the choice to the three call sites.
1248 #[test]
1249 fn is_comment_token_covers_all_comment_class_trivia() {
1250 let non_comment_trivia = [SyntaxKind::BOM, SyntaxKind::WHITESPACE, SyntaxKind::NEWLINE];
1251
1252 let mut trivia_missed_from_comment: Vec<SyntaxKind> = Vec::new();
1253 let mut comment_not_trivia: Vec<SyntaxKind> = Vec::new();
1254 for d in 0u16..=u16::MAX {
1255 let Ok(kind) = SyntaxKind::try_from(d) else {
1256 continue;
1257 };
1258 // Invariant 1: trivia (minus whitespace allow-list) ⊆ comment.
1259 if kind.is_trivia() && !non_comment_trivia.contains(&kind) && !is_comment_token(kind) {
1260 trivia_missed_from_comment.push(kind);
1261 }
1262 // Invariant 2: comment ⊆ trivia.
1263 if is_comment_token(kind) && !kind.is_trivia() {
1264 comment_not_trivia.push(kind);
1265 }
1266 }
1267 assert!(
1268 trivia_missed_from_comment.is_empty(),
1269 "trivia kinds present in is_trivia() but missing from \
1270 is_comment_token: {trivia_missed_from_comment:?}. Three \
1271 options: (a) add them to is_comment_token if they are \
1272 comment-class; (b) extend the non_comment_trivia allow- \
1273 list in this test if they are whitespace-class; (c) if \
1274 they are neither, revisit whether the body-continuation \
1275 predicates need a different abstraction and propagate \
1276 the decision to the three call sites.",
1277 );
1278 assert!(
1279 comment_not_trivia.is_empty(),
1280 "is_comment_token claims these kinds are comments but \
1281 is_trivia() disagrees: {comment_not_trivia:?}. Either \
1282 add them to is_trivia() (if they really are trivia) or \
1283 remove them from is_comment_token (if they are content \
1284 tokens that should not be absorbed as comment \
1285 continuations).",
1286 );
1287 }
1288
1289 #[test]
1290 fn empty_source() {
1291 assert_round_trips("");
1292 }
1293
1294 #[test]
1295 fn whitespace_only() {
1296 assert_round_trips(" \t ");
1297 }
1298
1299 #[test]
1300 fn bom_round_trips() {
1301 assert_round_trips("\u{FEFF}2024-01-01 open Assets:Bank\n");
1302 }
1303
1304 #[test]
1305 fn full_directive_round_trips() {
1306 assert_round_trips(
1307 "2024-01-01 open Assets:Bank USD\n\
1308 2024-01-15 * \"Coffee\"\n \
1309 Assets:Bank -5.00 USD\n \
1310 Expenses:Food\n",
1311 );
1312 }
1313
1314 #[test]
1315 fn line_comment_round_trips() {
1316 assert_round_trips("; preamble\n2024-01-01 open Assets:Bank\n");
1317 }
1318
1319 #[test]
1320 fn no_trailing_newline_round_trips() {
1321 assert_round_trips("2024-01-01 open Assets:Bank");
1322 }
1323
1324 #[test]
1325 fn root_kind_is_source_file() {
1326 let tree = parse_flat("");
1327 assert_eq!(tree.kind(), SyntaxKind::SOURCE_FILE);
1328 let structured = parse_structured("");
1329 assert_eq!(structured.kind(), SyntaxKind::SOURCE_FILE);
1330 }
1331}