Skip to main content

rustledger_parser/cst/
convert.rs

1//! CST -> `ParseResult` converter.
2//!
3//! [`parse_via_cst`] is the implementation behind the public
4//! [`crate::parse`] entry point. It walks the structured CST from
5//! [`crate::parse_structured`] via the typed-AST surface in
6//! [`crate::cst::ast`] and produces the legacy AST-shaped
7//! [`ParseResult`] that downstream consumers (loader, booking,
8//! validate, query, LSP) consume.
9//!
10//! ## Conversion scope
11//!
12//! Per-directive converters: Open, Close, Commodity, Note,
13//! Document, Event, Query, Price, Balance, Pad, Custom, and
14//! Transaction (with its full posting / cost-spec / price-
15//! annotation / metadata / trailing-comments machinery).
16//!
17//! State-only directives (Pushtag / Poptag / Pushmeta / Popmeta)
18//! mutate `tag_stack` / `meta_stack` inherited by subsequent
19//! directives; mismatched-pop and unclosed-at-EOF emit specific
20//! `ParseErrorKind` variants. Arithmetic AMOUNT expressions
21//! (`120 / 3 USD` ≡ `40 USD`) are evaluated; the same logic
22//! powers numeric values in BALANCE and PRICE directives.
23//!
24//! Field-level extractors populate `ParseResult.options`,
25//! `.includes`, `.plugins`, `.comments`, `.currency_occurrences`,
26//! `.account_occurrences`.
27//!
28//! ## Error surfacing
29//!
30//! A single [`walk_descendants_once`] pass collects standalone
31//! comments, currency occurrences, account occurrences, and inline
32//! `ERROR_TOKEN` / mid-file-BOM errors. Specialized extractors run alongside for
33//! `ERROR_NODE` classification, transaction body errors, unclosed
34//! cost braces, indented top-level directives, and bare-currency
35//! values in custom directives.
36
37use rust_decimal::Decimal;
38use rustledger_core::cost::{CostNumber, CostSpec};
39use rustledger_core::directive::{PriceAnnotation, PriceKind};
40use rustledger_core::{
41    Account, Amount, Currency, Directive, IncompleteAmount, InternedStr, Link, MetaValue, Metadata,
42    NaiveDate, Posting, Span, Spanned, Tag, naive_date,
43};
44
45use crate::ParseResult;
46use crate::cst::ast::{
47    self, AstNode, AstToken, BalanceDirective, CloseDirective, CommodityDirective, CustomDirective,
48    DocumentDirective, EventDirective, IncludeDirective, MetaEntry, NoteDirective, OpenDirective,
49    OptionDirective, PadDirective, PluginDirective, PostingFlagKind, PriceDirective,
50    QueryDirective, SourceFile, Transaction as AstTransaction, TransactionFlagKind,
51};
52
53/// Parse Beancount source via the CST and produce the AST-shaped
54/// [`ParseResult`]. This is the implementation behind
55/// [`crate::parse`]; the public entry delegates here unconditionally.
56///
57/// See the module-level rustdoc for the conversion scope.
58#[must_use]
59pub fn parse_via_cst(source: &str) -> ParseResult {
60    parse_via_cst_opts(source, /* collect_occurrences = */ true)
61}
62
63/// Like [`parse_via_cst`], but only collects `currency_occurrences` /
64/// `account_occurrences` when `collect_occurrences` is true.
65///
66/// Those two indices are consumed **solely by the LSP** (rename / references /
67/// highlight). The loader / CLI processing path never reads them, so passing
68/// `false` skips the per-`ACCOUNT`/`CURRENCY` `Account::new` / `Currency::new`
69/// construction and the per-token in-`ERROR_NODE` ancestor walk inside
70/// `walk_descendants_once` — profiling flagged that walk as the #1
71/// allocation-count site. Inline errors and top-level comments are still
72/// collected unconditionally (the processing path needs them).
73#[must_use]
74pub fn parse_via_cst_opts(source: &str, collect_occurrences: bool) -> ParseResult {
75    parse_via_cst_inner(source, collect_occurrences, /* use_green = */ true)
76}
77
78/// Test/fuzz hook: parse like [`crate::parse`] but force the **red** conversion
79/// path (green transaction conversion disabled). Used by the `green_eq_red`
80/// differential fuzz target to assert the green-wired path is output-equivalent.
81#[doc(hidden)]
82#[must_use]
83pub fn parse_red_only(source: &str) -> ParseResult {
84    parse_via_cst_inner(
85        source, /* collect_occurrences = */ true, /* use_green = */ false,
86    )
87}
88
89fn parse_via_cst_inner(source: &str, collect_occurrences: bool, use_green: bool) -> ParseResult {
90    // BOM detection mirrors the legacy parser's behavior: strip a
91    // leading 3-byte BOM from the source before tokenizing and
92    // record its presence in the result. Spans index the original
93    // source frame INCLUDING the BOM offset.
94    let (stripped, has_leading_bom) = crate::bom::strip_leading(source);
95    let bom_offset: u32 = if has_leading_bom { 3 } else { 0 };
96
97    let source_file = SourceFile::parse(stripped);
98
99    let mut directives: Vec<Spanned<Directive>> = Vec::new();
100    let mut directive_nodes: Vec<crate::SyntaxNode> = Vec::new();
101    let mut options: Vec<(String, String, Span)> = Vec::new();
102    let mut includes: Vec<(String, Span)> = Vec::new();
103    let mut plugins: Vec<(String, Option<String>, Span)> = Vec::new();
104    // Single-pass descendants walk that yields inline errors,
105    // top-level comments, and currency occurrences (replaces three
106    // separate `descendants_with_tokens` walks at 3·O(N) → 1·O(N)).
107    let DescendantsWalkResult {
108        inline_errors,
109        top_level_comments,
110        currency_occurrences,
111        account_occurrences,
112        cost_brace_errors,
113        link_meta_errors,
114        custom_pushmeta_errors,
115    } = if use_green {
116        // Green-tree walk (no per-node red allocation); byte-identical to red.
117        super::green::walk_descendants(
118            source_file.syntax(),
119            stripped,
120            bom_offset,
121            collect_occurrences,
122        )
123    } else {
124        walk_descendants_once(&source_file, bom_offset, collect_occurrences)
125    };
126
127    // Fused single pass over the top-level children replaces the
128    // five former per-child traversals (error-node, transaction-body,
129    // indented-directive, custom-value diagnostics + section-marker
130    // comments). See `walk_top_level_once`.
131    let TopLevelWalkResult {
132        errors: top_level_errors,
133        section_marker_comments,
134    } = if use_green {
135        super::green::walk_top_level(source_file.syntax(), stripped, bom_offset)
136    } else {
137        walk_top_level_once(&source_file, stripped, bom_offset)
138    };
139
140    let mut comments: Vec<Spanned<String>> = top_level_comments;
141    comments.extend(section_marker_comments);
142    // Merge in source order; the two helpers' classifiers are
143    // disjoint today (STAR-first vs COMMENT-kind-first) but
144    // dedup-by-start keeps the invariant local.
145    comments.sort_by_key(|s| s.span.start);
146    comments.dedup_by_key(|s| s.span.start);
147    let mut errors = top_level_errors;
148    // Three per-node shape rules — unclosed cost braces, links as metadata
149    // values, tags/links as custom/pushmeta values — in a FIXED order that
150    // both paths below reproduce.
151    //
152    // Green folds them into `walk_descendants`, which already visits every
153    // node, so they cost a `match` per node and nothing else. Red still runs
154    // them as three standalone whole-tree `descendants()` scans, each behind a
155    // byte-scan guard (`contains('{')` and friends) that skips the scan when
156    // the source cannot contain the construct at all.
157    //
158    // The guards are what made this cost invisible for so long: they are free
159    // on a ledger with no '{', '^' or '#', which is exactly the `simple`
160    // profiling shape. Real ledgers have tags and cost specs, and there the
161    // three red scans measured 7.46% of all instructions on `tagged` and
162    // 2.15% on `investment` (cachegrind ablation, 10k txns). Green — the path
163    // every caller but the parity test takes — no longer pays any of it.
164    //
165    // The green path needs no guards: no '{' in the source means the parser
166    // built no COST_SPEC node, so the folded rule finds nothing to report.
167    if use_green {
168        errors.extend(cost_brace_errors);
169        errors.extend(link_meta_errors);
170        errors.extend(custom_pushmeta_errors);
171    } else {
172        if stripped.contains('{') {
173            errors.extend(extract_unclosed_cost_brace_errors(
174                &source_file,
175                stripped,
176                bom_offset,
177            ));
178        }
179        if stripped.contains('^') {
180            errors.extend(extract_link_metadata_value_errors(&source_file, bom_offset));
181        }
182        if stripped.contains('^') || stripped.contains('#') {
183            errors.extend(extract_custom_pushmeta_taglink_errors(
184                &source_file,
185                bom_offset,
186            ));
187        }
188    }
189    errors.extend(inline_errors);
190    let warnings = Vec::new();
191
192    // pushtag/poptag/pushmeta/popmeta state. The legacy parser
193    // maintains a stack across directives; each Transaction
194    // inherits the active pushed-tag set, and EVERY directive
195    // inherits the active pushed-meta set. We pair each entry
196    // with the originating directive's span so unclosed-at-EOF
197    // diagnostics can point at the offending push.
198    let mut tag_stack: Vec<(Tag, Span)> = Vec::new();
199    // Vec-of-tuples (NOT a `Metadata` map) so legacy semantics
200    // are preserved: `pushmeta x: 1` then `pushmeta x: 2` should
201    // shadow (peek returns 2) and `popmeta x` should pop the
202    // most recent, leaving x=1 active. A HashMap would have lost
203    // the shadowed entry on the second push.
204    let mut meta_stack: Vec<(String, MetaValue, Span)> = Vec::new();
205
206    for directive in source_file.directives() {
207        // Helper to push a successfully-converted directive
208        // alongside its CST node so the post-pass span fixup
209        // can index them in parallel.
210        let cst_node = directive.syntax().clone();
211        // `is_directive_producing` tracks whether THIS arm is
212        // expected to emit a `Spanned<Directive>` (the 12
213        // directive types). The catch-all below uses it to
214        // surface a `SyntaxError` when a producing converter
215        // returned `None` without emitting a more specific
216        // diagnostic - the silent-drop class of bug the integ
217        // tests caught for `2024-01-01 open` (no account),
218        // `balance Assets:X` (no amount), etc.
219        let is_directive_producing = matches!(
220            directive,
221            ast::Directive::Open(_)
222                | ast::Directive::Close(_)
223                | ast::Directive::Commodity(_)
224                | ast::Directive::Note(_)
225                | ast::Directive::Document(_)
226                | ast::Directive::Event(_)
227                | ast::Directive::Query(_)
228                | ast::Directive::Price(_)
229                | ast::Directive::Balance(_)
230                | ast::Directive::Pad(_)
231                | ast::Directive::Custom(_)
232                | ast::Directive::Transaction(_)
233        );
234        let errors_before = errors.len();
235        let pushed_directive = match directive {
236            ast::Directive::Open(node) => convert_open(&node, bom_offset, &mut errors),
237            ast::Directive::Close(node) => convert_close(&node, bom_offset, &mut errors),
238            ast::Directive::Commodity(node) => convert_commodity(&node, bom_offset, &mut errors),
239            ast::Directive::Note(node) => convert_note(&node, bom_offset, &mut errors),
240            ast::Directive::Document(node) => convert_document(&node, bom_offset, &mut errors),
241            ast::Directive::Event(node) => convert_event(&node, bom_offset, &mut errors),
242            ast::Directive::Query(node) => convert_query(&node, bom_offset, &mut errors),
243            ast::Directive::Price(node) => convert_price(&node, bom_offset, &mut errors),
244            ast::Directive::Balance(node) => convert_balance(&node, bom_offset, &mut errors),
245            ast::Directive::Pad(node) => convert_pad(&node, bom_offset, &mut errors),
246            ast::Directive::Custom(node) => convert_custom(&node, bom_offset, &mut errors),
247            ast::Directive::Transaction(node) => {
248                // Green-tree conversion (no red-node allocation) with a red
249                // fallback for transactions it doesn't yet handle exactly. The
250                // green path returns `Some` only when its output is identical to
251                // red's, so the hybrid is output-equivalent to the pure-red path.
252                let green = node.syntax().green();
253                let base =
254                    u32::from(node.syntax().text_range().start()) as usize + bom_offset as usize;
255                let green_dir = if use_green {
256                    super::green::convert_transaction(green, base)
257                } else {
258                    None
259                };
260                match green_dir {
261                    Some(d) => Some(d),
262                    None => convert_transaction(&node, bom_offset, &mut errors),
263                }
264            }
265            ast::Directive::Option(node) => {
266                if let Some(triple) = convert_option(&node, bom_offset) {
267                    options.push(triple);
268                }
269                None
270            }
271            ast::Directive::Include(node) => {
272                if let Some(pair) = convert_include(&node, bom_offset) {
273                    includes.push(pair);
274                }
275                None
276            }
277            ast::Directive::Plugin(node) => {
278                if let Some(triple) = convert_plugin(&node, bom_offset) {
279                    plugins.push(triple);
280                }
281                None
282            }
283            // State-only side effects: mutate the inherited
284            // tag/meta sets that apply to subsequent directives.
285            ast::Directive::Pushtag(node) => {
286                if let Some(tag_token) = node.tag() {
287                    let span = node_span(node.syntax(), bom_offset);
288                    tag_stack.push((Tag::new(tag_token.text().trim_start_matches('#')), span));
289                }
290                None
291            }
292            ast::Directive::Poptag(node) => {
293                if let Some(tag_token) = node.tag() {
294                    let name = tag_token.text().trim_start_matches('#');
295                    if let Some(pos) = tag_stack.iter().rposition(|(t, _)| t.as_str() == name) {
296                        tag_stack.remove(pos);
297                    } else {
298                        errors.push(crate::ParseError::new(
299                            crate::ParseErrorKind::InvalidPoptag(name.to_string()),
300                            node_span(node.syntax(), bom_offset),
301                        ));
302                    }
303                }
304                None
305            }
306            ast::Directive::Pushmeta(node) => {
307                if let Some(key_token) = node.key() {
308                    let key = key_token.text_without_colon().to_string();
309                    let value = pushmeta_value(node.syntax());
310                    let span = node_span(node.syntax(), bom_offset);
311                    meta_stack.push((key, value, span));
312                }
313                None
314            }
315            ast::Directive::Popmeta(node) => {
316                if let Some(key_token) = node.key() {
317                    let key = key_token.text_without_colon().to_string();
318                    if let Some(pos) = meta_stack.iter().rposition(|(k, _, _)| k == &key) {
319                        meta_stack.remove(pos);
320                    } else {
321                        errors.push(crate::ParseError::new(
322                            crate::ParseErrorKind::InvalidPopmeta(key),
323                            node_span(node.syntax(), bom_offset),
324                        ));
325                    }
326                }
327                None
328            }
329        };
330        if let Some(mut spanned) = pushed_directive {
331            apply_inherited_state(&mut spanned.value, &tag_stack, &meta_stack);
332            directives.push(spanned);
333            directive_nodes.push(cst_node);
334        } else if is_directive_producing && errors.len() == errors_before {
335            // Producing converter silently dropped the directive
336            // (typically: a required field like an account on
337            // `open`, an amount on `balance`, or a source account
338            // on `pad` was missing). Mirror the legacy parser's
339            // top-level error-recovery path which emits a
340            // `SyntaxError("unexpected input")` for the failed
341            // span so downstream tooling sees the same shape.
342            errors.push(crate::ParseError::new(
343                crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
344                node_span(&cst_node, bom_offset),
345            ));
346        }
347    }
348
349    // Unclosed pushtag/pushmeta at EOF - legacy emits one error
350    // per leftover stack entry, pointing at the originating push
351    // directive's span.
352    for (tag, span) in &tag_stack {
353        errors.push(crate::ParseError::new(
354            crate::ParseErrorKind::UnclosedPushtag(tag.as_str().to_string()),
355            *span,
356        ));
357    }
358    for (key, _, span) in &meta_stack {
359        errors.push(crate::ParseError::new(
360            crate::ParseErrorKind::UnclosedPushmeta(key.clone()),
361            *span,
362        ));
363    }
364    errors.sort_by_key(|e| e.span.start);
365
366    // Post-pass: align directive spans with the legacy parser's
367    // convention (skip leading trivia, extend through inter-
368    // directive trivia to the next directive's start).
369    fixup_directive_spans(&source_file, bom_offset, &directive_nodes, &mut directives);
370
371    // Pre-compute the file-wide formatter alignment from the
372    // same `source_file` we just walked, so the formatter (and
373    // every LSP handler that calls it) can skip the O(N_postings)
374    // re-walk on every format request. See
375    // `ParseResult::alignment` rustdoc for the cache contract;
376    // the equivalence with a fresh `compute_alignment` call is
377    // pinned by `parse_result_alignment_cache::*` (lib.rs tests).
378    // NOT computed here: `compute_alignment` walks the tree through the red
379    // ast accessors, and rowan allocates a `Box<NodeData>` per red node with
380    // no recycling. Doing it eagerly charged every parse for a formatter pass
381    // it usually never reads — 5.7%-12.3% of instructions by workload. See
382    // `ParseResult::alignment`, which computes on first use and caches.
383    let alignment = std::sync::OnceLock::new();
384
385    // Capture the green root before we drop `source_file`. `.green()`
386    // borrows (`&GreenNodeData`), so promote to an owned `GreenNode`; it is
387    // reference-counted internally, cheap to clone, and `Send + Sync` — safe
388    // to stash in the `Arc<ParseResult>` the LSP shares across threads.
389    //
390    // `to_owned()`, not `into_owned()`: rowan 0.17 changed `green()` from
391    // returning `Cow<GreenNodeData>` to returning `&GreenNodeData`, so the
392    // promotion now goes through `ToOwned` instead of `Cow::into_owned`.
393    let syntax_root = source_file.syntax().green().to_owned();
394
395    ParseResult {
396        directives,
397        options,
398        includes,
399        plugins,
400        comments,
401        errors,
402        warnings,
403        currency_occurrences,
404        account_occurrences,
405        has_leading_bom,
406        syntax_root,
407        alignment,
408    }
409}
410
411// ---- Directive converters --------------------------------------
412
413/// Valid booking methods per beancount v3 - must match the
414/// whitelist legacy `parser::parse_open_directive` enforces. An
415/// `open` directive whose explicit booking string isn't on this
416/// list is rejected (directive dropped, `InvalidBookingMethod`
417/// error emitted) by both the legacy parser and `convert_open`.
418const VALID_BOOKING_METHODS: &[&str] = &[
419    "FIFO",
420    "STRICT",
421    "STRICT_WITH_SIZE",
422    "LIFO",
423    "HIFO",
424    "NONE",
425    "AVERAGE",
426];
427
428/// Reject `#tag` / `^link` tokens on a directive that does not take them.
429///
430/// beancount allows tags and links on TRANSACTIONS, and in v3 on `note` and
431/// `document` — nowhere else. rledger accepted them everywhere, silently: each
432/// `convert_*` reads the fields it wants and ignores the rest, so a trailing
433/// token was never objected to by anything. `2018-06-01 open Assets:A #tag`
434/// loaded clean here and is a parse error there (#1949).
435///
436/// Deliberately scans DIRECT child tokens only. Metadata lives in `META_ENTRY`
437/// child NODES, and a metadata VALUE may legitimately be a tag (`k: #x`), so a
438/// descendant walk would reject valid input — the opposite mistake, and a worse
439/// one.
440///
441/// Not called from `note` or `document`: both take tags and links in beancount
442/// v3 and we already agree with it there. A blanket rule over non-transaction
443/// directives would break the two cases that are currently right, which is why
444/// this is a per-directive call rather than one check in the dispatcher.
445///
446/// REPORTS BUT DOES NOT DROP, and that is a deliberate divergence in the error
447/// SET (both tools still reject the file). beancount treats this as a parser
448/// syntax error, so the directive never exists and every later reference to it
449/// cascades:
450///
451///   2018-06-01 open Assets:N #tag
452///   2018-06-02 * "t"
453///     Assets:N   1.00 USD
454///     ...
455///
456///   beancount   `ParserSyntaxError` + `ValidationError`: unknown account
457///   rledger     the tag error alone; the account is still opened
458///
459/// Keeping the directive means the user gets one error naming the real
460/// problem instead of that error plus a cascade of unopened-account noise
461/// pointing at innocent lines. The compat oracle cannot flag the difference,
462/// because its error axis compares only WHETHER a file errs and not which
463/// errors, so it is written down here rather than left to be rediscovered.
464fn reject_tags_and_links(
465    node: &crate::SyntaxNode,
466    directive: &str,
467    bom_offset: u32,
468    errors: &mut Vec<crate::ParseError>,
469) {
470    use crate::SyntaxKind as K;
471    for t in node
472        .children_with_tokens()
473        .filter_map(rowan::NodeOrToken::into_token)
474    {
475        let kind = t.kind();
476        if !matches!(kind, K::TAG | K::LINK) {
477            continue;
478        }
479        let what = if kind == K::TAG { "tag" } else { "link" };
480        let range = t.text_range();
481        let off = bom_offset as usize;
482        let span = Span::new(
483            usize::from(range.start()) + off,
484            usize::from(range.end()) + off,
485        );
486        errors.push(crate::ParseError::new(
487            crate::ParseErrorKind::SyntaxError(format!(
488                "the {directive} directive does not take a {what} ({}); \
489                 tags and links belong to transactions, and to note and \
490                 document directives",
491                t.text()
492            )),
493            span,
494        ));
495    }
496}
497
498fn convert_open(
499    node: &OpenDirective,
500    bom_offset: u32,
501    errors: &mut Vec<crate::ParseError>,
502) -> Option<Spanned<Directive>> {
503    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
504    reject_tags_and_links(node.syntax(), "open", bom_offset, errors);
505    let account = Account::new(node.account()?.text());
506    let currencies: Vec<Currency> = node.currencies().map(|c| Currency::new(c.text())).collect();
507    let booking = node.booking_method().and_then(|s| s.text_decoded());
508    let span = node_span(node.syntax(), bom_offset);
509    if let Some(b) = &booking
510        && !VALID_BOOKING_METHODS.contains(&b.as_str())
511    {
512        errors.push(crate::ParseError::new(
513            crate::ParseErrorKind::InvalidBookingMethod(b.clone()),
514            span,
515        ));
516        return None;
517    }
518    let meta = convert_meta_entries(node.syntax());
519
520    let open = rustledger_core::directive::Open {
521        date,
522        account,
523        currencies,
524        booking,
525        meta,
526    };
527    Some(Spanned::new(Directive::Open(open), span))
528}
529
530fn convert_close(
531    node: &CloseDirective,
532    bom_offset: u32,
533    errors: &mut Vec<crate::ParseError>,
534) -> Option<Spanned<Directive>> {
535    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
536    reject_tags_and_links(node.syntax(), "close", bom_offset, errors);
537    let account = Account::new(node.account()?.text());
538    let meta = convert_meta_entries(node.syntax());
539
540    let close = rustledger_core::directive::Close {
541        date,
542        account,
543        meta,
544    };
545    let span = node_span(node.syntax(), bom_offset);
546    Some(Spanned::new(Directive::Close(close), span))
547}
548
549fn convert_commodity(
550    node: &CommodityDirective,
551    bom_offset: u32,
552    errors: &mut Vec<crate::ParseError>,
553) -> Option<Spanned<Directive>> {
554    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
555    reject_tags_and_links(node.syntax(), "commodity", bom_offset, errors);
556    let currency = Currency::new(node.currency()?.text());
557    let meta = convert_meta_entries(node.syntax());
558
559    let commodity = rustledger_core::directive::Commodity {
560        date,
561        currency,
562        meta,
563    };
564    let span = node_span(node.syntax(), bom_offset);
565    Some(Spanned::new(Directive::Commodity(commodity), span))
566}
567
568fn convert_note(
569    node: &NoteDirective,
570    bom_offset: u32,
571    errors: &mut Vec<crate::ParseError>,
572) -> Option<Spanned<Directive>> {
573    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
574    let account = Account::new(node.account()?.text());
575    let comment = node.text()?.text_decoded()?;
576    let meta = convert_meta_entries(node.syntax());
577
578    let note = rustledger_core::directive::Note {
579        date,
580        account,
581        comment,
582        meta,
583    };
584    let span = node_span(node.syntax(), bom_offset);
585    Some(Spanned::new(Directive::Note(note), span))
586}
587
588fn convert_document(
589    node: &DocumentDirective,
590    bom_offset: u32,
591    errors: &mut Vec<crate::ParseError>,
592) -> Option<Spanned<Directive>> {
593    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
594    let account = Account::new(node.account()?.text());
595    let path = node.path()?.text_decoded()?;
596    // Trailing tags/links on the document header (legacy
597    // `parse_document_directive` collects them in a loop after
598    // the path STRING). TAG / LINK tokens only appear in the
599    // header (not in META_ENTRY children, which are walked
600    // separately below), so a direct-child token walk that
601    // stops at the first NEWLINE captures them in source order.
602    let mut tags: Vec<Tag> = Vec::new();
603    let mut links: Vec<Link> = Vec::new();
604    for el in node.syntax().children_with_tokens() {
605        let rowan::NodeOrToken::Token(t) = el else {
606            continue;
607        };
608        match t.kind() {
609            crate::SyntaxKind::NEWLINE => break,
610            crate::SyntaxKind::TAG => {
611                tags.push(Tag::new(t.text().trim_start_matches('#')));
612            }
613            crate::SyntaxKind::LINK => {
614                links.push(Link::new(t.text().trim_start_matches('^')));
615            }
616            _ => {}
617        }
618    }
619    let meta = convert_meta_entries(node.syntax());
620
621    let document = rustledger_core::directive::Document {
622        date,
623        account,
624        path,
625        tags,
626        links,
627        meta,
628    };
629    let span = node_span(node.syntax(), bom_offset);
630    Some(Spanned::new(Directive::Document(document), span))
631}
632
633fn convert_event(
634    node: &EventDirective,
635    bom_offset: u32,
636    errors: &mut Vec<crate::ParseError>,
637) -> Option<Spanned<Directive>> {
638    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
639    reject_tags_and_links(node.syntax(), "event", bom_offset, errors);
640    let event_type = node.event_type()?.text_decoded()?;
641    let value = node.value()?.text_decoded()?;
642    let meta = convert_meta_entries(node.syntax());
643
644    let event = rustledger_core::directive::Event {
645        date,
646        event_type,
647        value,
648        meta,
649    };
650    let span = node_span(node.syntax(), bom_offset);
651    Some(Spanned::new(Directive::Event(event), span))
652}
653
654fn convert_query(
655    node: &QueryDirective,
656    bom_offset: u32,
657    errors: &mut Vec<crate::ParseError>,
658) -> Option<Spanned<Directive>> {
659    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
660    let name = node.name()?.text_decoded()?;
661    let query = node.query()?.text_decoded()?;
662    let meta = convert_meta_entries(node.syntax());
663
664    let q = rustledger_core::directive::Query {
665        date,
666        name,
667        query,
668        meta,
669    };
670    let span = node_span(node.syntax(), bom_offset);
671    Some(Spanned::new(Directive::Query(q), span))
672}
673
674/// The span of a `balance` / `price` value that holds MORE than the single
675/// signed number those directives can represent — extra `NUMBER` tokens, or a
676/// misplaced `,`.
677///
678/// Both directives fall back to "take the first `NUMBER` token and apply a
679/// leading sign" when the value is not an arithmetic expression. That fallback
680/// reads only the first token, so anything after it was DISCARDED IN SILENCE:
681/// `price HOOL 1,23,4.50 USD` stored `1 USD`, a thousandfold error with exit 0.
682/// Postings have rejected the same shapes all along (the lexer's grouping regex
683/// is strict, and a split number never forms one `NUMBER` token) — this closes
684/// the same hole for the directive family (#1892 follow-up).
685///
686/// Deliberately NOT flagged:
687/// - a `~ tolerance` clause, whose second number is legitimate — the scan stops
688///   at the `TILDE`;
689/// - arithmetic (`0.25 + 0.75 USD`), which is evaluated before this is
690///   consulted, so a well-formed expression never reaches it.
691fn malformed_directive_value(node: &crate::SyntaxNode) -> Option<crate::TextRange> {
692    let mut numbers = 0usize;
693    let mut saw_comma = false;
694    let mut start: Option<crate::TextRange> = None;
695    let mut end: Option<crate::TextRange> = None;
696    for t in node
697        .children_with_tokens()
698        .filter_map(rowan::NodeOrToken::into_token)
699    {
700        match t.kind() {
701            // Tolerance begins; its number is not part of the value.
702            crate::SyntaxKind::TILDE => break,
703            // The trailing currency closes the value. A `price` directive's
704            // BASE currency precedes the number, so only break once a number
705            // has been seen.
706            crate::SyntaxKind::CURRENCY if numbers > 0 => break,
707            crate::SyntaxKind::NUMBER => {
708                numbers += 1;
709                start.get_or_insert(t.text_range());
710                end = Some(t.text_range());
711            }
712            crate::SyntaxKind::COMMA => {
713                saw_comma = true;
714                start.get_or_insert(t.text_range());
715                end = Some(t.text_range());
716            }
717            _ => {}
718        }
719    }
720    if !saw_comma && numbers <= 1 {
721        return None;
722    }
723    Some(crate::TextRange::new(start?.start(), end?.end()))
724}
725
726fn convert_price(
727    node: &PriceDirective,
728    bom_offset: u32,
729    errors: &mut Vec<crate::ParseError>,
730) -> Option<Spanned<Directive>> {
731    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
732    reject_tags_and_links(node.syntax(), "price", bom_offset, errors);
733    let base_currency = Currency::new(node.base_currency()?.text());
734    // Same arithmetic support as `convert_balance`: a price
735    // directive's value can use `+`, `-`, `*`, `/`, and parens.
736    let number = directive_arithmetic_value(node.syntax()).or_else(|| {
737        // The fallback keeps only the first NUMBER, so refuse a value that
738        // carries more than one — see `malformed_directive_value`.
739        if let Some(range) = malformed_directive_value(node.syntax()) {
740            let start: u32 = range.start().into();
741            let end: u32 = range.end().into();
742            let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
743            errors.push(crate::ParseError::new(
744                crate::ParseErrorKind::SyntaxError(
745                    "malformed amount: expected one number, optionally signed, \
746                     or an arithmetic expression. A thousands separator must be \
747                     inside the number, as in `-1,234.00`"
748                        .to_string(),
749                ),
750                span,
751            ));
752            return None;
753        }
754        let mut n = parse_decimal_token(node.number()?.text())?;
755        if node_has_minus_before_number(node.syntax()) {
756            // Python's rule: negating a zero yields a POSITIVE zero, so a
757            // literal `-0.00` loads as `0.00` exactly as beancount parses it.
758            // A bare `-n` would keep the sign bit and render `-0.00`.
759            n = rustledger_core::negate_python(n);
760        }
761        Some(n)
762    })?;
763    let quote_currency = Currency::new(node.quote_currency()?.text());
764    let amount = Amount::new(number, quote_currency);
765    let meta = convert_meta_entries(node.syntax());
766
767    let price = rustledger_core::directive::Price {
768        date,
769        currency: base_currency,
770        amount,
771        meta,
772    };
773    let span = node_span(node.syntax(), bom_offset);
774    Some(Spanned::new(Directive::Price(price), span))
775}
776
777fn convert_balance(
778    node: &BalanceDirective,
779    bom_offset: u32,
780    errors: &mut Vec<crate::ParseError>,
781) -> Option<Spanned<Directive>> {
782    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
783    reject_tags_and_links(node.syntax(), "balance", bom_offset, errors);
784    let account = Account::new(node.account()?.text());
785    // Beancount accepts arithmetic in the balance assertion's
786    // value (`balance Assets:X 0.25 + 0.75 GBP` ≡ 1.00 GBP).
787    // Falls back to the first NUMBER token if the expression
788    // can't be evaluated, with the legacy sign-flip behavior.
789    let number = directive_arithmetic_value(node.syntax()).or_else(|| {
790        // The fallback keeps only the first NUMBER, so refuse a value that
791        // carries more than one — see `malformed_directive_value`.
792        if let Some(range) = malformed_directive_value(node.syntax()) {
793            let start: u32 = range.start().into();
794            let end: u32 = range.end().into();
795            let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
796            errors.push(crate::ParseError::new(
797                crate::ParseErrorKind::SyntaxError(
798                    "malformed amount: expected one number, optionally signed, \
799                     or an arithmetic expression. A thousands separator must be \
800                     inside the number, as in `-1,234.00`"
801                        .to_string(),
802                ),
803                span,
804            ));
805            return None;
806        }
807        let mut n = parse_decimal_token(node.number()?.text())?;
808        if node_has_minus_before_number(node.syntax()) {
809            // Python's rule: negating a zero yields a POSITIVE zero, so a
810            // literal `-0.00` loads as `0.00` exactly as beancount parses it.
811            // A bare `-n` would keep the sign bit and render `-0.00`.
812            n = rustledger_core::negate_python(n);
813        }
814        Some(n)
815    })?;
816    let currency = Currency::new(node.currency()?.text());
817    let amount = Amount::new(number, currency);
818    let tolerance = extract_balance_tolerance(node.syntax());
819    let meta = convert_meta_entries(node.syntax());
820
821    let balance = rustledger_core::directive::Balance {
822        date,
823        account,
824        amount,
825        tolerance,
826        meta,
827    };
828    let span = node_span(node.syntax(), bom_offset);
829    Some(Spanned::new(Directive::Balance(balance), span))
830}
831
832/// Balance directives may include an explicit tolerance via a
833/// `~` (TILDE) token followed by a NUMBER. The typed-AST surface
834/// surfaces NUMBER via `number()` (which returns the FIRST one,
835/// the asserted balance); the tolerance NUMBER comes second.
836/// Walk raw tokens until TILDE, then collect the next NUMBER.
837fn extract_balance_tolerance(node: &crate::SyntaxNode) -> Option<Decimal> {
838    // Everything after the TILDE, trivia dropped. The tolerance is its own
839    // expression region: `10.00 ~ 0.005 * 2 USD` asserts a tolerance of 0.010.
840    //
841    // Taking the first NUMBER instead (as this did) truncated it to 0.005 and
842    // REJECTED files beancount accepts — and the E2002 message printed the
843    // truncated figure, so the diagnostic advertised the bug (#1944). Same
844    // root cause as the cost-spec truncation in #1939: a number-bearing
845    // position that never reached the shared evaluator.
846    let tail: Vec<crate::SyntaxToken> = node
847        .children_with_tokens()
848        .filter_map(rowan::NodeOrToken::into_token)
849        .skip_while(|t| t.kind() != crate::SyntaxKind::TILDE)
850        .skip(1)
851        .filter(|t| !is_trivia_kind(t.kind()))
852        .collect();
853    if tail.is_empty() {
854        return None;
855    }
856    if let Some(value) = cost_region_value(&tail) {
857        return Some(value);
858    }
859    // Not arithmetic: the plain first NUMBER, as before.
860    tail.iter()
861        .find(|t| t.kind() == crate::SyntaxKind::NUMBER)
862        .and_then(|t| parse_decimal_token(t.text()))
863}
864
865fn convert_pad(
866    node: &PadDirective,
867    bom_offset: u32,
868    errors: &mut Vec<crate::ParseError>,
869) -> Option<Spanned<Directive>> {
870    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
871    reject_tags_and_links(node.syntax(), "pad", bom_offset, errors);
872    let account = Account::new(node.target_account()?.text());
873    let source_account = Account::new(node.source_account()?.text());
874    let meta = convert_meta_entries(node.syntax());
875
876    let pad = rustledger_core::directive::Pad {
877        date,
878        account,
879        source_account,
880        meta,
881    };
882    let span = node_span(node.syntax(), bom_offset);
883    Some(Spanned::new(Directive::Pad(pad), span))
884}
885
886fn convert_custom(
887    node: &CustomDirective,
888    bom_offset: u32,
889    errors: &mut Vec<crate::ParseError>,
890) -> Option<Spanned<Directive>> {
891    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
892    let custom_type = node.custom_type()?.text_decoded()?;
893    let values = extract_custom_values(node.syntax());
894    let meta = convert_meta_entries(node.syntax());
895
896    let custom = rustledger_core::directive::Custom {
897        date,
898        custom_type,
899        values,
900        meta,
901    };
902    let span = node_span(node.syntax(), bom_offset);
903    Some(Spanned::new(Directive::Custom(custom), span))
904}
905
906/// Walk the heterogeneous value tokens after the `custom "type"`
907/// header. The legacy parser tries each value type in this order:
908/// string > account > bool > amount (NUMBER+CURRENCY) > number >
909/// date > currency. We replicate that priority on the flat token
910/// stream, with one structural pass that pairs an immediately-
911/// adjacent NUMBER+CURRENCY into an [`Amount`].
912fn extract_custom_values(node: &crate::SyntaxNode) -> Vec<MetaValue> {
913    let mut values = Vec::new();
914    let mut seen_type_string = false;
915    // Collect tokens by kind, skipping trivia. We do a two-pass:
916    // first form Amount pairs (NUMBER + CURRENCY adjacent, ignoring
917    // whitespace), then emit remaining tokens individually.
918    let raw: Vec<rowan::SyntaxToken<crate::BeancountLanguage>> = node
919        .children_with_tokens()
920        .filter_map(rowan::NodeOrToken::into_token)
921        .filter(|t| {
922            !matches!(
923                t.kind(),
924                crate::SyntaxKind::WHITESPACE
925                    | crate::SyntaxKind::NEWLINE
926                    | crate::SyntaxKind::COMMENT
927            )
928        })
929        .collect();
930
931    let mut i = 0;
932    while i < raw.len() {
933        // Skip the directive's header tokens (DATE, CUSTOM_KW, and
934        // the first STRING which is the custom-type name).
935        if !seen_type_string {
936            if raw[i].kind() == crate::SyntaxKind::STRING {
937                seen_type_string = true;
938            }
939            i += 1;
940            continue;
941        }
942        // One value at a time through the shared discriminator — this is what
943        // gives custom directives the same MINUS-sign, Tag/Link and
944        // `NUMBER CURRENCY` → Amount handling as metadata entries.
945        // ONE advance for both branches, so the loop provably terminates.
946        //
947        // It used to advance in two places, and only the value branch was
948        // guarded. That left the `else` free to move `i` BACKWARD, which does
949        // not merely spin: stepping back re-enters the branch above, pushes
950        // another value, steps forward, and repeats — allocating without
951        // bound. Memory runs out before any per-test timeout can fire, so on
952        // CI it takes the whole runner down and surfaces as "the runner has
953        // received a shutdown signal", indistinguishable from infrastructure.
954        // Two full mutation runs lost the same three parser shards to it
955        // (runs 30765289550 and 30768895946) before the cause was found.
956        //
957        // `value_tokens_to_meta` returns the index past what it consumed, so
958        // the assert is the contract and the clamp is the belt: in release a
959        // violation costs one wasted token rather than the process.
960        let next = if let Some((value, consumed)) = value_tokens_to_meta(&raw, i) {
961            values.push(value);
962            consumed
963        } else {
964            i + 1
965        };
966        debug_assert!(
967            next > i,
968            "value_tokens_to_meta must advance: returned {next} at {i}"
969        );
970        i = next.max(i + 1);
971    }
972    values
973}
974
975fn strip_string_quotes(raw: &str) -> Option<&str> {
976    let bytes = raw.as_bytes();
977    if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
978        return None;
979    }
980    Some(&raw[1..raw.len() - 1])
981}
982
983fn convert_option(node: &OptionDirective, bom_offset: u32) -> Option<(String, String, Span)> {
984    let key = node.key()?.text_decoded()?;
985    let value = node.value()?.text_decoded()?;
986    Some((
987        key,
988        value,
989        single_line_directive_span(node.syntax(), bom_offset),
990    ))
991}
992
993fn convert_include(node: &IncludeDirective, bom_offset: u32) -> Option<(String, Span)> {
994    let path = node.path()?.text_decoded()?;
995    Some((path, single_line_directive_span(node.syntax(), bom_offset)))
996}
997
998fn convert_plugin(
999    node: &PluginDirective,
1000    bom_offset: u32,
1001) -> Option<(String, Option<String>, Span)> {
1002    let module = node.module()?.text_decoded()?;
1003    let config = node.config().and_then(|c| c.text_decoded());
1004    Some((
1005        module,
1006        config,
1007        single_line_directive_span(node.syntax(), bom_offset),
1008    ))
1009}
1010
1011// ---- Transaction + Posting + sub-nodes -------------------------
1012
1013fn convert_transaction(
1014    node: &AstTransaction,
1015    bom_offset: u32,
1016    errors: &mut Vec<crate::ParseError>,
1017) -> Option<Spanned<Directive>> {
1018    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
1019
1020    // Flag: explicit (TransactionFlag) or implied (leading STRING
1021    // with no flag token; defaults to '*').
1022    let flag = node.flag().map_or('*', |f| flag_char_from_transaction(&f));
1023
1024    // Header strings, consumed straight off the iterator (no intermediate Vec,
1025    // no count-then-unwrap): 0 -> empty narration; 1 -> narration only;
1026    // 2 -> payee + narration; 3+ -> surface only the last as narration (the
1027    // middles are unreachable through this typed shape).
1028    let mut it = node.strings().filter_map(|s| s.text_decoded());
1029    let (payee_str, narration_str) = match (it.next(), it.next(), it.next()) {
1030        (None, _, _) => (None, String::new()),
1031        (Some(n), None, _) => (None, n),
1032        (Some(p), Some(n), None) => (Some(p), n),
1033        // 3+: `c` is the 3rd string; if more follow, `it.last()` is the actual
1034        // last (else it falls back to `c`). No clone in the common 0/1/2 cases.
1035        (Some(_), Some(_), Some(c)) => (None, it.last().unwrap_or(c)),
1036    };
1037
1038    let payee = payee_str.map(InternedStr::from);
1039    let narration = InternedStr::from(narration_str);
1040
1041    // Tags / links from the TRANSACTION node: the typed AST
1042    // accessor `tags()`/`links()` is scoped to the header region.
1043    // Trailing TAG / LINK tokens appearing on body lines (after
1044    // the header NEWLINE, OUTSIDE any POSTING / META_ENTRY child
1045    // node) are also part of the transaction's tag/link set per
1046    // Beancount semantics - `extract_transaction_body_errors`
1047    // already exempts them from the malformed-body diagnostic for
1048    // this reason. Aggregate them here so they don't silently
1049    // disappear.
1050    let mut tags: Vec<Tag> = node
1051        .tags()
1052        .map(|t| Tag::new(t.text().trim_start_matches('#')))
1053        .collect();
1054    let mut links: Vec<Link> = node
1055        .links()
1056        .map(|l| Link::new(l.text().trim_start_matches('^')))
1057        .collect();
1058    for el in node.syntax().children_with_tokens() {
1059        let rowan::NodeOrToken::Token(t) = el else {
1060            // Nodes (POSTING / META_ENTRY) own their own internal
1061            // tokens; we don't recurse into them.
1062            continue;
1063        };
1064        match t.kind() {
1065            crate::SyntaxKind::TAG => {
1066                let stripped = t.text().trim_start_matches('#');
1067                let new_tag = Tag::new(stripped);
1068                if !tags.contains(&new_tag) {
1069                    tags.push(new_tag);
1070                }
1071            }
1072            crate::SyntaxKind::LINK => {
1073                let stripped = t.text().trim_start_matches('^');
1074                let new_link = Link::new(stripped);
1075                if !links.contains(&new_link) {
1076                    links.push(new_link);
1077                }
1078            }
1079            _ => {}
1080        }
1081    }
1082
1083    // Transaction-level metadata (META_ENTRY children directly on
1084    // the TRANSACTION node, NOT on POSTING children).
1085    let meta = convert_meta_entries(node.syntax());
1086
1087    // Postings + pre-posting comments. The CST puts inter-
1088    // posting trivia (including `; comment` lines) as flat
1089    // tokens DIRECT under TRANSACTION between two POSTING
1090    // nodes. Walk in source order: COMMENT tokens accumulate
1091    // into `pending`, then attach to the next POSTING node's
1092    // `comments` field when we reach it. Tokens before the
1093    // header NEWLINE are skipped (they're transaction-header
1094    // content). Comments that remain in `pending` after the
1095    // final posting belong to the transaction itself
1096    // (legacy: `txn.trailing_comments = pending_comments`).
1097    let (postings, trailing_comments) = collect_postings_with_comments(node, bom_offset, errors);
1098
1099    // Deprecated `|` separator between payee and narration: a
1100    // PIPE token in the header region. Legacy treats this as a
1101    // recoverable warning-shaped error (`DeprecatedPipeSymbol`)
1102    // and keeps the directive, so we do the same here.
1103    if header_has_pipe(node) {
1104        errors.push(crate::ParseError::new(
1105            crate::ParseErrorKind::DeprecatedPipeSymbol,
1106            node_span(node.syntax(), bom_offset),
1107        ));
1108    }
1109
1110    let txn = rustledger_core::directive::Transaction {
1111        date,
1112        flag,
1113        payee,
1114        narration,
1115        tags,
1116        links,
1117        meta,
1118        postings,
1119        trailing_comments,
1120    };
1121    let span = node_span(node.syntax(), bom_offset);
1122    Some(Spanned::new(Directive::Transaction(txn), span))
1123}
1124
1125/// Returns true if the TRANSACTION header (direct-child tokens
1126/// up to the first NEWLINE) contains a `PIPE` token. The legacy
1127/// parser surfaces a `DeprecatedPipeSymbol` diagnostic for this
1128/// shape; the CST lexer classifies `|` as `PIPE`, so we just
1129/// scan the header directly.
1130fn header_has_pipe(node: &AstTransaction) -> bool {
1131    for el in node.syntax().children_with_tokens() {
1132        let rowan::NodeOrToken::Token(t) = el else {
1133            continue;
1134        };
1135        if t.kind() == crate::SyntaxKind::NEWLINE {
1136            return false;
1137        }
1138        if t.kind() == crate::SyntaxKind::PIPE {
1139            return true;
1140        }
1141    }
1142    false
1143}
1144
1145/// Walk a `TRANSACTION`'s children in source order, attaching any
1146/// inter-posting `; comment` lines that appear as flat tokens
1147/// between `POSTING` nodes to the NEXT posting's `comments`
1148/// field. Matches the legacy parser, which collects
1149/// `pending_comments` while reading the body and applies them to
1150/// the next posting it parses.
1151///
1152/// Tokens before the header-terminator NEWLINE belong to the
1153/// transaction header (date/flag/strings/tags/links) and are
1154/// skipped.
1155///
1156/// Returns `(postings, trailing_comments)`: the second element is
1157/// any pending comments left over AFTER the final posting, which
1158/// legacy assigns to `Transaction::trailing_comments`.
1159fn collect_postings_with_comments(
1160    node: &AstTransaction,
1161    bom_offset: u32,
1162    errors: &mut Vec<crate::ParseError>,
1163) -> (Vec<Spanned<Posting>>, Vec<String>) {
1164    let mut out = Vec::new();
1165    let mut pending: Vec<String> = Vec::new();
1166    let mut past_header = false;
1167    for el in node.syntax().children_with_tokens() {
1168        match el {
1169            rowan::NodeOrToken::Token(t) => {
1170                if !past_header {
1171                    if t.kind() == crate::SyntaxKind::NEWLINE {
1172                        past_header = true;
1173                    }
1174                    continue;
1175                }
1176                if is_comment_kind(t.kind()) {
1177                    pending.push(t.text().to_string());
1178                } else if !is_trivia_kind(t.kind())
1179                    && !matches!(t.kind(), crate::SyntaxKind::TAG | crate::SyntaxKind::LINK)
1180                {
1181                    // Non-trivia, non-comment token in the
1182                    // transaction body that's NOT inside a
1183                    // POSTING / META_ENTRY child node = malformed
1184                    // body line (caught separately by
1185                    // `extract_transaction_body_errors`). Treat
1186                    // the same as a failed POSTING: clear pending
1187                    // so the malformed line's preceding comments
1188                    // don't migrate onto the next valid posting.
1189                    //
1190                    // EXEMPT TAG / LINK: trailing tags/links on
1191                    // transaction body lines (after the header)
1192                    // are valid Beancount - they extend the
1193                    // transaction's tag/link set without being
1194                    // a new posting. Treating them as malformed
1195                    // would drop legitimate preceding comments
1196                    // that belong to the NEXT posting. The same
1197                    // exemption appears in
1198                    // `extract_transaction_body_errors`, which
1199                    // does the parallel "is this a malformed
1200                    // body line?" classification.
1201                    pending.clear();
1202                }
1203            }
1204            rowan::NodeOrToken::Node(n) => {
1205                if !past_header {
1206                    // META_ENTRY or POSTING before the header
1207                    // NEWLINE shouldn't happen in well-formed
1208                    // input; treat any child node as "past the
1209                    // header" if we somehow encounter one.
1210                    past_header = true;
1211                }
1212                if let Some(p) = ast::Posting::cast(n) {
1213                    if let Some(mut spanned) = convert_posting(&p, bom_offset, errors) {
1214                        if !pending.is_empty() {
1215                            spanned.value.comments = std::mem::take(&mut pending);
1216                        }
1217                        out.push(spanned);
1218                    } else {
1219                        // Failed posting consumes any pending
1220                        // inter-posting comments - they belonged
1221                        // to it. Without this clear, a malformed
1222                        // posting's preceding comments would
1223                        // migrate forward and attach to the NEXT
1224                        // successful posting, misattributing them
1225                        // visibly to the wrong account line.
1226                        pending.clear();
1227                    }
1228                }
1229                // META_ENTRY child nodes: comments collected so
1230                // far don't apply to them (they're transaction
1231                // metadata). Drop them.
1232            }
1233        }
1234    }
1235    (out, pending)
1236}
1237
1238fn flag_char_from_transaction(flag: &ast::TransactionFlag) -> char {
1239    match flag.classify() {
1240        TransactionFlagKind::Star | TransactionFlagKind::Txn => '*',
1241        TransactionFlagKind::Pending => '!',
1242        TransactionFlagKind::Hash => '#',
1243        TransactionFlagKind::Letter | TransactionFlagKind::CurrencyLetter => {
1244            flag.text().chars().next().unwrap_or('*')
1245        }
1246    }
1247}
1248
1249fn convert_posting(
1250    node: &ast::Posting,
1251    bom_offset: u32,
1252    errors: &mut Vec<crate::ParseError>,
1253) -> Option<Spanned<Posting>> {
1254    let account = Account::new(node.account()?.text());
1255
1256    let flag = node.flag().map(|f| flag_char_from_posting(&f));
1257
1258    // A well-formed posting has AT MOST one `AMOUNT` child node
1259    // (the units). The CST builder will accept input like
1260    // `Expenses:Food  5 USD + 3 USD` and produce TWO sibling
1261    // `AMOUNT` nodes joined by a flat PLUS token, because the
1262    // grammar doesn't enforce that PLUS between two complete
1263    // amounts is invalid. `Posting::amount()` returns only the
1264    // first via `first_child`, so without this guard the second
1265    // amount (and the joining `+`) would be silently dropped and
1266    // the user's transaction would balance against the wrong
1267    // number. Emit a `SyntaxError` pointing at the trailing
1268    // siblings and keep the first amount.
1269    let mut amount_children = node
1270        .syntax()
1271        .children()
1272        .filter(|n| ast::Amount::can_cast(n.kind()));
1273    let first_amount = amount_children.next();
1274    let first_amount_end: Option<u32> = first_amount.as_ref().map(|n| n.text_range().end().into());
1275    let mut sibling_start: Option<u32> = None;
1276    let mut sibling_end: u32 = 0;
1277    for extra in amount_children {
1278        let range = extra.text_range();
1279        let start_u32: u32 = range.start().into();
1280        let end_u32: u32 = range.end().into();
1281        if sibling_start.is_none() {
1282            sibling_start = Some(start_u32);
1283        }
1284        sibling_end = end_u32;
1285    }
1286    if let Some(start_u32) = sibling_start {
1287        // Extend the span back to the end of the FIRST AMOUNT so
1288        // the diagnostic underline covers any joining operator
1289        // (`+`, `*`, whitespace) between the kept amount and the
1290        // orphans. Without this, a user sees only `3 USD` in
1291        // `5 USD + 3 USD` highlighted - and may not realize the
1292        // `+ 3 USD` together is what needs to be removed.
1293        let underline_start = first_amount_end.unwrap_or(start_u32);
1294        let span = Span::new(
1295            (underline_start + bom_offset) as usize,
1296            (sibling_end + bom_offset) as usize,
1297        );
1298        errors.push(crate::ParseError::new(
1299            crate::ParseErrorKind::SyntaxError(
1300                "unexpected trailing tokens after posting amount".to_string(),
1301            ),
1302            span,
1303        ));
1304    }
1305    // The mirror of the guard above, for tokens dropped BEFORE the amount
1306    // rather than after it.
1307    //
1308    // `starts_amount` only opens an `AMOUNT` at `NUMBER`, `CURRENCY`,
1309    // `L_PAREN`, or a sign directly followed by one of those. Anything else
1310    // becomes a flat `POSTING` child, and nothing downstream reads flat
1311    // children — so `-,123.00 USD` parsed as `POSTING(MINUS COMMA
1312    // AMOUNT(NUMBER CURRENCY))` and booked as **+123.00**, silently losing the
1313    // sign. A stray comma is the way to hit this in practice, because a
1314    // thousands separator only belongs INSIDE a `NUMBER` token (the lexer's
1315    // grouping regex is strict) and a misplaced one splits the amount in two.
1316    //
1317    // Report rather than repair: `,123` and `-,123` have no agreed meaning, so
1318    // guessing one would be inventing data. See issue #1892's discussion.
1319    if let Some(range) = orphaned_amount_prefix(node.syntax()) {
1320        let start: u32 = range.start().into();
1321        let end: u32 = range.end().into();
1322        let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1323        errors.push(crate::ParseError::new(
1324            crate::ParseErrorKind::SyntaxError(
1325                "unexpected token before posting amount: a `+`/`-` must be \
1326                 followed by a number, and a thousands separator must be \
1327                 inside one (as in `-1,234.00`)"
1328                    .to_string(),
1329            ),
1330            span,
1331        ));
1332    }
1333
1334    let units = first_amount
1335        .and_then(ast::Amount::cast)
1336        .and_then(|amt| convert_amount_to_incomplete(&amt, errors, bom_offset));
1337    let cost = node.cost_spec().map(|cs| convert_cost_spec(&cs));
1338    let price = node
1339        .price_annotation()
1340        .map(|pa| convert_price_annotation(&pa, errors, bom_offset));
1341    let meta = convert_meta_entries(node.syntax());
1342
1343    // Trailing comments on the posting line: COMMENT direct-
1344    // child tokens BEFORE the terminator NEWLINE. The legacy
1345    // parser collects same-line `;` content into
1346    // `posting.trailing_comments`.
1347    let trailing_comments: Vec<String> = node
1348        .syntax()
1349        .children_with_tokens()
1350        .filter_map(rowan::NodeOrToken::into_token)
1351        .take_while(|t| t.kind() != crate::SyntaxKind::NEWLINE)
1352        .filter(|t| is_comment_kind(t.kind()))
1353        .map(|t| t.text().to_string())
1354        .collect();
1355
1356    let posting = Posting {
1357        account,
1358        units,
1359        cost: cost.map(Box::new),
1360        price: price.map(Box::new),
1361        flag,
1362        meta,
1363        comments: Vec::new(),
1364        trailing_comments,
1365    };
1366    let span = posting_span(node.syntax(), bom_offset);
1367    Some(Spanned::new(posting, span))
1368}
1369
1370fn flag_char_from_posting(flag: &ast::PostingFlag) -> char {
1371    match flag.classify() {
1372        PostingFlagKind::Star => '*',
1373        PostingFlagKind::Pending => '!',
1374        PostingFlagKind::Hash => '#',
1375        PostingFlagKind::Letter | PostingFlagKind::CurrencyLetter => {
1376            flag.text().chars().next().unwrap_or('*')
1377        }
1378    }
1379}
1380
1381/// Convert an AMOUNT node into an [`IncompleteAmount`]. Returns
1382/// `None` if neither a number nor a currency is present (which
1383/// shouldn't happen for a well-formed AMOUNT, but matches the
1384/// lossless CST contract). Sign is folded into the number.
1385///
1386/// **Arithmetic limitation**: when the AMOUNT contains an
1387/// arithmetic expression (`100+5 USD`), only the FIRST `NUMBER`
1388/// is used. A proper expression evaluator is deferred - none of
1389/// the directive types we currently handle outside of postings
1390/// use AMOUNT shapes that the legacy parser would have evaluated
1391/// differently.
1392fn convert_amount_to_incomplete(
1393    amt: &ast::Amount,
1394    errors: &mut Vec<crate::ParseError>,
1395    bom_offset: u32,
1396) -> Option<IncompleteAmount> {
1397    // Arithmetic AMOUNT expressions (`120 / 3 USD`, `(1+2) USD`):
1398    // run the recursive-descent evaluator on the flat token
1399    // stream. Fast-path plain `NUMBER CURRENCY` shapes to keep
1400    // the common case allocation-free.
1401    let number = if amt.is_arithmetic() {
1402        let evaluated = evaluate_amount_expression(amt);
1403        if evaluated.is_none() {
1404            // `is_arithmetic` was true but the evaluator gave up
1405            // (decimal overflow, division by zero, malformed
1406            // expression, unbalanced parens). Without this
1407            // emission the amount silently degrades to
1408            // `CurrencyOnly` and the user only sees a downstream
1409            // "transaction doesn't balance" - masking the actual
1410            // root cause. Pin the span to the AMOUNT node so the
1411            // diagnostic underlines the offending expression.
1412            let range = amt.syntax().text_range();
1413            let start: u32 = range.start().into();
1414            let end: u32 = range.end().into();
1415            let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1416            errors.push(crate::ParseError::new(
1417                crate::ParseErrorKind::SyntaxError(
1418                    "invalid arithmetic expression in amount (overflow, division by zero, or malformed)"
1419                        .to_string(),
1420                ),
1421                span,
1422            ));
1423        }
1424        evaluated
1425    } else {
1426        amt.number().and_then(|n| {
1427            let parsed = parse_decimal_token(n.text());
1428            if parsed.is_none() {
1429                // Symmetry with the arithmetic-failure path: when
1430                // a plain NUMBER token in an AMOUNT can't be
1431                // turned into a Decimal (e.g., 30+ digits - the
1432                // lexer's NUMBER regex has no max length but
1433                // `rust_decimal`'s 28-digit ceiling rejects it),
1434                // surface a diagnostic instead of silently
1435                // degrading to `CurrencyOnly`. Without this the
1436                // user only sees "transaction doesn't balance"
1437                // and never learns the parser dropped a number.
1438                let range = n.syntax().text_range();
1439                let start: u32 = range.start().into();
1440                let end: u32 = range.end().into();
1441                let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1442                errors.push(crate::ParseError::new(
1443                    crate::ParseErrorKind::SyntaxError(
1444                        "invalid number in amount (likely exceeds 28-digit Decimal precision)"
1445                            .to_string(),
1446                    ),
1447                    span,
1448                ));
1449            }
1450            let mut value = parsed?;
1451            if let Some(sign) = amt.sign()
1452                && sign.is_minus()
1453            {
1454                // See the sign note above — `negate_python` keeps a zero
1455                // unsigned, matching beancount's parse of `-0.00`.
1456                value = rustledger_core::negate_python(value);
1457            }
1458            Some(value)
1459        })
1460    };
1461    let currency = amt.currency().map(|c| Currency::new(c.text()));
1462    match (number, currency) {
1463        (Some(n), Some(c)) => Some(IncompleteAmount::Complete(Amount::new(n, c))),
1464        (Some(n), None) => Some(IncompleteAmount::NumberOnly(n)),
1465        (None, Some(c)) => Some(IncompleteAmount::CurrencyOnly(c)),
1466        (None, None) => None,
1467    }
1468}
1469
1470/// Evaluate the arithmetic expression inside an `AMOUNT` node and
1471/// return the resulting decimal. Returns `None` when evaluation
1472/// fails (division by zero, decimal overflow, malformed parens,
1473/// missing operand).
1474///
1475/// AMOUNT children are flat tokens (no expression sub-tree): a
1476/// sequence of `NUMBER`, `PLUS`, `MINUS`, `STAR`, `SLASH`,
1477/// `L_PAREN`, `R_PAREN`, and a trailing `CURRENCY` at depth 0
1478/// that's the amount's currency rather than part of the
1479/// expression. The currency is stripped first; the rest goes
1480/// through recursive descent mirroring legacy
1481/// `parser::parse_expr` / `parse_term` / `parse_primary`.
1482///
1483/// Operator precedence and unary handling match Python beancount:
1484/// `*` and `/` bind tighter than `+` and `-`; a leading or post-
1485/// operator `-` is unary negation.
1486fn evaluate_amount_expression(amt: &ast::Amount) -> Option<Decimal> {
1487    let tokens = amount_expression_tokens(amt);
1488    let mut cursor = 0usize;
1489    let value = parse_arith_expr(&tokens, &mut cursor)?;
1490    // Trailing tokens after a successful parse mean the expression
1491    // is malformed (`1+2 3 USD`); refuse rather than silently
1492    // dropping them.
1493    if cursor != tokens.len() {
1494        return None;
1495    }
1496    Some(value)
1497}
1498
1499/// Evaluate the arithmetic expression that appears as the
1500/// numeric value of a `BALANCE` / `PRICE` directive, returning
1501/// the resulting decimal or `None` if not arithmetic (single
1502/// NUMBER, callers fall back to `parse_decimal_token`).
1503///
1504/// Unlike `AMOUNT`, these directives don't wrap their value in
1505/// a dedicated node - the tokens are flat under the directive
1506/// node. The relevant region is from the FIRST `NUMBER` token up
1507/// to (but not including) the FIRST `CURRENCY` token at paren-
1508/// depth 0 (the amount currency). For BALANCE, this correctly
1509/// stops before any trailing `~ NUMBER [CURRENCY]` tolerance
1510/// region too.
1511///
1512/// Returns `Some` only when the slice contains at least one
1513/// arithmetic operator (`+`, `-`, `*`, `/`) or parens - for a
1514/// bare single `NUMBER`, returns `None` so the caller can use
1515/// the existing fast path (which preserves the legacy sign-flip
1516/// behavior).
1517fn directive_arithmetic_value(node: &crate::SyntaxNode) -> Option<Decimal> {
1518    let raw: Vec<crate::SyntaxToken> = node
1519        .children_with_tokens()
1520        .filter_map(rowan::NodeOrToken::into_token)
1521        .filter(|t| !is_trivia_kind(t.kind()))
1522        // Skip the directive header (DATE, keyword, ACCOUNT / base CURRENCY)
1523        // and stop at the first token that can BEGIN a value.
1524        //
1525        // This used to skip to the first `NUMBER`, which also swallowed a
1526        // leading `(`: `(1 + 5) / 2.1 USD` became `1 + 5 ) / 2.1`, failed to
1527        // parse, and fell through to "take the first NUMBER" — so the
1528        // directive silently asserted against **1**. A leading sign was
1529        // likewise dropped and re-applied by the caller. Neither header token
1530        // can be a `NUMBER`, `L_PAREN` or a sign, so this cannot over-skip.
1531        .skip_while(|t| {
1532            !matches!(
1533                t.kind(),
1534                crate::SyntaxKind::NUMBER
1535                    | crate::SyntaxKind::L_PAREN
1536                    | crate::SyntaxKind::MINUS
1537                    | crate::SyntaxKind::PLUS
1538            )
1539        })
1540        .collect();
1541    let mut depth: i32 = 0;
1542    let mut first_currency_idx: Option<usize> = None;
1543    for (i, t) in raw.iter().enumerate() {
1544        match t.kind() {
1545            crate::SyntaxKind::L_PAREN => depth += 1,
1546            crate::SyntaxKind::R_PAREN => depth -= 1,
1547            crate::SyntaxKind::CURRENCY if depth == 0 && first_currency_idx.is_none() => {
1548                first_currency_idx = Some(i);
1549            }
1550            _ => {}
1551        }
1552    }
1553    let end = first_currency_idx.unwrap_or(raw.len());
1554    let tokens: Vec<crate::SyntaxToken> = raw.into_iter().take(end).collect();
1555    // Fast-path: zero or one token = no arithmetic.
1556    let has_op = tokens.iter().any(|t| {
1557        matches!(
1558            t.kind(),
1559            crate::SyntaxKind::PLUS
1560                | crate::SyntaxKind::MINUS
1561                | crate::SyntaxKind::STAR
1562                | crate::SyntaxKind::SLASH
1563                | crate::SyntaxKind::L_PAREN
1564        )
1565    });
1566    if !has_op {
1567        return None;
1568    }
1569    let mut cursor = 0usize;
1570    let value = parse_arith_expr(&tokens, &mut cursor)?;
1571    if cursor != tokens.len() {
1572        return None;
1573    }
1574    Some(value)
1575}
1576
1577/// Collect AMOUNT's expression tokens - every non-trivia direct-
1578/// child token EXCEPT the trailing `CURRENCY` at paren-depth 0
1579/// (which is the amount's currency, not part of the expression).
1580/// Parens at any depth are preserved so `parse_arith_primary` can
1581/// recurse through them.
1582fn amount_expression_tokens(amt: &ast::Amount) -> Vec<crate::SyntaxToken> {
1583    let raw: Vec<crate::SyntaxToken> = amt
1584        .syntax()
1585        .children_with_tokens()
1586        .filter_map(rowan::NodeOrToken::into_token)
1587        .filter(|t| !is_trivia_kind(t.kind()))
1588        .collect();
1589    // Find the index of the LAST `CURRENCY` at depth 0 - same
1590    // disambiguator as `Amount::currency()`. Tokens before that
1591    // index form the arithmetic expression.
1592    let mut depth: i32 = 0;
1593    let mut trailing_currency_idx: Option<usize> = None;
1594    for (i, t) in raw.iter().enumerate() {
1595        match t.kind() {
1596            crate::SyntaxKind::L_PAREN => depth += 1,
1597            crate::SyntaxKind::R_PAREN => depth -= 1,
1598            crate::SyntaxKind::CURRENCY if depth == 0 => trailing_currency_idx = Some(i),
1599            _ => {}
1600        }
1601    }
1602    let end = trailing_currency_idx.unwrap_or(raw.len());
1603    raw.into_iter().take(end).collect()
1604}
1605
1606/// `expr := term (('+' | '-') term)*` - left-associative.
1607fn parse_arith_expr<T: TokenView>(tokens: &[T], cursor: &mut usize) -> Option<Decimal> {
1608    let mut result = parse_arith_term(tokens, cursor)?;
1609    while let Some(op) = tokens.get(*cursor).map(TokenView::kind) {
1610        match op {
1611            crate::SyntaxKind::PLUS => {
1612                *cursor += 1;
1613                let rhs = parse_arith_term(tokens, cursor)?;
1614                result = result.checked_add(rhs)?;
1615            }
1616            crate::SyntaxKind::MINUS => {
1617                *cursor += 1;
1618                let rhs = parse_arith_term(tokens, cursor)?;
1619                result = result.checked_sub(rhs)?;
1620            }
1621            _ => break,
1622        }
1623    }
1624    Some(result)
1625}
1626
1627/// `term := primary (('*' | '/') primary)*` - left-associative.
1628fn parse_arith_term<T: TokenView>(tokens: &[T], cursor: &mut usize) -> Option<Decimal> {
1629    let mut result = parse_arith_primary(tokens, cursor)?;
1630    while let Some(op) = tokens.get(*cursor).map(TokenView::kind) {
1631        match op {
1632            crate::SyntaxKind::STAR => {
1633                *cursor += 1;
1634                let rhs = parse_arith_primary(tokens, cursor)?;
1635                result = result.checked_mul(rhs)?;
1636            }
1637            crate::SyntaxKind::SLASH => {
1638                *cursor += 1;
1639                let rhs = parse_arith_primary(tokens, cursor)?;
1640                if rhs.is_zero() {
1641                    return None;
1642                }
1643                result = result.checked_div(rhs)?;
1644            }
1645            _ => break,
1646        }
1647    }
1648    Some(result)
1649}
1650
1651/// `primary := '(' expr ')' | '-' primary | '+' primary | NUMBER`.
1652fn parse_arith_primary<T: TokenView>(tokens: &[T], cursor: &mut usize) -> Option<Decimal> {
1653    let t = tokens.get(*cursor)?;
1654    match t.kind() {
1655        crate::SyntaxKind::L_PAREN => {
1656            *cursor += 1;
1657            let inner = parse_arith_expr(tokens, cursor)?;
1658            // Mandatory closer; bail (returning None) on unbalance
1659            // - `Amount::currency()` already refuses to surface a
1660            // currency for unbalanced parens, so the amount as a
1661            // whole degrades cleanly to `NumberOnly`/`None`.
1662            let close = tokens.get(*cursor)?;
1663            if close.kind() != crate::SyntaxKind::R_PAREN {
1664                return None;
1665            }
1666            *cursor += 1;
1667            Some(inner)
1668        }
1669        crate::SyntaxKind::MINUS => {
1670            *cursor += 1;
1671            let inner = parse_arith_primary(tokens, cursor)?;
1672            Some(-inner)
1673        }
1674        crate::SyntaxKind::PLUS => {
1675            *cursor += 1;
1676            parse_arith_primary(tokens, cursor)
1677        }
1678        crate::SyntaxKind::NUMBER => {
1679            let value = parse_decimal_token(t.text())?;
1680            *cursor += 1;
1681            Some(value)
1682        }
1683        _ => None,
1684    }
1685}
1686
1687/// The slice of a cost-spec segment holding its NUMBER expression: from the
1688/// first token that can begin a value up to the first `CURRENCY` at paren
1689/// depth 0.
1690///
1691/// Depth matters because a currency cannot appear inside the parens of a
1692/// numeric expression, but stopping at the first `CURRENCY` unconditionally
1693/// would be wrong the moment one ever could.
1694fn cost_number_region<T: TokenView>(seg: &[T]) -> &[T] {
1695    use crate::SyntaxKind as K;
1696    let start = seg
1697        .iter()
1698        .position(|t| matches!(t.kind(), K::NUMBER | K::L_PAREN | K::MINUS | K::PLUS))
1699        .unwrap_or(seg.len());
1700    let mut depth = 0i32;
1701    let mut end = seg.len();
1702    for (i, t) in seg.iter().enumerate().skip(start) {
1703        match t.kind() {
1704            K::L_PAREN => depth += 1,
1705            K::R_PAREN => depth -= 1,
1706            K::CURRENCY if depth == 0 => {
1707                end = i;
1708                break;
1709            }
1710            // A comma ends the number region too (`{10 USD, 2014-02-25}`);
1711            // without this a malformed spec could drag the date into the
1712            // expression and fail the whole parse instead of just the number.
1713            K::COMMA if depth == 0 => {
1714                end = i;
1715                break;
1716            }
1717            _ => {}
1718        }
1719    }
1720    &seg[start..end]
1721}
1722
1723/// Evaluate a cost-spec number region, but ONLY when it is genuinely
1724/// arithmetic.
1725///
1726/// Returns `None` for an UNSIGNED bare `NUMBER` so the caller keeps its
1727/// existing single-token latch, leaving the overwhelmingly common case on the
1728/// allocation-free path.
1729///
1730/// A SIGNED number is not in that set: `MINUS`/`PLUS` counts as an operator, so
1731/// `{-200.00 USD}` routes through the evaluator. That is deliberate and is
1732/// itself a fix — the latch only ever read `NUMBER` tokens and never applied a
1733/// leading sign, so a negative cost was booked as POSITIVE. An earlier draft of
1734/// this comment claimed the latch "carries legacy sign handling"; it does not,
1735/// and the two `TotalsAndSigns` corpus fixtures prove it.
1736///
1737/// Why this exists at all: the price path has always evaluated expressions
1738/// (`convert_amount_to_incomplete` -> `evaluate_amount_expression`) while the
1739/// cost path latched the first `NUMBER` token and silently dropped the rest, so
1740/// `{10.00 * 3 USD}` booked a cost of 10.00. Same computation, two
1741/// implementations, nothing asserting agreement — the exact drift shape
1742/// CLAUDE.md's Canonical-Function Discipline describes. The fix is to share the
1743/// evaluator, not to teach this path its own arithmetic (#1939).
1744fn cost_region_value<T: TokenView>(seg: &[T]) -> Option<Decimal> {
1745    use crate::SyntaxKind as K;
1746    // Trivia first. `cost_spec_from_tokens` is handed EVERY child token,
1747    // whitespace included, and the evaluator refuses any token it does not
1748    // recognize — so without this the region is `10.00 WS * WS 3` and every
1749    // expression silently falls back to the latched first number, i.e. the bug
1750    // this is meant to fix, still there but now with more code. The other two
1751    // evaluators (`directive_arithmetic_value`, `amount_expression_tokens`)
1752    // both filter trivia for the same reason.
1753    let kept: Vec<&T> = seg.iter().filter(|t| !is_trivia_kind(t.kind())).collect();
1754    let region = cost_number_region(&kept);
1755    if !region.iter().any(|t| {
1756        matches!(
1757            t.kind(),
1758            K::PLUS | K::MINUS | K::STAR | K::SLASH | K::L_PAREN
1759        )
1760    }) {
1761        return None;
1762    }
1763    let mut cursor = 0usize;
1764    let value = parse_arith_expr(region, &mut cursor)?;
1765    // Trailing tokens mean the expression is malformed; refuse rather than
1766    // silently dropping them, matching `evaluate_amount_expression`.
1767    if cursor != region.len() {
1768        return None;
1769    }
1770    Some(value)
1771}
1772
1773fn convert_cost_spec(cs: &ast::CostSpec) -> CostSpec {
1774    cost_spec_from_tokens(
1775        cs.syntax()
1776            .children_with_tokens()
1777            .filter_map(rowan::NodeOrToken::into_token),
1778    )
1779}
1780
1781/// Minimal view of a lexed token — kind + text — implemented by both tree
1782/// walkers (red `SyntaxToken`, green `&GreenTokenData`) so the token-level
1783/// semantic helpers ([`cost_spec_from_tokens`], [`meta_value_from_tokens`])
1784/// are SHARED rather than hand-mirrored. Every historical green/red fuzz
1785/// divergence (#1704, #1713, the `{*}` merge flag) landed in a hand-mirrored
1786/// copy of these semantics; with one implementation the class is gone.
1787pub(super) trait TokenView {
1788    /// The token's [`crate::SyntaxKind`].
1789    fn kind(&self) -> crate::SyntaxKind;
1790    /// The token's source text.
1791    fn text(&self) -> &str;
1792}
1793
1794impl<T: TokenView> TokenView for &T {
1795    fn kind(&self) -> crate::SyntaxKind {
1796        (*self).kind()
1797    }
1798    fn text(&self) -> &str {
1799        (*self).text()
1800    }
1801}
1802
1803impl TokenView for rowan::SyntaxToken<crate::BeancountLanguage> {
1804    // `Self::kind`/`Self::text` resolve to the INHERENT `SyntaxToken`
1805    // methods (inherent associated functions take precedence over trait
1806    // methods in path resolution) — this is delegation, not recursion.
1807    // The `Self::` form is clippy's own preference here (`use_self`).
1808    fn kind(&self) -> crate::SyntaxKind {
1809        Self::kind(self)
1810    }
1811    fn text(&self) -> &str {
1812        Self::text(self)
1813    }
1814}
1815
1816impl TokenView for &rowan::GreenTokenData {
1817    fn kind(&self) -> crate::SyntaxKind {
1818        <crate::BeancountLanguage as rowan::Language>::kind_from_raw((*self).kind())
1819    }
1820    fn text(&self) -> &str {
1821        (*self).text()
1822    }
1823}
1824/// The `{*}` merge-flag state machine.
1825///
1826/// ONE implementation, driven by both token walkers: [`cost_spec_from_tokens`]
1827/// feeds it inside its single pass, and [`super::ast::CostSpec::is_merge`]
1828/// feeds it while walking the red tree. The rule was hand-mirrored in those two
1829/// places until the 2026-08-01 mutation run showed EVERY mutant in this machine
1830/// surviving on the `convert.rs` side: the only tests exercising the rule went
1831/// through the `ast.rs` copy, so the canonical could have been broken outright
1832/// without one test failing. That is the drift this module's `TokenView` doc
1833/// says it exists to prevent, in the one place it had not been applied.
1834///
1835/// The rule: the flag is decided by the first non-whitespace, non-opener token
1836/// after an opener (`*` means merge, anything else means not). A `*` elsewhere
1837/// is the multiplication operator, as in `{500 * 2 USD}`, and a pass that
1838/// re-arms on later openers flips the flag on malformed input where it must not.
1839#[derive(Default)]
1840pub(in crate::cst) struct MergeFlag {
1841    past_opener: bool,
1842    decided: bool,
1843    merge: bool,
1844}
1845
1846impl MergeFlag {
1847    /// Feed the next token kind, in source order. Ignores everything once the
1848    /// flag is decided.
1849    pub(in crate::cst) const fn feed(&mut self, kind: crate::SyntaxKind) {
1850        use crate::SyntaxKind as K;
1851        if self.decided {
1852            return;
1853        }
1854        match kind {
1855            K::L_BRACE | K::L_DOUBLE_BRACE | K::L_BRACE_HASH => self.past_opener = true,
1856            K::WHITESPACE => {}
1857            K::STAR if self.past_opener => {
1858                self.merge = true;
1859                self.decided = true;
1860            }
1861            _ if self.past_opener => self.decided = true,
1862            _ => {}
1863        }
1864    }
1865
1866    /// Whether the tokens fed so far describe a `{*}` merge cost.
1867    pub(in crate::cst) const fn is_merge(&self) -> bool {
1868        self.merge
1869    }
1870}
1871
1872/// Convert the direct child tokens of a `COST_SPEC` node into a [`CostSpec`]
1873/// (forms `{N CCY}`, `{{T CCY}}`, `{N # T CCY}`, `{*}` merge, plus optional
1874/// date + label). The single source of truth for BOTH walkers (red
1875/// `convert_cost_spec`, green `convert_cost_spec`).
1876///
1877/// Cost numbers are plain `NUMBER` tokens (no arithmetic evaluation); an
1878/// unparsable one yields `number: None` with no diagnostic, so this needs no
1879/// bail and always returns a `CostSpec`.
1880///
1881/// There are TWO number semantics and both must be carried (#1713):
1882/// - the compound `{a # b}` path retries past UNPARSABLE number tokens on
1883///   each side of the hash (`is_none()` guards re-arm when the parse fails);
1884/// - the plain path uses the first NUMBER *token*, parsed or not (a latch).
1885///
1886/// A single latched tracker satisfies only the plain path: on
1887/// `{<garbage-number> 2 # ...}` the compound side must retry to `2` while
1888/// the latch keeps `None` (the historical `fuzz_green_eq_red` divergence).
1889///
1890/// Compound `{a # b}` (beancount `compound_amount`): per-unit AND a lump
1891/// total on top; the cost totals `N*a + b`. Surfaced as written — units may
1892/// be interpolated later, so the combined total cannot be computed here;
1893/// booking derives it (#1700). An omitted side is zero, which is
1894/// arithmetically exact (`{# b}` ≡ `{{b}}`, `{a #}` ≡ `{a}`). The hash can
1895/// arrive fused with the brace as a single `L_BRACE_HASH` opener.
1896///
1897/// `is_total` = any `{{` present anywhere. The `{*}` merge flag is decided
1898/// by the first non-whitespace, non-opener token after an opener (`*` →
1899/// merge, anything else → not — a STAR elsewhere is the multiplication
1900/// operator, e.g. `{500 * 2 USD}`); a scan-everything pass that re-arms on
1901/// later openers flips the flag on malformed inputs where it must not
1902/// (another historical divergence).
1903pub(super) fn cost_spec_from_tokens(tokens: impl Iterator<Item = impl TokenView>) -> CostSpec {
1904    use crate::SyntaxKind as K;
1905    // Materialized because the arithmetic evaluator needs random access
1906    // (backtracking over a `(`...`)` group). Cost specs are a handful of
1907    // tokens, so the allocation is not on any hot path worth defending.
1908    let toks: Vec<_> = tokens.collect();
1909    let mut is_total = false;
1910    let mut first_number: Option<Decimal> = None; // latched (plain path)
1911    let mut seen_number = false;
1912    let mut pre_hash: Option<Decimal> = None; // retried (compound path)
1913    let mut past_hash = false;
1914    let mut post_hash_total: Option<Decimal> = None; // retried (compound path)
1915    let mut currency: Option<Currency> = None;
1916    let mut date: Option<NaiveDate> = None;
1917    let mut date_seen = false;
1918    let mut label: Option<String> = None;
1919    let mut label_seen = false;
1920    let mut merge_flag = MergeFlag::default();
1921    for t in &toks {
1922        let kind = t.kind();
1923        // Runs alongside the value machine below; see `MergeFlag`.
1924        merge_flag.feed(kind);
1925        match kind {
1926            K::L_DOUBLE_BRACE => is_total = true,
1927            K::NUMBER => {
1928                if past_hash {
1929                    if post_hash_total.is_none() {
1930                        post_hash_total = parse_decimal_token(t.text());
1931                    }
1932                } else {
1933                    if pre_hash.is_none() {
1934                        pre_hash = parse_decimal_token(t.text());
1935                    }
1936                    if !seen_number {
1937                        seen_number = true;
1938                        first_number = parse_decimal_token(t.text());
1939                    }
1940                }
1941            }
1942            K::HASH | K::L_BRACE_HASH => past_hash = true,
1943            K::CURRENCY if currency.is_none() => currency = Some(Currency::new(t.text())),
1944            K::DATE if !date_seen => {
1945                date_seen = true;
1946                date = parse_date_token(t.text());
1947            }
1948            K::STRING if !label_seen => {
1949                label_seen = true;
1950                label = decode_string_token(t.text());
1951            }
1952            _ => {}
1953        }
1954    }
1955    // ARITHMETIC OVERRIDE. The latches above take the first NUMBER of each
1956    // region, which is right for `{10.00 USD}` and wrong for both
1957    // `{10.00 * 3 USD}` and `{-200.00 USD}` — the latch reads NUMBER tokens
1958    // only, so it truncated the first and dropped the sign of the second.
1959    // `cost_region_value` returns None for an unsigned bare number, so that
1960    // case keeps the latch untouched. See #1939.
1961    let hash_at = toks
1962        .iter()
1963        .position(|t| matches!(t.kind(), K::HASH | K::L_BRACE_HASH));
1964    match hash_at {
1965        Some(i) => {
1966            if let Some(v) = cost_region_value(&toks[..i]) {
1967                pre_hash = Some(v);
1968            }
1969            if let Some(v) = cost_region_value(&toks[i + 1..]) {
1970                post_hash_total = Some(v);
1971            }
1972        }
1973        None => {
1974            if let Some(v) = cost_region_value(&toks) {
1975                first_number = Some(v);
1976            }
1977        }
1978    }
1979
1980    // A malformed component list means we do not know the cost, so do not
1981    // report one (#2008). `{, 100.0 USD, , }` and `{45.23 USD / 2015-07-16 /
1982    // "blabla"}` both have a number our recovery can scrape out, and scraping
1983    // it is how `rledger check` came to print `E3001 does not balance:
1984    // residual 980.10 USD` — an arithmetic complaint about a typo, whose
1985    // number was produced by this function rather than by anything the author
1986    // wrote. Dropping to "unknown" hands the posting to interpolation, which
1987    // already knows how to solve for a single unknown per currency group and
1988    // to reject more than one.
1989    //
1990    // The currency is kept: `{, 100.0 USD, , }` names USD unambiguously, so
1991    // "an unknown cost in USD" is the honest reading. Same rule as `{ # USD}`
1992    // above — never invent a number the author did not write.
1993    let shape_ok =
1994        super::cost_spec_shape::first_cost_spec_defect(toks.iter().map(|t| (t.kind(), ())))
1995            .is_none();
1996
1997    let number = if !shape_ok {
1998        None
1999    } else if past_hash {
2000        match (pre_hash, post_hash_total) {
2001            // `{ # USD}` — no number on EITHER side of the `#`. There is no
2002            // cost number here at all, so say so; `unwrap_or_default()` used to
2003            // invent `Compound { per_unit: 0, total: 0 }`, a perfectly
2004            // determinable zero cost. That is why #2008 case 5 loaded clean:
2005            // interpolation counts a cost spec with no determinable number as
2006            // one unknown for its currency and enforces "at most one per
2007            // currency group", but an invented zero is not an unknown, so the
2008            // rule never saw it.
2009            //
2010            // Reported as `None`, exactly like `{USD}`, because that is what
2011            // the two shapes have in common: a currency and no number.
2012            //
2013            // Scoped to BOTH sides missing. `{100 # USD}` and `{# 500 USD}`
2014            // still default the absent side to zero — beancount treats it as
2015            // MISSING and would solve for it, which is a different and larger
2016            // change. The corpus says that is not urgent: `{ # CCY}` appears in
2017            // exactly one file (the #2008 fixture), and the one-sided forms
2018            // appear only in other parser-lima conformance fixtures. Widening
2019            // this without an oracle to check against is how a compat fix
2020            // starts breaking real ledgers.
2021            (None, None) => None,
2022            (per_unit, total) => Some(CostNumber::Compound {
2023                per_unit: per_unit.unwrap_or_default(),
2024                total: total.unwrap_or_default(),
2025            }),
2026        }
2027    } else {
2028        match (first_number, is_total) {
2029            (Some(v), true) => Some(CostNumber::Total { value: v }),
2030            (Some(v), false) => Some(CostNumber::PerUnit { value: v }),
2031            (None, _) => None,
2032        }
2033    };
2034    CostSpec {
2035        number,
2036        currency,
2037        date,
2038        label,
2039        merge: merge_flag.is_merge(),
2040    }
2041}
2042
2043fn convert_price_annotation(
2044    pa: &ast::PriceAnnotation,
2045    errors: &mut Vec<crate::ParseError>,
2046    bom_offset: u32,
2047) -> PriceAnnotation {
2048    let kind = if pa.is_total() {
2049        PriceKind::Total
2050    } else {
2051        PriceKind::Unit
2052    };
2053    let amount = pa
2054        .amount()
2055        .and_then(|a| convert_amount_to_incomplete(&a, errors, bom_offset));
2056    PriceAnnotation { kind, amount }
2057}
2058
2059// ---- Metadata extraction ---------------------------------------
2060
2061/// Extract the [`Metadata`] map from the directive node's
2062/// `META_ENTRY` sub-line children. Matches the legacy parser's
2063/// behavior: each entry's key (with trailing `:` stripped) maps
2064/// to a typed [`MetaValue`] derived from the value tokens.
2065fn convert_meta_entries(node: &crate::SyntaxNode) -> Metadata {
2066    let mut meta = Metadata::default();
2067    for entry in node.children().filter_map(MetaEntry::cast) {
2068        let Some(key_token) = entry.key() else {
2069            continue;
2070        };
2071        let key = key_token.text_without_colon().to_string();
2072        let value = meta_value_from_entry(&entry);
2073        meta.insert(key, value);
2074    }
2075    meta
2076}
2077
2078/// The span of flat `POSTING` tokens sitting between the account and the
2079/// amount, if any — tokens the conversion would otherwise discard in silence.
2080///
2081/// CANONICAL: both conversion paths consult this. The green path bails to red
2082/// when it returns `Some` (red owns the diagnostic), so the two cannot disagree
2083/// about which postings are well formed — the property `fuzz_green_eq_red`
2084/// checks.
2085///
2086/// Deliberately narrow: only a stray sign or comma counts. Those are the
2087/// tokens that silently CHANGE A VALUE — a dropped `-` flips the sign, and a
2088/// misplaced `,` splits a number. Any other junk between the account and the
2089/// amount (a `✨` inside an account name, a mangled transaction header) is
2090/// already reported as "unexpected input" by error recovery, and reporting it
2091/// again here would double up on the same span with a message about thousands
2092/// separators that does not describe the actual problem.
2093///
2094/// Trivia and the posting flag are not orphans, and neither is anything at or
2095/// after the first `AMOUNT`: trailing junk is already reported separately.
2096pub(super) const fn is_orphanable_amount_prefix(kind: crate::SyntaxKind) -> bool {
2097    matches!(
2098        kind,
2099        crate::SyntaxKind::MINUS | crate::SyntaxKind::PLUS | crate::SyntaxKind::COMMA
2100    )
2101}
2102
2103pub(super) fn orphaned_amount_prefix(node: &crate::SyntaxNode) -> Option<crate::TextRange> {
2104    let mut seen_account = false;
2105    let mut start: Option<crate::TextRange> = None;
2106    let mut end: Option<crate::TextRange> = None;
2107    for el in node.children_with_tokens() {
2108        match el {
2109            rowan::NodeOrToken::Node(n) => {
2110                // Only the AMOUNT ends the prefix — deliberately NOT any
2111                // structured node. A `COST_SPEC` or `PRICE_ANNOTATION` can
2112                // legitimately precede the units, and a stray sign or comma
2113                // sitting between one of those and the amount is just as
2114                // orphaned as one right after the account. Their own commas
2115                // live INSIDE their nodes, so this cannot false-positive on
2116                // `{100.00 USD, 2020-01-01}`.
2117                if ast::Amount::can_cast(n.kind()) {
2118                    break;
2119                }
2120            }
2121            rowan::NodeOrToken::Token(t) => {
2122                let kind = t.kind();
2123                if kind == crate::SyntaxKind::ACCOUNT {
2124                    seen_account = true;
2125                    continue;
2126                }
2127                // NEWLINE FIRST: `is_trivia_kind` counts it as trivia, so
2128                // testing that earlier would skip it and keep scanning past the
2129                // end of the posting line — and would leave the check below
2130                // unreachable. The green mirror stops at the newline, so
2131                // getting this order wrong is also how the two paths drift.
2132                if kind == crate::SyntaxKind::NEWLINE {
2133                    break;
2134                }
2135                if !seen_account || is_trivia_kind(kind) || is_comment_kind(kind) {
2136                    continue;
2137                }
2138                if !is_orphanable_amount_prefix(kind) {
2139                    continue;
2140                }
2141                start.get_or_insert(t.text_range());
2142                end = Some(t.text_range());
2143            }
2144        }
2145    }
2146    let (s, e) = (start?, end?);
2147    Some(crate::TextRange::new(s.start(), e.end()))
2148}
2149
2150/// Returns true if a node's flat direct-child tokens contain a
2151/// `MINUS` BEFORE the first `NUMBER`. Used to detect signed
2152/// numeric values in directives like Balance / Price whose typed-
2153/// AST accessors return the unsigned NUMBER token only.
2154fn node_has_minus_before_number(node: &crate::SyntaxNode) -> bool {
2155    for el in node.children_with_tokens() {
2156        let rowan::NodeOrToken::Token(t) = el else {
2157            continue;
2158        };
2159        match t.kind() {
2160            crate::SyntaxKind::MINUS => return true,
2161            crate::SyntaxKind::NUMBER => return false,
2162            _ => {}
2163        }
2164    }
2165    false
2166}
2167
2168/// Discriminate one *value group* of raw metadata/custom value tokens
2169/// (`tokens[start..]`, trivia already filtered) into a [`MetaValue`], returning
2170/// the value and the index just past it. This is the single source of truth for
2171/// the raw token-walk extractors ([`pushmeta_value`] and
2172/// [`extract_custom_values`]) so they can't drift — they previously did:
2173/// `extract_custom_values` dropped the leading `MINUS` (so `custom "x" -50.00`
2174/// emitted `+50.00`) and dropped `Tag`/`Link` entirely, while `pushmeta_value`
2175/// skipped the `NUMBER CURRENCY` → `Amount` lookahead.
2176///
2177/// Discrimination mirrors the typed [`meta_value_from_entry`] (the `META_ENTRY`
2178/// sibling, which additionally escape-decodes strings via the typed AST). A
2179/// leading `MINUS` negates the following `NUMBER`; an adjacent `CURRENCY` makes
2180/// it an `Amount`. Returns `None` for a non-value token (the caller advances).
2181fn value_tokens_to_meta(
2182    tokens: &[rowan::SyntaxToken<crate::BeancountLanguage>],
2183    start: usize,
2184) -> Option<(MetaValue, usize)> {
2185    let mut i = start;
2186    let mut negate = false;
2187    if tokens.get(i).map(rowan::SyntaxToken::kind) == Some(crate::SyntaxKind::MINUS) {
2188        negate = true;
2189        i += 1;
2190    }
2191    let t = tokens.get(i)?;
2192    match t.kind() {
2193        crate::SyntaxKind::STRING => {
2194            let s = strip_string_quotes(t.text())?;
2195            Some((MetaValue::String(s.to_string()), i + 1))
2196        }
2197        crate::SyntaxKind::NUMBER => {
2198            let mut decimal = parse_decimal_token(t.text())?;
2199            if negate {
2200                decimal = -decimal;
2201            }
2202            // Adjacent `CURRENCY` → `Amount` (negate applies to the amount too).
2203            if let Some(next) = tokens.get(i + 1)
2204                && next.kind() == crate::SyntaxKind::CURRENCY
2205            {
2206                return Some((
2207                    MetaValue::Amount(Amount::new(decimal, Currency::new(next.text()))),
2208                    i + 2,
2209                ));
2210            }
2211            Some((number_meta_value(t.text(), decimal), i + 1))
2212        }
2213        crate::SyntaxKind::DATE => Some((MetaValue::Date(parse_date_token(t.text())?), i + 1)),
2214        crate::SyntaxKind::ACCOUNT => Some((MetaValue::Account(Account::new(t.text())), i + 1)),
2215        crate::SyntaxKind::CURRENCY => Some((MetaValue::Currency(Currency::new(t.text())), i + 1)),
2216        crate::SyntaxKind::BOOL_TRUE => Some((MetaValue::Bool(true), i + 1)),
2217        crate::SyntaxKind::BOOL_FALSE => Some((MetaValue::Bool(false), i + 1)),
2218        crate::SyntaxKind::TAG => Some((
2219            MetaValue::Tag(Tag::new(t.text().trim_start_matches('#'))),
2220            i + 1,
2221        )),
2222        crate::SyntaxKind::LINK => Some((
2223            MetaValue::Link(Link::new(t.text().trim_start_matches('^'))),
2224            i + 1,
2225        )),
2226        _ => None,
2227    }
2228}
2229
2230/// Discriminate the value tokens under a `META_ENTRY` into a typed
2231/// [`MetaValue`] — thin wrapper over the shared [`meta_value_from_tokens`].
2232/// The raw-token sibling is [`value_tokens_to_meta`]; keep the two in sync.
2233fn meta_value_from_entry(entry: &MetaEntry) -> MetaValue {
2234    meta_value_from_tokens(
2235        entry
2236            .syntax()
2237            .children_with_tokens()
2238            .filter_map(rowan::NodeOrToken::into_token),
2239    )
2240}
2241
2242/// Derive the typed [`MetaValue`] from a `META_ENTRY` node's direct child
2243/// tokens. The single source of truth for BOTH walkers (red
2244/// [`meta_value_from_entry`], green `meta_value`).
2245///
2246/// Matches the legacy parser's preference order: string > number/amount >
2247/// date > account > currency > bool > tag/link > none, where each candidate
2248/// is the FIRST token of its kind and a type that's present-but-unparsable
2249/// (a malformed string, an over-precision number, a bad date) falls through
2250/// to the next.
2251///
2252/// A `MINUS` token after the `META_KEY` and before the first `NUMBER`
2253/// negates the number (legacy `parse_signed_number`, e.g. `precision: -1`);
2254/// a first `CURRENCY` anywhere alongside the number makes it an `Amount`
2255/// (legacy priority where `parse_amount` runs before `parse_signed_number`).
2256pub(super) fn meta_value_from_tokens(tokens: impl Iterator<Item = impl TokenView>) -> MetaValue {
2257    use crate::SyntaxKind as K;
2258    // Materialized so the arithmetic evaluator can look at the value region as
2259    // a slice. `key: 2 * 3` is 6 in beancount and was 2 here — the same
2260    // first-NUMBER truncation as the cost spec in #1939, in a third place
2261    // (#1944).
2262    let toks: Vec<_> = tokens.collect();
2263    let mut string_t: Option<String> = None;
2264    let mut number_t: Option<String> = None;
2265    let mut currency_t: Option<String> = None;
2266    let mut date_t: Option<String> = None;
2267    let mut account_t: Option<String> = None;
2268    let mut bool_v: Option<bool> = None;
2269    let mut tag_link: Option<MetaValue> = None;
2270    let mut past_key = false;
2271    let mut minus = false;
2272    let mut minus_decided = false;
2273
2274    for t in &toks {
2275        let kind = t.kind();
2276        // First-of-kind value tokens (the `first_token` accessor semantics).
2277        match kind {
2278            K::STRING if string_t.is_none() => string_t = Some(t.text().to_string()),
2279            K::NUMBER if number_t.is_none() => number_t = Some(t.text().to_string()),
2280            K::CURRENCY if currency_t.is_none() => currency_t = Some(t.text().to_string()),
2281            K::DATE if date_t.is_none() => date_t = Some(t.text().to_string()),
2282            K::ACCOUNT if account_t.is_none() => account_t = Some(t.text().to_string()),
2283            K::BOOL_TRUE if bool_v.is_none() => bool_v = Some(true),
2284            K::BOOL_FALSE if bool_v.is_none() => bool_v = Some(false),
2285            K::TAG if tag_link.is_none() => {
2286                tag_link = Some(MetaValue::Tag(Tag::new(t.text().trim_start_matches('#'))));
2287            }
2288            K::LINK if tag_link.is_none() => {
2289                tag_link = Some(MetaValue::Link(Link::new(t.text().trim_start_matches('^'))));
2290            }
2291            _ => {}
2292        }
2293        if past_key && !minus_decided {
2294            match kind {
2295                K::MINUS => {
2296                    minus = true;
2297                    minus_decided = true;
2298                }
2299                K::NUMBER => minus_decided = true,
2300                _ => {}
2301            }
2302        }
2303        // Gates the sign machine so a MINUS in or before the key position is
2304        // not read as a value's sign. No input reachable through the parser
2305        // exercises it: the key is always the first meaningful token, and the
2306        // malformed shapes that would put a MINUS ahead of it (`- key: 42`,
2307        // `-key: 42`, `key- : 42`) yield no metadata entry at all. Kept as a
2308        // guard on the token contract rather than deleted, since this helper
2309        // is shared with the green walker and takes whatever tokens it is
2310        // handed. Flipping this comparison survives mutation testing for the
2311        // same reason -- recorded so the next reader does not hunt for a test.
2312        if kind == K::META_KEY {
2313            past_key = true;
2314        }
2315    }
2316
2317    if let Some(s) = string_t
2318        && let Some(decoded) = decode_string_token(&s)
2319    {
2320        return MetaValue::String(decoded);
2321    }
2322    // Arithmetic override, mirroring the cost-spec fix. Only when the value
2323    // region really is an expression; a bare number keeps the latch above and
2324    // its `Int` vs `Number` discrimination, which is archived (cache v11) and
2325    // must not shift for ordinary metadata.
2326    //
2327    // The sign is NOT reapplied on this path: a leading MINUS is part of the
2328    // expression the evaluator already consumed, so `minus` would double it.
2329    let value_region: Vec<&_> = toks
2330        .iter()
2331        .skip_while(|t| t.kind() != K::META_KEY)
2332        .filter(|t| !is_trivia_kind(t.kind()) && t.kind() != K::META_KEY)
2333        .collect();
2334    if let Some(dec) = cost_region_value(&value_region) {
2335        if let Some(c) = currency_t {
2336            return MetaValue::Amount(Amount::new(dec, Currency::new(&c)));
2337        }
2338        // Render the RESULT to decide Int vs Number, so `2 * 3` is Int(6) —
2339        // what beancount reports — rather than inheriting the first operand's
2340        // spelling.
2341        return number_meta_value(&dec.to_string(), dec);
2342    }
2343    if let Some(nt) = number_t
2344        && let Some(mut dec) = parse_decimal_token(&nt)
2345    {
2346        if minus {
2347            dec = -dec;
2348        }
2349        if let Some(c) = currency_t {
2350            return MetaValue::Amount(Amount::new(dec, Currency::new(&c)));
2351        }
2352        return number_meta_value(&nt, dec);
2353    }
2354    if let Some(dt) = date_t
2355        && let Some(date) = parse_date_token(&dt)
2356    {
2357        return MetaValue::Date(date);
2358    }
2359    if let Some(a) = account_t {
2360        return MetaValue::Account(Account::new(&a));
2361    }
2362    if let Some(c) = currency_t {
2363        return MetaValue::Currency(Currency::new(&c));
2364    }
2365    if let Some(b) = bool_v {
2366        return MetaValue::Bool(b);
2367    }
2368    if let Some(tl) = tag_link {
2369        return tl;
2370    }
2371    MetaValue::None
2372}
2373
2374// ---- Inherited state (pushtag/poptag/pushmeta/popmeta) ---------
2375
2376/// Merge active pushed-tag and pushed-meta state into a freshly
2377/// converted directive's value. Mirrors the legacy parser's
2378/// `apply_pushed_tags` + `apply_pushed_meta`: tags apply ONLY to
2379/// `Transaction`; meta applies to every directive's `meta` field.
2380///
2381/// The meta stack is a `Vec` (not a map) to preserve shadow/pop
2382/// semantics - `pushmeta x: 1; pushmeta x: 2; popmeta x` should
2383/// leave `x = 1` active, which a map-replacing-on-insert can't
2384/// express. Iterating in push order and inserting into the
2385/// directive's meta means later entries naturally win, matching
2386/// "topmost-shadow wins" behavior.
2387fn apply_inherited_state(
2388    value: &mut Directive,
2389    tag_stack: &[(Tag, Span)],
2390    meta_stack: &[(String, MetaValue, Span)],
2391) {
2392    if let Directive::Transaction(txn) = value {
2393        for (tag, _) in tag_stack {
2394            if !txn.tags.contains(tag) {
2395                txn.tags.push(tag.clone());
2396            }
2397        }
2398    }
2399    if meta_stack.is_empty() {
2400        return;
2401    }
2402    let meta = match value {
2403        Directive::Transaction(d) => &mut d.meta,
2404        Directive::Balance(d) => &mut d.meta,
2405        Directive::Open(d) => &mut d.meta,
2406        Directive::Close(d) => &mut d.meta,
2407        Directive::Commodity(d) => &mut d.meta,
2408        Directive::Pad(d) => &mut d.meta,
2409        Directive::Event(d) => &mut d.meta,
2410        Directive::Query(d) => &mut d.meta,
2411        Directive::Note(d) => &mut d.meta,
2412        Directive::Document(d) => &mut d.meta,
2413        Directive::Price(d) => &mut d.meta,
2414        Directive::Custom(d) => &mut d.meta,
2415    };
2416    for (k, v, _) in meta_stack {
2417        meta.insert(k.clone(), v.clone());
2418    }
2419}
2420
2421/// Extract the value tokens after the `META_KEY` of a Pushmeta
2422/// directive into a typed [`MetaValue`]. Walks the directive's
2423/// direct-child tokens (the directive isn't a `META_ENTRY` so the
2424/// typed-AST accessors aren't reusable).
2425fn pushmeta_value(node: &crate::SyntaxNode) -> MetaValue {
2426    // The first value token after the key wins. `value_tokens_to_meta` returns
2427    // `None` for the key/colon (and any non-value token), so the loop walks to
2428    // the first real value — sharing MINUS-sign, Tag/Link and
2429    // `NUMBER CURRENCY` → Amount handling with metadata/custom values.
2430    let raw: Vec<rowan::SyntaxToken<crate::BeancountLanguage>> = node
2431        .children_with_tokens()
2432        .filter_map(rowan::NodeOrToken::into_token)
2433        .filter(|t| {
2434            !matches!(
2435                t.kind(),
2436                crate::SyntaxKind::WHITESPACE
2437                    | crate::SyntaxKind::NEWLINE
2438                    | crate::SyntaxKind::COMMENT
2439            )
2440        })
2441        .collect();
2442
2443    let mut i = 0;
2444    while i < raw.len() {
2445        if let Some((value, _)) = value_tokens_to_meta(&raw, i) {
2446            return value;
2447        }
2448        i += 1;
2449    }
2450    MetaValue::None
2451}
2452
2453// ---- ParseResult.comments --------------------------------------
2454
2455/// Comment-like syntax kinds that the legacy parser surfaces as
2456/// `ParseResult.comments` entries when they appear at the top
2457/// level (outside any directive's content).
2458pub(super) const fn is_comment_kind(kind: crate::SyntaxKind) -> bool {
2459    matches!(
2460        kind,
2461        crate::SyntaxKind::COMMENT
2462            | crate::SyntaxKind::PERCENT_COMMENT
2463            | crate::SyntaxKind::SHEBANG
2464            | crate::SyntaxKind::EMACS_DIRECTIVE
2465    )
2466}
2467
2468/// Output of the fused top-level pass [`walk_top_level_once`].
2469pub(super) struct TopLevelWalkResult {
2470    pub(super) errors: Vec<crate::ParseError>,
2471    pub(super) section_marker_comments: Vec<Spanned<String>>,
2472}
2473
2474/// Single walk over `source_file`'s direct children that runs
2475/// every per-directive diagnostic in one pass, replacing five
2476/// separate `source_file.syntax().children()` traversals
2477/// (`extract_error_node_errors`, `extract_transaction_body_errors`,
2478/// `extract_indented_directive_errors`, `extract_custom_value_errors`,
2479/// `extract_section_marker_comments`). Each former pass re-walked
2480/// the top-level child list and materialized a fresh red node per
2481/// directive; on a large ledger that is 5·O(N) red-node churn for
2482/// work that is naturally per-child. The checks are independent and
2483/// all diagnostics are span-sorted by the caller, so fusing them is
2484/// order-preserving.
2485fn walk_top_level_once(
2486    source_file: &SourceFile,
2487    stripped: &str,
2488    bom_offset: u32,
2489) -> TopLevelWalkResult {
2490    let mut errors: Vec<crate::ParseError> = Vec::new();
2491    let mut section_marker_comments: Vec<Spanned<String>> = Vec::new();
2492    for child in source_file.syntax().children() {
2493        let kind = child.kind();
2494        // Applies to every recognized directive node (incl. CUSTOM).
2495        if ast::Directive::can_cast(kind) {
2496            indented_directive_check(&child, stripped, bom_offset, &mut errors);
2497        }
2498        match kind {
2499            crate::SyntaxKind::CUSTOM_DIRECTIVE => {
2500                custom_value_check(&child, bom_offset, &mut errors);
2501            }
2502            crate::SyntaxKind::TRANSACTION => {
2503                transaction_header_check(&child, stripped, bom_offset, &mut errors);
2504                transaction_body_check(&child, bom_offset, &mut errors);
2505            }
2506            crate::SyntaxKind::ERROR_NODE => {
2507                error_node_check(&child, stripped, bom_offset, &mut errors);
2508                section_marker_check(&child, bom_offset, &mut section_marker_comments);
2509            }
2510            _ => {}
2511        }
2512    }
2513    TopLevelWalkResult {
2514        errors,
2515        section_marker_comments,
2516    }
2517}
2518
2519/// A `^link` is not a valid metadata VALUE (#1954).
2520///
2521/// beancount's grammar has no production for it — `ref: ^inv-1` fails with
2522/// `syntax error, unexpected LINK, expecting end of file or EOL` — while a
2523/// `#tag` in the same position is perfectly valid there. We accepted both.
2524///
2525/// Deliberately asymmetric, and that asymmetry is the whole point: rejecting
2526/// TAG here as well would break input beancount accepts, which is the mistake
2527/// #1953 avoided in the mirror-image case. Tags and links lex as sibling kinds
2528/// and are handled as a pair almost everywhere in this file, so the pairing is
2529/// the natural thing to reach for and the wrong thing to do.
2530///
2531/// Scoped to `META_ENTRY` nodes. A link on a TRANSACTION (`* "x" ^lnk`) is a
2532/// direct child of the transaction, not of a metadata entry, so it is
2533/// untouched — as it must be, that being the one place links belong.
2534///
2535/// NOT extended to `custom` / `pushmeta` values, which run through
2536/// `value_tokens_to_meta` rather than this path. beancount rejects BOTH a tag
2537/// and a link there, so it is a different rule needing its own evidence;
2538/// filed separately rather than folded in here.
2539fn extract_link_metadata_value_errors(
2540    source_file: &SourceFile,
2541    bom_offset: u32,
2542) -> Vec<crate::ParseError> {
2543    let mut out = Vec::new();
2544    for entry in source_file.syntax().descendants() {
2545        if entry.kind() != crate::SyntaxKind::META_ENTRY {
2546            continue;
2547        }
2548        for el in entry.children_with_tokens() {
2549            let rowan::NodeOrToken::Token(t) = el else {
2550                continue;
2551            };
2552            if t.kind() != crate::SyntaxKind::LINK {
2553                continue;
2554            }
2555            let range = t.text_range();
2556            let off = bom_offset as usize;
2557            out.push(crate::ParseError::new(
2558                crate::ParseErrorKind::SyntaxError(format!(
2559                    "a link ({}) is not a valid metadata value; beancount \
2560                     accepts a tag here but not a link",
2561                    t.text()
2562                )),
2563                Span::new(
2564                    usize::from(range.start()) + off,
2565                    usize::from(range.end()) + off,
2566                ),
2567            ));
2568        }
2569    }
2570    out
2571}
2572
2573/// Walk every `COST_SPEC` node in the tree and emit a
2574/// `SyntaxError("unclosed cost specification: missing '}'")` for
2575/// any spec whose opener (`{`, `{{`, or `{#`) doesn't have a
2576/// matching closer at the spec's depth-0. Mirrors the legacy
2577/// parser's deferred-error emission at `parser.rs:705-707` so a
2578/// `10 AAPL {150 USD\n` posting or an EOF-truncated cost block
2579/// surfaces a diagnostic instead of silently producing a half-
2580/// built cost spec.
2581/// Tags and links are not valid `custom` or `pushmeta` VALUES (#1958).
2582///
2583/// Two different rules, and conflating them is the trap here:
2584///
2585/// | position          | `#tag`   | `^link`  |
2586/// |-------------------|----------|----------|
2587/// | metadata value    | valid    | rejected |
2588/// | `pushmeta k: ...` | valid    | rejected |
2589/// | `custom "t" ...`  | rejected | rejected |
2590///
2591/// `pushmeta` follows the METADATA rule - it pushes a metadata key/value, so a
2592/// tag is fine there and only a link is not. `custom` is stricter than both and
2593/// takes neither. Checked against beancount per position, each with a control
2594/// on the same directive.
2595///
2596/// This is why the check cannot live inside `value_tokens_to_meta`, which both
2597/// callers share: one rule in the shared helper would either let a tag through
2598/// in `custom` or wrongly reject one in `pushmeta`. It is the same shape as
2599/// #1953 (`note`/`document` DO take both) and #1954 (a tag is valid where a
2600/// link is not) - three times now the right answer has split a pair that lexes
2601/// and reads as one.
2602fn extract_custom_pushmeta_taglink_errors(
2603    source_file: &SourceFile,
2604    bom_offset: u32,
2605) -> Vec<crate::ParseError> {
2606    use crate::SyntaxKind as K;
2607    let mut out = Vec::new();
2608    for node in source_file.syntax().descendants() {
2609        let (reject_tag, what) = match node.kind() {
2610            K::CUSTOM_DIRECTIVE => (true, "custom"),
2611            K::PUSHMETA_DIRECTIVE => (false, "pushmeta"),
2612            _ => continue,
2613        };
2614        for el in node.children_with_tokens() {
2615            let rowan::NodeOrToken::Token(t) = el else {
2616                continue;
2617            };
2618            let kind = t.kind();
2619            let bad = match kind {
2620                K::LINK => true,
2621                K::TAG => reject_tag,
2622                _ => false,
2623            };
2624            if !bad {
2625                continue;
2626            }
2627            let noun = if kind == K::TAG { "tag" } else { "link" };
2628            let range = t.text_range();
2629            let off = bom_offset as usize;
2630            out.push(crate::ParseError::new(
2631                crate::ParseErrorKind::SyntaxError(format!(
2632                    "a {noun} ({}) is not a valid {what} value",
2633                    t.text()
2634                )),
2635                Span::new(
2636                    usize::from(range.start()) + off,
2637                    usize::from(range.end()) + off,
2638                ),
2639            ));
2640        }
2641    }
2642    out
2643}
2644
2645fn extract_unclosed_cost_brace_errors(
2646    source_file: &SourceFile,
2647    stripped: &str,
2648    bom_offset: u32,
2649) -> Vec<crate::ParseError> {
2650    let mut out = Vec::new();
2651    for cs in source_file.syntax().descendants() {
2652        if cs.kind() != crate::SyntaxKind::COST_SPEC {
2653            continue;
2654        }
2655        let mut has_opener = false;
2656        let mut has_closer = false;
2657        for el in cs.children_with_tokens() {
2658            let rowan::NodeOrToken::Token(t) = el else {
2659                continue;
2660            };
2661            match t.kind() {
2662                crate::SyntaxKind::L_BRACE
2663                | crate::SyntaxKind::L_DOUBLE_BRACE
2664                | crate::SyntaxKind::L_BRACE_HASH => has_opener = true,
2665                crate::SyntaxKind::R_BRACE | crate::SyntaxKind::R_DOUBLE_BRACE => has_closer = true,
2666                _ => {}
2667            }
2668        }
2669        if has_opener && !has_closer {
2670            out.push(crate::ParseError::new(
2671                crate::ParseErrorKind::SyntaxError(
2672                    "unclosed cost specification: missing '}'".to_string(),
2673                ),
2674                node_span(&cs, bom_offset),
2675            ));
2676            // An unclosed spec has no meaningful component list; reporting a
2677            // shape defect on top would just be noise about the truncation.
2678            continue;
2679        }
2680
2681        // Component-list shape (#2008 cases 1 and 2). Fused into this walk
2682        // rather than given its own: `descendants()` allocates a red node per
2683        // node, which is why this scan already sits behind a `contains('{')`
2684        // guard, and a second identical pass would double a cost the codebase
2685        // deliberately profiled down.
2686        let tokens = cs
2687            .children_with_tokens()
2688            .filter_map(rowan::NodeOrToken::into_token)
2689            .map(|t| {
2690                let r = t.text_range();
2691                (t.kind(), usize::from(r.start())..usize::from(r.end()))
2692            });
2693        if let Some((defect, range)) = super::cost_spec_shape::first_cost_spec_defect(tokens) {
2694            // `get` rather than indexing: a non-char-boundary range must not
2695            // panic the parser.
2696            let message = match stripped.get(range.clone()) {
2697                Some(text) => super::cost_spec_shape::cost_defect_message(defect, text),
2698                None => format!(
2699                    "malformed cost specification at bytes {}..{} ({defect:?})",
2700                    range.start, range.end
2701                ),
2702            };
2703            out.push(crate::ParseError::new(
2704                crate::ParseErrorKind::SyntaxError(message),
2705                Span::new(
2706                    range.start + bom_offset as usize,
2707                    range.end + bom_offset as usize,
2708                ),
2709            ));
2710        }
2711    }
2712    out
2713}
2714
2715/// Walk every top-level directive in `source_file` and emit a
2716/// `SyntaxError("top-level directive must start at column 0")`
2717/// for any whose content (first non-trivia token) starts at a
2718/// non-zero column. Per the Beancount language spec, top-level
2719/// directives are required to begin at column 0; indentation is
2720/// reserved for postings and metadata inside a transaction body.
2721///
2722/// The CST grammar happily accepts an indented `open` / `balance`
2723/// / etc., which is why this surfaces at converter level instead
2724/// of as a lex/parse error.
2725fn indented_directive_check(
2726    child: &crate::SyntaxNode,
2727    stripped: &str,
2728    bom_offset: u32,
2729    out: &mut Vec<crate::ParseError>,
2730) {
2731    // Caller dispatches: `child` is a recognized directive node.
2732    // Find the directive's content start - the first non-
2733    // trivia token. Leading WHITESPACE / NEWLINE / COMMENT
2734    // can land inside the directive node per the Directive-
2735    // Terminator Rule's inter-directive trivia attachment.
2736    let Some(content) = child
2737        .children_with_tokens()
2738        .filter_map(rowan::NodeOrToken::into_token)
2739        .find(|t| !is_trivia_kind(t.kind()))
2740    else {
2741        return;
2742    };
2743    let content_start: usize = u32::from(content.text_range().start()) as usize;
2744    // Column = offset since the last NEWLINE in the source,
2745    // or since byte 0 if this is the first line. >0 means
2746    // the directive's first content token has leading WS on
2747    // its own line - that's the indent error.
2748    // Find the line start by scanning the BYTES before `content_start`, not by
2749    // slicing the `str`. On malformed/error-recovered input a token's start
2750    // offset can land inside a multi-byte UTF-8 char, and
2751    // `stripped[..content_start]` would then panic ("not a char boundary").
2752    // Byte slicing is boundary-agnostic, and a newline (`\n`) is always a single
2753    // ASCII byte, so the found position is a valid offset. `.get(..)` also guards
2754    // a (theoretical) out-of-bounds offset. Regression: fuzz_regressions.rs.
2755    let line_start = stripped
2756        .as_bytes()
2757        .get(..content_start)
2758        .and_then(|bytes| bytes.iter().rposition(|&b| b == b'\n'))
2759        .map_or(0, |nl| nl + 1);
2760    if content_start > line_start {
2761        let end: u32 = content.text_range().end().into();
2762        let span = Span::new(
2763            (line_start as u32 + bom_offset) as usize,
2764            (end + bom_offset) as usize,
2765        );
2766        out.push(crate::ParseError::new(
2767            crate::ParseErrorKind::SyntaxError(
2768                "top-level directive must start at column 0".to_string(),
2769            ),
2770            span,
2771        ));
2772    }
2773}
2774
2775/// Walk each `CUSTOM` directive and emit a `SyntaxError` for
2776/// every bare `CURRENCY` token in the value position (a CURRENCY
2777/// not paired with a preceding NUMBER as an Amount).
2778///
2779/// Per the Beancount language spec, custom-directive values are
2780/// limited to string / date / decimal / amount / boolean -
2781/// `bean-check` rejects a bare currency literal with a syntax
2782/// error. Rustledger's `extract_custom_values` has historically
2783/// been more lenient, accepting ACCOUNT / TAG / LINK in value
2784/// position too; we keep that extension (it's covered by the
2785/// existing `test_parse_custom_directive` integration test) but
2786/// surface a diagnostic for the bare-CURRENCY case so the
2787/// compat metric reflects bean-check's exit-code rejection on
2788/// shapes like `custom "x" 10 USD "y" NZD …`.
2789fn custom_value_check(
2790    child: &crate::SyntaxNode,
2791    bom_offset: u32,
2792    out: &mut Vec<crate::ParseError>,
2793) {
2794    // Caller dispatches: `child` is a CUSTOM_DIRECTIVE.
2795    {
2796        // Collect non-trivia tokens, then skip past the
2797        // directive's header: DATE, CUSTOM_KW, and the first
2798        // STRING (the custom-type name). Everything after that
2799        // is values.
2800        let raw: Vec<crate::SyntaxToken> = child
2801            .children_with_tokens()
2802            .filter_map(rowan::NodeOrToken::into_token)
2803            .filter(|t| !is_trivia_kind(t.kind()))
2804            .collect();
2805        let mut seen_type_string = false;
2806        let mut i = 0;
2807        while i < raw.len() {
2808            let t = &raw[i];
2809            if !seen_type_string {
2810                if t.kind() == crate::SyntaxKind::STRING {
2811                    seen_type_string = true;
2812                }
2813                i += 1;
2814                continue;
2815            }
2816            if t.kind() == crate::SyntaxKind::CURRENCY {
2817                // Only flag BARE CURRENCY - one that doesn't
2818                // follow a NUMBER (Amount-pairing). The Amount
2819                // pairing is handled by `extract_custom_values`
2820                // via i+1 lookahead, so a CURRENCY that's NOT
2821                // preceded by a NUMBER at i-1 is bare.
2822                let preceded_by_number = i > 0 && raw[i - 1].kind() == crate::SyntaxKind::NUMBER;
2823                if !preceded_by_number {
2824                    let range = t.text_range();
2825                    let start: u32 = range.start().into();
2826                    let end: u32 = range.end().into();
2827                    let span =
2828                        Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2829                    out.push(crate::ParseError::new(
2830                        crate::ParseErrorKind::SyntaxError(
2831                            "bare currency literal is not a valid custom directive value"
2832                                .to_string(),
2833                        ),
2834                        span,
2835                    ));
2836                }
2837            }
2838            i += 1;
2839        }
2840    }
2841}
2842
2843/// Walk a `TRANSACTION` body and emit a `SyntaxError` for any body
2844/// line that contains flat catch-all tokens (e.g., an
2845/// unrecognized identifier where a posting was expected).
2846/// Matches the legacy parser, which fails its inner posting
2847/// parser on such lines and recovers by skipping to the next
2848/// NEWLINE while emitting a `SyntaxError`.
2849/// The `unexpected input` diagnostic for one catch-all transaction-body line.
2850///
2851/// Mirrors `green::unexpected_body_input`; shared by the newline- and
2852/// EOF-terminated sites so the span rule cannot drift between them.
2853fn unexpected_body_input(line_start: u32, end: u32, bom_offset: u32) -> crate::ParseError {
2854    crate::ParseError::new(
2855        crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2856        Span::new(
2857            (line_start + bom_offset) as usize,
2858            (end + bom_offset) as usize,
2859        ),
2860    )
2861}
2862
2863/// Reject transaction headers beancount's grammar refuses (#2008 cases 3, 4,
2864/// 6, 7). The rule itself lives in [`super::txn_header`] so this and its green
2865/// mirror (`green::tl_transaction_header_check`) cannot drift; here we only
2866/// enumerate the header tokens.
2867///
2868/// Token enumeration goes through `Transaction::header_tokens`, the same
2869/// accessor `flag()` / `strings()` / `tags()` use, so "what counts as the
2870/// header" has one definition.
2871fn transaction_header_check(
2872    child: &crate::SyntaxNode,
2873    stripped: &str,
2874    bom_offset: u32,
2875    out: &mut Vec<crate::ParseError>,
2876) {
2877    let Some(txn) = ast::Transaction::cast(child.clone()) else {
2878        return;
2879    };
2880    let tokens = txn.header_tokens().map(|t| {
2881        let r = t.text_range();
2882        (t.kind(), usize::from(r.start())..usize::from(r.end()))
2883    });
2884    if let Some((defect, range)) = super::txn_header::first_header_defect(tokens) {
2885        out.push(header_defect_error(defect, &range, stripped, bom_offset));
2886    }
2887}
2888
2889/// Shared by both walkers so one defect cannot be reported two ways.
2890pub(super) fn header_defect_error(
2891    defect: super::txn_header::HeaderDefect,
2892    range: &std::ops::Range<usize>,
2893    stripped: &str,
2894    bom_offset: u32,
2895) -> crate::ParseError {
2896    // `get` rather than indexing: a range that is not a char boundary would
2897    // panic, and a parser must not panic on malformed input. Token ranges are
2898    // always char boundaries, so this is unreachable today — but an empty
2899    // slice would render as `unexpected "" in transaction header`, which names
2900    // nothing. Fall back to the byte range instead, so even the unreachable
2901    // branch produces something a reader can act on.
2902    let message = match stripped.get(range.clone()) {
2903        Some(text) => super::txn_header::defect_message(defect, text),
2904        None => format!(
2905            "malformed transaction header at bytes {}..{} ({defect:?})",
2906            range.start, range.end
2907        ),
2908    };
2909    crate::ParseError::new(
2910        crate::ParseErrorKind::SyntaxError(message),
2911        Span::new(
2912            range.start + bom_offset as usize,
2913            range.end + bom_offset as usize,
2914        ),
2915    )
2916}
2917
2918fn transaction_body_check(
2919    child: &crate::SyntaxNode,
2920    bom_offset: u32,
2921    out: &mut Vec<crate::ParseError>,
2922) {
2923    // Caller dispatches: `child` is a TRANSACTION.
2924    {
2925        // Skip past the header NEWLINE, then look for catch-all
2926        // tokens (non-trivia, non-comment) appearing on lines
2927        // OUTSIDE POSTING / META_ENTRY child nodes.
2928        // Track whether we've SEEN at least one non-trivia
2929        // header token (DATE / flag / STRING / etc.); only AFTER
2930        // that does the next NEWLINE count as the header
2931        // terminator. Otherwise leading-trivia NEWLINEs from the
2932        // Directive-Terminator Rule would falsely trip
2933        // past_header on the very first iteration.
2934        let mut past_header = false;
2935        let mut saw_header_content = false;
2936        let mut line_start: Option<u32> = None;
2937        let mut line_has_content = false;
2938        for el in child.children_with_tokens() {
2939            match el {
2940                rowan::NodeOrToken::Token(t) => {
2941                    if !past_header {
2942                        if t.kind() == crate::SyntaxKind::NEWLINE {
2943                            if saw_header_content {
2944                                past_header = true;
2945                            }
2946                        } else if !is_trivia_kind(t.kind()) {
2947                            saw_header_content = true;
2948                        }
2949                        continue;
2950                    }
2951                    let range = t.text_range();
2952                    let start: u32 = range.start().into();
2953                    let end: u32 = range.end().into();
2954                    if line_start.is_none() {
2955                        line_start = Some(start);
2956                    }
2957                    if t.kind() == crate::SyntaxKind::NEWLINE {
2958                        if line_has_content && let Some(ls) = line_start {
2959                            out.push(unexpected_body_input(ls, end, bom_offset));
2960                        }
2961                        line_start = None;
2962                        line_has_content = false;
2963                    } else if !is_trivia_kind(t.kind())
2964                        && !is_comment_kind(t.kind())
2965                        && !matches!(t.kind(), crate::SyntaxKind::TAG | crate::SyntaxKind::LINK)
2966                    {
2967                        // TAG / LINK on body lines is valid
2968                        // Beancount syntax (tags/links after the
2969                        // first line continue the transaction's
2970                        // tag/link list). Don't flag as
2971                        // unexpected-input.
2972                        line_has_content = true;
2973                    }
2974                }
2975                rowan::NodeOrToken::Node(_) => {
2976                    // POSTING / META_ENTRY: not catch-all. Reset.
2977                    line_start = None;
2978                    line_has_content = false;
2979                    if !past_header {
2980                        past_header = true;
2981                    }
2982                }
2983            }
2984        }
2985        // EOF terminates the final body line, same as a NEWLINE. Mirrors
2986        // `green::tl_transaction_body_check` (#1884).
2987        if past_header
2988            && line_has_content
2989            && let Some(ls) = line_start
2990        {
2991            let end: u32 = child.text_range().end().into();
2992            out.push(unexpected_body_input(ls, end, bom_offset));
2993        }
2994    }
2995}
2996
2997/// Walk an `ERROR_NODE` and emit a
2998/// `ParseError` for each line that is NEITHER a section marker
2999/// (`*`-starting) NOR a column-0 comment. The variant emitted
3000/// mirrors the legacy parser's error-recovery classifier
3001/// (`parser.rs:2186-2249`): BOM-in-line → `BomInDirectiveBody`
3002/// (with `BOM_REMOVAL_HINT`); Unicode-character account →
3003/// `InvalidAccount`; otherwise → `SyntaxError("unexpected
3004/// input")`. `stripped` is the post-BOM-strip source so token
3005/// `text_range` indices into it correctly.
3006/// Emit the recovery diagnostics for one `ERROR_NODE` line.
3007///
3008/// Mirrors `green::emit_error_node_line`, and exists for the same reason:
3009/// this runs from BOTH the newline-terminated and the EOF-terminated site, and
3010/// two inline copies is how the span rule or the secondary BOM diagnostic
3011/// drifts between them.
3012fn emit_error_node_line(
3013    first_non_trivia: Option<crate::SyntaxKind>,
3014    line_start: Option<u32>,
3015    end: u32,
3016    bom_offset: u32,
3017    stripped: &str,
3018    out: &mut Vec<crate::ParseError>,
3019) {
3020    let is_section = matches!(first_non_trivia, Some(crate::SyntaxKind::STAR));
3021    let is_comment = matches!(first_non_trivia, Some(k) if is_comment_kind(k));
3022    if is_section || is_comment || first_non_trivia.is_none() {
3023        return;
3024    }
3025    let Some(ls) = line_start else { return };
3026    // Legacy span INCLUDES the terminator NEWLINE (skip_to_newline consumes it
3027    // before span_from is called); at EOF the terminator is the node end.
3028    let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
3029    let line_text = stripped.get(ls as usize..end as usize).unwrap_or("");
3030    let primary = classify_recovery_error(line_text, span);
3031    let primary_is_bom = matches!(primary.kind, crate::ParseErrorKind::BomInDirectiveBody);
3032    out.push(primary);
3033    // Additive secondary `BomInDirectiveBody` when a different primary
3034    // diagnostic already fired AND the line ALSO contains a BOM byte. Matches
3035    // legacy `parser.rs:2258-2263`: without it, a Windows-exported line with
3036    // both problems surfaces only the actionable root cause and the user has no
3037    // clue the invisible BOM byte is also corrupting the line.
3038    if !primary_is_bom && line_text.contains(crate::bom::BOM_CHAR) {
3039        out.push(
3040            crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
3041                .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
3042        );
3043    }
3044}
3045
3046fn error_node_check(
3047    child: &crate::SyntaxNode,
3048    stripped: &str,
3049    bom_offset: u32,
3050    out: &mut Vec<crate::ParseError>,
3051) {
3052    // Caller dispatches: `child` is an ERROR_NODE.
3053    {
3054        let mut line_start: Option<u32> = None;
3055        let mut first_non_trivia: Option<crate::SyntaxKind> = None;
3056        for el in child.children_with_tokens() {
3057            let rowan::NodeOrToken::Token(t) = el else {
3058                continue;
3059            };
3060            let range = t.text_range();
3061            let start: u32 = range.start().into();
3062            let end: u32 = range.end().into();
3063            if line_start.is_none() {
3064                line_start = Some(start);
3065            }
3066            if t.kind() == crate::SyntaxKind::NEWLINE {
3067                emit_error_node_line(first_non_trivia, line_start, end, bom_offset, stripped, out);
3068                line_start = None;
3069                first_non_trivia = None;
3070                continue;
3071            }
3072            if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
3073                first_non_trivia = Some(t.kind());
3074            }
3075        }
3076        // EOF terminates the final line exactly as a NEWLINE would. Mirrors
3077        // `green::tl_error_node_check`; without it the two paths disagree on
3078        // any input lacking a trailing newline, and a malformed last line
3079        // produced no diagnostic at all (#1884).
3080        let end: u32 = child.text_range().end().into();
3081        emit_error_node_line(first_non_trivia, line_start, end, bom_offset, stripped, out);
3082    }
3083}
3084
3085/// Pick the most specific `ParseError` variant for an
3086/// error-recovery line, mirroring the legacy parser's classifier
3087/// at `parser.rs:2186-2249`:
3088/// 1. A Unicode-character account (`Assets:Café:…`) → primary
3089///    `InvalidAccount` - it's the actionable root cause.
3090/// 2. A mid-file BOM byte (`U+FEFF`) → `BomInDirectiveBody` with
3091///    `BOM_REMOVAL_HINT` so miette surfaces the remediation step.
3092/// 3. Anything else → `SyntaxError("unexpected input")`.
3093///
3094/// Order matters: a Windows-exported file with a Unicode account
3095/// AND an internal BOM gets the Unicode-account diagnostic
3096/// (the BOM is usually a side effect, not the root cause).
3097pub(super) fn classify_recovery_error(line_text: &str, span: Span) -> crate::ParseError {
3098    if let Some(account) = crate::diagnostics::find_unicode_account(line_text) {
3099        return crate::ParseError::new(
3100            crate::ParseErrorKind::InvalidAccount(account.to_string()),
3101            span,
3102        );
3103    }
3104    if line_text.contains(crate::bom::BOM_CHAR) {
3105        return crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
3106            .with_hint(crate::diagnostics::BOM_REMOVAL_HINT);
3107    }
3108    crate::ParseError::new(
3109        crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
3110        span,
3111    )
3112}
3113
3114/// Walk every descendant token and emit a `ParseError` for each
3115/// `ERROR_TOKEN` (or BOM-containing token) that lands inside an
3116/// otherwise-valid directive node - i.e., NOT inside an
3117/// `ERROR_NODE` ancestor. Catches lexer-reject bytes the
3118/// outer recovery path misses:
3119/// - `.` in `.50 USD` (leading-decimal in posting amount) →
3120///   `SyntaxError`.
3121/// - Mid-file U+FEFF byte inside a recognized directive (e.g.,
3122///   `open Assets:Bank \u{FEFF}USD`) → `BomInDirectiveBody` with
3123///   `BOM_REMOVAL_HINT`.
3124///
3125/// The leading `SyntaxKind::BOM` token is skipped (the
3126/// legitimate strict-byte-0 BOM is already tracked by
3127/// `has_leading_bom`). `ERROR_NODE` descendants are skipped -
3128/// `extract_error_node_errors` / `classify_recovery_error`
3129/// already cover those.
3130/// Result of the fused descendants-walk visitor that powers
3131/// `walk_descendants_once`.
3132pub(super) struct DescendantsWalkResult {
3133    pub(super) inline_errors: Vec<crate::ParseError>,
3134    pub(super) top_level_comments: Vec<Spanned<String>>,
3135    pub(super) currency_occurrences: Vec<Spanned<Currency>>,
3136    pub(super) account_occurrences: Vec<Spanned<rustledger_core::Account>>,
3137    /// The three per-node shape rules, kept in SEPARATE vecs rather than
3138    /// merged into `inline_errors`.
3139    ///
3140    /// Two reasons. They are emitted at a different point in the error order
3141    /// than the inline errors (see `parse_via_cst_inner`), and each vec stays
3142    /// grouped the way its former standalone pass emitted it — document order
3143    /// within a rule, rules in a fixed sequence. Merging them would interleave
3144    /// the three by position, which no test pins today but which is
3145    /// observable in every diagnostic list rledger prints.
3146    ///
3147    /// Populated only by the GREEN walker. The red walker leaves them empty
3148    /// and `parse_via_cst_inner` calls the standalone `extract_*` functions
3149    /// for that path instead — see the call site for why the fold is
3150    /// green-only.
3151    pub(super) cost_brace_errors: Vec<crate::ParseError>,
3152    pub(super) link_meta_errors: Vec<crate::ParseError>,
3153    pub(super) custom_pushmeta_errors: Vec<crate::ParseError>,
3154}
3155
3156/// Fused single-pass visitor over `source_file`'s descendants -
3157/// replaces three separate walks (`extract_inline_token_errors`,
3158/// `extract_top_level_comments`, `extract_currency_occurrences`)
3159/// with one traversal. Each walk had its own per-token cost; the
3160/// LSP runs them on every keystroke, so collapsing 3·O(N) → 1·O(N)
3161/// matters at editor-edge latencies. The state of each former
3162/// walk is maintained inline below.
3163fn walk_descendants_once(
3164    source_file: &SourceFile,
3165    bom_offset: u32,
3166    collect_occurrences: bool,
3167) -> DescendantsWalkResult {
3168    let mut inline_errors: Vec<crate::ParseError> = Vec::new();
3169    let mut top_level_comments: Vec<Spanned<String>> = Vec::new();
3170    let mut currency_occurrences: Vec<Spanned<Currency>> = Vec::new();
3171    let mut account_occurrences: Vec<Spanned<rustledger_core::Account>> = Vec::new();
3172
3173    // `extract_top_level_comments` state: column-0 tracking.
3174    let mut preceded_by_ws = false;
3175
3176    for el in source_file.syntax().descendants_with_tokens() {
3177        let rowan::NodeOrToken::Token(t) = el else {
3178            // `extract_top_level_comments` used the Node arm to
3179            // reset preceded_by_ws when entering a recognized
3180            // directive. Keep that behavior - directive leading
3181            // trivia still gets column-0-classified correctly.
3182            if let rowan::NodeOrToken::Node(n) = el
3183                && ast::Directive::can_cast(n.kind())
3184            {
3185                preceded_by_ws = false;
3186            }
3187            continue;
3188        };
3189
3190        // ---- `extract_top_level_comments` state machine -------
3191        match t.kind() {
3192            crate::SyntaxKind::NEWLINE => preceded_by_ws = false,
3193            crate::SyntaxKind::WHITESPACE => preceded_by_ws = true,
3194            k if is_comment_kind(k) => {
3195                if !preceded_by_ws {
3196                    let range = t.text_range();
3197                    let start: u32 = range.start().into();
3198                    let end: u32 = range.end().into();
3199                    let span =
3200                        Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
3201                    top_level_comments.push(Spanned::new(t.text().to_string(), span));
3202                }
3203            }
3204            _ => {
3205                preceded_by_ws = false;
3206            }
3207        }
3208
3209        // ---- `extract_inline_token_errors` + currency walks ---
3210        if t.kind() == crate::SyntaxKind::BOM {
3211            continue;
3212        }
3213        // ERROR_NODE-ancestor check is only consulted for tokens
3214        // whose downstream emission depends on it (CURRENCY, BOM-
3215        // text-containing, ERROR_TOKEN). For well-formed source
3216        // most tokens fall into none of those buckets - gating
3217        // the per-token `parent_ancestors` walk on relevance
3218        // saves an O(depth) probe per WHITESPACE/NEWLINE/comment
3219        // token, which dominates token counts in real ledgers.
3220        let kind = t.kind();
3221        let has_bom = t.text().contains(crate::bom::BOM_CHAR);
3222        let is_error_token = kind == crate::SyntaxKind::ERROR_TOKEN;
3223        // CURRENCY/ACCOUNT need the in-ERROR_NODE probe only to decide whether
3224        // to record an occurrence; skip it entirely when not collecting.
3225        let needs_in_error_check = (collect_occurrences
3226            && matches!(
3227                kind,
3228                crate::SyntaxKind::CURRENCY | crate::SyntaxKind::ACCOUNT
3229            ))
3230            || has_bom
3231            || is_error_token;
3232        if !needs_in_error_check {
3233            continue;
3234        }
3235        let in_error_node = t
3236            .parent_ancestors()
3237            .any(|a| a.kind() == crate::SyntaxKind::ERROR_NODE);
3238
3239        // CURRENCY occurrences: only outside ERROR_NODE, and only when the
3240        // caller wants them (LSP path).
3241        if collect_occurrences && kind == crate::SyntaxKind::CURRENCY && !in_error_node {
3242            let range = t.text_range();
3243            let start: u32 = range.start().into();
3244            let end: u32 = range.end().into();
3245            let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
3246            currency_occurrences.push(Spanned::new(Currency::new(t.text()), span));
3247        }
3248
3249        // ACCOUNT occurrences: only outside ERROR_NODE. The same
3250        // rationale as CURRENCY applies - the lexer classifies an
3251        // `ACCOUNT` token by its character shape independent of
3252        // whether the surrounding directive parses cleanly, and
3253        // source-position-aware tooling (LSP rename / references /
3254        // document-highlight) wants the token as the user typed it
3255        // even during a mid-edit broken state.
3256        if collect_occurrences && kind == crate::SyntaxKind::ACCOUNT && !in_error_node {
3257            let range = t.text_range();
3258            let start: u32 = range.start().into();
3259            let end: u32 = range.end().into();
3260            let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
3261            account_occurrences.push(Spanned::new(rustledger_core::Account::new(t.text()), span));
3262        }
3263
3264        // Inline errors: BOM byte in a recognized directive
3265        // (-> BomInDirectiveBody + hint) or ERROR_TOKEN inside a
3266        // recognized directive (-> SyntaxError). Both skip when
3267        // already inside an ERROR_NODE (handled by the recovery
3268        // classifier).
3269        if (!has_bom && !is_error_token) || in_error_node {
3270            continue;
3271        }
3272        let range = t.text_range();
3273        let start: u32 = range.start().into();
3274        let end: u32 = range.end().into();
3275        let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
3276        if has_bom {
3277            inline_errors.push(
3278                crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
3279                    .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
3280            );
3281        } else {
3282            inline_errors.push(crate::ParseError::new(
3283                crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
3284                span,
3285            ));
3286        }
3287    }
3288
3289    DescendantsWalkResult {
3290        inline_errors,
3291        top_level_comments,
3292        currency_occurrences,
3293        account_occurrences,
3294        // Red keeps the three shape rules as standalone `extract_*` passes;
3295        // `parse_via_cst_inner` calls them for this path. Folding them in here
3296        // too would need each token's IMMEDIATE parent kind, and this walk is
3297        // a flat `descendants_with_tokens()` — recovering the parent means
3298        // `t.parent()`, which allocates the very red `NodeData` the green fold
3299        // exists to avoid.
3300        cost_brace_errors: Vec::new(),
3301        link_meta_errors: Vec::new(),
3302        custom_pushmeta_errors: Vec::new(),
3303    }
3304}
3305
3306/// Emit empty-string comments for org-mode section-marker
3307/// lines (`* Heading`, `** Subheading`) inside `ERROR_NODE`
3308/// children. The legacy parser's `parse_entry` matches
3309/// `Token::Star` and emits `Comment(String::new(), line_span)`;
3310/// the structured CST wraps these lines in `ERROR_NODE`s so we
3311/// have to walk them and synthesize the same shape.
3312fn section_marker_check(
3313    child: &crate::SyntaxNode,
3314    bom_offset: u32,
3315    out: &mut Vec<Spanned<String>>,
3316) {
3317    // Caller dispatches: `child` is an ERROR_NODE.
3318    // Walk tokens line-by-line. A line starts at the start
3319    // of the first token after a NEWLINE (or at the node's
3320    // start) and ends at the next NEWLINE (inclusive).
3321    let mut line_start: Option<u32> = None;
3322    let mut first_non_trivia: Option<crate::SyntaxKind> = None;
3323    for el in child.children_with_tokens() {
3324        let rowan::NodeOrToken::Token(t) = el else {
3325            continue;
3326        };
3327        let range = t.text_range();
3328        let start: u32 = range.start().into();
3329        let end: u32 = range.end().into();
3330        if line_start.is_none() {
3331            line_start = Some(start);
3332        }
3333        if t.kind() == crate::SyntaxKind::NEWLINE {
3334            if first_non_trivia == Some(crate::SyntaxKind::STAR)
3335                && let Some(ls) = line_start
3336            {
3337                let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
3338                out.push(Spanned::new(String::new(), span));
3339            }
3340            line_start = None;
3341            first_non_trivia = None;
3342            continue;
3343        }
3344        if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
3345            first_non_trivia = Some(t.kind());
3346        }
3347    }
3348    // EOF terminates the final line. Mirrors `green::tl_section_marker_check`.
3349    if first_non_trivia == Some(crate::SyntaxKind::STAR)
3350        && let Some(ls) = line_start
3351    {
3352        let end: u32 = child.text_range().end().into();
3353        let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
3354        out.push(Spanned::new(String::new(), span));
3355    }
3356}
3357
3358// `extract_top_level_comments` and `extract_currency_occurrences`
3359// are folded into `walk_descendants_once` above - see the
3360// comments there for the column-0 / ERROR_NODE-exclusion rules.
3361
3362// ---- Token parsing helpers -------------------------------------
3363
3364/// Parse a date token, accepting the same shapes as the legacy
3365/// parser: canonical `YYYY-MM-DD`, slash-separated `YYYY/M/D`,
3366/// and single-digit month/day. Returns `None` when the token
3367/// can't be turned into a real calendar date (invalid month,
3368/// invalid day for the given month, etc.).
3369pub(super) fn parse_date_token(text: &str) -> Option<NaiveDate> {
3370    // Fast path: canonical "YYYY-MM-DD".
3371    if text.len() == 10
3372        && text.as_bytes()[4] == b'-'
3373        && text.as_bytes()[7] == b'-'
3374        && let (Ok(y), Ok(m), Ok(d)) = (
3375            text[0..4].parse::<i32>(),
3376            text[5..7].parse::<u32>(),
3377            text[8..10].parse::<u32>(),
3378        )
3379    {
3380        return naive_date(y, m, d);
3381    }
3382    // Slow path: share legacy's normalizer so single-digit
3383    // month/day (`2024-1-15`, `2024-01-5`) and slash separators
3384    // are accepted everywhere the legacy parser accepts them.
3385    crate::diagnostics::normalize_date_str(text)
3386        .parse::<NaiveDate>()
3387        .ok()
3388}
3389
3390/// Parse a directive's `DATE` token. On success returns the
3391/// `NaiveDate`; on a token whose calendar values don't form a
3392/// real date (`2024-13-01`, Feb 29 in a non-leap year) emits
3393/// `InvalidDateValue` with the legacy parser's human-readable
3394/// message and returns `None`. This mirrors
3395/// `parser.rs:181-182` so the CST and legacy parsers surface the
3396/// same diagnostics for malformed dates in directive position.
3397fn parse_directive_date(
3398    date_tok: &ast::Date,
3399    errors: &mut Vec<crate::ParseError>,
3400    bom_offset: u32,
3401) -> Option<NaiveDate> {
3402    let text = date_tok.text();
3403    if let Some(d) = parse_date_token(text) {
3404        return Some(d);
3405    }
3406    let range = date_tok.syntax().text_range();
3407    let start: u32 = range.start().into();
3408    let end: u32 = range.end().into();
3409    let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
3410    errors.push(crate::ParseError::new(
3411        crate::ParseErrorKind::InvalidDateValue(crate::diagnostics::describe_invalid_date(text)),
3412        span,
3413    ));
3414    None
3415}
3416
3417/// Decode a `STRING` token's text (with surrounding quotes) into its semantic
3418/// value: quotes stripped, escapes decoded (`\"`→`"`, `\\`→`\`, `\n`/`\t`/`\r`,
3419/// unknown escape drops the backslash). `None` if not a well-formed quoted
3420/// string. Text-based so both the red ([`ast::StringLit::text_decoded`]) and
3421/// green conversion paths share one source of truth.
3422pub(super) fn decode_string_token(text: &str) -> Option<String> {
3423    let bytes = text.as_bytes();
3424    if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
3425        return None;
3426    }
3427    let raw = &text[1..text.len() - 1];
3428    if !raw.contains('\\') {
3429        return Some(raw.to_string());
3430    }
3431    let mut out = String::with_capacity(raw.len());
3432    let mut chars = raw.chars();
3433    while let Some(c) = chars.next() {
3434        if c != '\\' {
3435            out.push(c);
3436            continue;
3437        }
3438        match chars.next() {
3439            Some('"') => out.push('"'),
3440            Some('\\') => out.push('\\'),
3441            Some('n') => out.push('\n'),
3442            Some('t') => out.push('\t'),
3443            Some('r') => out.push('\r'),
3444            Some(other) => out.push(other),
3445            None => {}
3446        }
3447    }
3448    Some(out)
3449}
3450
3451/// Parse a numeric token. Tolerates leading sign and thousands-
3452/// separator commas (legacy parser drops them).
3453pub(super) fn parse_decimal_token(text: &str) -> Option<Decimal> {
3454    use std::str::FromStr;
3455    let cleaned: String;
3456    let s = if text.contains(',') {
3457        cleaned = text.replace(',', "");
3458        cleaned.as_str()
3459    } else {
3460        text
3461    };
3462    Decimal::from_str(s).ok()
3463}
3464
3465/// Choose `Int` vs `Number` for a numeric metadata literal.
3466///
3467/// Beancount represents integer metadata (`key: 42`) as an int and decimal
3468/// metadata (`key: 42.0`) as a `Decimal`. `text` is the original (unsigned)
3469/// NUMBER token: a literal with no `.` or exponent that fits in `i64` becomes
3470/// `Int`; decimals, exponents, and out-of-range integers stay `Number`. `value`
3471/// is the parsed (and sign-applied) magnitude. (Thousands-separator commas are
3472/// irrelevant to integer-ness, so they aren't stripped before the check.)
3473pub(super) fn number_meta_value(text: &str, value: Decimal) -> MetaValue {
3474    use rust_decimal::prelude::ToPrimitive;
3475    if !text.contains('.')
3476        && !text.contains('e')
3477        && !text.contains('E')
3478        && let Some(i) = value.to_i64()
3479    {
3480        return MetaValue::Int(i);
3481    }
3482    MetaValue::Number(value)
3483}
3484
3485// ---- Span helpers ----------------------------------------------
3486
3487/// Convert a CST node's [`rowan::TextRange`] (relative to the
3488/// post-BOM source frame) into a [`Span`] in the original-source
3489/// frame.
3490fn node_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
3491    let range = node.text_range();
3492    let start: u32 = range.start().into();
3493    let end: u32 = range.end().into();
3494    Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
3495}
3496
3497/// Trivia kinds that don't count toward a span's start/end when
3498/// matching the legacy parser's span convention.
3499///
3500/// Covers WHITESPACE / NEWLINE plus EVERY comment-trivia kind
3501/// (`COMMENT`, `PERCENT_COMMENT`, `SHEBANG`, `EMACS_DIRECTIVE`)
3502/// so files with ledger-style `%` comments or org-mode
3503/// `#!`/`#+` lines have the same span/header-tracking behavior
3504/// as files with only `;` comments. Mirrors
3505/// `SyntaxKind::is_trivia()` minus `BOM` - a mid-file BOM byte
3506/// is an error to surface (`extract_inline_token_errors` /
3507/// `classify_recovery_error`), not trivia to silently skip.
3508pub(super) const fn is_trivia_kind(kind: crate::SyntaxKind) -> bool {
3509    matches!(
3510        kind,
3511        crate::SyntaxKind::WHITESPACE
3512            | crate::SyntaxKind::NEWLINE
3513            | crate::SyntaxKind::COMMENT
3514            | crate::SyntaxKind::PERCENT_COMMENT
3515            | crate::SyntaxKind::SHEBANG
3516            | crate::SyntaxKind::EMACS_DIRECTIVE
3517    )
3518}
3519
3520/// Span policy for `Posting`: the legacy parser ends the posting
3521/// span at the position just before the line's terminating
3522/// NEWLINE. The CST node's range INCLUDES the terminator
3523/// NEWLINE; trim it by using the NEWLINE token's start position.
3524/// We look at the FIRST direct-child NEWLINE token because
3525/// posting-attached metadata sub-lines (which have their own
3526/// inner NEWLINEs) come after the line terminator and shouldn't
3527/// extend the posting-line span.
3528fn posting_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
3529    let range = node.text_range();
3530    let start: u32 = range.start().into();
3531    let end_raw: u32 = range.end().into();
3532    // Postings have no inter-directive leading trivia: their
3533    // first direct-child NEWLINE IS the terminator.
3534    let end = node
3535        .children_with_tokens()
3536        .filter_map(rowan::NodeOrToken::into_token)
3537        .find(|t| t.kind() == crate::SyntaxKind::NEWLINE)
3538        .map_or(end_raw, |t| u32::from(t.text_range().start()));
3539    Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
3540}
3541
3542/// Span policy for non-Directive single-line constructs that
3543/// participate in inter-directive trivia attachment (Option,
3544/// Include, Plugin). Unlike Posting these may have leading
3545/// trivia (blank-line NEWLINEs, comments) inside the node from
3546/// the Directive-Terminator Rule. Start at the first non-trivia
3547/// content token; end at the first NEWLINE after that.
3548fn single_line_directive_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
3549    let range = node.text_range();
3550    let start_raw: u32 = range.start().into();
3551    let end_raw: u32 = range.end().into();
3552    let mut content_start: Option<u32> = None;
3553    let mut terminator: Option<u32> = None;
3554    for t in node
3555        .children_with_tokens()
3556        .filter_map(rowan::NodeOrToken::into_token)
3557    {
3558        if content_start.is_none() {
3559            if !is_trivia_kind(t.kind()) {
3560                content_start = Some(u32::from(t.text_range().start()));
3561            }
3562        } else if t.kind() == crate::SyntaxKind::NEWLINE {
3563            terminator = Some(u32::from(t.text_range().start()));
3564            break;
3565        }
3566    }
3567    let start = content_start.unwrap_or(start_raw);
3568    let end = terminator.unwrap_or(end_raw);
3569    Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
3570}
3571
3572/// Span policy for top-level directives: legacy directives start
3573/// at the first content character (skipping leading trivia from
3574/// the Directive-Terminator Rule) and extend through any
3575/// inter-directive trivia up to where the NEXT directive begins.
3576/// Computed in a post-pass since each directive's end depends on
3577/// the next one's start.
3578fn fixup_directive_spans(
3579    source_file: &SourceFile,
3580    bom_offset: u32,
3581    converted_nodes: &[crate::SyntaxNode],
3582    directives: &mut [Spanned<Directive>],
3583) {
3584    debug_assert_eq!(
3585        converted_nodes.len(),
3586        directives.len(),
3587        "converted_nodes and directives must be parallel arrays"
3588    );
3589
3590    // Walk EVERY top-level Directive-castable child (including
3591    // pushtag/poptag/pushmeta/popmeta that we filter out of the
3592    // ParseResult) so the "next directive's start" boundary used
3593    // for span end-fixup matches the legacy parser: there, each
3594    // visible directive's span ends at the next /input/
3595    // directive's start, regardless of whether that next
3596    // directive is preserved.
3597    let all_starts: Vec<(usize, usize)> = source_file
3598        .syntax()
3599        .children()
3600        .filter(|n| ast::Directive::can_cast(n.kind()))
3601        .map(|n| {
3602            let raw_start: u32 = n.text_range().start().into();
3603            let content_start = n
3604                .descendants_with_tokens()
3605                .filter_map(rowan::NodeOrToken::into_token)
3606                .find(|t| !is_trivia_kind(t.kind()))
3607                .map_or_else(
3608                    || (raw_start + bom_offset) as usize,
3609                    |t| (u32::from(t.text_range().start()) + bom_offset) as usize,
3610                );
3611            ((raw_start + bom_offset) as usize, content_start)
3612        })
3613        .collect();
3614
3615    // `all_starts` is built from `children()`, i.e. siblings in document
3616    // order, and sibling text ranges are disjoint and increasing — so it is
3617    // sorted ascending on `raw_start` and every key is unique. The lookup
3618    // below binary-searches on that.
3619    debug_assert!(
3620        all_starts.windows(2).all(|w| w[0].0 < w[1].0),
3621        "all_starts must be strictly ascending by raw_start for the binary \
3622         search below; sibling text ranges are disjoint and increasing, so a \
3623         failure here means the enumeration is no longer document-ordered",
3624    );
3625
3626    let source_end: usize =
3627        (u32::from(source_file.syntax().text_range().end()) + bom_offset) as usize;
3628
3629    // For each converted directive, find its position in the all
3630    // list by raw_start (which is unique per CST node), then use
3631    // the NEXT all_starts content_start as its span end.
3632    //
3633    // INVARIANT: every node in `converted_nodes` was yielded by
3634    // `source_file.directives()`, which is the same iteration
3635    // `all_starts` filters from. So `position` always succeeds in
3636    // well-formed input. Falling back to the node's own
3637    // `text_range` rather than panicking keeps the parser usable
3638    // when a future change to the typed-AST surface ever de-syncs
3639    // those two enumerations - a `panic!()` reachable from user
3640    // input is a `#![forbid(unsafe_code)]`-class regression for an
3641    // LSP/WASM consumer.
3642    for (i, spanned) in directives.iter_mut().enumerate() {
3643        let node = &converted_nodes[i];
3644        let raw_start: usize = (u32::from(node.text_range().start()) + bom_offset) as usize;
3645        let node_end: usize = (u32::from(node.text_range().end()) + bom_offset) as usize;
3646        // Binary search, NOT a linear `position()` scan. This loop runs once
3647        // per directive over an `all_starts` that has one entry per
3648        // directive, so a linear probe made span fixup O(N^2) in the
3649        // directive count. Measured on the `simple` profiling shape: 10x the
3650        // transactions cost 21.8x the instructions, and cachegrind put ~40%
3651        // of a 20k-transaction run inside this one scan and its slice-iterator
3652        // internals. It is invisible on small inputs, which is why it sat
3653        // here — at 2k transactions the same scan is under 2%.
3654        //
3655        // Semantics are unchanged: keys are unique (see the debug_assert
3656        // above), so where `position` found the sole match, `binary_search`
3657        // finds the same one, and a miss still falls through to the
3658        // defensive branch below rather than panicking.
3659        if let Ok(pos) = all_starts.binary_search_by_key(&raw_start, |(rs, _)| *rs) {
3660            let start = all_starts[pos].1;
3661            let end = all_starts
3662                .get(pos + 1)
3663                .map_or(source_end, |(_, content)| *content);
3664            spanned.span = Span::new(start, end);
3665        } else {
3666            // Defensive fallback: match the success-path
3667            // convention by also trimming leading trivia. Without
3668            // this trim the fallback span would underline blank
3669            // lines / column-0 comments above the directive when
3670            // LSP/miette renders the diagnostic, even though the
3671            // directive itself starts further down.
3672            let content_start = node
3673                .descendants_with_tokens()
3674                .filter_map(rowan::NodeOrToken::into_token)
3675                .find(|t| !is_trivia_kind(t.kind()))
3676                .map_or(raw_start, |t| {
3677                    (u32::from(t.text_range().start()) + bom_offset) as usize
3678                });
3679            spanned.span = Span::new(content_start, node_end);
3680        }
3681    }
3682}
3683
3684#[cfg(test)]
3685mod tests {
3686    use super::*;
3687
3688    /// A directive's span ends at the next *input* directive's content start,
3689    /// INCLUDING directives filtered out of the `ParseResult`
3690    /// (`pushtag`/`poptag`/`pushmeta`/`popmeta`).
3691    ///
3692    /// This is the case that makes `all_starts` longer than `directives`, so
3693    /// it is the one that breaks if the lookup ever stops keying on the CST
3694    /// node's own start — e.g. "optimizing" the binary search into an index
3695    /// into `directives`, which would end the `open` span at the transaction
3696    /// instead of at the `pushtag` between them.
3697    #[test]
3698    fn spans_end_at_the_next_input_directive_even_when_it_is_filtered_out() {
3699        let src = "2024-01-01 open Assets:Bank USD\n\
3700                   pushtag #trip\n\
3701                   2024-01-02 * \"a\"\n  Assets:Bank 1 USD\n  Assets:Other\n\
3702                   poptag #trip\n\
3703                   2024-01-03 close Assets:Bank\n";
3704        let parsed = crate::parse(src);
3705
3706        let at = |needle: &str| src.find(needle).expect("fixture contains it");
3707        let spans: Vec<(usize, usize)> = parsed
3708            .directives
3709            .iter()
3710            .map(|d| (d.span.start, d.span.end))
3711            .collect();
3712
3713        assert_eq!(
3714            spans,
3715            vec![
3716                // `open` stops at `pushtag`, NOT at the transaction.
3717                (0, at("pushtag")),
3718                // the transaction stops at `poptag`, NOT at `close`.
3719                (at("2024-01-02"), at("poptag")),
3720                // the last directive runs to end of source.
3721                (at("2024-01-03"), src.len()),
3722            ],
3723            "pushtag/poptag are filtered from `directives` but still bound the \
3724             preceding directive's span",
3725        );
3726    }
3727
3728    /// Match a `SyntaxError` by message prefix rather than by `Debug` output.
3729    /// The `Debug` rendering of `ParseErrorKind` can change without any
3730    /// semantic change, and an assertion that reads it would fail for no
3731    /// reason; the variant plus the message prefix is the real contract.
3732    fn has_syntax_error(result: &ParseResult, prefix: &str) -> bool {
3733        result.errors.iter().any(
3734            |e| matches!(&e.kind, crate::ParseErrorKind::SyntaxError(m) if m.starts_with(prefix)),
3735        )
3736    }
3737
3738    fn assert_directive_count(result: &ParseResult, expected: usize) {
3739        assert_eq!(
3740            result.directives.len(),
3741            expected,
3742            "directive count mismatch: {:#?}",
3743            result.directives
3744        );
3745    }
3746
3747    #[test]
3748    fn open_directive_basic() {
3749        let src = "2024-01-15 open Assets:Cash\n";
3750        let result = parse_via_cst(src);
3751        assert_directive_count(&result, 1);
3752        let Directive::Open(open) = &result.directives[0].value else {
3753            panic!("expected Open, got {:?}", result.directives[0].value);
3754        };
3755        assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
3756        assert_eq!(open.account.as_str(), "Assets:Cash");
3757        assert!(open.currencies.is_empty());
3758        assert!(open.booking.is_none());
3759        assert!(open.meta.is_empty());
3760    }
3761
3762    #[test]
3763    fn open_directive_with_currencies_and_booking() {
3764        let src = "2024-01-15 open Assets:Brokerage USD,EUR \"STRICT\"\n";
3765        let result = parse_via_cst(src);
3766        assert_directive_count(&result, 1);
3767        let Directive::Open(open) = &result.directives[0].value else {
3768            panic!("expected Open");
3769        };
3770        let currencies: Vec<&str> = open.currencies.iter().map(Currency::as_str).collect();
3771        assert_eq!(currencies, vec!["USD", "EUR"]);
3772        assert_eq!(open.booking.as_deref(), Some("STRICT"));
3773    }
3774
3775    #[test]
3776    fn open_directive_with_metadata() {
3777        let src = "2024-01-15 open Assets:Cash\n  note: \"main checking\"\n  number: 42\n";
3778        let result = parse_via_cst(src);
3779        assert_directive_count(&result, 1);
3780        let Directive::Open(open) = &result.directives[0].value else {
3781            panic!("expected Open");
3782        };
3783        assert_eq!(
3784            open.meta.get("note"),
3785            Some(&MetaValue::String("main checking".to_string()))
3786        );
3787        assert_eq!(
3788            open.meta.get("number"),
3789            // Unquoted integer metadata is now `Int`, not `Number`.
3790            Some(&MetaValue::Int(42))
3791        );
3792    }
3793
3794    #[test]
3795    fn close_directive_basic() {
3796        let src = "2024-12-31 close Assets:Cash\n";
3797        let result = parse_via_cst(src);
3798        assert_directive_count(&result, 1);
3799        let Directive::Close(close) = &result.directives[0].value else {
3800            panic!("expected Close, got {:?}", result.directives[0].value);
3801        };
3802        assert_eq!(close.date, naive_date(2024, 12, 31).unwrap());
3803        assert_eq!(close.account.as_str(), "Assets:Cash");
3804    }
3805
3806    #[test]
3807    fn commodity_directive_basic() {
3808        let src = "2024-01-01 commodity HOOL\n";
3809        let result = parse_via_cst(src);
3810        assert_directive_count(&result, 1);
3811        let Directive::Commodity(c) = &result.directives[0].value else {
3812            panic!("expected Commodity");
3813        };
3814        assert_eq!(c.currency.as_str(), "HOOL");
3815    }
3816
3817    #[test]
3818    fn bom_offset_is_included_in_spans() {
3819        let src = "\u{FEFF}2024-01-15 open Assets:Cash\n";
3820        let result = parse_via_cst(src);
3821        assert!(result.has_leading_bom);
3822        let span = result.directives[0].span;
3823        assert_eq!(span.start, 3, "span should include BOM offset");
3824    }
3825
3826    #[test]
3827    fn note_directive_basic() {
3828        let src = "2024-01-15 note Assets:Cash \"deposit received\"\n";
3829        let result = parse_via_cst(src);
3830        assert_directive_count(&result, 1);
3831        let Directive::Note(note) = &result.directives[0].value else {
3832            panic!("expected Note");
3833        };
3834        assert_eq!(note.date, naive_date(2024, 1, 15).unwrap());
3835        assert_eq!(note.account.as_str(), "Assets:Cash");
3836        assert_eq!(note.comment, "deposit received");
3837    }
3838
3839    #[test]
3840    fn document_directive_basic() {
3841        let src = "2024-01-15 document Assets:Cash \"/path/to/file.pdf\"\n";
3842        let result = parse_via_cst(src);
3843        assert_directive_count(&result, 1);
3844        let Directive::Document(d) = &result.directives[0].value else {
3845            panic!("expected Document");
3846        };
3847        assert_eq!(d.account.as_str(), "Assets:Cash");
3848        assert_eq!(d.path, "/path/to/file.pdf");
3849        // tags/links currently unimplemented - pin as empty.
3850        assert!(d.tags.is_empty());
3851        assert!(d.links.is_empty());
3852    }
3853
3854    #[test]
3855    fn event_directive_basic() {
3856        let src = "2024-01-15 event \"location\" \"Berlin\"\n";
3857        let result = parse_via_cst(src);
3858        assert_directive_count(&result, 1);
3859        let Directive::Event(e) = &result.directives[0].value else {
3860            panic!("expected Event");
3861        };
3862        assert_eq!(e.event_type, "location");
3863        assert_eq!(e.value, "Berlin");
3864    }
3865
3866    #[test]
3867    fn query_directive_basic() {
3868        let src = "2024-01-15 query \"income\" \"SELECT account, sum(position)\"\n";
3869        let result = parse_via_cst(src);
3870        assert_directive_count(&result, 1);
3871        let Directive::Query(q) = &result.directives[0].value else {
3872            panic!("expected Query");
3873        };
3874        assert_eq!(q.name, "income");
3875        assert_eq!(q.query, "SELECT account, sum(position)");
3876    }
3877
3878    #[test]
3879    fn price_directive_basic() {
3880        let src = "2024-01-15 price USD 1.10 EUR\n";
3881        let result = parse_via_cst(src);
3882        assert_directive_count(&result, 1);
3883        let Directive::Price(p) = &result.directives[0].value else {
3884            panic!("expected Price");
3885        };
3886        assert_eq!(p.currency.as_str(), "USD");
3887        assert_eq!(p.amount.number, Decimal::new(110, 2));
3888        assert_eq!(p.amount.currency.as_str(), "EUR");
3889    }
3890
3891    #[test]
3892    fn balance_directive_basic() {
3893        let src = "2024-06-30 balance Assets:Cash 100.00 USD\n";
3894        let result = parse_via_cst(src);
3895        assert_directive_count(&result, 1);
3896        let Directive::Balance(b) = &result.directives[0].value else {
3897            panic!("expected Balance");
3898        };
3899        assert_eq!(b.account.as_str(), "Assets:Cash");
3900        assert_eq!(b.amount.number, Decimal::new(10000, 2));
3901        assert_eq!(b.amount.currency.as_str(), "USD");
3902        assert!(b.tolerance.is_none());
3903    }
3904
3905    #[test]
3906    fn balance_directive_with_explicit_tolerance() {
3907        let src = "2024-06-30 balance Assets:Cash 100.00 ~ 0.05 USD\n";
3908        let result = parse_via_cst(src);
3909        assert_directive_count(&result, 1);
3910        let Directive::Balance(b) = &result.directives[0].value else {
3911            panic!("expected Balance");
3912        };
3913        assert_eq!(b.amount.number, Decimal::new(10000, 2));
3914        assert_eq!(b.tolerance, Some(Decimal::new(5, 2)));
3915    }
3916
3917    #[test]
3918    fn pad_directive_basic() {
3919        let src = "2024-01-01 pad Assets:Cash Equity:Opening-Balances\n";
3920        let result = parse_via_cst(src);
3921        assert_directive_count(&result, 1);
3922        let Directive::Pad(p) = &result.directives[0].value else {
3923            panic!("expected Pad");
3924        };
3925        assert_eq!(p.account.as_str(), "Assets:Cash");
3926        assert_eq!(p.source_account.as_str(), "Equity:Opening-Balances");
3927    }
3928
3929    #[test]
3930    fn custom_directive_basic() {
3931        let src = "2024-01-01 custom \"budget\" \"food\" 500 USD\n";
3932        let result = parse_via_cst(src);
3933        assert_directive_count(&result, 1);
3934        let Directive::Custom(c) = &result.directives[0].value else {
3935            panic!("expected Custom");
3936        };
3937        assert_eq!(c.custom_type, "budget");
3938        assert_eq!(c.values.len(), 2);
3939        assert_eq!(c.values[0], MetaValue::String("food".to_string()));
3940        // 500 USD becomes an Amount (NUMBER + CURRENCY adjacent).
3941        let MetaValue::Amount(amt) = &c.values[1] else {
3942            panic!("expected Amount, got {:?}", c.values[1]);
3943        };
3944        assert_eq!(amt.number, Decimal::from(500));
3945        assert_eq!(amt.currency.as_str(), "USD");
3946    }
3947
3948    #[test]
3949    fn custom_directive_heterogeneous_values() {
3950        let src = "2024-01-01 custom \"test\" Assets:Cash TRUE 42 2024-06-15\n";
3951        let result = parse_via_cst(src);
3952        let Directive::Custom(c) = &result.directives[0].value else {
3953            panic!("expected Custom");
3954        };
3955        assert_eq!(c.values.len(), 4);
3956        assert!(matches!(c.values[0], MetaValue::Account(_)));
3957        assert_eq!(c.values[1], MetaValue::Bool(true));
3958        assert_eq!(c.values[2], MetaValue::Int(42));
3959        assert!(matches!(c.values[3], MetaValue::Date(_)));
3960    }
3961
3962    #[test]
3963    fn number_meta_value_int_vs_decimal_discriminator() {
3964        use rust_decimal_macros::dec;
3965        // Integer literals -> Int (the token text is unsigned; `value` carries
3966        // the sign, e.g. `precision: -1` parses the token "1" with value -1).
3967        assert_eq!(number_meta_value("42", dec!(42)), MetaValue::Int(42));
3968        assert_eq!(number_meta_value("0", dec!(0)), MetaValue::Int(0));
3969        assert_eq!(number_meta_value("1", dec!(-1)), MetaValue::Int(-1));
3970        // Decimal point -> Number.
3971        assert_eq!(
3972            number_meta_value("42.0", dec!(42.0)),
3973            MetaValue::Number(dec!(42.0))
3974        );
3975        // Exponent -> Number. The lexer doesn't currently emit exponent NUMBER
3976        // tokens, so this isn't reachable from real input today; it pins the
3977        // helper's `e`/`E` guard against a future lexer that does.
3978        assert_eq!(
3979            number_meta_value("1e3", dec!(1000)),
3980            MetaValue::Number(dec!(1000))
3981        );
3982        // i64 overflow stays Number (within Decimal's range).
3983        let huge = "99999999999999999999999999";
3984        let huge_dec = Decimal::from_str_exact(huge).unwrap();
3985        assert_eq!(
3986            number_meta_value(huge, huge_dec),
3987            MetaValue::Number(huge_dec)
3988        );
3989    }
3990
3991    #[test]
3992    fn option_directive_populates_options_field() {
3993        let src = "option \"title\" \"My Ledger\"\n";
3994        let result = parse_via_cst(src);
3995        assert_directive_count(&result, 0);
3996        assert_eq!(result.options.len(), 1);
3997        assert_eq!(result.options[0].0, "title");
3998        assert_eq!(result.options[0].1, "My Ledger");
3999    }
4000
4001    #[test]
4002    fn include_directive_populates_includes_field() {
4003        let src = "include \"shared.beancount\"\n";
4004        let result = parse_via_cst(src);
4005        assert_directive_count(&result, 0);
4006        assert_eq!(result.includes.len(), 1);
4007        assert_eq!(result.includes[0].0, "shared.beancount");
4008    }
4009
4010    #[test]
4011    fn plugin_directive_with_config() {
4012        let src = "plugin \"my.plugin\" \"cfg\"\n";
4013        let result = parse_via_cst(src);
4014        assert_directive_count(&result, 0);
4015        assert_eq!(result.plugins.len(), 1);
4016        assert_eq!(result.plugins[0].0, "my.plugin");
4017        assert_eq!(result.plugins[0].1.as_deref(), Some("cfg"));
4018    }
4019
4020    #[test]
4021    fn plugin_directive_without_config() {
4022        let src = "plugin \"my.plugin\"\n";
4023        let result = parse_via_cst(src);
4024        assert_eq!(result.plugins.len(), 1);
4025        assert_eq!(result.plugins[0].0, "my.plugin");
4026        assert!(result.plugins[0].1.is_none());
4027    }
4028
4029    // ---- Transaction converter tests ------------------------------
4030
4031    #[test]
4032    fn transaction_basic_two_postings() {
4033        let src = "2024-01-15 * \"Coffee Shop\" \"Morning coffee\"\n  \
4034                   Expenses:Food:Coffee  5.00 USD\n  \
4035                   Assets:Cash\n";
4036        let result = parse_via_cst(src);
4037        assert_directive_count(&result, 1);
4038        let Directive::Transaction(t) = &result.directives[0].value else {
4039            panic!("expected Transaction");
4040        };
4041        assert_eq!(t.date, naive_date(2024, 1, 15).unwrap());
4042        assert_eq!(t.flag, '*');
4043        assert_eq!(
4044            t.payee.as_ref().map(InternedStr::as_str),
4045            Some("Coffee Shop")
4046        );
4047        assert_eq!(t.narration.as_str(), "Morning coffee");
4048        assert_eq!(t.postings.len(), 2);
4049
4050        let p0 = &t.postings[0].value;
4051        assert_eq!(p0.account.as_str(), "Expenses:Food:Coffee");
4052        let Some(IncompleteAmount::Complete(amt)) = &p0.units else {
4053            panic!("expected complete units, got {:?}", p0.units);
4054        };
4055        assert_eq!(amt.number, Decimal::new(500, 2));
4056        assert_eq!(amt.currency.as_str(), "USD");
4057
4058        let p1 = &t.postings[1].value;
4059        assert_eq!(p1.account.as_str(), "Assets:Cash");
4060        assert!(p1.units.is_none(), "auto-posting has no units");
4061    }
4062
4063    #[test]
4064    fn transaction_narration_only_no_payee() {
4065        let src = "2024-01-15 ! \"Pending\"\n  Assets:Cash  -5 USD\n";
4066        let result = parse_via_cst(src);
4067        let Directive::Transaction(t) = &result.directives[0].value else {
4068            panic!("expected Transaction");
4069        };
4070        assert_eq!(t.flag, '!');
4071        assert!(t.payee.is_none());
4072        assert_eq!(t.narration.as_str(), "Pending");
4073    }
4074
4075    #[test]
4076    fn transaction_three_plus_header_strings_surface_last_as_narration() {
4077        // A header with 3+ strings is ambiguous (the grammar caps payee+narration
4078        // at two); the lossless CST still keeps all of them, so the typed surface
4079        // drops the payee and surfaces only the LAST string as narration. Locks
4080        // the `(Some, Some, Some(c)) => it.last().unwrap_or(c)` arm.
4081        let src = "2024-01-15 * \"a\" \"b\" \"c\"\n  Assets:Cash  -5 USD\n";
4082        let result = parse_via_cst(src);
4083        let Directive::Transaction(t) = &result.directives[0].value else {
4084            panic!("expected Transaction");
4085        };
4086        assert!(t.payee.is_none(), "3+ strings drop the payee");
4087        assert_eq!(t.narration.as_str(), "c", "last string becomes narration");
4088    }
4089
4090    #[test]
4091    fn transaction_implied_flag_via_leading_string() {
4092        let src = "2024-01-15 \"Implied\"\n  Assets:Cash  -5 USD\n";
4093        let result = parse_via_cst(src);
4094        let Directive::Transaction(t) = &result.directives[0].value else {
4095            panic!("expected Transaction");
4096        };
4097        assert_eq!(t.flag, '*', "implied flag defaults to *");
4098    }
4099
4100    #[test]
4101    fn transaction_with_tags_and_links() {
4102        let src = "2024-01-15 * \"Coffee\" #daily ^trip1\n  Assets:Cash  -5 USD\n";
4103        let result = parse_via_cst(src);
4104        let Directive::Transaction(t) = &result.directives[0].value else {
4105            panic!("expected Transaction");
4106        };
4107        assert_eq!(t.tags.len(), 1);
4108        assert_eq!(t.tags[0].as_str(), "daily");
4109        assert_eq!(t.links.len(), 1);
4110        assert_eq!(t.links[0].as_str(), "trip1");
4111    }
4112
4113    #[test]
4114    fn transaction_with_signed_amount() {
4115        let src = "2024-01-15 * \"x\"\n  Assets:Cash  -5.00 USD\n";
4116        let result = parse_via_cst(src);
4117        let Directive::Transaction(t) = &result.directives[0].value else {
4118            panic!("expected Transaction");
4119        };
4120        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4121            panic!("expected complete units");
4122        };
4123        assert_eq!(amt.number, Decimal::new(-500, 2));
4124    }
4125
4126    #[test]
4127    fn transaction_with_posting_flag() {
4128        let src = "2024-01-15 * \"x\"\n  ! Assets:Cash  -5 USD\n";
4129        let result = parse_via_cst(src);
4130        let Directive::Transaction(t) = &result.directives[0].value else {
4131            panic!("expected Transaction");
4132        };
4133        assert_eq!(t.postings[0].value.flag, Some('!'));
4134    }
4135
4136    #[test]
4137    fn transaction_with_cost_spec_per_unit() {
4138        let src = "2024-01-15 * \"buy\"\n  \
4139                   Assets:Inv  10 HOOL {500.00 USD}\n  \
4140                   Assets:Cash\n";
4141        let result = parse_via_cst(src);
4142        let Directive::Transaction(t) = &result.directives[0].value else {
4143            panic!("expected Transaction");
4144        };
4145        let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
4146        assert!(!cost.merge);
4147        let Some(CostNumber::PerUnit { value }) = &cost.number else {
4148            panic!("expected PerUnit");
4149        };
4150        assert_eq!(*value, Decimal::new(50000, 2));
4151        assert_eq!(cost.currency.as_ref().unwrap().as_str(), "USD");
4152    }
4153
4154    #[test]
4155    fn transaction_with_cost_spec_total() {
4156        let src = "2024-01-15 * \"buy\"\n  \
4157                   Assets:Inv  10 HOOL {{5000 USD}}\n  \
4158                   Assets:Cash\n";
4159        let result = parse_via_cst(src);
4160        let Directive::Transaction(t) = &result.directives[0].value else {
4161            panic!("expected Transaction");
4162        };
4163        let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
4164        let Some(CostNumber::Total { value }) = &cost.number else {
4165            panic!("expected Total");
4166        };
4167        assert_eq!(*value, Decimal::from(5000));
4168    }
4169
4170    #[test]
4171    fn transaction_with_price_annotation_unit() {
4172        let src = "2024-01-15 * \"buy\"\n  \
4173                   Assets:Inv  10 HOOL @ 510 USD\n  \
4174                   Assets:Cash\n";
4175        let result = parse_via_cst(src);
4176        let Directive::Transaction(t) = &result.directives[0].value else {
4177            panic!("expected Transaction");
4178        };
4179        let price = t.postings[0]
4180            .value
4181            .price
4182            .as_ref()
4183            .expect("price annotation");
4184        assert!(price.is_unit());
4185        let Some(IncompleteAmount::Complete(amt)) = &price.amount else {
4186            panic!("expected complete price amount");
4187        };
4188        assert_eq!(amt.number, Decimal::from(510));
4189        assert_eq!(amt.currency.as_str(), "USD");
4190    }
4191
4192    #[test]
4193    fn transaction_with_price_annotation_total() {
4194        let src = "2024-01-15 * \"buy\"\n  \
4195                   Assets:Inv  10 HOOL @@ 5100 USD\n  \
4196                   Assets:Cash\n";
4197        let result = parse_via_cst(src);
4198        let Directive::Transaction(t) = &result.directives[0].value else {
4199            panic!("expected Transaction");
4200        };
4201        let price = t.postings[0]
4202            .value
4203            .price
4204            .as_ref()
4205            .expect("price annotation");
4206        assert!(!price.is_unit(), "@@ is total form");
4207    }
4208
4209    // ---- regression tests for review findings (#1281) ----------
4210
4211    #[test]
4212    fn document_directive_preserves_tags_and_links() {
4213        // Finding 1: convert_document was filling tags/links empty
4214        // unconditionally. Legacy parse_document_directive collects
4215        // trailing `#tag` / `^link` tokens after the path STRING.
4216        let src = "2024-06-01 document Assets:Bank \"stmt.pdf\" #quarter1 ^scan42 #urgent\n";
4217        let result = parse_via_cst(src);
4218        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4219        let Directive::Document(doc) = &result.directives[0].value else {
4220            panic!("expected Document");
4221        };
4222        let tags: Vec<&str> = doc.tags.iter().map(Tag::as_str).collect();
4223        let links: Vec<&str> = doc.links.iter().map(Link::as_str).collect();
4224        assert_eq!(tags, vec!["quarter1", "urgent"]);
4225        assert_eq!(links, vec!["scan42"]);
4226    }
4227
4228    #[test]
4229    fn open_directive_rejects_invalid_booking_method() {
4230        // Finding 2: convert_open accepted any booking string; legacy
4231        // validates against [FIFO, STRICT, STRICT_WITH_SIZE, LIFO,
4232        // HIFO, NONE, AVERAGE] and on mismatch drops the directive
4233        // AND emits InvalidBookingMethod.
4234        let src = "2024-01-01 open Assets:Bank USD \"GARBAGE\"\n";
4235        let result = parse_via_cst(src);
4236        assert_eq!(result.directives.len(), 0, "directive should be dropped");
4237        assert_eq!(result.errors.len(), 1);
4238        let err = &result.errors[0];
4239        assert!(
4240            matches!(
4241                &err.kind,
4242                crate::ParseErrorKind::InvalidBookingMethod(s) if s == "GARBAGE"
4243            ),
4244            "expected InvalidBookingMethod, got {:?}",
4245            err.kind,
4246        );
4247    }
4248
4249    #[test]
4250    fn open_directive_accepts_all_valid_booking_methods() {
4251        for method in VALID_BOOKING_METHODS {
4252            let src = format!("2024-01-01 open Assets:Bank USD \"{method}\"\n");
4253            let result = parse_via_cst(&src);
4254            assert!(
4255                result.errors.is_empty(),
4256                "{method} rejected: {:?}",
4257                result.errors
4258            );
4259            let Directive::Open(open) = &result.directives[0].value else {
4260                panic!("{method}: expected Open");
4261            };
4262            assert_eq!(open.booking.as_deref(), Some(*method));
4263        }
4264    }
4265
4266    #[test]
4267    fn unclosed_pushtag_at_eof_emits_diagnostic() {
4268        // Finding 3: legacy emits one UnclosedPushtag per leftover
4269        // tag at EOF, pointing at the originating push directive.
4270        let src = "pushtag #active\n2024-01-01 open Assets:Bank USD\n";
4271        let result = parse_via_cst(src);
4272        let unclosed: Vec<_> = result
4273            .errors
4274            .iter()
4275            .filter_map(|e| match &e.kind {
4276                crate::ParseErrorKind::UnclosedPushtag(t) => Some(t.clone()),
4277                _ => None,
4278            })
4279            .collect();
4280        assert_eq!(unclosed, vec!["active".to_string()]);
4281    }
4282
4283    #[test]
4284    fn unclosed_pushmeta_at_eof_emits_diagnostic() {
4285        // Finding 4: same as pushtag, for pushmeta.
4286        let src = "pushmeta location: \"NYC\"\n2024-01-01 open Assets:Bank USD\n";
4287        let result = parse_via_cst(src);
4288        let unclosed: Vec<_> = result
4289            .errors
4290            .iter()
4291            .filter_map(|e| match &e.kind {
4292                crate::ParseErrorKind::UnclosedPushmeta(k) => Some(k.clone()),
4293                _ => None,
4294            })
4295            .collect();
4296        assert_eq!(unclosed, vec!["location".to_string()]);
4297    }
4298
4299    #[test]
4300    fn invalid_poptag_on_mismatch_emits_diagnostic() {
4301        // Finding 5: poptag for a tag never pushed should error,
4302        // not silently no-op.
4303        let src = "pushtag #foo\npoptag #bar\npoptag #foo\n";
4304        let result = parse_via_cst(src);
4305        let mismatches: Vec<_> = result
4306            .errors
4307            .iter()
4308            .filter_map(|e| match &e.kind {
4309                crate::ParseErrorKind::InvalidPoptag(t) => Some(t.clone()),
4310                _ => None,
4311            })
4312            .collect();
4313        assert_eq!(mismatches, vec!["bar".to_string()]);
4314        // and the matching #foo poptag should leave NO unclosed
4315        // diagnostic - i.e. the stack is empty after the matched pop.
4316        let leftover: Vec<_> = result
4317            .errors
4318            .iter()
4319            .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushtag(_)))
4320            .collect();
4321        assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
4322    }
4323
4324    #[test]
4325    fn invalid_popmeta_on_mismatch_emits_diagnostic() {
4326        // Finding 6: popmeta for a key never pushed should error,
4327        // not silently no-op. Also checks Vec-stack shadow semantics:
4328        // pushmeta x: 1; pushmeta x: 2; popmeta x leaves x=1 active.
4329        let src = "pushmeta location: \"NYC\"\npopmeta nope:\npopmeta location:\n";
4330        let result = parse_via_cst(src);
4331        let mismatches: Vec<_> = result
4332            .errors
4333            .iter()
4334            .filter_map(|e| match &e.kind {
4335                crate::ParseErrorKind::InvalidPopmeta(k) => Some(k.clone()),
4336                _ => None,
4337            })
4338            .collect();
4339        assert_eq!(mismatches, vec!["nope".to_string()]);
4340        let leftover: Vec<_> = result
4341            .errors
4342            .iter()
4343            .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushmeta(_)))
4344            .collect();
4345        assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
4346    }
4347
4348    #[test]
4349    fn pushmeta_shadow_pop_restores_prior_value() {
4350        // Vec-stack semantics (the reason meta_stack isn't a HashMap):
4351        // shadow-pop must restore the prior value, not delete the key.
4352        let src = "pushmeta loc: \"NYC\"\n\
4353                   pushmeta loc: \"LDN\"\n\
4354                   popmeta loc:\n\
4355                   2024-01-01 open Assets:Bank USD\n\
4356                   popmeta loc:\n";
4357        let result = parse_via_cst(src);
4358        let Directive::Open(open) = &result.directives[0].value else {
4359            panic!("expected Open");
4360        };
4361        assert_eq!(
4362            open.meta.get("loc"),
4363            Some(&MetaValue::String("NYC".to_string())),
4364            "shadow pop should restore NYC, got {:?}",
4365            open.meta.get("loc"),
4366        );
4367    }
4368
4369    #[test]
4370    fn error_recovery_classifies_bom_in_directive_body() {
4371        // Finding 7: error-recovery path should distinguish BOM-in-
4372        // line from a generic SyntaxError so users see the
4373        // BOM-removal hint instead of "unexpected input".
4374        let src = "garbage\u{FEFF}content\n";
4375        let result = parse_via_cst(src);
4376        let bom_errors: Vec<_> = result
4377            .errors
4378            .iter()
4379            .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
4380            .collect();
4381        assert_eq!(bom_errors.len(), 1, "errors: {:?}", result.errors);
4382        assert!(
4383            bom_errors[0].hint.is_some(),
4384            "BomInDirectiveBody should carry BOM_REMOVAL_HINT",
4385        );
4386    }
4387
4388    #[test]
4389    fn error_recovery_emits_both_invalid_account_and_bom_for_dual_line() {
4390        // Round-2 finding: legacy `parser.rs:2258-2263` emits a
4391        // SECONDARY `BomInDirectiveBody` whenever the line ALSO
4392        // contains a BOM byte and the primary diagnostic isn't
4393        // BOM itself. Without this, a Windows-exported file with
4394        // a Unicode account AND an internal BOM loses the BOM
4395        // hint entirely.
4396        let src = "garbage Assets:Café\u{FEFF}content\n";
4397        let result = parse_via_cst(src);
4398        let invalid_account_count = result
4399            .errors
4400            .iter()
4401            .filter(|e| matches!(e.kind, crate::ParseErrorKind::InvalidAccount(_)))
4402            .count();
4403        let bom_count = result
4404            .errors
4405            .iter()
4406            .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
4407            .count();
4408        assert_eq!(
4409            invalid_account_count, 1,
4410            "expected one InvalidAccount: {:?}",
4411            result.errors
4412        );
4413        assert_eq!(
4414            bom_count, 1,
4415            "expected secondary BomInDirectiveBody: {:?}",
4416            result.errors
4417        );
4418        // The secondary BOM diagnostic must carry the hint so
4419        // miette renders the remediation step.
4420        let bom_err = result
4421            .errors
4422            .iter()
4423            .find(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
4424            .unwrap();
4425        assert!(bom_err.hint.is_some());
4426    }
4427
4428    #[test]
4429    fn error_recovery_classifies_unicode_account() {
4430        // Finding 7: a Unicode-character account name (Assets:Café)
4431        // should surface as InvalidAccount, not generic SyntaxError.
4432        // We embed it in a malformed line so the parser routes to
4433        // the error-recovery path.
4434        let src = "garbage Assets:Café content\n";
4435        let result = parse_via_cst(src);
4436        let unicode_errors: Vec<_> = result
4437            .errors
4438            .iter()
4439            .filter_map(|e| match &e.kind {
4440                crate::ParseErrorKind::InvalidAccount(s) => Some(s.clone()),
4441                _ => None,
4442            })
4443            .collect();
4444        assert_eq!(unicode_errors, vec!["Assets:Café".to_string()]);
4445    }
4446
4447    #[test]
4448    fn transaction_with_pipe_emits_deprecated_pipe_symbol() {
4449        // Finding 7 (transaction path): legacy emits
4450        // DeprecatedPipeSymbol when a `|` separates payee/narration.
4451        let src = "2024-01-15 * \"Acme\" | \"invoice\"\n  Assets:Cash  -5 USD\n  Expenses:X\n";
4452        let result = parse_via_cst(src);
4453        let pipe_count = result
4454            .errors
4455            .iter()
4456            .filter(|e| matches!(e.kind, crate::ParseErrorKind::DeprecatedPipeSymbol))
4457            .count();
4458        assert_eq!(pipe_count, 1, "errors: {:?}", result.errors);
4459        // and the transaction itself is kept (legacy behavior).
4460        assert_eq!(result.directives.len(), 1);
4461    }
4462
4463    #[test]
4464    fn transaction_trailing_comments_after_final_posting() {
4465        // Finding 8: comments that appear AFTER the last posting
4466        // but inside the transaction body belong to
4467        // Transaction::trailing_comments, not lost.
4468        let src = "2024-01-15 * \"x\"\n  \
4469                   Assets:Cash  -5 USD\n  \
4470                   Expenses:X\n  \
4471                   ; trailing one\n  \
4472                   ; trailing two\n";
4473        let result = parse_via_cst(src);
4474        let Directive::Transaction(t) = &result.directives[0].value else {
4475            panic!("expected Transaction");
4476        };
4477        assert_eq!(
4478            t.trailing_comments.len(),
4479            2,
4480            "got: {:?}",
4481            t.trailing_comments
4482        );
4483        assert!(t.trailing_comments[0].contains("trailing one"));
4484        assert!(t.trailing_comments[1].contains("trailing two"));
4485    }
4486
4487    // ---- arithmetic AMOUNT evaluation (phase 3.7 flip blocker) -
4488
4489    #[test]
4490    fn posting_amount_evaluates_division() {
4491        // Regression for `test_arithmetic_expressions_consistency`:
4492        // `120 / 3 USD` must evaluate to 40 USD so the transaction
4493        // balances. Without this the CST flip breaks every ledger
4494        // using arithmetic split syntax.
4495        let src = "2024-01-15 * \"split\"\n  \
4496                   Expenses:Food   120 / 3 USD\n  \
4497                   Assets:Bank    -40 USD\n";
4498        let result = parse_via_cst(src);
4499        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4500        let Directive::Transaction(t) = &result.directives[0].value else {
4501            panic!("expected Transaction");
4502        };
4503        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4504            panic!("expected complete amount on posting 0");
4505        };
4506        assert_eq!(amt.number, Decimal::from(40));
4507        assert_eq!(amt.currency.as_str(), "USD");
4508    }
4509
4510    #[test]
4511    fn posting_amount_evaluates_addition_and_multiplication_precedence() {
4512        // `2 + 3 * 4 USD` = 14 USD (standard precedence).
4513        let src = "2024-01-15 * \"x\"\n  \
4514                   Expenses:X   2 + 3 * 4 USD\n  \
4515                   Assets:Y   -14 USD\n";
4516        let result = parse_via_cst(src);
4517        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4518        let Directive::Transaction(t) = &result.directives[0].value else {
4519            panic!("expected Transaction");
4520        };
4521        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4522            panic!("expected complete amount");
4523        };
4524        assert_eq!(amt.number, Decimal::from(14));
4525    }
4526
4527    #[test]
4528    fn posting_amount_evaluates_parens_override_precedence() {
4529        // `(2 + 3) * 4 USD` = 20 USD.
4530        let src = "2024-01-15 * \"x\"\n  \
4531                   Expenses:X   (2 + 3) * 4 USD\n  \
4532                   Assets:Y   -20 USD\n";
4533        let result = parse_via_cst(src);
4534        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4535        let Directive::Transaction(t) = &result.directives[0].value else {
4536            panic!("expected Transaction");
4537        };
4538        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4539            panic!("expected complete amount");
4540        };
4541        assert_eq!(amt.number, Decimal::from(20));
4542    }
4543
4544    #[test]
4545    fn posting_amount_evaluates_subtraction_left_associative() {
4546        // `10 - 3 - 2 USD` = 5 USD (left-associative, not 9).
4547        let src = "2024-01-15 * \"x\"\n  \
4548                   Expenses:X   10 - 3 - 2 USD\n  \
4549                   Assets:Y   -5 USD\n";
4550        let result = parse_via_cst(src);
4551        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4552        let Directive::Transaction(t) = &result.directives[0].value else {
4553            panic!("expected Transaction");
4554        };
4555        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4556            panic!("expected complete amount");
4557        };
4558        assert_eq!(amt.number, Decimal::from(5));
4559    }
4560
4561    #[test]
4562    fn posting_amount_division_by_zero_drops_number() {
4563        // `5 / 0 USD` - legacy returns parse error; we return None
4564        // from the evaluator, which degrades to CurrencyOnly here.
4565        // The transaction won't balance and downstream validation
4566        // surfaces that as the user-facing error.
4567        let src = "2024-01-15 * \"x\"\n  \
4568                   Expenses:X   5 / 0 USD\n  \
4569                   Assets:Y\n";
4570        let result = parse_via_cst(src);
4571        let Directive::Transaction(t) = &result.directives[0].value else {
4572            panic!("expected Transaction");
4573        };
4574        // Either the units degrade to CurrencyOnly (number lost)
4575        // or to None - both are acceptable since the input is
4576        // semantically invalid. The strict assertion is that we
4577        // DON'T silently return 5 (the first NUMBER) as the value.
4578        match &t.postings[0].value.units {
4579            None | Some(IncompleteAmount::CurrencyOnly(_)) => {}
4580            other => panic!("div-by-zero leaked: {other:?}"),
4581        }
4582    }
4583
4584    // ---- round-8 final compat regressions (#1282 flip) ---------
4585
4586    #[test]
4587    fn indented_top_level_directive_emits_error() {
4588        // A top-level directive that starts at column N>0 is a
4589        // syntax error per the Beancount spec; the CST grammar
4590        // accepts it silently, so the converter has to surface
4591        // the diagnostic at directive-content-start position.
4592        let src = "2020-07-28 open Assets:Foo\n  2020-07-28 open Assets:Bar\n";
4593        let result = parse_via_cst(src);
4594        let indent_errs = result
4595            .errors
4596            .iter()
4597            .filter(|e| match &e.kind {
4598                crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
4599                _ => false,
4600            })
4601            .count();
4602        assert_eq!(
4603            indent_errs, 1,
4604            "expected one column-0 diagnostic, got: {:?}",
4605            result.errors
4606        );
4607    }
4608
4609    #[test]
4610    fn indented_directive_after_blank_line_still_emits_error() {
4611        // Same as above but with a blank line between the
4612        // first directive and the indented one - the blank line
4613        // shouldn't mask the indentation error.
4614        let src = "2020-07-28 open Assets:Foo\n\n  2020-07-28 open Assets:Bar\n";
4615        let result = parse_via_cst(src);
4616        let indent_errs = result
4617            .errors
4618            .iter()
4619            .filter(|e| match &e.kind {
4620                crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
4621                _ => false,
4622            })
4623            .count();
4624        assert_eq!(indent_errs, 1, "errors: {:?}", result.errors);
4625    }
4626
4627    #[test]
4628    fn top_level_directive_at_column_0_no_diagnostic() {
4629        // Sanity: well-formed top-level directives must NOT
4630        // trigger the indent diagnostic.
4631        let src = "2020-07-28 open Assets:Foo\n2020-07-28 open Assets:Bar\n";
4632        let result = parse_via_cst(src);
4633        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4634    }
4635
4636    #[test]
4637    fn custom_directive_with_bare_currency_emits_error() {
4638        // `bean-check` rejects bare currency literals in custom
4639        // value position; the CST converter mirrors that.
4640        let src = "2025-01-01 custom \"x\" 10 USD \"y\" NZD\n";
4641        let result = parse_via_cst(src);
4642        let bare_curr_errs = result
4643            .errors
4644            .iter()
4645            .filter(|e| match &e.kind {
4646                crate::ParseErrorKind::SyntaxError(s) => s.contains("bare currency"),
4647                _ => false,
4648            })
4649            .count();
4650        assert_eq!(
4651            bare_curr_errs, 1,
4652            "expected one bare-currency diagnostic, got: {:?}",
4653            result.errors
4654        );
4655    }
4656
4657    #[test]
4658    fn custom_directive_with_amount_no_error() {
4659        // Sanity: `10 USD` (NUMBER + CURRENCY paired as Amount)
4660        // is a valid custom value and must NOT trigger the
4661        // bare-currency diagnostic.
4662        let src = "2025-01-01 custom \"x\" 10 USD\n";
4663        let result = parse_via_cst(src);
4664        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4665    }
4666
4667    // ---- round-7 compat regressions (#1282 flip) ---------------
4668
4669    #[test]
4670    fn balance_assertion_evaluates_arithmetic_value() {
4671        // PR #1282 compat regression: rledger emitted a balance
4672        // failure for `Assets:X  0.25+ 0.75 GBP` because only
4673        // the first NUMBER (0.25) was used as the assertion
4674        // target. CST converters for BALANCE/PRICE now evaluate
4675        // arithmetic the same way posting AMOUNTs do.
4676        let src = "2024-01-01 open Assets:X GBP\n\
4677                   2024-01-01 open Equity:Open GBP\n\
4678                   2024-01-02 * \"deposit\"\n  \
4679                   Assets:X         1.00 GBP\n  \
4680                   Equity:Open     -1.00 GBP\n\
4681                   2024-01-03 balance Assets:X  0.25 + 0.75 GBP\n";
4682        let result = parse_via_cst(src);
4683        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4684        let bal = result
4685            .directives
4686            .iter()
4687            .find_map(|d| match &d.value {
4688                Directive::Balance(b) => Some(b),
4689                _ => None,
4690            })
4691            .expect("expected a Balance directive");
4692        assert_eq!(bal.amount.number, Decimal::from(1));
4693        assert_eq!(bal.amount.currency.as_str(), "GBP");
4694    }
4695
4696    #[test]
4697    fn price_directive_evaluates_arithmetic_value() {
4698        let src = "2024-01-01 price USD  1/2 EUR\n";
4699        let result = parse_via_cst(src);
4700        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4701        let Directive::Price(p) = &result.directives[0].value else {
4702            panic!("expected Price");
4703        };
4704        assert_eq!(p.amount.number, Decimal::new(5, 1));
4705        assert_eq!(p.amount.currency.as_str(), "EUR");
4706    }
4707
4708    // ---- round-5 architecture review (#1281) -------------------
4709
4710    #[test]
4711    fn body_line_tag_does_not_drop_following_postings_comment() {
4712        // F2-bis: trailing TAG / LINK tokens on transaction body
4713        // lines are valid Beancount (extend the transaction's
4714        // tag/link set). Before the exemption was added, the
4715        // `pending.clear()` over-fired on the TAG and silently
4716        // dropped the preceding comment that semantically
4717        // belonged to the next posting.
4718        let src = "2024-01-01 * \"x\"\n  \
4719                   Assets:A   100 USD\n  \
4720                   ; comment-for-B\n  \
4721                   #late-tag\n  \
4722                   Assets:B   -100 USD\n";
4723        let result = parse_via_cst(src);
4724        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4725        let Directive::Transaction(t) = &result.directives[0].value else {
4726            panic!("expected Transaction");
4727        };
4728        // The trailing tag joins the transaction's tag set.
4729        assert!(
4730            t.tags.iter().any(|tag| tag.as_str() == "late-tag"),
4731            "expected #late-tag in tags: {:?}",
4732            t.tags,
4733        );
4734        // And the comment survives - attached to the next posting.
4735        let b = t.postings.last().expect("at least one posting");
4736        assert_eq!(b.value.account.as_str(), "Assets:B");
4737        assert!(
4738            b.value.comments.iter().any(|c| c.contains("comment-for-B")),
4739            "expected comment-for-B to survive on Assets:B: {:?}",
4740            b.value.comments,
4741        );
4742    }
4743
4744    #[test]
4745    fn oversized_number_in_amount_emits_diagnostic() {
4746        // F5-bis: the non-arithmetic NUMBER path is now symmetric
4747        // with the arithmetic-evaluation path. A NUMBER whose
4748        // text the lexer accepts but `Decimal::from_str` rejects
4749        // (e.g., 30+ digits, exceeding the 28-digit precision
4750        // ceiling) used to silently degrade to `CurrencyOnly`.
4751        let huge = "1".to_string() + &"2345678901234567890".repeat(2); // 39 digits
4752        let src = format!("2024-01-15 * \"big\"\n  Expenses:X   {huge} USD\n  Assets:Y\n");
4753        let result = parse_via_cst(&src);
4754        let invalid_num = result
4755            .errors
4756            .iter()
4757            .filter(|e| match &e.kind {
4758                crate::ParseErrorKind::SyntaxError(s) => s.contains("invalid number"),
4759                _ => false,
4760            })
4761            .count();
4762        assert_eq!(
4763            invalid_num, 1,
4764            "expected one invalid-number diagnostic, got: {:?}",
4765            result.errors
4766        );
4767    }
4768
4769    // ---- round-4 architecture review (#1281) -------------------
4770
4771    #[test]
4772    fn posting_with_two_amount_siblings_emits_error_and_keeps_first() {
4773        // F1: a posting like `Expenses:Food  5 USD + 3 USD` builds
4774        // two sibling AMOUNT nodes in the CST. `Posting::amount()`
4775        // only returns the first. Without an explicit guard the
4776        // second AMOUNT plus the joining `+` would be silently
4777        // dropped - the user's transaction would balance against
4778        // 5 USD instead of the intended 8 USD with no diagnostic.
4779        let src = "2024-01-15 * \"ambig\"\n  \
4780                   Expenses:Food   5 USD + 3 USD\n  \
4781                   Assets:Bank\n";
4782        let result = parse_via_cst(src);
4783        let trailing_count = result
4784            .errors
4785            .iter()
4786            .filter(|e| match &e.kind {
4787                crate::ParseErrorKind::SyntaxError(s) => s.contains("trailing tokens"),
4788                _ => false,
4789            })
4790            .count();
4791        assert_eq!(
4792            trailing_count, 1,
4793            "expected one trailing-tokens diagnostic, got: {:?}",
4794            result.errors
4795        );
4796        // The first AMOUNT is still surfaced so partial recovery
4797        // works for downstream tooling.
4798        let Directive::Transaction(t) = &result.directives[0].value else {
4799            panic!("expected Transaction");
4800        };
4801        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4802            panic!("expected complete units from the first AMOUNT");
4803        };
4804        assert_eq!(amt.number, Decimal::from(5));
4805    }
4806
4807    #[test]
4808    fn comments_dont_leak_across_failed_posting() {
4809        // F2: when convert_posting returns None, the queue of
4810        // pending pre-posting comments must be CLEARED so they
4811        // don't migrate forward and attach to the next valid
4812        // posting. Without the clear, comments labelled for the
4813        // failed posting would silently re-attach to the wrong
4814        // account, visibly misleading the user.
4815        let src = "2024-01-15 * \"test\"\n  \
4816                   Assets:A   100 USD\n  \
4817                   ; comment-for-bad\n  \
4818                   ; another-comment\n  \
4819                   bogus_token_line_no_account\n  \
4820                   ; comment-for-good\n  \
4821                   Assets:B   -100 USD\n";
4822        let result = parse_via_cst(src);
4823        let Directive::Transaction(t) = &result.directives[0].value else {
4824            panic!("expected Transaction");
4825        };
4826        // Assets:B is the LAST successful posting; the only
4827        // comment that should attach to it is the one that
4828        // immediately precedes it (`; comment-for-good`). The
4829        // pre-failed-posting comments belong to the failed
4830        // posting and should be DROPPED with it.
4831        let b = t.postings.last().expect("at least one posting");
4832        assert_eq!(b.value.account.as_str(), "Assets:B");
4833        assert!(
4834            !b.value
4835                .comments
4836                .iter()
4837                .any(|c| c.contains("comment-for-bad")),
4838            "comment-for-bad leaked across failed posting onto Assets:B: {:?}",
4839            b.value.comments
4840        );
4841        assert!(
4842            !b.value
4843                .comments
4844                .iter()
4845                .any(|c| c.contains("another-comment")),
4846            "another-comment leaked: {:?}",
4847            b.value.comments
4848        );
4849    }
4850
4851    #[test]
4852    fn arithmetic_overflow_in_amount_emits_diagnostic() {
4853        // F5: when `is_arithmetic` is true but the evaluator
4854        // gives up (overflow, div-by-zero), the converter used
4855        // to silently produce CurrencyOnly. Now an explicit
4856        // SyntaxError fires so the user sees the actual root
4857        // cause instead of just a downstream "doesn't balance".
4858        // Decimal max is 28 digits - `9999999999999999999999999999 *
4859        // 9999999999999999999999999999` overflows.
4860        let huge = "9999999999999999999999999999 * 9999999999999999999999999999";
4861        let src = format!("2024-01-15 * \"big\"\n  Expenses:X   {huge} USD\n  Assets:Y\n");
4862        let result = parse_via_cst(&src);
4863        let arith_errs = result
4864            .errors
4865            .iter()
4866            .filter(|e| match &e.kind {
4867                crate::ParseErrorKind::SyntaxError(s) => s.contains("arithmetic"),
4868                _ => false,
4869            })
4870            .count();
4871        assert_eq!(
4872            arith_errs, 1,
4873            "expected one arithmetic-error diagnostic, got: {:?}",
4874            result.errors
4875        );
4876    }
4877
4878    // ---- 14 emission-gap regressions (#1281 round-3 review) ----
4879
4880    #[test]
4881    fn date_with_single_digit_month_parses() {
4882        let result = parse_via_cst("2024-1-15 open Assets:Checking\n");
4883        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4884        let Directive::Open(open) = &result.directives[0].value else {
4885            panic!("expected Open");
4886        };
4887        assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
4888    }
4889
4890    #[test]
4891    fn date_with_single_digit_day_parses() {
4892        let result = parse_via_cst("2024-01-5 open Assets:Cash USD\n");
4893        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4894        let Directive::Open(open) = &result.directives[0].value else {
4895            panic!("expected Open");
4896        };
4897        assert_eq!(open.date, naive_date(2024, 1, 5).unwrap());
4898    }
4899
4900    #[test]
4901    fn date_with_single_digit_month_and_day_parses() {
4902        let result = parse_via_cst("2024-1-1 open Assets:Cash USD\n");
4903        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4904        let Directive::Open(open) = &result.directives[0].value else {
4905            panic!("expected Open");
4906        };
4907        assert_eq!(open.date, naive_date(2024, 1, 1).unwrap());
4908    }
4909
4910    #[test]
4911    fn date_with_month_out_of_range_emits_invalid_date_value() {
4912        let result = parse_via_cst("2024-13-01 open Assets:Cash USD\n");
4913        let invalid_date: Vec<_> = result
4914            .errors
4915            .iter()
4916            .filter_map(|e| match &e.kind {
4917                crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
4918                _ => None,
4919            })
4920            .collect();
4921        assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
4922        let msg = &invalid_date[0];
4923        assert!(
4924            msg.contains("month") && msg.contains("out of range"),
4925            "msg: {msg}"
4926        );
4927    }
4928
4929    #[test]
4930    fn date_with_invalid_leap_year_emits_invalid_date_value() {
4931        let result = parse_via_cst("2023-02-29 open Assets:Cash USD\n");
4932        let invalid_date: Vec<_> = result
4933            .errors
4934            .iter()
4935            .filter_map(|e| match &e.kind {
4936                crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
4937                _ => None,
4938            })
4939            .collect();
4940        assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
4941        let msg = &invalid_date[0];
4942        assert!(
4943            msg.contains("day") && msg.contains("out of range") && msg.contains("2023-02"),
4944            "msg: {msg}"
4945        );
4946    }
4947
4948    #[test]
4949    fn date_with_completely_invalid_value_still_emits_error() {
4950        // `2024-13-45` has BOTH month and day out of range; any
4951        // error variant satisfies the original integration test's
4952        // `!result.errors.is_empty()` assertion.
4953        let result = parse_via_cst("2024-13-45 open Assets:Bank\n");
4954        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4955    }
4956
4957    #[test]
4958    fn open_directive_without_account_emits_error() {
4959        // `2024-01-01 open` with no account is rejected by legacy
4960        // via the top-level error-recovery path. CST emits the
4961        // catch-all `SyntaxError` from `parse_via_cst`'s
4962        // is_directive_producing/errors_before tracker.
4963        let result = parse_via_cst("2024-01-01 open\n");
4964        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4965    }
4966
4967    #[test]
4968    fn open_directive_with_lowercase_account_emits_error() {
4969        // `lowercase:invalid` doesn't match the ACCOUNT regex
4970        // (uppercase first letter required), so the open directive
4971        // has no ACCOUNT child. Same catch-all path as the no-
4972        // account case.
4973        let result = parse_via_cst("2024-01-01 open lowercase:invalid\n");
4974        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4975    }
4976
4977    #[test]
4978    fn incomplete_open_at_eof_emits_error() {
4979        // Regression for the PR #740 "incomplete-at-EOF" finding:
4980        // `2024-01-01 open` at EOF with no trailing newline must
4981        // not be silently dropped.
4982        let result = parse_via_cst("2024-01-01 open");
4983        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4984    }
4985
4986    #[test]
4987    fn balance_directive_without_amount_emits_error() {
4988        let result = parse_via_cst("2024-01-15 balance Assets:Checking\n");
4989        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4990    }
4991
4992    #[test]
4993    fn pad_directive_without_source_account_emits_error() {
4994        let result = parse_via_cst("2024-01-15 pad Assets:Checking\n");
4995        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4996    }
4997
4998    #[test]
4999    fn cost_spec_n_hash_t_parses_as_compound() {
5000        use rust_decimal_macros::dec;
5001        // This test previously pinned `{N # T}` -> Total{T} — the #1700
5002        // misparse (beancount's compound_amount weighs N*per + total,
5003        // so dropping the per-unit silently misweighed every compound
5004        // spec). It now pins the corrected as-written form.
5005        let src = "2024-01-01 open Assets:Stock\n\
5006                   2024-01-01 open Assets:Cash USD\n\
5007                   2024-01-15 *\n  \
5008                   Assets:Stock  10 STK {50 # 1500 USD}\n  \
5009                   Assets:Cash  -1500.00 USD\n";
5010        let result = parse_via_cst(src);
5011        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
5012        let Directive::Transaction(txn) = &result.directives[2].value else {
5013            panic!("expected Transaction at index 2");
5014        };
5015        let cost = txn.postings[0]
5016            .value
5017            .cost
5018            .as_ref()
5019            .expect("cost spec present");
5020        assert_eq!(
5021            cost.number,
5022            Some(CostNumber::Compound {
5023                per_unit: dec!(50),
5024                total: dec!(1500)
5025            }),
5026            "the `{{N # T CCY}}` form must carry both components as written"
5027        );
5028    }
5029
5030    #[test]
5031    fn unclosed_cost_brace_emits_error() {
5032        let src = "2024-01-01 open Assets:Stock\n\
5033                   2024-01-01 open Assets:Cash USD\n\
5034                   2024-01-15 *\n  \
5035                   Assets:Stock 10 AAPL {150 USD\n  \
5036                   Assets:Cash -1500 USD\n";
5037        let result = parse_via_cst(src);
5038        let has_unclosed: bool = result
5039            .errors
5040            .iter()
5041            .any(|e| e.message().contains("unclosed cost"));
5042        assert!(
5043            has_unclosed,
5044            "expected 'unclosed cost' error, got: {:?}",
5045            result.errors
5046        );
5047    }
5048
5049    #[test]
5050    fn unclosed_cost_brace_at_eof_emits_error() {
5051        let src = "2024-01-01 open Assets:Stock\n\
5052                   2024-01-01 open Assets:Cash USD\n\
5053                   2024-01-15 *\n  \
5054                   Assets:Stock 10 AAPL {150 USD";
5055        let result = parse_via_cst(src);
5056        let has_unclosed: bool = result
5057            .errors
5058            .iter()
5059            .any(|e| e.message().contains("unclosed cost"));
5060        assert!(
5061            has_unclosed,
5062            "expected 'unclosed cost' error at EOF, got: {:?}",
5063            result.errors
5064        );
5065    }
5066
5067    #[test]
5068    fn leading_decimal_in_posting_amount_emits_error() {
5069        // `.50 USD` (no integer part before the decimal) must be
5070        // rejected by both parsers; valid `0.50 USD` still works
5071        // (covered by other tests).
5072        let src = "2024-01-15 * \"Test\"\n  \
5073                   Expenses:Food  .50 USD\n  \
5074                   Assets:Checking\n";
5075        let result = parse_via_cst(src);
5076        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
5077    }
5078
5079    #[test]
5080    fn transaction_with_metadata_on_directive_and_posting() {
5081        let src = "2024-01-15 * \"x\"\n  \
5082                   tag1: \"hello\"\n  \
5083                   Assets:Cash  -5 USD\n    \
5084                       receipt: \"abc123\"\n";
5085        let result = parse_via_cst(src);
5086        let Directive::Transaction(t) = &result.directives[0].value else {
5087            panic!("expected Transaction");
5088        };
5089        assert_eq!(
5090            t.meta.get("tag1"),
5091            Some(&MetaValue::String("hello".to_string()))
5092        );
5093        let p_meta = &t.postings[0].value.meta;
5094        assert_eq!(
5095            p_meta.get("receipt"),
5096            Some(&MetaValue::String("abc123".to_string()))
5097        );
5098    }
5099
5100    /// Pins the `ERROR_NODE` exclusion contract on
5101    /// `account_occurrences`. The rustdoc on `ParseResult::
5102    /// account_occurrences` distinguishes two failure modes:
5103    ///
5104    /// - **Typed-conversion failure** (e.g. `InvalidBookingMethod`
5105    ///   on an `open` whose booking string is garbage): the CST is
5106    ///   intact, the `ACCOUNT` node is NOT inside `ERROR_NODE`, so
5107    ///   the token IS tracked. The LSP rename can still hit it
5108    ///   during mid-edit.
5109    /// - **CST-recovery wrap**: a directive so garbled that the
5110    ///   CST wraps the region in `ERROR_NODE`. The `ACCOUNT` token
5111    ///   is inside `ERROR_NODE`, NOT tracked.
5112    ///
5113    /// The two policies are deliberate. This test pins both.
5114    #[test]
5115    fn account_occurrences_policy_for_failing_directives() {
5116        // Case A: typed-conversion failure. `open Assets:Bank
5117        // "GARBAGE"` parses syntactically but fails the booking-
5118        // method whitelist. The ACCOUNT token IS tracked.
5119        let src = "2024-01-01 open Assets:Bank \"GARBAGE\"\n";
5120        let r = parse_via_cst(src);
5121        assert!(
5122            r.account_occurrences
5123                .iter()
5124                .any(|o| o.value == "Assets:Bank"),
5125            "typed-conversion failure should keep the ACCOUNT token in \
5126             account_occurrences (got {:?}); rename mid-edit relies on this",
5127            r.account_occurrences,
5128        );
5129
5130        // Case B: CST-recovery wrap. `opn Assets:Bank USD` (typo
5131        // `opn`) is unrecognized at the directive position and the
5132        // recovery walker wraps it in ERROR_NODE. The ACCOUNT
5133        // token is excluded.
5134        let src = "2024-01-01 opn Assets:Bank USD\n";
5135        let r = parse_via_cst(src);
5136        assert!(
5137            !r.account_occurrences
5138                .iter()
5139                .any(|o| o.value == "Assets:Bank"),
5140            "ERROR_NODE-wrapped ACCOUNT should be EXCLUDED from \
5141             account_occurrences (got {:?}); rename should not hit garbled \
5142             mid-edit syntax",
5143            r.account_occurrences,
5144        );
5145    }
5146
5147    // ---- cost-spec token latches and the `{*}` merge machine ----
5148    //
5149    // Added after the 2026-08-01 mutation run: every mutant in these two
5150    // machines survived. The merge rule was tested only through the `ast.rs`
5151    // copy (now deleted, it delegates here), and nothing at all exercised the
5152    // first-token latches, so a cost spec carrying a duplicate date, label or
5153    // currency was untested in either tree walker.
5154
5155    /// Parse one posting's cost spec, or panic with the source for context.
5156    fn cost_of(src: &str) -> CostSpec {
5157        let result = parse_via_cst(src);
5158        let Some(Directive::Transaction(txn)) = result.directives.first().map(|d| &d.value) else {
5159            panic!("expected a transaction from {src:?}");
5160        };
5161        txn.postings
5162            .first()
5163            .and_then(|p| p.cost.as_deref().cloned())
5164            .unwrap_or_else(|| panic!("expected a cost spec from {src:?}"))
5165    }
5166
5167    fn posting_with_cost(spec: &str) -> String {
5168        format!("2020-01-01 * \"t\"\n  Assets:A 1 HOOL {spec}\n  Assets:B\n")
5169    }
5170
5171    /// A repeated DATE, STRING or CURRENCY keeps the FIRST occurrence. Malformed
5172    /// input is the only way to get here, and "first wins" is what keeps the
5173    /// green and red walkers agreeing on it.
5174    #[test]
5175    fn cost_spec_latches_the_first_date_label_and_currency() {
5176        let cost = cost_of(&posting_with_cost(
5177            "{2 USD, 2020-06-01, 2021-02-02, \"first\", \"second\"}",
5178        ));
5179        assert_eq!(cost.date, naive_date(2020, 6, 1), "the FIRST date wins");
5180        assert_eq!(cost.label.as_deref(), Some("first"), "the FIRST label wins");
5181        assert_eq!(
5182            cost.currency
5183                .as_ref()
5184                .map(rustledger_core::Currency::as_str),
5185            Some("USD"),
5186            "the FIRST currency wins"
5187        );
5188
5189        // Currency specifically, with nothing else competing.
5190        let cost = cost_of(&posting_with_cost("{2 USD, EUR}"));
5191        assert_eq!(
5192            cost.currency
5193                .as_ref()
5194                .map(rustledger_core::Currency::as_str),
5195            Some("USD")
5196        );
5197    }
5198
5199    /// The latch is on the first token of a KIND, not the first that parses.
5200    ///
5201    /// `9999-99-99` lexes as a DATE and fails to parse. The latch must still
5202    /// close, leaving the date empty — falling through to a later, valid DATE
5203    /// would make the two walkers disagree on malformed input, which is the
5204    /// divergence class this design exists to prevent.
5205    #[test]
5206    fn cost_spec_latch_closes_on_an_unparsable_first_token() {
5207        let cost = cost_of(&posting_with_cost("{2 USD, 9999-99-99, 2021-02-02}"));
5208        assert_eq!(
5209            cost.date, None,
5210            "an unparsable first DATE must not let a later one through"
5211        );
5212    }
5213
5214    /// The merge flag is decided by the first non-whitespace, non-opener token
5215    /// after an opener. Each row pins one arm of that machine.
5216    #[test]
5217    fn cost_spec_merge_flag_is_decided_by_the_first_token_after_an_opener() {
5218        for (spec, expected, why) in [
5219            ("{*}", true, "bare star directly after the opener"),
5220            (
5221                "{ * }",
5222                true,
5223                "whitespace never decides, so the star still does",
5224            ),
5225            ("{{*}}", true, "`{{` is an opener too"),
5226            (
5227                "{2 USD, *}",
5228                false,
5229                "the number decided it first; a later star cannot re-arm",
5230            ),
5231            (
5232                "{500 * 2 USD}",
5233                false,
5234                "a star past the first token is multiplication",
5235            ),
5236        ] {
5237            assert_eq!(
5238                cost_of(&posting_with_cost(spec)).merge,
5239                expected,
5240                "{spec}: {why}"
5241            );
5242        }
5243    }
5244
5245    /// The red-tree accessor and the token-level canonical must agree, since
5246    /// they are now one machine fed by two walkers. Asserts on the exact pair
5247    /// rather than on either alone, so deleting the delegation is caught.
5248    #[test]
5249    fn ast_is_merge_agrees_with_the_converted_cost_spec() {
5250        for spec in [
5251            "{*}",
5252            "{ * }",
5253            "{{*}}",
5254            "{2 USD, *}",
5255            "{500 * 2 USD}",
5256            "{2 USD}",
5257        ] {
5258            let src = posting_with_cost(spec);
5259            let converted = cost_of(&src).merge;
5260
5261            let parsed = crate::parse(&src);
5262            let root = ast::SourceFile::cast(parsed.syntax_node()).expect("source file");
5263            let from_ast = root
5264                .syntax()
5265                .descendants()
5266                .find_map(ast::CostSpec::cast)
5267                .map_or_else(|| panic!("no CostSpec node in {src:?}"), |cs| cs.is_merge());
5268
5269            assert_eq!(
5270                from_ast, converted,
5271                "{spec}: ast::CostSpec::is_merge disagrees with the converted CostSpec"
5272            );
5273        }
5274    }
5275
5276    /// `MergeFlag` guards `past_opener` because a token stream may begin before
5277    /// the opener. Both tree walkers happen to start AT the opener, so this is
5278    /// unreachable through them and only a direct feed can pin it -- but the
5279    /// guard is what stops a leading `*` (or any leading token) from deciding
5280    /// the flag, and it costs nothing to keep it honest.
5281    #[test]
5282    fn merge_flag_ignores_tokens_before_the_opener() {
5283        use crate::SyntaxKind as K;
5284
5285        // A star BEFORE any opener is not a merge marker: nothing has opened
5286        // yet, so it cannot be the first token after an opener.
5287        let mut flag = MergeFlag::default();
5288        for kind in [K::STAR, K::L_BRACE, K::R_BRACE] {
5289            flag.feed(kind);
5290        }
5291        assert!(
5292            !flag.is_merge(),
5293            "a star before the opener must not decide the flag"
5294        );
5295
5296        // And a non-star before the opener must not close the machine early,
5297        // or the real `{*}` that follows would be missed.
5298        let mut flag = MergeFlag::default();
5299        for kind in [K::NUMBER, K::L_BRACE, K::STAR, K::R_BRACE] {
5300            flag.feed(kind);
5301        }
5302        assert!(
5303            flag.is_merge(),
5304            "a token before the opener must not consume the decision"
5305        );
5306    }
5307
5308    /// Every diagnostic span in this module is built as `offset + bom_offset`,
5309    /// and a wrong offset puts the editor's squiggle on the wrong text.
5310    ///
5311    /// The error paths were already exercised, but only for the PRESENCE of an
5312    /// error, so the 2026-08-01 mutation run could flip `+` to `-` or `*` in
5313    /// five different span computations without a single failure. Each row
5314    /// below drives one of them and asserts the exact range, once with no BOM
5315    /// and once with one, so the addition itself is pinned rather than merely
5316    /// the arithmetic happening to agree at zero.
5317    #[test]
5318    fn diagnostic_spans_point_at_the_offending_text_with_and_without_a_bom() {
5319        // (label, source, the substring the span must cover)
5320        let cases = [
5321            (
5322                "price with two numbers",
5323                "2024-01-15 price HOOL 1 2 USD\n",
5324                "1 2",
5325            ),
5326            (
5327                "balance with two numbers",
5328                "2024-01-15 balance Assets:Cash 1 2 USD\n",
5329                "1 2",
5330            ),
5331            (
5332                "posting with a second amount",
5333                "2024-01-15 *\n  Assets:A 5 USD + 3 USD\n  Assets:B\n",
5334                // Underlined from the END of the first amount on purpose, so
5335                // the reader sees `5 USD + 3 USD` and not just the tail.
5336                " + 3 USD",
5337            ),
5338            (
5339                // A `+`/`-` binds to the amount as its sign, so the orphan that
5340                // actually reaches this path is a stray comma - someone writing
5341                // `1,234` with the separator outside the number.
5342                "orphaned comma before a posting amount",
5343                "2024-01-15 *\n  Assets:A , 1,234.00 USD\n  Assets:B\n",
5344                ",",
5345            ),
5346        ];
5347
5348        for (label, src, needle) in cases {
5349            for bom in [false, true] {
5350                let full = if bom {
5351                    format!("\u{FEFF}{src}")
5352                } else {
5353                    src.to_string()
5354                };
5355                let result = parse_via_cst(&full);
5356                let bom_len = if bom { "\u{FEFF}".len() } else { 0 };
5357
5358                let expected_start = src
5359                    .find(needle)
5360                    .unwrap_or_else(|| panic!("{label}: {needle:?} not in the fixture"))
5361                    + bom_len;
5362                let expected_end = expected_start + needle.len();
5363
5364                let hit = result
5365                    .errors
5366                    .iter()
5367                    .find(|e| e.span.start == expected_start && e.span.end == expected_end);
5368                assert!(
5369                    hit.is_some(),
5370                    "{label} (bom={bom}): expected an error spanning {expected_start}..{expected_end} \
5371                     (the {needle:?}), got {:?}",
5372                    result
5373                        .errors
5374                        .iter()
5375                        .map(|e| (e.span.start, e.span.end))
5376                        .collect::<Vec<_>>()
5377                );
5378            }
5379        }
5380    }
5381
5382    /// A leading `-` on a `price`/`balance` number is a separate MINUS token,
5383    /// so the converter has to re-apply the sign the AST accessor drops. Both
5384    /// the negation and the scanner that finds it were untested.
5385    #[test]
5386    fn negative_numbers_in_price_and_balance_keep_their_sign() {
5387        // SPACED, so the sign is its own MINUS token and the AST accessor
5388        // hands back an unsigned number. `-1.50` written closed up lexes as a
5389        // single signed NUMBER and never reaches the scanner at all.
5390        let result = parse_via_cst("2024-01-15 price HOOL - 1.50 USD\n");
5391        let Some(Directive::Price(p)) = result.directives.first().map(|d| &d.value) else {
5392            panic!("expected a Price, got {:?}", result.directives);
5393        };
5394        assert_eq!(p.amount.number, rust_decimal_macros::dec!(-1.50));
5395
5396        let result = parse_via_cst("2024-01-15 balance Assets:Cash - 1.50 USD\n");
5397        let Some(Directive::Balance(b)) = result.directives.first().map(|d| &d.value) else {
5398            panic!("expected a Balance, got {:?}", result.directives);
5399        };
5400        assert_eq!(b.amount.number, rust_decimal_macros::dec!(-1.50));
5401
5402        // And the positive case must stay positive: a scanner that reports
5403        // "minus" for everything would pass the assertions above alone.
5404        let result = parse_via_cst("2024-01-15 price HOOL 1.50 USD\n");
5405        let Some(Directive::Price(p)) = result.directives.first().map(|d| &d.value) else {
5406            panic!("expected a Price");
5407        };
5408        assert_eq!(p.amount.number, rust_decimal_macros::dec!(1.50));
5409    }
5410
5411    /// `price` puts the BASE currency BEFORE the number, so the scan that
5412    /// rejects a two-number value may only stop at a currency once a number has
5413    /// been seen. Getting that guard wrong makes every `price` directive look
5414    /// malformed, or stops rejecting the thing it exists to reject.
5415    #[test]
5416    fn price_base_currency_before_the_number_is_not_a_malformed_value() {
5417        let result = parse_via_cst("2024-01-15 price HOOL 1.50 USD\n");
5418        assert!(
5419            result.errors.is_empty(),
5420            "a well-formed price must not be reported as malformed: {:?}",
5421            result.errors
5422        );
5423        assert_eq!(result.directives.len(), 1);
5424
5425        // Two numbers still must be rejected.
5426        let result = parse_via_cst("2024-01-15 price HOOL 1 2 USD\n");
5427        assert!(
5428            has_syntax_error(&result, "malformed amount"),
5429            "two numbers must still be refused: {:?}",
5430            result.errors
5431        );
5432    }
5433
5434    /// Only `+`, `-` and `,` are orphanable. A posting FLAG sits between the
5435    /// account and the amount too, and treating it as an orphan would reject
5436    /// perfectly ordinary input.
5437    #[test]
5438    fn a_posting_flag_is_not_an_orphaned_amount_prefix() {
5439        let result = parse_via_cst("2024-01-15 *\n  ! Assets:A 5 USD\n  Assets:B\n");
5440        assert!(
5441            !has_syntax_error(&result, "unexpected token before posting amount"),
5442            "a posting flag is not an orphan: {:?}",
5443            result.errors
5444        );
5445    }
5446
5447    /// Both diagnostics inside posting-amount conversion carry spans built with
5448    /// the BOM offset, and neither was pinned. An arithmetic expression that
5449    /// cannot be evaluated and a number past the Decimal ceiling are the two
5450    /// ways in.
5451    #[test]
5452    fn posting_amount_diagnostics_point_at_the_offending_amount() {
5453        // 30 digits: past `rust_decimal`'s ~28-digit ceiling.
5454        let huge = "1".repeat(30);
5455        let cases = [
5456            (
5457                // The span covers the whole AMOUNT node, currency included:
5458                // the expression is what is wrong, but the amount is what the
5459                // reader has to replace.
5460                "unevaluatable arithmetic",
5461                "2024-01-15 *\n  Assets:A (1/0) USD\n  Assets:B\n".to_string(),
5462                "(1/0) USD".to_string(),
5463            ),
5464            (
5465                "number past the Decimal ceiling",
5466                format!("2024-01-15 *\n  Assets:A {huge} USD\n  Assets:B\n"),
5467                huge,
5468            ),
5469        ];
5470
5471        for (label, src, needle) in cases {
5472            for bom in [false, true] {
5473                let full = if bom {
5474                    format!("\u{FEFF}{src}")
5475                } else {
5476                    src.clone()
5477                };
5478                let bom_len = if bom { "\u{FEFF}".len() } else { 0 };
5479                let result = parse_via_cst(&full);
5480
5481                let start = src.find(&needle).expect("needle present") + bom_len;
5482                let end = start + needle.len();
5483                assert!(
5484                    result
5485                        .errors
5486                        .iter()
5487                        .any(|e| e.span.start == start && e.span.end == end),
5488                    "{label} (bom={bom}): expected a span {start}..{end}, got {:?}",
5489                    result
5490                        .errors
5491                        .iter()
5492                        .map(|e| (e.span.start, e.span.end))
5493                        .collect::<Vec<_>>()
5494                );
5495            }
5496        }
5497    }
5498
5499    /// The trailing currency closes a directive value, and it must only do so
5500    /// once a number has been seen (a `price` names its base currency first).
5501    /// Without the break, a stray number after the currency would be counted
5502    /// and a well-formed directive rejected.
5503    #[test]
5504    fn a_trailing_currency_closes_the_value_scan() {
5505        let result = parse_via_cst("2024-01-15 price HOOL 1.50 USD 2\n");
5506        assert!(
5507            !has_syntax_error(&result, "malformed amount"),
5508            "the scan must stop at the closing currency, so the stray `2` is not \
5509             a second number of the VALUE: {:?}",
5510            result.errors
5511        );
5512    }
5513
5514    /// Only tokens AFTER the account can be orphans, and only `+`, `-` and `,`
5515    /// qualify. Both halves of that were untested, so each row here would be
5516    /// reported as an orphan by a slightly wrong predicate.
5517    #[test]
5518    fn orphan_detection_ignores_pre_account_and_non_sign_tokens() {
5519        let orphan_reported = |src: &str| {
5520            has_syntax_error(
5521                &parse_via_cst(src),
5522                "unexpected token before posting amount",
5523            )
5524        };
5525
5526        assert!(
5527            !orphan_reported("2024-01-15 *\n  , Assets:A 1 USD\n  Assets:B\n"),
5528            "a comma BEFORE the account is not an orphaned amount prefix"
5529        );
5530        assert!(
5531            !orphan_reported("2024-01-15 *\n  Assets:A \"note\" 1 USD\n  Assets:B\n"),
5532            "a non-sign token between account and amount is not an orphan"
5533        );
5534        // The genuine orphan still is one, so the assertions above cannot pass
5535        // by the detector simply never firing.
5536        assert!(
5537            orphan_reported("2024-01-15 *\n  Assets:A , 1 USD\n  Assets:B\n"),
5538            "a comma after the account IS an orphan"
5539        );
5540    }
5541
5542    /// A posting's trailing comment is collected up to the newline. Stopping on
5543    /// the wrong condition silently drops every one of them.
5544    #[test]
5545    fn posting_trailing_comment_is_captured() {
5546        let result = parse_via_cst("2024-01-15 *\n  Assets:A 1 USD ; why\n  Assets:B\n");
5547        let Some(Directive::Transaction(txn)) = result.directives.first().map(|d| &d.value) else {
5548            panic!("expected a transaction");
5549        };
5550        let first = &txn.postings[0];
5551        assert!(
5552            first.trailing_comments.iter().any(|c| c.contains("why")),
5553            "expected the trailing comment on the posting, got {:?}",
5554            first.trailing_comments
5555        );
5556    }
5557
5558    /// The sign scanner behind `price`/`balance` fallback conversion.
5559    ///
5560    /// Reaching it takes work: `directive_arithmetic_value` runs first and
5561    /// handles ordinary unary minus, so `- 1.50` never gets here. The fallback
5562    /// only runs when the arithmetic parse declines, and `- 1.50 - USD` is one
5563    /// such shape -- error recovery tolerates the trailing operator, the
5564    /// arithmetic parse gives up, and the AST accessor then hands back an
5565    /// UNSIGNED number that this scanner has to re-sign.
5566    ///
5567    /// Probed for rather than assumed: the whole test suite and all 995 corpus
5568    /// files leave this branch cold, so it looked like dead code until an
5569    /// adversarial sweep found the inputs. Worth stating, because deleting it
5570    /// on that first impression would have been wrong.
5571    #[test]
5572    fn price_and_balance_fallback_re_signs_a_leading_minus() {
5573        let number_of = |src: &str| -> Decimal {
5574            let r = parse_via_cst(src);
5575            match r.directives.first().map(|d| &d.value) {
5576                Some(Directive::Price(p)) => p.amount.number,
5577                Some(Directive::Balance(b)) => b.amount.number,
5578                other => panic!("expected price/balance from {src:?}, got {other:?}"),
5579            }
5580        };
5581
5582        // MINUS before the number: re-signed.
5583        assert_eq!(
5584            number_of("2024-01-15 price HOOL - 1.50 - USD\n"),
5585            rust_decimal_macros::dec!(-1.50)
5586        );
5587        assert_eq!(
5588            number_of("2024-01-15 balance Assets:C - 1.50 - USD\n"),
5589            rust_decimal_macros::dec!(-1.50)
5590        );
5591
5592        // NUMBER first: the scan stops there, so a LATER minus must not flip
5593        // the sign. Without this the "stop at the number" arm is free to vanish.
5594        assert_eq!(
5595            number_of("2024-01-15 price HOOL 1.50 - USD\n"),
5596            rust_decimal_macros::dec!(1.50)
5597        );
5598        assert_eq!(
5599            number_of("2024-01-15 balance Assets:C 1.50 - USD\n"),
5600            rust_decimal_macros::dec!(1.50)
5601        );
5602    }
5603
5604    /// The red conversion path is a mirror of the green one, kept for the
5605    /// `green_eq_red` differential fuzz target. Production parses via green, so
5606    /// a test written against `parse_via_cst` exercises the mirror only where
5607    /// the two SHARE a helper -- which is why the 2026-08-01 mutation run
5608    /// showed red-only code uncovered even though its green twin was tested.
5609    ///
5610    /// These drive `parse_red_only` directly, and assert the two paths agree,
5611    /// so the mirror cannot rot silently.
5612    #[test]
5613    fn red_path_matches_green_on_posting_comments_and_orphan_detection() {
5614        let orphan_msg = "unexpected token before posting amount";
5615
5616        // Trailing comment on a posting line, collected by the red converter's
5617        // own scan up to the terminating NEWLINE.
5618        let src = "2024-01-15 *\n  Assets:A 1 USD ; why\n  Assets:B\n";
5619        for (label, result) in [("green", parse_via_cst(src)), ("red", parse_red_only(src))] {
5620            let Some(Directive::Transaction(txn)) = result.directives.first().map(|d| &d.value)
5621            else {
5622                panic!("{label}: expected a transaction");
5623            };
5624            assert!(
5625                txn.postings[0]
5626                    .trailing_comments
5627                    .iter()
5628                    .any(|c| c.contains("why")),
5629                "{label}: trailing comment lost, got {:?}",
5630                txn.postings[0].trailing_comments
5631            );
5632        }
5633
5634        // Orphan detection, through the red converter: a comma after the
5635        // account is one, a comma before it and a non-sign token are not.
5636        let orphan_reported = |src: &str| has_syntax_error(&parse_red_only(src), orphan_msg);
5637        assert!(
5638            orphan_reported("2024-01-15 *\n  Assets:A , 1 USD\n  Assets:B\n"),
5639            "red: a comma after the account IS an orphan"
5640        );
5641        assert!(
5642            !orphan_reported("2024-01-15 *\n  , Assets:A 1 USD\n  Assets:B\n"),
5643            "red: a comma BEFORE the account is not"
5644        );
5645        assert!(
5646            !orphan_reported("2024-01-15 *\n  Assets:A \"note\" 1 USD\n  Assets:B\n"),
5647            "red: a non-sign token between account and amount is not"
5648        );
5649    }
5650
5651    // ---- metadata and custom values: the other token-level canonical ----
5652    //
5653    // `meta_value_from_tokens` is the twin of `cost_spec_from_tokens` and had
5654    // the same shape of gap: first-of-kind latches and a sign machine that no
5655    // test touched. `value_tokens_to_meta` is the sibling used by custom
5656    // directives and the red path.
5657
5658    fn meta_of(entries: &str) -> rustledger_core::Metadata {
5659        let src = format!("2024-01-15 open Assets:A\n{entries}");
5660        let result = parse_via_cst(&src);
5661        let Some(Directive::Open(open)) = result.directives.first().map(|d| &d.value) else {
5662            panic!("expected an Open from {src:?}, errors {:?}", result.errors);
5663        };
5664        open.meta.clone()
5665    }
5666
5667    fn custom_values(line: &str) -> Vec<MetaValue> {
5668        let result = parse_via_cst(line);
5669        let Some(Directive::Custom(c)) = result.directives.first().map(|d| &d.value) else {
5670            panic!(
5671                "expected a Custom from {line:?}, errors {:?}",
5672                result.errors
5673            );
5674        };
5675        c.values.clone()
5676    }
5677
5678    /// Every value kind a metadata entry can carry. Deleting any one arm made
5679    /// that kind silently fall through to the next candidate in the priority
5680    /// order, which is invisible unless the kind is asserted directly.
5681    #[test]
5682    fn metadata_values_cover_every_kind() {
5683        let meta = meta_of(
5684            "  str: \"hello\"\n  num: 42\n  amt: 42 USD\n  dt: 2024-06-01\n  \
5685             acct: Assets:B\n  cur: USD\n  yes: TRUE\n  no: FALSE\n  \
5686             tg: #mytag\n  lk: ^mylink\n",
5687        );
5688        let got = |k: &str| meta.get(k).cloned().unwrap_or(MetaValue::None);
5689
5690        assert_eq!(got("str"), MetaValue::String("hello".into()));
5691        assert_eq!(got("num"), MetaValue::Int(42));
5692        assert_eq!(
5693            got("amt"),
5694            MetaValue::Amount(Amount::new(rust_decimal_macros::dec!(42), "USD"))
5695        );
5696        assert_eq!(got("dt"), MetaValue::Date(naive_date(2024, 6, 1).unwrap()));
5697        assert_eq!(got("acct"), MetaValue::Account(Account::new("Assets:B")));
5698        assert_eq!(got("cur"), MetaValue::Currency(Currency::new("USD")));
5699        assert_eq!(got("yes"), MetaValue::Bool(true));
5700        assert_eq!(got("no"), MetaValue::Bool(false));
5701        assert_eq!(got("tg"), MetaValue::Tag(Tag::new("mytag")));
5702        assert_eq!(got("lk"), MetaValue::Link(Link::new("mylink")));
5703    }
5704
5705    /// First-of-kind latching, the same rule `cost_spec_from_tokens` uses. A
5706    /// repeated token of any kind keeps the FIRST, and nothing exercised that
5707    /// for metadata, so every latch guard could be flipped freely.
5708    #[test]
5709    fn metadata_latches_the_first_token_of_each_kind() {
5710        // Two-character keys because beancount requires them, and so do we
5711        // since #1955. These are fixture names only; the test is about
5712        // first-of-kind LATCHING and nothing here depends on key length.
5713        let meta = meta_of(
5714            "  ss: \"one\" \"two\"\n  nn: 1 2\n  cc: USD EUR\n  dd: 2024-06-01 2025-07-02\n  \
5715             aa: Assets:First Assets:Second\n  bb: TRUE FALSE\n  tt: #first #second\n",
5716        );
5717        let got = |k: &str| meta.get(k).cloned().unwrap_or(MetaValue::None);
5718
5719        assert_eq!(got("ss"), MetaValue::String("one".into()));
5720        assert_eq!(got("nn"), MetaValue::Int(1));
5721        assert_eq!(got("dd"), MetaValue::Date(naive_date(2024, 6, 1).unwrap()));
5722        assert_eq!(got("aa"), MetaValue::Account(Account::new("Assets:First")));
5723        assert_eq!(got("bb"), MetaValue::Bool(true), "TRUE came first");
5724        assert_eq!(got("tt"), MetaValue::Tag(Tag::new("first")));
5725        // `c` pairs a number-less currency run: the FIRST currency wins.
5726        assert_eq!(got("cc"), MetaValue::Currency(Currency::new("USD")));
5727    }
5728
5729    /// The sign machine: a MINUS after the key negates the number.
5730    ///
5731    /// The third case CHANGED with #1944. It used to assert that `42 - 1` is
5732    /// `Int(42)` — "a minus past the number is not a sign; the first NUMBER
5733    /// closes it". That described the truncation faithfully but was never the
5734    /// right answer: beancount evaluates it and reports **41**, verified
5735    /// directly against the oracle before this expectation was touched. The
5736    /// old assertion was pinning a bug as intended behavior, which is why it
5737    /// took a differential comparison rather than a reading to notice.
5738    ///
5739    /// The sign machine itself is unchanged and still pinned by the first two
5740    /// cases: a leading MINUS is now consumed by the expression evaluator
5741    /// instead of a separate flag, and reaches the same values.
5742    #[test]
5743    fn metadata_minus_applies_only_before_the_number() {
5744        let meta = meta_of("  neg: -42\n  negamt: -42 USD\n  after: 42 - 1\n");
5745        let got = |k: &str| meta.get(k).cloned().unwrap_or(MetaValue::None);
5746
5747        assert_eq!(got("neg"), MetaValue::Int(-42));
5748        assert_eq!(
5749            got("negamt"),
5750            MetaValue::Amount(Amount::new(rust_decimal_macros::dec!(-42), "USD")),
5751            "the sign applies to the amount too"
5752        );
5753        assert_eq!(
5754            got("after"),
5755            MetaValue::Int(41),
5756            "an expression in a metadata value is evaluated, matching beancount"
5757        );
5758    }
5759
5760    /// `value_tokens_to_meta` walks a token run and returns the NEXT index, so
5761    /// a wrong advance either drops values or repeats them. Custom directives
5762    /// are the surface that reads several values in a row, which makes the
5763    /// advance observable.
5764    #[test]
5765    fn custom_directive_values_advance_one_value_at_a_time() {
5766        assert_eq!(
5767            custom_values("2024-01-15 custom \"b\" FALSE TRUE FALSE\n"),
5768            vec![
5769                MetaValue::Bool(false),
5770                MetaValue::Bool(true),
5771                MetaValue::Bool(false)
5772            ],
5773            "each bool consumes exactly one token"
5774        );
5775
5776        assert_eq!(
5777            custom_values("2024-01-15 custom \"b\" 42 USD TRUE\n"),
5778            vec![
5779                MetaValue::Amount(Amount::new(rust_decimal_macros::dec!(42), "USD")),
5780                MetaValue::Bool(true)
5781            ],
5782            "NUMBER + CURRENCY consumes TWO tokens and the next value still lands"
5783        );
5784
5785        assert_eq!(
5786            custom_values("2024-01-15 custom \"b\" USD TRUE\n"),
5787            vec![
5788                MetaValue::Currency(Currency::new("USD")),
5789                MetaValue::Bool(true)
5790            ],
5791            "a lone CURRENCY is a value in its own right, not an amount fragment"
5792        );
5793
5794        assert_eq!(
5795            custom_values("2024-01-15 custom \"b\" -42 #tag ^link 2024-06-01 Assets:B\n"),
5796            vec![
5797                MetaValue::Int(-42),
5798                MetaValue::Tag(Tag::new("tag")),
5799                MetaValue::Link(Link::new("link")),
5800                MetaValue::Date(naive_date(2024, 6, 1).unwrap()),
5801                MetaValue::Account(Account::new("Assets:B")),
5802            ],
5803            "MINUS + NUMBER consumes two tokens; the rest follow in order"
5804        );
5805    }
5806
5807    /// The bool and tag/link latches, in the order that actually exercises the
5808    /// SECOND arm of each pair. `TRUE FALSE` only proves the first arm latches;
5809    /// reversing it is what pins the guard on the other one.
5810    #[test]
5811    fn metadata_latches_bool_and_taglink_in_either_order() {
5812        // Two-character keys, per #1955; `tl` / `lt` already were.
5813        let meta = meta_of("  bb: FALSE TRUE\n  tl: #tag ^link\n  lt: ^link #tag\n");
5814        let got = |k: &str| meta.get(k).cloned().unwrap_or(MetaValue::None);
5815
5816        assert_eq!(
5817            got("bb"),
5818            MetaValue::Bool(false),
5819            "FALSE came first, so the later TRUE must not overwrite it"
5820        );
5821        assert_eq!(
5822            got("tl"),
5823            MetaValue::Tag(Tag::new("tag")),
5824            "tag and link share one slot; the tag came first"
5825        );
5826        assert_eq!(
5827            got("lt"),
5828            MetaValue::Link(Link::new("link")),
5829            "and the link wins when it comes first"
5830        );
5831    }
5832
5833    /// `extract_custom_values` advances by the index the discriminator returns.
5834    /// A helper that failed to advance would spin the loop forever, so the
5835    /// caller clamps. This pins that a long run of values terminates and is
5836    /// read in order -- a hang here would let malformed input stall the parser.
5837    #[test]
5838    fn custom_values_terminate_on_a_long_run() {
5839        let values = custom_values(
5840            "2024-01-15 custom \"b\" 1 USD 2 EUR TRUE FALSE #a ^b 2024-06-01 Assets:X \"s\"\n",
5841        );
5842        assert_eq!(
5843            values.len(),
5844            9,
5845            "every value consumed exactly once, got {values:?}"
5846        );
5847        assert_eq!(
5848            values.first(),
5849            Some(&MetaValue::Amount(Amount::new(
5850                rust_decimal_macros::dec!(1),
5851                "USD"
5852            )))
5853        );
5854        assert_eq!(values.last(), Some(&MetaValue::String("s".into())));
5855    }
5856
5857    /// The scan skips the directive header (date, keyword, and the type-name
5858    /// string) before reading values, steps past tokens that are not values,
5859    /// and terminates when there are none. Each of those is a separate step in
5860    /// the loop and none had a test.
5861    #[test]
5862    fn custom_directive_scan_skips_the_header_and_non_values() {
5863        assert_eq!(
5864            custom_values("2024-01-15 custom \"b\"\n"),
5865            vec![],
5866            "the type name is the header, not a value, and no values is valid"
5867        );
5868
5869        assert_eq!(
5870            custom_values("2024-01-15 custom \"b\" \"x\" 42\n"),
5871            vec![MetaValue::String("x".into()), MetaValue::Int(42)],
5872            "the FIRST string is the type name; a later one IS a value"
5873        );
5874
5875        // A `*` is not a value, so the scan must step over it rather than
5876        // stall. Both positions matter: before any value, and between two.
5877        assert_eq!(
5878            custom_values("2024-01-15 custom \"b\" * 42\n"),
5879            vec![MetaValue::Int(42)],
5880            "a non-value token before the first value is stepped over"
5881        );
5882        assert_eq!(
5883            custom_values("2024-01-15 custom \"b\" 42 * 7\n"),
5884            vec![MetaValue::Int(42), MetaValue::Int(7)],
5885            "and between two values"
5886        );
5887    }
5888}