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    } = if use_green {
113        // Green-tree walk (no per-node red allocation); byte-identical to red.
114        super::green::walk_descendants(source_file.syntax(), bom_offset, collect_occurrences)
115    } else {
116        walk_descendants_once(&source_file, bom_offset, collect_occurrences)
117    };
118
119    // Fused single pass over the top-level children replaces the
120    // five former per-child traversals (error-node, transaction-body,
121    // indented-directive, custom-value diagnostics + section-marker
122    // comments). See `walk_top_level_once`.
123    let TopLevelWalkResult {
124        errors: top_level_errors,
125        section_marker_comments,
126    } = if use_green {
127        super::green::walk_top_level(source_file.syntax(), stripped, bom_offset)
128    } else {
129        walk_top_level_once(&source_file, stripped, bom_offset)
130    };
131
132    let mut comments: Vec<Spanned<String>> = top_level_comments;
133    comments.extend(section_marker_comments);
134    // Merge in source order; the two helpers' classifiers are
135    // disjoint today (STAR-first vs COMMENT-kind-first) but
136    // dedup-by-start keeps the invariant local.
137    comments.sort_by_key(|s| s.span.start);
138    comments.dedup_by_key(|s| s.span.start);
139    let mut errors = top_level_errors;
140    // Cost specs are always delimited by '{' (L_BRACE / L_DOUBLE_BRACE /
141    // L_BRACE_HASH all start with it). If the source has no '{' at all there can
142    // be no COST_SPEC node, so skip the whole-tree `descendants()` scan inside
143    // `extract_unclosed_cost_brace_errors` — it allocates a red `SyntaxNode` per
144    // node. Profiling flagged that scan as ~50k wasted allocations on
145    // cost-spec-free ledgers (the common case); the `contains('{')` guard is a
146    // memchr byte scan with no allocation. A '{' inside a string/comment only
147    // costs the (already-correct) full scan — never a false skip.
148    if stripped.contains('{') {
149        errors.extend(extract_unclosed_cost_brace_errors(&source_file, bom_offset));
150    }
151    errors.extend(inline_errors);
152    let warnings = Vec::new();
153
154    // pushtag/poptag/pushmeta/popmeta state. The legacy parser
155    // maintains a stack across directives; each Transaction
156    // inherits the active pushed-tag set, and EVERY directive
157    // inherits the active pushed-meta set. We pair each entry
158    // with the originating directive's span so unclosed-at-EOF
159    // diagnostics can point at the offending push.
160    let mut tag_stack: Vec<(Tag, Span)> = Vec::new();
161    // Vec-of-tuples (NOT a `Metadata` map) so legacy semantics
162    // are preserved: `pushmeta x: 1` then `pushmeta x: 2` should
163    // shadow (peek returns 2) and `popmeta x` should pop the
164    // most recent, leaving x=1 active. A HashMap would have lost
165    // the shadowed entry on the second push.
166    let mut meta_stack: Vec<(String, MetaValue, Span)> = Vec::new();
167
168    for directive in source_file.directives() {
169        // Helper to push a successfully-converted directive
170        // alongside its CST node so the post-pass span fixup
171        // can index them in parallel.
172        let cst_node = directive.syntax().clone();
173        // `is_directive_producing` tracks whether THIS arm is
174        // expected to emit a `Spanned<Directive>` (the 12
175        // directive types). The catch-all below uses it to
176        // surface a `SyntaxError` when a producing converter
177        // returned `None` without emitting a more specific
178        // diagnostic - the silent-drop class of bug the integ
179        // tests caught for `2024-01-01 open` (no account),
180        // `balance Assets:X` (no amount), etc.
181        let is_directive_producing = matches!(
182            directive,
183            ast::Directive::Open(_)
184                | ast::Directive::Close(_)
185                | ast::Directive::Commodity(_)
186                | ast::Directive::Note(_)
187                | ast::Directive::Document(_)
188                | ast::Directive::Event(_)
189                | ast::Directive::Query(_)
190                | ast::Directive::Price(_)
191                | ast::Directive::Balance(_)
192                | ast::Directive::Pad(_)
193                | ast::Directive::Custom(_)
194                | ast::Directive::Transaction(_)
195        );
196        let errors_before = errors.len();
197        let pushed_directive = match directive {
198            ast::Directive::Open(node) => convert_open(&node, bom_offset, &mut errors),
199            ast::Directive::Close(node) => convert_close(&node, bom_offset, &mut errors),
200            ast::Directive::Commodity(node) => convert_commodity(&node, bom_offset, &mut errors),
201            ast::Directive::Note(node) => convert_note(&node, bom_offset, &mut errors),
202            ast::Directive::Document(node) => convert_document(&node, bom_offset, &mut errors),
203            ast::Directive::Event(node) => convert_event(&node, bom_offset, &mut errors),
204            ast::Directive::Query(node) => convert_query(&node, bom_offset, &mut errors),
205            ast::Directive::Price(node) => convert_price(&node, bom_offset, &mut errors),
206            ast::Directive::Balance(node) => convert_balance(&node, bom_offset, &mut errors),
207            ast::Directive::Pad(node) => convert_pad(&node, bom_offset, &mut errors),
208            ast::Directive::Custom(node) => convert_custom(&node, bom_offset, &mut errors),
209            ast::Directive::Transaction(node) => {
210                // Green-tree conversion (no red-node allocation) with a red
211                // fallback for transactions it doesn't yet handle exactly. The
212                // green path returns `Some` only when its output is identical to
213                // red's, so the hybrid is output-equivalent to the pure-red path.
214                let green = node.syntax().green();
215                let base =
216                    u32::from(node.syntax().text_range().start()) as usize + bom_offset as usize;
217                let green_dir = if use_green {
218                    super::green::convert_transaction(&green, base)
219                } else {
220                    None
221                };
222                match green_dir {
223                    Some(d) => Some(d),
224                    None => convert_transaction(&node, bom_offset, &mut errors),
225                }
226            }
227            ast::Directive::Option(node) => {
228                if let Some(triple) = convert_option(&node, bom_offset) {
229                    options.push(triple);
230                }
231                None
232            }
233            ast::Directive::Include(node) => {
234                if let Some(pair) = convert_include(&node, bom_offset) {
235                    includes.push(pair);
236                }
237                None
238            }
239            ast::Directive::Plugin(node) => {
240                if let Some(triple) = convert_plugin(&node, bom_offset) {
241                    plugins.push(triple);
242                }
243                None
244            }
245            // State-only side effects: mutate the inherited
246            // tag/meta sets that apply to subsequent directives.
247            ast::Directive::Pushtag(node) => {
248                if let Some(tag_token) = node.tag() {
249                    let span = node_span(node.syntax(), bom_offset);
250                    tag_stack.push((Tag::new(tag_token.text().trim_start_matches('#')), span));
251                }
252                None
253            }
254            ast::Directive::Poptag(node) => {
255                if let Some(tag_token) = node.tag() {
256                    let name = tag_token.text().trim_start_matches('#');
257                    if let Some(pos) = tag_stack.iter().rposition(|(t, _)| t.as_str() == name) {
258                        tag_stack.remove(pos);
259                    } else {
260                        errors.push(crate::ParseError::new(
261                            crate::ParseErrorKind::InvalidPoptag(name.to_string()),
262                            node_span(node.syntax(), bom_offset),
263                        ));
264                    }
265                }
266                None
267            }
268            ast::Directive::Pushmeta(node) => {
269                if let Some(key_token) = node.key() {
270                    let key = key_token.text_without_colon().to_string();
271                    let value = pushmeta_value(node.syntax());
272                    let span = node_span(node.syntax(), bom_offset);
273                    meta_stack.push((key, value, span));
274                }
275                None
276            }
277            ast::Directive::Popmeta(node) => {
278                if let Some(key_token) = node.key() {
279                    let key = key_token.text_without_colon().to_string();
280                    if let Some(pos) = meta_stack.iter().rposition(|(k, _, _)| k == &key) {
281                        meta_stack.remove(pos);
282                    } else {
283                        errors.push(crate::ParseError::new(
284                            crate::ParseErrorKind::InvalidPopmeta(key),
285                            node_span(node.syntax(), bom_offset),
286                        ));
287                    }
288                }
289                None
290            }
291        };
292        if let Some(mut spanned) = pushed_directive {
293            apply_inherited_state(&mut spanned.value, &tag_stack, &meta_stack);
294            directives.push(spanned);
295            directive_nodes.push(cst_node);
296        } else if is_directive_producing && errors.len() == errors_before {
297            // Producing converter silently dropped the directive
298            // (typically: a required field like an account on
299            // `open`, an amount on `balance`, or a source account
300            // on `pad` was missing). Mirror the legacy parser's
301            // top-level error-recovery path which emits a
302            // `SyntaxError("unexpected input")` for the failed
303            // span so downstream tooling sees the same shape.
304            errors.push(crate::ParseError::new(
305                crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
306                node_span(&cst_node, bom_offset),
307            ));
308        }
309    }
310
311    // Unclosed pushtag/pushmeta at EOF - legacy emits one error
312    // per leftover stack entry, pointing at the originating push
313    // directive's span.
314    for (tag, span) in &tag_stack {
315        errors.push(crate::ParseError::new(
316            crate::ParseErrorKind::UnclosedPushtag(tag.as_str().to_string()),
317            *span,
318        ));
319    }
320    for (key, _, span) in &meta_stack {
321        errors.push(crate::ParseError::new(
322            crate::ParseErrorKind::UnclosedPushmeta(key.clone()),
323            *span,
324        ));
325    }
326    errors.sort_by_key(|e| e.span.start);
327
328    // Post-pass: align directive spans with the legacy parser's
329    // convention (skip leading trivia, extend through inter-
330    // directive trivia to the next directive's start).
331    fixup_directive_spans(&source_file, bom_offset, &directive_nodes, &mut directives);
332
333    // Pre-compute the file-wide formatter alignment from the
334    // same `source_file` we just walked, so the formatter (and
335    // every LSP handler that calls it) can skip the O(N_postings)
336    // re-walk on every format request. See
337    // `ParseResult::alignment` rustdoc for the cache contract;
338    // the equivalence with a fresh `compute_alignment` call is
339    // pinned by `parse_result_alignment_cache::*` (lib.rs tests).
340    let alignment = crate::cst::format::compute_alignment(&source_file);
341
342    // Capture the green root before we drop `source_file`. The
343    // `.green()` call returns a Cow so we promote to owned with
344    // `into_owned()`; the resulting `GreenNode` is reference-
345    // counted internally, cheap to clone, and `Send + Sync` -
346    // safe to stash in `Arc<ParseResult>` that the LSP shares
347    // across threads.
348    let syntax_root = source_file.syntax().green().into_owned();
349
350    ParseResult {
351        directives,
352        options,
353        includes,
354        plugins,
355        comments,
356        errors,
357        warnings,
358        currency_occurrences,
359        account_occurrences,
360        has_leading_bom,
361        syntax_root,
362        alignment,
363    }
364}
365
366// ---- Directive converters --------------------------------------
367
368/// Valid booking methods per beancount v3 - must match the
369/// whitelist legacy `parser::parse_open_directive` enforces. An
370/// `open` directive whose explicit booking string isn't on this
371/// list is rejected (directive dropped, `InvalidBookingMethod`
372/// error emitted) by both the legacy parser and `convert_open`.
373const VALID_BOOKING_METHODS: &[&str] = &[
374    "FIFO",
375    "STRICT",
376    "STRICT_WITH_SIZE",
377    "LIFO",
378    "HIFO",
379    "NONE",
380    "AVERAGE",
381];
382
383fn convert_open(
384    node: &OpenDirective,
385    bom_offset: u32,
386    errors: &mut Vec<crate::ParseError>,
387) -> Option<Spanned<Directive>> {
388    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
389    let account = Account::new(node.account()?.text());
390    let currencies: Vec<Currency> = node.currencies().map(|c| Currency::new(c.text())).collect();
391    let booking = node.booking_method().and_then(|s| s.text_decoded());
392    let span = node_span(node.syntax(), bom_offset);
393    if let Some(b) = &booking
394        && !VALID_BOOKING_METHODS.contains(&b.as_str())
395    {
396        errors.push(crate::ParseError::new(
397            crate::ParseErrorKind::InvalidBookingMethod(b.clone()),
398            span,
399        ));
400        return None;
401    }
402    let meta = convert_meta_entries(node.syntax());
403
404    let open = rustledger_core::directive::Open {
405        date,
406        account,
407        currencies,
408        booking,
409        meta,
410    };
411    Some(Spanned::new(Directive::Open(open), span))
412}
413
414fn convert_close(
415    node: &CloseDirective,
416    bom_offset: u32,
417    errors: &mut Vec<crate::ParseError>,
418) -> Option<Spanned<Directive>> {
419    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
420    let account = Account::new(node.account()?.text());
421    let meta = convert_meta_entries(node.syntax());
422
423    let close = rustledger_core::directive::Close {
424        date,
425        account,
426        meta,
427    };
428    let span = node_span(node.syntax(), bom_offset);
429    Some(Spanned::new(Directive::Close(close), span))
430}
431
432fn convert_commodity(
433    node: &CommodityDirective,
434    bom_offset: u32,
435    errors: &mut Vec<crate::ParseError>,
436) -> Option<Spanned<Directive>> {
437    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
438    let currency = Currency::new(node.currency()?.text());
439    let meta = convert_meta_entries(node.syntax());
440
441    let commodity = rustledger_core::directive::Commodity {
442        date,
443        currency,
444        meta,
445    };
446    let span = node_span(node.syntax(), bom_offset);
447    Some(Spanned::new(Directive::Commodity(commodity), span))
448}
449
450fn convert_note(
451    node: &NoteDirective,
452    bom_offset: u32,
453    errors: &mut Vec<crate::ParseError>,
454) -> Option<Spanned<Directive>> {
455    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
456    let account = Account::new(node.account()?.text());
457    let comment = node.text()?.text_decoded()?;
458    let meta = convert_meta_entries(node.syntax());
459
460    let note = rustledger_core::directive::Note {
461        date,
462        account,
463        comment,
464        meta,
465    };
466    let span = node_span(node.syntax(), bom_offset);
467    Some(Spanned::new(Directive::Note(note), span))
468}
469
470fn convert_document(
471    node: &DocumentDirective,
472    bom_offset: u32,
473    errors: &mut Vec<crate::ParseError>,
474) -> Option<Spanned<Directive>> {
475    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
476    let account = Account::new(node.account()?.text());
477    let path = node.path()?.text_decoded()?;
478    // Trailing tags/links on the document header (legacy
479    // `parse_document_directive` collects them in a loop after
480    // the path STRING). TAG / LINK tokens only appear in the
481    // header (not in META_ENTRY children, which are walked
482    // separately below), so a direct-child token walk that
483    // stops at the first NEWLINE captures them in source order.
484    let mut tags: Vec<Tag> = Vec::new();
485    let mut links: Vec<Link> = Vec::new();
486    for el in node.syntax().children_with_tokens() {
487        let rowan::NodeOrToken::Token(t) = el else {
488            continue;
489        };
490        match t.kind() {
491            crate::SyntaxKind::NEWLINE => break,
492            crate::SyntaxKind::TAG => {
493                tags.push(Tag::new(t.text().trim_start_matches('#')));
494            }
495            crate::SyntaxKind::LINK => {
496                links.push(Link::new(t.text().trim_start_matches('^')));
497            }
498            _ => {}
499        }
500    }
501    let meta = convert_meta_entries(node.syntax());
502
503    let document = rustledger_core::directive::Document {
504        date,
505        account,
506        path,
507        tags,
508        links,
509        meta,
510    };
511    let span = node_span(node.syntax(), bom_offset);
512    Some(Spanned::new(Directive::Document(document), span))
513}
514
515fn convert_event(
516    node: &EventDirective,
517    bom_offset: u32,
518    errors: &mut Vec<crate::ParseError>,
519) -> Option<Spanned<Directive>> {
520    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
521    let event_type = node.event_type()?.text_decoded()?;
522    let value = node.value()?.text_decoded()?;
523    let meta = convert_meta_entries(node.syntax());
524
525    let event = rustledger_core::directive::Event {
526        date,
527        event_type,
528        value,
529        meta,
530    };
531    let span = node_span(node.syntax(), bom_offset);
532    Some(Spanned::new(Directive::Event(event), span))
533}
534
535fn convert_query(
536    node: &QueryDirective,
537    bom_offset: u32,
538    errors: &mut Vec<crate::ParseError>,
539) -> Option<Spanned<Directive>> {
540    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
541    let name = node.name()?.text_decoded()?;
542    let query = node.query()?.text_decoded()?;
543    let meta = convert_meta_entries(node.syntax());
544
545    let q = rustledger_core::directive::Query {
546        date,
547        name,
548        query,
549        meta,
550    };
551    let span = node_span(node.syntax(), bom_offset);
552    Some(Spanned::new(Directive::Query(q), span))
553}
554
555fn convert_price(
556    node: &PriceDirective,
557    bom_offset: u32,
558    errors: &mut Vec<crate::ParseError>,
559) -> Option<Spanned<Directive>> {
560    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
561    let base_currency = Currency::new(node.base_currency()?.text());
562    // Same arithmetic support as `convert_balance`: a price
563    // directive's value can use `+`, `-`, `*`, `/`, and parens.
564    let number = directive_arithmetic_value(node.syntax()).or_else(|| {
565        let mut n = parse_decimal_token(node.number()?.text())?;
566        if node_has_minus_before_number(node.syntax()) {
567            n = -n;
568        }
569        Some(n)
570    })?;
571    let quote_currency = Currency::new(node.quote_currency()?.text());
572    let amount = Amount::new(number, quote_currency);
573    let meta = convert_meta_entries(node.syntax());
574
575    let price = rustledger_core::directive::Price {
576        date,
577        currency: base_currency,
578        amount,
579        meta,
580    };
581    let span = node_span(node.syntax(), bom_offset);
582    Some(Spanned::new(Directive::Price(price), span))
583}
584
585fn convert_balance(
586    node: &BalanceDirective,
587    bom_offset: u32,
588    errors: &mut Vec<crate::ParseError>,
589) -> Option<Spanned<Directive>> {
590    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
591    let account = Account::new(node.account()?.text());
592    // Beancount accepts arithmetic in the balance assertion's
593    // value (`balance Assets:X 0.25 + 0.75 GBP` ≡ 1.00 GBP).
594    // Falls back to the first NUMBER token if the expression
595    // can't be evaluated, with the legacy sign-flip behavior.
596    let number = directive_arithmetic_value(node.syntax()).or_else(|| {
597        let mut n = parse_decimal_token(node.number()?.text())?;
598        if node_has_minus_before_number(node.syntax()) {
599            n = -n;
600        }
601        Some(n)
602    })?;
603    let currency = Currency::new(node.currency()?.text());
604    let amount = Amount::new(number, currency);
605    let tolerance = extract_balance_tolerance(node.syntax());
606    let meta = convert_meta_entries(node.syntax());
607
608    let balance = rustledger_core::directive::Balance {
609        date,
610        account,
611        amount,
612        tolerance,
613        meta,
614    };
615    let span = node_span(node.syntax(), bom_offset);
616    Some(Spanned::new(Directive::Balance(balance), span))
617}
618
619/// Balance directives may include an explicit tolerance via a
620/// `~` (TILDE) token followed by a NUMBER. The typed-AST surface
621/// surfaces NUMBER via `number()` (which returns the FIRST one,
622/// the asserted balance); the tolerance NUMBER comes second.
623/// Walk raw tokens until TILDE, then collect the next NUMBER.
624fn extract_balance_tolerance(node: &crate::SyntaxNode) -> Option<Decimal> {
625    let mut past_tilde = false;
626    for el in node.children_with_tokens() {
627        let rowan::NodeOrToken::Token(t) = el else {
628            continue;
629        };
630        if past_tilde && t.kind() == crate::SyntaxKind::NUMBER {
631            return parse_decimal_token(t.text());
632        }
633        if t.kind() == crate::SyntaxKind::TILDE {
634            past_tilde = true;
635        }
636    }
637    None
638}
639
640fn convert_pad(
641    node: &PadDirective,
642    bom_offset: u32,
643    errors: &mut Vec<crate::ParseError>,
644) -> Option<Spanned<Directive>> {
645    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
646    let account = Account::new(node.target_account()?.text());
647    let source_account = Account::new(node.source_account()?.text());
648    let meta = convert_meta_entries(node.syntax());
649
650    let pad = rustledger_core::directive::Pad {
651        date,
652        account,
653        source_account,
654        meta,
655    };
656    let span = node_span(node.syntax(), bom_offset);
657    Some(Spanned::new(Directive::Pad(pad), span))
658}
659
660fn convert_custom(
661    node: &CustomDirective,
662    bom_offset: u32,
663    errors: &mut Vec<crate::ParseError>,
664) -> Option<Spanned<Directive>> {
665    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
666    let custom_type = node.custom_type()?.text_decoded()?;
667    let values = extract_custom_values(node.syntax());
668    let meta = convert_meta_entries(node.syntax());
669
670    let custom = rustledger_core::directive::Custom {
671        date,
672        custom_type,
673        values,
674        meta,
675    };
676    let span = node_span(node.syntax(), bom_offset);
677    Some(Spanned::new(Directive::Custom(custom), span))
678}
679
680/// Walk the heterogeneous value tokens after the `custom "type"`
681/// header. The legacy parser tries each value type in this order:
682/// string > account > bool > amount (NUMBER+CURRENCY) > number >
683/// date > currency. We replicate that priority on the flat token
684/// stream, with one structural pass that pairs an immediately-
685/// adjacent NUMBER+CURRENCY into an [`Amount`].
686fn extract_custom_values(node: &crate::SyntaxNode) -> Vec<MetaValue> {
687    let mut values = Vec::new();
688    let mut seen_type_string = false;
689    // Collect tokens by kind, skipping trivia. We do a two-pass:
690    // first form Amount pairs (NUMBER + CURRENCY adjacent, ignoring
691    // whitespace), then emit remaining tokens individually.
692    let raw: Vec<rowan::SyntaxToken<crate::BeancountLanguage>> = node
693        .children_with_tokens()
694        .filter_map(rowan::NodeOrToken::into_token)
695        .filter(|t| {
696            !matches!(
697                t.kind(),
698                crate::SyntaxKind::WHITESPACE
699                    | crate::SyntaxKind::NEWLINE
700                    | crate::SyntaxKind::COMMENT
701            )
702        })
703        .collect();
704
705    let mut i = 0;
706    while i < raw.len() {
707        // Skip the directive's header tokens (DATE, CUSTOM_KW, and
708        // the first STRING which is the custom-type name).
709        if !seen_type_string {
710            if raw[i].kind() == crate::SyntaxKind::STRING {
711                seen_type_string = true;
712            }
713            i += 1;
714            continue;
715        }
716        // One value at a time through the shared discriminator — this is what
717        // gives custom directives the same MINUS-sign, Tag/Link and
718        // `NUMBER CURRENCY` → Amount handling as metadata entries.
719        if let Some((value, next)) = value_tokens_to_meta(&raw, i) {
720            values.push(value);
721            i = next;
722        } else {
723            i += 1;
724        }
725    }
726    values
727}
728
729fn strip_string_quotes(raw: &str) -> Option<&str> {
730    let bytes = raw.as_bytes();
731    if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
732        return None;
733    }
734    Some(&raw[1..raw.len() - 1])
735}
736
737fn convert_option(node: &OptionDirective, bom_offset: u32) -> Option<(String, String, Span)> {
738    let key = node.key()?.text_decoded()?;
739    let value = node.value()?.text_decoded()?;
740    Some((
741        key,
742        value,
743        single_line_directive_span(node.syntax(), bom_offset),
744    ))
745}
746
747fn convert_include(node: &IncludeDirective, bom_offset: u32) -> Option<(String, Span)> {
748    let path = node.path()?.text_decoded()?;
749    Some((path, single_line_directive_span(node.syntax(), bom_offset)))
750}
751
752fn convert_plugin(
753    node: &PluginDirective,
754    bom_offset: u32,
755) -> Option<(String, Option<String>, Span)> {
756    let module = node.module()?.text_decoded()?;
757    let config = node.config().and_then(|c| c.text_decoded());
758    Some((
759        module,
760        config,
761        single_line_directive_span(node.syntax(), bom_offset),
762    ))
763}
764
765// ---- Transaction + Posting + sub-nodes -------------------------
766
767fn convert_transaction(
768    node: &AstTransaction,
769    bom_offset: u32,
770    errors: &mut Vec<crate::ParseError>,
771) -> Option<Spanned<Directive>> {
772    let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
773
774    // Flag: explicit (TransactionFlag) or implied (leading STRING
775    // with no flag token; defaults to '*').
776    let flag = node.flag().map_or('*', |f| flag_char_from_transaction(&f));
777
778    // Header strings, consumed straight off the iterator (no intermediate Vec,
779    // no count-then-unwrap): 0 -> empty narration; 1 -> narration only;
780    // 2 -> payee + narration; 3+ -> surface only the last as narration (the
781    // middles are unreachable through this typed shape).
782    let mut it = node.strings().filter_map(|s| s.text_decoded());
783    let (payee_str, narration_str) = match (it.next(), it.next(), it.next()) {
784        (None, _, _) => (None, String::new()),
785        (Some(n), None, _) => (None, n),
786        (Some(p), Some(n), None) => (Some(p), n),
787        // 3+: `c` is the 3rd string; if more follow, `it.last()` is the actual
788        // last (else it falls back to `c`). No clone in the common 0/1/2 cases.
789        (Some(_), Some(_), Some(c)) => (None, it.last().unwrap_or(c)),
790    };
791
792    let payee = payee_str.map(InternedStr::from);
793    let narration = InternedStr::from(narration_str);
794
795    // Tags / links from the TRANSACTION node: the typed AST
796    // accessor `tags()`/`links()` is scoped to the header region.
797    // Trailing TAG / LINK tokens appearing on body lines (after
798    // the header NEWLINE, OUTSIDE any POSTING / META_ENTRY child
799    // node) are also part of the transaction's tag/link set per
800    // Beancount semantics - `extract_transaction_body_errors`
801    // already exempts them from the malformed-body diagnostic for
802    // this reason. Aggregate them here so they don't silently
803    // disappear.
804    let mut tags: Vec<Tag> = node
805        .tags()
806        .map(|t| Tag::new(t.text().trim_start_matches('#')))
807        .collect();
808    let mut links: Vec<Link> = node
809        .links()
810        .map(|l| Link::new(l.text().trim_start_matches('^')))
811        .collect();
812    for el in node.syntax().children_with_tokens() {
813        let rowan::NodeOrToken::Token(t) = el else {
814            // Nodes (POSTING / META_ENTRY) own their own internal
815            // tokens; we don't recurse into them.
816            continue;
817        };
818        match t.kind() {
819            crate::SyntaxKind::TAG => {
820                let stripped = t.text().trim_start_matches('#');
821                let new_tag = Tag::new(stripped);
822                if !tags.contains(&new_tag) {
823                    tags.push(new_tag);
824                }
825            }
826            crate::SyntaxKind::LINK => {
827                let stripped = t.text().trim_start_matches('^');
828                let new_link = Link::new(stripped);
829                if !links.contains(&new_link) {
830                    links.push(new_link);
831                }
832            }
833            _ => {}
834        }
835    }
836
837    // Transaction-level metadata (META_ENTRY children directly on
838    // the TRANSACTION node, NOT on POSTING children).
839    let meta = convert_meta_entries(node.syntax());
840
841    // Postings + pre-posting comments. The CST puts inter-
842    // posting trivia (including `; comment` lines) as flat
843    // tokens DIRECT under TRANSACTION between two POSTING
844    // nodes. Walk in source order: COMMENT tokens accumulate
845    // into `pending`, then attach to the next POSTING node's
846    // `comments` field when we reach it. Tokens before the
847    // header NEWLINE are skipped (they're transaction-header
848    // content). Comments that remain in `pending` after the
849    // final posting belong to the transaction itself
850    // (legacy: `txn.trailing_comments = pending_comments`).
851    let (postings, trailing_comments) = collect_postings_with_comments(node, bom_offset, errors);
852
853    // Deprecated `|` separator between payee and narration: a
854    // PIPE token in the header region. Legacy treats this as a
855    // recoverable warning-shaped error (`DeprecatedPipeSymbol`)
856    // and keeps the directive, so we do the same here.
857    if header_has_pipe(node) {
858        errors.push(crate::ParseError::new(
859            crate::ParseErrorKind::DeprecatedPipeSymbol,
860            node_span(node.syntax(), bom_offset),
861        ));
862    }
863
864    let txn = rustledger_core::directive::Transaction {
865        date,
866        flag,
867        payee,
868        narration,
869        tags,
870        links,
871        meta,
872        postings,
873        trailing_comments,
874    };
875    let span = node_span(node.syntax(), bom_offset);
876    Some(Spanned::new(Directive::Transaction(txn), span))
877}
878
879/// Returns true if the TRANSACTION header (direct-child tokens
880/// up to the first NEWLINE) contains a `PIPE` token. The legacy
881/// parser surfaces a `DeprecatedPipeSymbol` diagnostic for this
882/// shape; the CST lexer classifies `|` as `PIPE`, so we just
883/// scan the header directly.
884fn header_has_pipe(node: &AstTransaction) -> bool {
885    for el in node.syntax().children_with_tokens() {
886        let rowan::NodeOrToken::Token(t) = el else {
887            continue;
888        };
889        if t.kind() == crate::SyntaxKind::NEWLINE {
890            return false;
891        }
892        if t.kind() == crate::SyntaxKind::PIPE {
893            return true;
894        }
895    }
896    false
897}
898
899/// Walk a `TRANSACTION`'s children in source order, attaching any
900/// inter-posting `; comment` lines that appear as flat tokens
901/// between `POSTING` nodes to the NEXT posting's `comments`
902/// field. Matches the legacy parser, which collects
903/// `pending_comments` while reading the body and applies them to
904/// the next posting it parses.
905///
906/// Tokens before the header-terminator NEWLINE belong to the
907/// transaction header (date/flag/strings/tags/links) and are
908/// skipped.
909///
910/// Returns `(postings, trailing_comments)`: the second element is
911/// any pending comments left over AFTER the final posting, which
912/// legacy assigns to `Transaction::trailing_comments`.
913fn collect_postings_with_comments(
914    node: &AstTransaction,
915    bom_offset: u32,
916    errors: &mut Vec<crate::ParseError>,
917) -> (Vec<Spanned<Posting>>, Vec<String>) {
918    let mut out = Vec::new();
919    let mut pending: Vec<String> = Vec::new();
920    let mut past_header = false;
921    for el in node.syntax().children_with_tokens() {
922        match el {
923            rowan::NodeOrToken::Token(t) => {
924                if !past_header {
925                    if t.kind() == crate::SyntaxKind::NEWLINE {
926                        past_header = true;
927                    }
928                    continue;
929                }
930                if is_comment_kind(t.kind()) {
931                    pending.push(t.text().to_string());
932                } else if !is_trivia_kind(t.kind())
933                    && !matches!(t.kind(), crate::SyntaxKind::TAG | crate::SyntaxKind::LINK)
934                {
935                    // Non-trivia, non-comment token in the
936                    // transaction body that's NOT inside a
937                    // POSTING / META_ENTRY child node = malformed
938                    // body line (caught separately by
939                    // `extract_transaction_body_errors`). Treat
940                    // the same as a failed POSTING: clear pending
941                    // so the malformed line's preceding comments
942                    // don't migrate onto the next valid posting.
943                    //
944                    // EXEMPT TAG / LINK: trailing tags/links on
945                    // transaction body lines (after the header)
946                    // are valid Beancount - they extend the
947                    // transaction's tag/link set without being
948                    // a new posting. Treating them as malformed
949                    // would drop legitimate preceding comments
950                    // that belong to the NEXT posting. The same
951                    // exemption appears in
952                    // `extract_transaction_body_errors`, which
953                    // does the parallel "is this a malformed
954                    // body line?" classification.
955                    pending.clear();
956                }
957            }
958            rowan::NodeOrToken::Node(n) => {
959                if !past_header {
960                    // META_ENTRY or POSTING before the header
961                    // NEWLINE shouldn't happen in well-formed
962                    // input; treat any child node as "past the
963                    // header" if we somehow encounter one.
964                    past_header = true;
965                }
966                if let Some(p) = ast::Posting::cast(n) {
967                    if let Some(mut spanned) = convert_posting(&p, bom_offset, errors) {
968                        if !pending.is_empty() {
969                            spanned.value.comments = std::mem::take(&mut pending);
970                        }
971                        out.push(spanned);
972                    } else {
973                        // Failed posting consumes any pending
974                        // inter-posting comments - they belonged
975                        // to it. Without this clear, a malformed
976                        // posting's preceding comments would
977                        // migrate forward and attach to the NEXT
978                        // successful posting, misattributing them
979                        // visibly to the wrong account line.
980                        pending.clear();
981                    }
982                }
983                // META_ENTRY child nodes: comments collected so
984                // far don't apply to them (they're transaction
985                // metadata). Drop them.
986            }
987        }
988    }
989    (out, pending)
990}
991
992fn flag_char_from_transaction(flag: &ast::TransactionFlag) -> char {
993    match flag.classify() {
994        TransactionFlagKind::Star | TransactionFlagKind::Txn => '*',
995        TransactionFlagKind::Pending => '!',
996        TransactionFlagKind::Hash => '#',
997        TransactionFlagKind::Letter | TransactionFlagKind::CurrencyLetter => {
998            flag.text().chars().next().unwrap_or('*')
999        }
1000    }
1001}
1002
1003fn convert_posting(
1004    node: &ast::Posting,
1005    bom_offset: u32,
1006    errors: &mut Vec<crate::ParseError>,
1007) -> Option<Spanned<Posting>> {
1008    let account = Account::new(node.account()?.text());
1009
1010    let flag = node.flag().map(|f| flag_char_from_posting(&f));
1011
1012    // A well-formed posting has AT MOST one `AMOUNT` child node
1013    // (the units). The CST builder will accept input like
1014    // `Expenses:Food  5 USD + 3 USD` and produce TWO sibling
1015    // `AMOUNT` nodes joined by a flat PLUS token, because the
1016    // grammar doesn't enforce that PLUS between two complete
1017    // amounts is invalid. `Posting::amount()` returns only the
1018    // first via `first_child`, so without this guard the second
1019    // amount (and the joining `+`) would be silently dropped and
1020    // the user's transaction would balance against the wrong
1021    // number. Emit a `SyntaxError` pointing at the trailing
1022    // siblings and keep the first amount.
1023    let mut amount_children = node
1024        .syntax()
1025        .children()
1026        .filter(|n| ast::Amount::can_cast(n.kind()));
1027    let first_amount = amount_children.next();
1028    let first_amount_end: Option<u32> = first_amount.as_ref().map(|n| n.text_range().end().into());
1029    let mut sibling_start: Option<u32> = None;
1030    let mut sibling_end: u32 = 0;
1031    for extra in amount_children {
1032        let range = extra.text_range();
1033        let start_u32: u32 = range.start().into();
1034        let end_u32: u32 = range.end().into();
1035        if sibling_start.is_none() {
1036            sibling_start = Some(start_u32);
1037        }
1038        sibling_end = end_u32;
1039    }
1040    if let Some(start_u32) = sibling_start {
1041        // Extend the span back to the end of the FIRST AMOUNT so
1042        // the diagnostic underline covers any joining operator
1043        // (`+`, `*`, whitespace) between the kept amount and the
1044        // orphans. Without this, a user sees only `3 USD` in
1045        // `5 USD + 3 USD` highlighted - and may not realize the
1046        // `+ 3 USD` together is what needs to be removed.
1047        let underline_start = first_amount_end.unwrap_or(start_u32);
1048        let span = Span::new(
1049            (underline_start + bom_offset) as usize,
1050            (sibling_end + bom_offset) as usize,
1051        );
1052        errors.push(crate::ParseError::new(
1053            crate::ParseErrorKind::SyntaxError(
1054                "unexpected trailing tokens after posting amount".to_string(),
1055            ),
1056            span,
1057        ));
1058    }
1059    let units = first_amount
1060        .and_then(ast::Amount::cast)
1061        .and_then(|amt| convert_amount_to_incomplete(&amt, errors, bom_offset));
1062    let cost = node.cost_spec().map(|cs| convert_cost_spec(&cs));
1063    let price = node
1064        .price_annotation()
1065        .map(|pa| convert_price_annotation(&pa, errors, bom_offset));
1066    let meta = convert_meta_entries(node.syntax());
1067
1068    // Trailing comments on the posting line: COMMENT direct-
1069    // child tokens BEFORE the terminator NEWLINE. The legacy
1070    // parser collects same-line `;` content into
1071    // `posting.trailing_comments`.
1072    let trailing_comments: Vec<String> = node
1073        .syntax()
1074        .children_with_tokens()
1075        .filter_map(rowan::NodeOrToken::into_token)
1076        .take_while(|t| t.kind() != crate::SyntaxKind::NEWLINE)
1077        .filter(|t| is_comment_kind(t.kind()))
1078        .map(|t| t.text().to_string())
1079        .collect();
1080
1081    let posting = Posting {
1082        account,
1083        units,
1084        cost,
1085        price,
1086        flag,
1087        meta,
1088        comments: Vec::new(),
1089        trailing_comments,
1090    };
1091    let span = posting_span(node.syntax(), bom_offset);
1092    Some(Spanned::new(posting, span))
1093}
1094
1095fn flag_char_from_posting(flag: &ast::PostingFlag) -> char {
1096    match flag.classify() {
1097        PostingFlagKind::Star => '*',
1098        PostingFlagKind::Pending => '!',
1099        PostingFlagKind::Hash => '#',
1100        PostingFlagKind::Letter | PostingFlagKind::CurrencyLetter => {
1101            flag.text().chars().next().unwrap_or('*')
1102        }
1103    }
1104}
1105
1106/// Convert an AMOUNT node into an [`IncompleteAmount`]. Returns
1107/// `None` if neither a number nor a currency is present (which
1108/// shouldn't happen for a well-formed AMOUNT, but matches the
1109/// lossless CST contract). Sign is folded into the number.
1110///
1111/// **Arithmetic limitation**: when the AMOUNT contains an
1112/// arithmetic expression (`100+5 USD`), only the FIRST `NUMBER`
1113/// is used. A proper expression evaluator is deferred - none of
1114/// the directive types we currently handle outside of postings
1115/// use AMOUNT shapes that the legacy parser would have evaluated
1116/// differently.
1117fn convert_amount_to_incomplete(
1118    amt: &ast::Amount,
1119    errors: &mut Vec<crate::ParseError>,
1120    bom_offset: u32,
1121) -> Option<IncompleteAmount> {
1122    // Arithmetic AMOUNT expressions (`120 / 3 USD`, `(1+2) USD`):
1123    // run the recursive-descent evaluator on the flat token
1124    // stream. Fast-path plain `NUMBER CURRENCY` shapes to keep
1125    // the common case allocation-free.
1126    let number = if amt.is_arithmetic() {
1127        let evaluated = evaluate_amount_expression(amt);
1128        if evaluated.is_none() {
1129            // `is_arithmetic` was true but the evaluator gave up
1130            // (decimal overflow, division by zero, malformed
1131            // expression, unbalanced parens). Without this
1132            // emission the amount silently degrades to
1133            // `CurrencyOnly` and the user only sees a downstream
1134            // "transaction doesn't balance" - masking the actual
1135            // root cause. Pin the span to the AMOUNT node so the
1136            // diagnostic underlines the offending expression.
1137            let range = amt.syntax().text_range();
1138            let start: u32 = range.start().into();
1139            let end: u32 = range.end().into();
1140            let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1141            errors.push(crate::ParseError::new(
1142                crate::ParseErrorKind::SyntaxError(
1143                    "invalid arithmetic expression in amount (overflow, division by zero, or malformed)"
1144                        .to_string(),
1145                ),
1146                span,
1147            ));
1148        }
1149        evaluated
1150    } else {
1151        amt.number().and_then(|n| {
1152            let parsed = parse_decimal_token(n.text());
1153            if parsed.is_none() {
1154                // Symmetry with the arithmetic-failure path: when
1155                // a plain NUMBER token in an AMOUNT can't be
1156                // turned into a Decimal (e.g., 30+ digits - the
1157                // lexer's NUMBER regex has no max length but
1158                // `rust_decimal`'s 28-digit ceiling rejects it),
1159                // surface a diagnostic instead of silently
1160                // degrading to `CurrencyOnly`. Without this the
1161                // user only sees "transaction doesn't balance"
1162                // and never learns the parser dropped a number.
1163                let range = n.syntax().text_range();
1164                let start: u32 = range.start().into();
1165                let end: u32 = range.end().into();
1166                let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1167                errors.push(crate::ParseError::new(
1168                    crate::ParseErrorKind::SyntaxError(
1169                        "invalid number in amount (likely exceeds 28-digit Decimal precision)"
1170                            .to_string(),
1171                    ),
1172                    span,
1173                ));
1174            }
1175            let mut value = parsed?;
1176            if let Some(sign) = amt.sign()
1177                && sign.is_minus()
1178            {
1179                value = -value;
1180            }
1181            Some(value)
1182        })
1183    };
1184    let currency = amt.currency().map(|c| Currency::new(c.text()));
1185    match (number, currency) {
1186        (Some(n), Some(c)) => Some(IncompleteAmount::Complete(Amount::new(n, c))),
1187        (Some(n), None) => Some(IncompleteAmount::NumberOnly(n)),
1188        (None, Some(c)) => Some(IncompleteAmount::CurrencyOnly(c)),
1189        (None, None) => None,
1190    }
1191}
1192
1193/// Evaluate the arithmetic expression inside an `AMOUNT` node and
1194/// return the resulting decimal. Returns `None` when evaluation
1195/// fails (division by zero, decimal overflow, malformed parens,
1196/// missing operand).
1197///
1198/// AMOUNT children are flat tokens (no expression sub-tree): a
1199/// sequence of `NUMBER`, `PLUS`, `MINUS`, `STAR`, `SLASH`,
1200/// `L_PAREN`, `R_PAREN`, and a trailing `CURRENCY` at depth 0
1201/// that's the amount's currency rather than part of the
1202/// expression. The currency is stripped first; the rest goes
1203/// through recursive descent mirroring legacy
1204/// `parser::parse_expr` / `parse_term` / `parse_primary`.
1205///
1206/// Operator precedence and unary handling match Python beancount:
1207/// `*` and `/` bind tighter than `+` and `-`; a leading or post-
1208/// operator `-` is unary negation.
1209fn evaluate_amount_expression(amt: &ast::Amount) -> Option<Decimal> {
1210    let tokens = amount_expression_tokens(amt);
1211    let mut cursor = 0usize;
1212    let value = parse_arith_expr(&tokens, &mut cursor)?;
1213    // Trailing tokens after a successful parse mean the expression
1214    // is malformed (`1+2 3 USD`); refuse rather than silently
1215    // dropping them.
1216    if cursor != tokens.len() {
1217        return None;
1218    }
1219    Some(value)
1220}
1221
1222/// Evaluate the arithmetic expression that appears as the
1223/// numeric value of a `BALANCE` / `PRICE` directive, returning
1224/// the resulting decimal or `None` if not arithmetic (single
1225/// NUMBER, callers fall back to `parse_decimal_token`).
1226///
1227/// Unlike `AMOUNT`, these directives don't wrap their value in
1228/// a dedicated node - the tokens are flat under the directive
1229/// node. The relevant region is from the FIRST `NUMBER` token up
1230/// to (but not including) the FIRST `CURRENCY` token at paren-
1231/// depth 0 (the amount currency). For BALANCE, this correctly
1232/// stops before any trailing `~ NUMBER [CURRENCY]` tolerance
1233/// region too.
1234///
1235/// Returns `Some` only when the slice contains at least one
1236/// arithmetic operator (`+`, `-`, `*`, `/`) or parens - for a
1237/// bare single `NUMBER`, returns `None` so the caller can use
1238/// the existing fast path (which preserves the legacy sign-flip
1239/// behavior).
1240fn directive_arithmetic_value(node: &crate::SyntaxNode) -> Option<Decimal> {
1241    let raw: Vec<crate::SyntaxToken> = node
1242        .children_with_tokens()
1243        .filter_map(rowan::NodeOrToken::into_token)
1244        .filter(|t| !is_trivia_kind(t.kind()))
1245        .skip_while(|t| t.kind() != crate::SyntaxKind::NUMBER)
1246        .collect();
1247    let mut depth: i32 = 0;
1248    let mut first_currency_idx: Option<usize> = None;
1249    for (i, t) in raw.iter().enumerate() {
1250        match t.kind() {
1251            crate::SyntaxKind::L_PAREN => depth += 1,
1252            crate::SyntaxKind::R_PAREN => depth -= 1,
1253            crate::SyntaxKind::CURRENCY if depth == 0 && first_currency_idx.is_none() => {
1254                first_currency_idx = Some(i);
1255            }
1256            _ => {}
1257        }
1258    }
1259    let end = first_currency_idx.unwrap_or(raw.len());
1260    let tokens: Vec<crate::SyntaxToken> = raw.into_iter().take(end).collect();
1261    // Fast-path: zero or one token = no arithmetic.
1262    let has_op = tokens.iter().any(|t| {
1263        matches!(
1264            t.kind(),
1265            crate::SyntaxKind::PLUS
1266                | crate::SyntaxKind::MINUS
1267                | crate::SyntaxKind::STAR
1268                | crate::SyntaxKind::SLASH
1269                | crate::SyntaxKind::L_PAREN
1270        )
1271    });
1272    if !has_op {
1273        return None;
1274    }
1275    let mut cursor = 0usize;
1276    let value = parse_arith_expr(&tokens, &mut cursor)?;
1277    if cursor != tokens.len() {
1278        return None;
1279    }
1280    Some(value)
1281}
1282
1283/// Collect AMOUNT's expression tokens - every non-trivia direct-
1284/// child token EXCEPT the trailing `CURRENCY` at paren-depth 0
1285/// (which is the amount's currency, not part of the expression).
1286/// Parens at any depth are preserved so `parse_arith_primary` can
1287/// recurse through them.
1288fn amount_expression_tokens(amt: &ast::Amount) -> Vec<crate::SyntaxToken> {
1289    let raw: Vec<crate::SyntaxToken> = amt
1290        .syntax()
1291        .children_with_tokens()
1292        .filter_map(rowan::NodeOrToken::into_token)
1293        .filter(|t| !is_trivia_kind(t.kind()))
1294        .collect();
1295    // Find the index of the LAST `CURRENCY` at depth 0 - same
1296    // disambiguator as `Amount::currency()`. Tokens before that
1297    // index form the arithmetic expression.
1298    let mut depth: i32 = 0;
1299    let mut trailing_currency_idx: Option<usize> = None;
1300    for (i, t) in raw.iter().enumerate() {
1301        match t.kind() {
1302            crate::SyntaxKind::L_PAREN => depth += 1,
1303            crate::SyntaxKind::R_PAREN => depth -= 1,
1304            crate::SyntaxKind::CURRENCY if depth == 0 => trailing_currency_idx = Some(i),
1305            _ => {}
1306        }
1307    }
1308    let end = trailing_currency_idx.unwrap_or(raw.len());
1309    raw.into_iter().take(end).collect()
1310}
1311
1312/// `expr := term (('+' | '-') term)*` - left-associative.
1313fn parse_arith_expr(tokens: &[crate::SyntaxToken], cursor: &mut usize) -> Option<Decimal> {
1314    let mut result = parse_arith_term(tokens, cursor)?;
1315    while let Some(op) = tokens.get(*cursor).map(crate::SyntaxToken::kind) {
1316        match op {
1317            crate::SyntaxKind::PLUS => {
1318                *cursor += 1;
1319                let rhs = parse_arith_term(tokens, cursor)?;
1320                result = result.checked_add(rhs)?;
1321            }
1322            crate::SyntaxKind::MINUS => {
1323                *cursor += 1;
1324                let rhs = parse_arith_term(tokens, cursor)?;
1325                result = result.checked_sub(rhs)?;
1326            }
1327            _ => break,
1328        }
1329    }
1330    Some(result)
1331}
1332
1333/// `term := primary (('*' | '/') primary)*` - left-associative.
1334fn parse_arith_term(tokens: &[crate::SyntaxToken], cursor: &mut usize) -> Option<Decimal> {
1335    let mut result = parse_arith_primary(tokens, cursor)?;
1336    while let Some(op) = tokens.get(*cursor).map(crate::SyntaxToken::kind) {
1337        match op {
1338            crate::SyntaxKind::STAR => {
1339                *cursor += 1;
1340                let rhs = parse_arith_primary(tokens, cursor)?;
1341                result = result.checked_mul(rhs)?;
1342            }
1343            crate::SyntaxKind::SLASH => {
1344                *cursor += 1;
1345                let rhs = parse_arith_primary(tokens, cursor)?;
1346                if rhs.is_zero() {
1347                    return None;
1348                }
1349                result = result.checked_div(rhs)?;
1350            }
1351            _ => break,
1352        }
1353    }
1354    Some(result)
1355}
1356
1357/// `primary := '(' expr ')' | '-' primary | '+' primary | NUMBER`.
1358fn parse_arith_primary(tokens: &[crate::SyntaxToken], cursor: &mut usize) -> Option<Decimal> {
1359    let t = tokens.get(*cursor)?;
1360    match t.kind() {
1361        crate::SyntaxKind::L_PAREN => {
1362            *cursor += 1;
1363            let inner = parse_arith_expr(tokens, cursor)?;
1364            // Mandatory closer; bail (returning None) on unbalance
1365            // - `Amount::currency()` already refuses to surface a
1366            // currency for unbalanced parens, so the amount as a
1367            // whole degrades cleanly to `NumberOnly`/`None`.
1368            let close = tokens.get(*cursor)?;
1369            if close.kind() != crate::SyntaxKind::R_PAREN {
1370                return None;
1371            }
1372            *cursor += 1;
1373            Some(inner)
1374        }
1375        crate::SyntaxKind::MINUS => {
1376            *cursor += 1;
1377            let inner = parse_arith_primary(tokens, cursor)?;
1378            Some(-inner)
1379        }
1380        crate::SyntaxKind::PLUS => {
1381            *cursor += 1;
1382            parse_arith_primary(tokens, cursor)
1383        }
1384        crate::SyntaxKind::NUMBER => {
1385            let value = parse_decimal_token(t.text())?;
1386            *cursor += 1;
1387            Some(value)
1388        }
1389        _ => None,
1390    }
1391}
1392
1393fn convert_cost_spec(cs: &ast::CostSpec) -> CostSpec {
1394    cost_spec_from_tokens(
1395        cs.syntax()
1396            .children_with_tokens()
1397            .filter_map(rowan::NodeOrToken::into_token),
1398    )
1399}
1400
1401/// Minimal view of a lexed token — kind + text — implemented by both tree
1402/// walkers (red `SyntaxToken`, green `&GreenTokenData`) so the token-level
1403/// semantic helpers ([`cost_spec_from_tokens`], [`meta_value_from_tokens`])
1404/// are SHARED rather than hand-mirrored. Every historical green/red fuzz
1405/// divergence (#1704, #1713, the `{*}` merge flag) landed in a hand-mirrored
1406/// copy of these semantics; with one implementation the class is gone.
1407pub(super) trait TokenView {
1408    /// The token's [`crate::SyntaxKind`].
1409    fn kind(&self) -> crate::SyntaxKind;
1410    /// The token's source text.
1411    fn text(&self) -> &str;
1412}
1413
1414impl TokenView for rowan::SyntaxToken<crate::BeancountLanguage> {
1415    // `Self::kind`/`Self::text` resolve to the INHERENT `SyntaxToken`
1416    // methods (inherent associated functions take precedence over trait
1417    // methods in path resolution) — this is delegation, not recursion.
1418    // The `Self::` form is clippy's own preference here (`use_self`).
1419    fn kind(&self) -> crate::SyntaxKind {
1420        Self::kind(self)
1421    }
1422    fn text(&self) -> &str {
1423        Self::text(self)
1424    }
1425}
1426
1427impl TokenView for &rowan::GreenTokenData {
1428    fn kind(&self) -> crate::SyntaxKind {
1429        <crate::BeancountLanguage as rowan::Language>::kind_from_raw((*self).kind())
1430    }
1431    fn text(&self) -> &str {
1432        (*self).text()
1433    }
1434}
1435
1436/// Convert the direct child tokens of a `COST_SPEC` node into a [`CostSpec`]
1437/// (forms `{N CCY}`, `{{T CCY}}`, `{N # T CCY}`, `{*}` merge, plus optional
1438/// date + label). The single source of truth for BOTH walkers (red
1439/// `convert_cost_spec`, green `convert_cost_spec`).
1440///
1441/// Cost numbers are plain `NUMBER` tokens (no arithmetic evaluation); an
1442/// unparsable one yields `number: None` with no diagnostic, so this needs no
1443/// bail and always returns a `CostSpec`.
1444///
1445/// There are TWO number semantics and both must be carried (#1713):
1446/// - the compound `{a # b}` path retries past UNPARSABLE number tokens on
1447///   each side of the hash (`is_none()` guards re-arm when the parse fails);
1448/// - the plain path uses the first NUMBER *token*, parsed or not (a latch).
1449///
1450/// A single latched tracker satisfies only the plain path: on
1451/// `{<garbage-number> 2 # ...}` the compound side must retry to `2` while
1452/// the latch keeps `None` (the historical `fuzz_green_eq_red` divergence).
1453///
1454/// Compound `{a # b}` (beancount `compound_amount`): per-unit AND a lump
1455/// total on top; the cost totals `N*a + b`. Surfaced as written — units may
1456/// be interpolated later, so the combined total cannot be computed here;
1457/// booking derives it (#1700). An omitted side is zero, which is
1458/// arithmetically exact (`{# b}` ≡ `{{b}}`, `{a #}` ≡ `{a}`). The hash can
1459/// arrive fused with the brace as a single `L_BRACE_HASH` opener.
1460///
1461/// `is_total` = any `{{` present anywhere. The `{*}` merge flag is decided
1462/// by the first non-whitespace, non-opener token after an opener (`*` →
1463/// merge, anything else → not — a STAR elsewhere is the multiplication
1464/// operator, e.g. `{500 * 2 USD}`); a scan-everything pass that re-arms on
1465/// later openers flips the flag on malformed inputs where it must not
1466/// (another historical divergence).
1467pub(super) fn cost_spec_from_tokens(tokens: impl Iterator<Item = impl TokenView>) -> CostSpec {
1468    use crate::SyntaxKind as K;
1469    let mut is_total = false;
1470    let mut first_number: Option<Decimal> = None; // latched (plain path)
1471    let mut seen_number = false;
1472    let mut pre_hash: Option<Decimal> = None; // retried (compound path)
1473    let mut past_hash = false;
1474    let mut post_hash_total: Option<Decimal> = None; // retried (compound path)
1475    let mut currency: Option<Currency> = None;
1476    let mut date: Option<NaiveDate> = None;
1477    let mut date_seen = false;
1478    let mut label: Option<String> = None;
1479    let mut label_seen = false;
1480    let mut merge = false;
1481    let mut merge_decided = false;
1482    let mut past_opener = false;
1483    for t in tokens {
1484        let kind = t.kind();
1485        // Merge-flag machine (runs alongside the value machine below; openers
1486        // and whitespace never decide, the first other token after an opener
1487        // does).
1488        if !merge_decided {
1489            match kind {
1490                K::L_BRACE | K::L_DOUBLE_BRACE | K::L_BRACE_HASH => past_opener = true,
1491                K::WHITESPACE => {}
1492                K::STAR if past_opener => {
1493                    merge = true;
1494                    merge_decided = true;
1495                }
1496                _ if past_opener => merge_decided = true,
1497                _ => {}
1498            }
1499        }
1500        match kind {
1501            K::L_DOUBLE_BRACE => is_total = true,
1502            K::NUMBER => {
1503                if past_hash {
1504                    if post_hash_total.is_none() {
1505                        post_hash_total = parse_decimal_token(t.text());
1506                    }
1507                } else {
1508                    if pre_hash.is_none() {
1509                        pre_hash = parse_decimal_token(t.text());
1510                    }
1511                    if !seen_number {
1512                        seen_number = true;
1513                        first_number = parse_decimal_token(t.text());
1514                    }
1515                }
1516            }
1517            K::HASH | K::L_BRACE_HASH => past_hash = true,
1518            K::CURRENCY if currency.is_none() => currency = Some(Currency::new(t.text())),
1519            K::DATE if !date_seen => {
1520                date_seen = true;
1521                date = parse_date_token(t.text());
1522            }
1523            K::STRING if !label_seen => {
1524                label_seen = true;
1525                label = decode_string_token(t.text());
1526            }
1527            _ => {}
1528        }
1529    }
1530    let number = if past_hash {
1531        Some(CostNumber::Compound {
1532            per_unit: pre_hash.unwrap_or_default(),
1533            total: post_hash_total.unwrap_or_default(),
1534        })
1535    } else {
1536        match (first_number, is_total) {
1537            (Some(v), true) => Some(CostNumber::Total { value: v }),
1538            (Some(v), false) => Some(CostNumber::PerUnit { value: v }),
1539            (None, _) => None,
1540        }
1541    };
1542    CostSpec {
1543        number,
1544        currency,
1545        date,
1546        label,
1547        merge,
1548    }
1549}
1550
1551fn convert_price_annotation(
1552    pa: &ast::PriceAnnotation,
1553    errors: &mut Vec<crate::ParseError>,
1554    bom_offset: u32,
1555) -> PriceAnnotation {
1556    let kind = if pa.is_total() {
1557        PriceKind::Total
1558    } else {
1559        PriceKind::Unit
1560    };
1561    let amount = pa
1562        .amount()
1563        .and_then(|a| convert_amount_to_incomplete(&a, errors, bom_offset));
1564    PriceAnnotation { kind, amount }
1565}
1566
1567// ---- Metadata extraction ---------------------------------------
1568
1569/// Extract the [`Metadata`] map from the directive node's
1570/// `META_ENTRY` sub-line children. Matches the legacy parser's
1571/// behavior: each entry's key (with trailing `:` stripped) maps
1572/// to a typed [`MetaValue`] derived from the value tokens.
1573fn convert_meta_entries(node: &crate::SyntaxNode) -> Metadata {
1574    let mut meta = Metadata::default();
1575    for entry in node.children().filter_map(MetaEntry::cast) {
1576        let Some(key_token) = entry.key() else {
1577            continue;
1578        };
1579        let key = key_token.text_without_colon().to_string();
1580        let value = meta_value_from_entry(&entry);
1581        meta.insert(key, value);
1582    }
1583    meta
1584}
1585
1586/// Returns true if a node's flat direct-child tokens contain a
1587/// `MINUS` BEFORE the first `NUMBER`. Used to detect signed
1588/// numeric values in directives like Balance / Price whose typed-
1589/// AST accessors return the unsigned NUMBER token only.
1590fn node_has_minus_before_number(node: &crate::SyntaxNode) -> bool {
1591    for el in node.children_with_tokens() {
1592        let rowan::NodeOrToken::Token(t) = el else {
1593            continue;
1594        };
1595        match t.kind() {
1596            crate::SyntaxKind::MINUS => return true,
1597            crate::SyntaxKind::NUMBER => return false,
1598            _ => {}
1599        }
1600    }
1601    false
1602}
1603
1604/// Discriminate one *value group* of raw metadata/custom value tokens
1605/// (`tokens[start..]`, trivia already filtered) into a [`MetaValue`], returning
1606/// the value and the index just past it. This is the single source of truth for
1607/// the raw token-walk extractors ([`pushmeta_value`] and
1608/// [`extract_custom_values`]) so they can't drift — they previously did:
1609/// `extract_custom_values` dropped the leading `MINUS` (so `custom "x" -50.00`
1610/// emitted `+50.00`) and dropped `Tag`/`Link` entirely, while `pushmeta_value`
1611/// skipped the `NUMBER CURRENCY` → `Amount` lookahead.
1612///
1613/// Discrimination mirrors the typed [`meta_value_from_entry`] (the `META_ENTRY`
1614/// sibling, which additionally escape-decodes strings via the typed AST). A
1615/// leading `MINUS` negates the following `NUMBER`; an adjacent `CURRENCY` makes
1616/// it an `Amount`. Returns `None` for a non-value token (the caller advances).
1617fn value_tokens_to_meta(
1618    tokens: &[rowan::SyntaxToken<crate::BeancountLanguage>],
1619    start: usize,
1620) -> Option<(MetaValue, usize)> {
1621    let mut i = start;
1622    let mut negate = false;
1623    if tokens.get(i).map(rowan::SyntaxToken::kind) == Some(crate::SyntaxKind::MINUS) {
1624        negate = true;
1625        i += 1;
1626    }
1627    let t = tokens.get(i)?;
1628    match t.kind() {
1629        crate::SyntaxKind::STRING => {
1630            let s = strip_string_quotes(t.text())?;
1631            Some((MetaValue::String(s.to_string()), i + 1))
1632        }
1633        crate::SyntaxKind::NUMBER => {
1634            let mut decimal = parse_decimal_token(t.text())?;
1635            if negate {
1636                decimal = -decimal;
1637            }
1638            // Adjacent `CURRENCY` → `Amount` (negate applies to the amount too).
1639            if let Some(next) = tokens.get(i + 1)
1640                && next.kind() == crate::SyntaxKind::CURRENCY
1641            {
1642                return Some((
1643                    MetaValue::Amount(Amount::new(decimal, Currency::new(next.text()))),
1644                    i + 2,
1645                ));
1646            }
1647            Some((number_meta_value(t.text(), decimal), i + 1))
1648        }
1649        crate::SyntaxKind::DATE => Some((MetaValue::Date(parse_date_token(t.text())?), i + 1)),
1650        crate::SyntaxKind::ACCOUNT => Some((MetaValue::Account(Account::new(t.text())), i + 1)),
1651        crate::SyntaxKind::CURRENCY => Some((MetaValue::Currency(Currency::new(t.text())), i + 1)),
1652        crate::SyntaxKind::BOOL_TRUE => Some((MetaValue::Bool(true), i + 1)),
1653        crate::SyntaxKind::BOOL_FALSE => Some((MetaValue::Bool(false), i + 1)),
1654        crate::SyntaxKind::TAG => Some((
1655            MetaValue::Tag(Tag::new(t.text().trim_start_matches('#'))),
1656            i + 1,
1657        )),
1658        crate::SyntaxKind::LINK => Some((
1659            MetaValue::Link(Link::new(t.text().trim_start_matches('^'))),
1660            i + 1,
1661        )),
1662        _ => None,
1663    }
1664}
1665
1666/// Discriminate the value tokens under a `META_ENTRY` into a typed
1667/// [`MetaValue`] — thin wrapper over the shared [`meta_value_from_tokens`].
1668/// The raw-token sibling is [`value_tokens_to_meta`]; keep the two in sync.
1669fn meta_value_from_entry(entry: &MetaEntry) -> MetaValue {
1670    meta_value_from_tokens(
1671        entry
1672            .syntax()
1673            .children_with_tokens()
1674            .filter_map(rowan::NodeOrToken::into_token),
1675    )
1676}
1677
1678/// Derive the typed [`MetaValue`] from a `META_ENTRY` node's direct child
1679/// tokens. The single source of truth for BOTH walkers (red
1680/// [`meta_value_from_entry`], green `meta_value`).
1681///
1682/// Matches the legacy parser's preference order: string > number/amount >
1683/// date > account > currency > bool > tag/link > none, where each candidate
1684/// is the FIRST token of its kind and a type that's present-but-unparsable
1685/// (a malformed string, an over-precision number, a bad date) falls through
1686/// to the next.
1687///
1688/// A `MINUS` token after the `META_KEY` and before the first `NUMBER`
1689/// negates the number (legacy `parse_signed_number`, e.g. `precision: -1`);
1690/// a first `CURRENCY` anywhere alongside the number makes it an `Amount`
1691/// (legacy priority where `parse_amount` runs before `parse_signed_number`).
1692pub(super) fn meta_value_from_tokens(tokens: impl Iterator<Item = impl TokenView>) -> MetaValue {
1693    use crate::SyntaxKind as K;
1694    let mut string_t: Option<String> = None;
1695    let mut number_t: Option<String> = None;
1696    let mut currency_t: Option<String> = None;
1697    let mut date_t: Option<String> = None;
1698    let mut account_t: Option<String> = None;
1699    let mut bool_v: Option<bool> = None;
1700    let mut tag_link: Option<MetaValue> = None;
1701    let mut past_key = false;
1702    let mut minus = false;
1703    let mut minus_decided = false;
1704
1705    for t in tokens {
1706        let kind = t.kind();
1707        // First-of-kind value tokens (the `first_token` accessor semantics).
1708        match kind {
1709            K::STRING if string_t.is_none() => string_t = Some(t.text().to_string()),
1710            K::NUMBER if number_t.is_none() => number_t = Some(t.text().to_string()),
1711            K::CURRENCY if currency_t.is_none() => currency_t = Some(t.text().to_string()),
1712            K::DATE if date_t.is_none() => date_t = Some(t.text().to_string()),
1713            K::ACCOUNT if account_t.is_none() => account_t = Some(t.text().to_string()),
1714            K::BOOL_TRUE if bool_v.is_none() => bool_v = Some(true),
1715            K::BOOL_FALSE if bool_v.is_none() => bool_v = Some(false),
1716            K::TAG if tag_link.is_none() => {
1717                tag_link = Some(MetaValue::Tag(Tag::new(t.text().trim_start_matches('#'))));
1718            }
1719            K::LINK if tag_link.is_none() => {
1720                tag_link = Some(MetaValue::Link(Link::new(t.text().trim_start_matches('^'))));
1721            }
1722            _ => {}
1723        }
1724        if past_key && !minus_decided {
1725            match kind {
1726                K::MINUS => {
1727                    minus = true;
1728                    minus_decided = true;
1729                }
1730                K::NUMBER => minus_decided = true,
1731                _ => {}
1732            }
1733        }
1734        if kind == K::META_KEY {
1735            past_key = true;
1736        }
1737    }
1738
1739    if let Some(s) = string_t
1740        && let Some(decoded) = decode_string_token(&s)
1741    {
1742        return MetaValue::String(decoded);
1743    }
1744    if let Some(nt) = number_t
1745        && let Some(mut dec) = parse_decimal_token(&nt)
1746    {
1747        if minus {
1748            dec = -dec;
1749        }
1750        if let Some(c) = currency_t {
1751            return MetaValue::Amount(Amount::new(dec, Currency::new(&c)));
1752        }
1753        return number_meta_value(&nt, dec);
1754    }
1755    if let Some(dt) = date_t
1756        && let Some(date) = parse_date_token(&dt)
1757    {
1758        return MetaValue::Date(date);
1759    }
1760    if let Some(a) = account_t {
1761        return MetaValue::Account(Account::new(&a));
1762    }
1763    if let Some(c) = currency_t {
1764        return MetaValue::Currency(Currency::new(&c));
1765    }
1766    if let Some(b) = bool_v {
1767        return MetaValue::Bool(b);
1768    }
1769    if let Some(tl) = tag_link {
1770        return tl;
1771    }
1772    MetaValue::None
1773}
1774
1775// ---- Inherited state (pushtag/poptag/pushmeta/popmeta) ---------
1776
1777/// Merge active pushed-tag and pushed-meta state into a freshly
1778/// converted directive's value. Mirrors the legacy parser's
1779/// `apply_pushed_tags` + `apply_pushed_meta`: tags apply ONLY to
1780/// `Transaction`; meta applies to every directive's `meta` field.
1781///
1782/// The meta stack is a `Vec` (not a map) to preserve shadow/pop
1783/// semantics - `pushmeta x: 1; pushmeta x: 2; popmeta x` should
1784/// leave `x = 1` active, which a map-replacing-on-insert can't
1785/// express. Iterating in push order and inserting into the
1786/// directive's meta means later entries naturally win, matching
1787/// "topmost-shadow wins" behavior.
1788fn apply_inherited_state(
1789    value: &mut Directive,
1790    tag_stack: &[(Tag, Span)],
1791    meta_stack: &[(String, MetaValue, Span)],
1792) {
1793    if let Directive::Transaction(txn) = value {
1794        for (tag, _) in tag_stack {
1795            if !txn.tags.contains(tag) {
1796                txn.tags.push(tag.clone());
1797            }
1798        }
1799    }
1800    if meta_stack.is_empty() {
1801        return;
1802    }
1803    let meta = match value {
1804        Directive::Transaction(d) => &mut d.meta,
1805        Directive::Balance(d) => &mut d.meta,
1806        Directive::Open(d) => &mut d.meta,
1807        Directive::Close(d) => &mut d.meta,
1808        Directive::Commodity(d) => &mut d.meta,
1809        Directive::Pad(d) => &mut d.meta,
1810        Directive::Event(d) => &mut d.meta,
1811        Directive::Query(d) => &mut d.meta,
1812        Directive::Note(d) => &mut d.meta,
1813        Directive::Document(d) => &mut d.meta,
1814        Directive::Price(d) => &mut d.meta,
1815        Directive::Custom(d) => &mut d.meta,
1816    };
1817    for (k, v, _) in meta_stack {
1818        meta.insert(k.clone(), v.clone());
1819    }
1820}
1821
1822/// Extract the value tokens after the `META_KEY` of a Pushmeta
1823/// directive into a typed [`MetaValue`]. Walks the directive's
1824/// direct-child tokens (the directive isn't a `META_ENTRY` so the
1825/// typed-AST accessors aren't reusable).
1826fn pushmeta_value(node: &crate::SyntaxNode) -> MetaValue {
1827    // The first value token after the key wins. `value_tokens_to_meta` returns
1828    // `None` for the key/colon (and any non-value token), so the loop walks to
1829    // the first real value — sharing MINUS-sign, Tag/Link and
1830    // `NUMBER CURRENCY` → Amount handling with metadata/custom values.
1831    let raw: Vec<rowan::SyntaxToken<crate::BeancountLanguage>> = node
1832        .children_with_tokens()
1833        .filter_map(rowan::NodeOrToken::into_token)
1834        .filter(|t| {
1835            !matches!(
1836                t.kind(),
1837                crate::SyntaxKind::WHITESPACE
1838                    | crate::SyntaxKind::NEWLINE
1839                    | crate::SyntaxKind::COMMENT
1840            )
1841        })
1842        .collect();
1843
1844    let mut i = 0;
1845    while i < raw.len() {
1846        if let Some((value, _)) = value_tokens_to_meta(&raw, i) {
1847            return value;
1848        }
1849        i += 1;
1850    }
1851    MetaValue::None
1852}
1853
1854// ---- ParseResult.comments --------------------------------------
1855
1856/// Comment-like syntax kinds that the legacy parser surfaces as
1857/// `ParseResult.comments` entries when they appear at the top
1858/// level (outside any directive's content).
1859pub(super) const fn is_comment_kind(kind: crate::SyntaxKind) -> bool {
1860    matches!(
1861        kind,
1862        crate::SyntaxKind::COMMENT
1863            | crate::SyntaxKind::PERCENT_COMMENT
1864            | crate::SyntaxKind::SHEBANG
1865            | crate::SyntaxKind::EMACS_DIRECTIVE
1866    )
1867}
1868
1869/// Output of the fused top-level pass [`walk_top_level_once`].
1870pub(super) struct TopLevelWalkResult {
1871    pub(super) errors: Vec<crate::ParseError>,
1872    pub(super) section_marker_comments: Vec<Spanned<String>>,
1873}
1874
1875/// Single walk over `source_file`'s direct children that runs
1876/// every per-directive diagnostic in one pass, replacing five
1877/// separate `source_file.syntax().children()` traversals
1878/// (`extract_error_node_errors`, `extract_transaction_body_errors`,
1879/// `extract_indented_directive_errors`, `extract_custom_value_errors`,
1880/// `extract_section_marker_comments`). Each former pass re-walked
1881/// the top-level child list and materialized a fresh red node per
1882/// directive; on a large ledger that is 5·O(N) red-node churn for
1883/// work that is naturally per-child. The checks are independent and
1884/// all diagnostics are span-sorted by the caller, so fusing them is
1885/// order-preserving.
1886fn walk_top_level_once(
1887    source_file: &SourceFile,
1888    stripped: &str,
1889    bom_offset: u32,
1890) -> TopLevelWalkResult {
1891    let mut errors: Vec<crate::ParseError> = Vec::new();
1892    let mut section_marker_comments: Vec<Spanned<String>> = Vec::new();
1893    for child in source_file.syntax().children() {
1894        let kind = child.kind();
1895        // Applies to every recognized directive node (incl. CUSTOM).
1896        if ast::Directive::can_cast(kind) {
1897            indented_directive_check(&child, stripped, bom_offset, &mut errors);
1898        }
1899        match kind {
1900            crate::SyntaxKind::CUSTOM_DIRECTIVE => {
1901                custom_value_check(&child, bom_offset, &mut errors);
1902            }
1903            crate::SyntaxKind::TRANSACTION => {
1904                transaction_body_check(&child, bom_offset, &mut errors);
1905            }
1906            crate::SyntaxKind::ERROR_NODE => {
1907                error_node_check(&child, stripped, bom_offset, &mut errors);
1908                section_marker_check(&child, bom_offset, &mut section_marker_comments);
1909            }
1910            _ => {}
1911        }
1912    }
1913    TopLevelWalkResult {
1914        errors,
1915        section_marker_comments,
1916    }
1917}
1918
1919/// Walk every `COST_SPEC` node in the tree and emit a
1920/// `SyntaxError("unclosed cost specification: missing '}'")` for
1921/// any spec whose opener (`{`, `{{`, or `{#`) doesn't have a
1922/// matching closer at the spec's depth-0. Mirrors the legacy
1923/// parser's deferred-error emission at `parser.rs:705-707` so a
1924/// `10 AAPL {150 USD\n` posting or an EOF-truncated cost block
1925/// surfaces a diagnostic instead of silently producing a half-
1926/// built cost spec.
1927fn extract_unclosed_cost_brace_errors(
1928    source_file: &SourceFile,
1929    bom_offset: u32,
1930) -> Vec<crate::ParseError> {
1931    let mut out = Vec::new();
1932    for cs in source_file.syntax().descendants() {
1933        if cs.kind() != crate::SyntaxKind::COST_SPEC {
1934            continue;
1935        }
1936        let mut has_opener = false;
1937        let mut has_closer = false;
1938        for el in cs.children_with_tokens() {
1939            let rowan::NodeOrToken::Token(t) = el else {
1940                continue;
1941            };
1942            match t.kind() {
1943                crate::SyntaxKind::L_BRACE
1944                | crate::SyntaxKind::L_DOUBLE_BRACE
1945                | crate::SyntaxKind::L_BRACE_HASH => has_opener = true,
1946                crate::SyntaxKind::R_BRACE | crate::SyntaxKind::R_DOUBLE_BRACE => has_closer = true,
1947                _ => {}
1948            }
1949        }
1950        if has_opener && !has_closer {
1951            out.push(crate::ParseError::new(
1952                crate::ParseErrorKind::SyntaxError(
1953                    "unclosed cost specification: missing '}'".to_string(),
1954                ),
1955                node_span(&cs, bom_offset),
1956            ));
1957        }
1958    }
1959    out
1960}
1961
1962/// Walk every top-level directive in `source_file` and emit a
1963/// `SyntaxError("top-level directive must start at column 0")`
1964/// for any whose content (first non-trivia token) starts at a
1965/// non-zero column. Per the Beancount language spec, top-level
1966/// directives are required to begin at column 0; indentation is
1967/// reserved for postings and metadata inside a transaction body.
1968///
1969/// The CST grammar happily accepts an indented `open` / `balance`
1970/// / etc., which is why this surfaces at converter level instead
1971/// of as a lex/parse error.
1972fn indented_directive_check(
1973    child: &crate::SyntaxNode,
1974    stripped: &str,
1975    bom_offset: u32,
1976    out: &mut Vec<crate::ParseError>,
1977) {
1978    // Caller dispatches: `child` is a recognized directive node.
1979    // Find the directive's content start - the first non-
1980    // trivia token. Leading WHITESPACE / NEWLINE / COMMENT
1981    // can land inside the directive node per the Directive-
1982    // Terminator Rule's inter-directive trivia attachment.
1983    let Some(content) = child
1984        .children_with_tokens()
1985        .filter_map(rowan::NodeOrToken::into_token)
1986        .find(|t| !is_trivia_kind(t.kind()))
1987    else {
1988        return;
1989    };
1990    let content_start: usize = u32::from(content.text_range().start()) as usize;
1991    // Column = offset since the last NEWLINE in the source,
1992    // or since byte 0 if this is the first line. >0 means
1993    // the directive's first content token has leading WS on
1994    // its own line - that's the indent error.
1995    // Find the line start by scanning the BYTES before `content_start`, not by
1996    // slicing the `str`. On malformed/error-recovered input a token's start
1997    // offset can land inside a multi-byte UTF-8 char, and
1998    // `stripped[..content_start]` would then panic ("not a char boundary").
1999    // Byte slicing is boundary-agnostic, and a newline (`\n`) is always a single
2000    // ASCII byte, so the found position is a valid offset. `.get(..)` also guards
2001    // a (theoretical) out-of-bounds offset. Regression: fuzz_regressions.rs.
2002    let line_start = stripped
2003        .as_bytes()
2004        .get(..content_start)
2005        .and_then(|bytes| bytes.iter().rposition(|&b| b == b'\n'))
2006        .map_or(0, |nl| nl + 1);
2007    if content_start > line_start {
2008        let end: u32 = content.text_range().end().into();
2009        let span = Span::new(
2010            (line_start as u32 + bom_offset) as usize,
2011            (end + bom_offset) as usize,
2012        );
2013        out.push(crate::ParseError::new(
2014            crate::ParseErrorKind::SyntaxError(
2015                "top-level directive must start at column 0".to_string(),
2016            ),
2017            span,
2018        ));
2019    }
2020}
2021
2022/// Walk each `CUSTOM` directive and emit a `SyntaxError` for
2023/// every bare `CURRENCY` token in the value position (a CURRENCY
2024/// not paired with a preceding NUMBER as an Amount).
2025///
2026/// Per the Beancount language spec, custom-directive values are
2027/// limited to string / date / decimal / amount / boolean -
2028/// `bean-check` rejects a bare currency literal with a syntax
2029/// error. Rustledger's `extract_custom_values` has historically
2030/// been more lenient, accepting ACCOUNT / TAG / LINK in value
2031/// position too; we keep that extension (it's covered by the
2032/// existing `test_parse_custom_directive` integration test) but
2033/// surface a diagnostic for the bare-CURRENCY case so the
2034/// compat metric reflects bean-check's exit-code rejection on
2035/// shapes like `custom "x" 10 USD "y" NZD …`.
2036fn custom_value_check(
2037    child: &crate::SyntaxNode,
2038    bom_offset: u32,
2039    out: &mut Vec<crate::ParseError>,
2040) {
2041    // Caller dispatches: `child` is a CUSTOM_DIRECTIVE.
2042    {
2043        // Collect non-trivia tokens, then skip past the
2044        // directive's header: DATE, CUSTOM_KW, and the first
2045        // STRING (the custom-type name). Everything after that
2046        // is values.
2047        let raw: Vec<crate::SyntaxToken> = child
2048            .children_with_tokens()
2049            .filter_map(rowan::NodeOrToken::into_token)
2050            .filter(|t| !is_trivia_kind(t.kind()))
2051            .collect();
2052        let mut seen_type_string = false;
2053        let mut i = 0;
2054        while i < raw.len() {
2055            let t = &raw[i];
2056            if !seen_type_string {
2057                if t.kind() == crate::SyntaxKind::STRING {
2058                    seen_type_string = true;
2059                }
2060                i += 1;
2061                continue;
2062            }
2063            if t.kind() == crate::SyntaxKind::CURRENCY {
2064                // Only flag BARE CURRENCY - one that doesn't
2065                // follow a NUMBER (Amount-pairing). The Amount
2066                // pairing is handled by `extract_custom_values`
2067                // via i+1 lookahead, so a CURRENCY that's NOT
2068                // preceded by a NUMBER at i-1 is bare.
2069                let preceded_by_number = i > 0 && raw[i - 1].kind() == crate::SyntaxKind::NUMBER;
2070                if !preceded_by_number {
2071                    let range = t.text_range();
2072                    let start: u32 = range.start().into();
2073                    let end: u32 = range.end().into();
2074                    let span =
2075                        Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2076                    out.push(crate::ParseError::new(
2077                        crate::ParseErrorKind::SyntaxError(
2078                            "bare currency literal is not a valid custom directive value"
2079                                .to_string(),
2080                        ),
2081                        span,
2082                    ));
2083                }
2084            }
2085            i += 1;
2086        }
2087    }
2088}
2089
2090/// Walk a `TRANSACTION` body and emit a `SyntaxError` for any body
2091/// line that contains flat catch-all tokens (e.g., an
2092/// unrecognized identifier where a posting was expected).
2093/// Matches the legacy parser, which fails its inner posting
2094/// parser on such lines and recovers by skipping to the next
2095/// NEWLINE while emitting a `SyntaxError`.
2096fn transaction_body_check(
2097    child: &crate::SyntaxNode,
2098    bom_offset: u32,
2099    out: &mut Vec<crate::ParseError>,
2100) {
2101    // Caller dispatches: `child` is a TRANSACTION.
2102    {
2103        // Skip past the header NEWLINE, then look for catch-all
2104        // tokens (non-trivia, non-comment) appearing on lines
2105        // OUTSIDE POSTING / META_ENTRY child nodes.
2106        // Track whether we've SEEN at least one non-trivia
2107        // header token (DATE / flag / STRING / etc.); only AFTER
2108        // that does the next NEWLINE count as the header
2109        // terminator. Otherwise leading-trivia NEWLINEs from the
2110        // Directive-Terminator Rule would falsely trip
2111        // past_header on the very first iteration.
2112        let mut past_header = false;
2113        let mut saw_header_content = false;
2114        let mut line_start: Option<u32> = None;
2115        let mut line_has_content = false;
2116        for el in child.children_with_tokens() {
2117            match el {
2118                rowan::NodeOrToken::Token(t) => {
2119                    if !past_header {
2120                        if t.kind() == crate::SyntaxKind::NEWLINE {
2121                            if saw_header_content {
2122                                past_header = true;
2123                            }
2124                        } else if !is_trivia_kind(t.kind()) {
2125                            saw_header_content = true;
2126                        }
2127                        continue;
2128                    }
2129                    let range = t.text_range();
2130                    let start: u32 = range.start().into();
2131                    let end: u32 = range.end().into();
2132                    if line_start.is_none() {
2133                        line_start = Some(start);
2134                    }
2135                    if t.kind() == crate::SyntaxKind::NEWLINE {
2136                        if line_has_content && let Some(ls) = line_start {
2137                            // Skip leading WHITESPACE in the span.
2138                            let span =
2139                                Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2140                            // Find first non-whitespace position
2141                            // for a tighter span matching legacy.
2142                            out.push(crate::ParseError::new(
2143                                crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2144                                span,
2145                            ));
2146                        }
2147                        line_start = None;
2148                        line_has_content = false;
2149                    } else if !is_trivia_kind(t.kind())
2150                        && !is_comment_kind(t.kind())
2151                        && !matches!(t.kind(), crate::SyntaxKind::TAG | crate::SyntaxKind::LINK)
2152                    {
2153                        // TAG / LINK on body lines is valid
2154                        // Beancount syntax (tags/links after the
2155                        // first line continue the transaction's
2156                        // tag/link list). Don't flag as
2157                        // unexpected-input.
2158                        line_has_content = true;
2159                    }
2160                }
2161                rowan::NodeOrToken::Node(_) => {
2162                    // POSTING / META_ENTRY: not catch-all. Reset.
2163                    line_start = None;
2164                    line_has_content = false;
2165                    if !past_header {
2166                        past_header = true;
2167                    }
2168                }
2169            }
2170        }
2171    }
2172}
2173
2174/// Walk an `ERROR_NODE` and emit a
2175/// `ParseError` for each line that is NEITHER a section marker
2176/// (`*`-starting) NOR a column-0 comment. The variant emitted
2177/// mirrors the legacy parser's error-recovery classifier
2178/// (`parser.rs:2186-2249`): BOM-in-line → `BomInDirectiveBody`
2179/// (with `BOM_REMOVAL_HINT`); Unicode-character account →
2180/// `InvalidAccount`; otherwise → `SyntaxError("unexpected
2181/// input")`. `stripped` is the post-BOM-strip source so token
2182/// `text_range` indices into it correctly.
2183fn error_node_check(
2184    child: &crate::SyntaxNode,
2185    stripped: &str,
2186    bom_offset: u32,
2187    out: &mut Vec<crate::ParseError>,
2188) {
2189    // Caller dispatches: `child` is an ERROR_NODE.
2190    {
2191        let mut line_start: Option<u32> = None;
2192        let mut first_non_trivia: Option<crate::SyntaxKind> = None;
2193        for el in child.children_with_tokens() {
2194            let rowan::NodeOrToken::Token(t) = el else {
2195                continue;
2196            };
2197            let range = t.text_range();
2198            let start: u32 = range.start().into();
2199            let end: u32 = range.end().into();
2200            if line_start.is_none() {
2201                line_start = Some(start);
2202            }
2203            if t.kind() == crate::SyntaxKind::NEWLINE {
2204                // Decide the line's classification.
2205                let is_section = matches!(first_non_trivia, Some(crate::SyntaxKind::STAR));
2206                let is_comment = matches!(first_non_trivia, Some(k) if is_comment_kind(k));
2207                if !is_section
2208                    && !is_comment
2209                    && first_non_trivia.is_some()
2210                    && let Some(ls) = line_start
2211                {
2212                    // Legacy span INCLUDES the terminator NEWLINE
2213                    // (skip_to_newline consumes it before
2214                    // span_from is called).
2215                    let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2216                    let line_text = stripped.get(ls as usize..end as usize).unwrap_or("");
2217                    let primary = classify_recovery_error(line_text, span);
2218                    let primary_is_bom =
2219                        matches!(primary.kind, crate::ParseErrorKind::BomInDirectiveBody);
2220                    out.push(primary);
2221                    // Additive secondary `BomInDirectiveBody` when
2222                    // a different primary diagnostic (Unicode
2223                    // account / generic syntax) already fired AND
2224                    // the line ALSO contains a BOM byte. Matches
2225                    // legacy `parser.rs:2258-2263`: without this,
2226                    // a Windows-exported line with both problems
2227                    // surfaces only the actionable root cause and
2228                    // the user has no clue the invisible BOM byte
2229                    // is also corrupting the line.
2230                    if !primary_is_bom && line_text.contains(crate::bom::BOM_CHAR) {
2231                        out.push(
2232                            crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2233                                .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
2234                        );
2235                    }
2236                }
2237                line_start = None;
2238                first_non_trivia = None;
2239                continue;
2240            }
2241            if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
2242                first_non_trivia = Some(t.kind());
2243            }
2244        }
2245    }
2246}
2247
2248/// Pick the most specific `ParseError` variant for an
2249/// error-recovery line, mirroring the legacy parser's classifier
2250/// at `parser.rs:2186-2249`:
2251/// 1. A Unicode-character account (`Assets:Café:…`) → primary
2252///    `InvalidAccount` - it's the actionable root cause.
2253/// 2. A mid-file BOM byte (`U+FEFF`) → `BomInDirectiveBody` with
2254///    `BOM_REMOVAL_HINT` so miette surfaces the remediation step.
2255/// 3. Anything else → `SyntaxError("unexpected input")`.
2256///
2257/// Order matters: a Windows-exported file with a Unicode account
2258/// AND an internal BOM gets the Unicode-account diagnostic
2259/// (the BOM is usually a side effect, not the root cause).
2260pub(super) fn classify_recovery_error(line_text: &str, span: Span) -> crate::ParseError {
2261    if let Some(account) = crate::diagnostics::find_unicode_account(line_text) {
2262        return crate::ParseError::new(
2263            crate::ParseErrorKind::InvalidAccount(account.to_string()),
2264            span,
2265        );
2266    }
2267    if line_text.contains(crate::bom::BOM_CHAR) {
2268        return crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2269            .with_hint(crate::diagnostics::BOM_REMOVAL_HINT);
2270    }
2271    crate::ParseError::new(
2272        crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2273        span,
2274    )
2275}
2276
2277/// Walk every descendant token and emit a `ParseError` for each
2278/// `ERROR_TOKEN` (or BOM-containing token) that lands inside an
2279/// otherwise-valid directive node - i.e., NOT inside an
2280/// `ERROR_NODE` ancestor. Catches lexer-reject bytes the
2281/// outer recovery path misses:
2282/// - `.` in `.50 USD` (leading-decimal in posting amount) →
2283///   `SyntaxError`.
2284/// - Mid-file U+FEFF byte inside a recognized directive (e.g.,
2285///   `open Assets:Bank \u{FEFF}USD`) → `BomInDirectiveBody` with
2286///   `BOM_REMOVAL_HINT`.
2287///
2288/// The leading `SyntaxKind::BOM` token is skipped (the
2289/// legitimate strict-byte-0 BOM is already tracked by
2290/// `has_leading_bom`). `ERROR_NODE` descendants are skipped -
2291/// `extract_error_node_errors` / `classify_recovery_error`
2292/// already cover those.
2293/// Result of the fused descendants-walk visitor that powers
2294/// `walk_descendants_once`.
2295pub(super) struct DescendantsWalkResult {
2296    pub(super) inline_errors: Vec<crate::ParseError>,
2297    pub(super) top_level_comments: Vec<Spanned<String>>,
2298    pub(super) currency_occurrences: Vec<Spanned<Currency>>,
2299    pub(super) account_occurrences: Vec<Spanned<rustledger_core::Account>>,
2300}
2301
2302/// Fused single-pass visitor over `source_file`'s descendants -
2303/// replaces three separate walks (`extract_inline_token_errors`,
2304/// `extract_top_level_comments`, `extract_currency_occurrences`)
2305/// with one traversal. Each walk had its own per-token cost; the
2306/// LSP runs them on every keystroke, so collapsing 3·O(N) → 1·O(N)
2307/// matters at editor-edge latencies. The state of each former
2308/// walk is maintained inline below.
2309fn walk_descendants_once(
2310    source_file: &SourceFile,
2311    bom_offset: u32,
2312    collect_occurrences: bool,
2313) -> DescendantsWalkResult {
2314    let mut inline_errors: Vec<crate::ParseError> = Vec::new();
2315    let mut top_level_comments: Vec<Spanned<String>> = Vec::new();
2316    let mut currency_occurrences: Vec<Spanned<Currency>> = Vec::new();
2317    let mut account_occurrences: Vec<Spanned<rustledger_core::Account>> = Vec::new();
2318
2319    // `extract_top_level_comments` state: column-0 tracking.
2320    let mut preceded_by_ws = false;
2321
2322    for el in source_file.syntax().descendants_with_tokens() {
2323        let rowan::NodeOrToken::Token(t) = el else {
2324            // `extract_top_level_comments` used the Node arm to
2325            // reset preceded_by_ws when entering a recognized
2326            // directive. Keep that behavior - directive leading
2327            // trivia still gets column-0-classified correctly.
2328            if let rowan::NodeOrToken::Node(n) = el
2329                && ast::Directive::can_cast(n.kind())
2330            {
2331                preceded_by_ws = false;
2332            }
2333            continue;
2334        };
2335
2336        // ---- `extract_top_level_comments` state machine -------
2337        match t.kind() {
2338            crate::SyntaxKind::NEWLINE => preceded_by_ws = false,
2339            crate::SyntaxKind::WHITESPACE => preceded_by_ws = true,
2340            k if is_comment_kind(k) => {
2341                if !preceded_by_ws {
2342                    let range = t.text_range();
2343                    let start: u32 = range.start().into();
2344                    let end: u32 = range.end().into();
2345                    let span =
2346                        Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2347                    top_level_comments.push(Spanned::new(t.text().to_string(), span));
2348                }
2349            }
2350            _ => {
2351                preceded_by_ws = false;
2352            }
2353        }
2354
2355        // ---- `extract_inline_token_errors` + currency walks ---
2356        if t.kind() == crate::SyntaxKind::BOM {
2357            continue;
2358        }
2359        // ERROR_NODE-ancestor check is only consulted for tokens
2360        // whose downstream emission depends on it (CURRENCY, BOM-
2361        // text-containing, ERROR_TOKEN). For well-formed source
2362        // most tokens fall into none of those buckets - gating
2363        // the per-token `parent_ancestors` walk on relevance
2364        // saves an O(depth) probe per WHITESPACE/NEWLINE/comment
2365        // token, which dominates token counts in real ledgers.
2366        let kind = t.kind();
2367        let has_bom = t.text().contains(crate::bom::BOM_CHAR);
2368        let is_error_token = kind == crate::SyntaxKind::ERROR_TOKEN;
2369        // CURRENCY/ACCOUNT need the in-ERROR_NODE probe only to decide whether
2370        // to record an occurrence; skip it entirely when not collecting.
2371        let needs_in_error_check = (collect_occurrences
2372            && matches!(
2373                kind,
2374                crate::SyntaxKind::CURRENCY | crate::SyntaxKind::ACCOUNT
2375            ))
2376            || has_bom
2377            || is_error_token;
2378        if !needs_in_error_check {
2379            continue;
2380        }
2381        let in_error_node = t
2382            .parent_ancestors()
2383            .any(|a| a.kind() == crate::SyntaxKind::ERROR_NODE);
2384
2385        // CURRENCY occurrences: only outside ERROR_NODE, and only when the
2386        // caller wants them (LSP path).
2387        if collect_occurrences && kind == crate::SyntaxKind::CURRENCY && !in_error_node {
2388            let range = t.text_range();
2389            let start: u32 = range.start().into();
2390            let end: u32 = range.end().into();
2391            let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2392            currency_occurrences.push(Spanned::new(Currency::new(t.text()), span));
2393        }
2394
2395        // ACCOUNT occurrences: only outside ERROR_NODE. The same
2396        // rationale as CURRENCY applies - the lexer classifies an
2397        // `ACCOUNT` token by its character shape independent of
2398        // whether the surrounding directive parses cleanly, and
2399        // source-position-aware tooling (LSP rename / references /
2400        // document-highlight) wants the token as the user typed it
2401        // even during a mid-edit broken state.
2402        if collect_occurrences && kind == crate::SyntaxKind::ACCOUNT && !in_error_node {
2403            let range = t.text_range();
2404            let start: u32 = range.start().into();
2405            let end: u32 = range.end().into();
2406            let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2407            account_occurrences.push(Spanned::new(rustledger_core::Account::new(t.text()), span));
2408        }
2409
2410        // Inline errors: BOM byte in a recognized directive
2411        // (-> BomInDirectiveBody + hint) or ERROR_TOKEN inside a
2412        // recognized directive (-> SyntaxError). Both skip when
2413        // already inside an ERROR_NODE (handled by the recovery
2414        // classifier).
2415        if (!has_bom && !is_error_token) || in_error_node {
2416            continue;
2417        }
2418        let range = t.text_range();
2419        let start: u32 = range.start().into();
2420        let end: u32 = range.end().into();
2421        let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2422        if has_bom {
2423            inline_errors.push(
2424                crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2425                    .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
2426            );
2427        } else {
2428            inline_errors.push(crate::ParseError::new(
2429                crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2430                span,
2431            ));
2432        }
2433    }
2434
2435    DescendantsWalkResult {
2436        inline_errors,
2437        top_level_comments,
2438        currency_occurrences,
2439        account_occurrences,
2440    }
2441}
2442
2443/// Emit empty-string comments for org-mode section-marker
2444/// lines (`* Heading`, `** Subheading`) inside `ERROR_NODE`
2445/// children. The legacy parser's `parse_entry` matches
2446/// `Token::Star` and emits `Comment(String::new(), line_span)`;
2447/// the structured CST wraps these lines in `ERROR_NODE`s so we
2448/// have to walk them and synthesize the same shape.
2449fn section_marker_check(
2450    child: &crate::SyntaxNode,
2451    bom_offset: u32,
2452    out: &mut Vec<Spanned<String>>,
2453) {
2454    // Caller dispatches: `child` is an ERROR_NODE.
2455    // Walk tokens line-by-line. A line starts at the start
2456    // of the first token after a NEWLINE (or at the node's
2457    // start) and ends at the next NEWLINE (inclusive).
2458    let mut line_start: Option<u32> = None;
2459    let mut first_non_trivia: Option<crate::SyntaxKind> = None;
2460    for el in child.children_with_tokens() {
2461        let rowan::NodeOrToken::Token(t) = el else {
2462            continue;
2463        };
2464        let range = t.text_range();
2465        let start: u32 = range.start().into();
2466        let end: u32 = range.end().into();
2467        if line_start.is_none() {
2468            line_start = Some(start);
2469        }
2470        if t.kind() == crate::SyntaxKind::NEWLINE {
2471            if first_non_trivia == Some(crate::SyntaxKind::STAR)
2472                && let Some(ls) = line_start
2473            {
2474                let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2475                out.push(Spanned::new(String::new(), span));
2476            }
2477            line_start = None;
2478            first_non_trivia = None;
2479            continue;
2480        }
2481        if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
2482            first_non_trivia = Some(t.kind());
2483        }
2484    }
2485}
2486
2487// `extract_top_level_comments` and `extract_currency_occurrences`
2488// are folded into `walk_descendants_once` above - see the
2489// comments there for the column-0 / ERROR_NODE-exclusion rules.
2490
2491// ---- Token parsing helpers -------------------------------------
2492
2493/// Parse a date token, accepting the same shapes as the legacy
2494/// parser: canonical `YYYY-MM-DD`, slash-separated `YYYY/M/D`,
2495/// and single-digit month/day. Returns `None` when the token
2496/// can't be turned into a real calendar date (invalid month,
2497/// invalid day for the given month, etc.).
2498pub(super) fn parse_date_token(text: &str) -> Option<NaiveDate> {
2499    // Fast path: canonical "YYYY-MM-DD".
2500    if text.len() == 10
2501        && text.as_bytes()[4] == b'-'
2502        && text.as_bytes()[7] == b'-'
2503        && let (Ok(y), Ok(m), Ok(d)) = (
2504            text[0..4].parse::<i32>(),
2505            text[5..7].parse::<u32>(),
2506            text[8..10].parse::<u32>(),
2507        )
2508    {
2509        return naive_date(y, m, d);
2510    }
2511    // Slow path: share legacy's normalizer so single-digit
2512    // month/day (`2024-1-15`, `2024-01-5`) and slash separators
2513    // are accepted everywhere the legacy parser accepts them.
2514    crate::diagnostics::normalize_date_str(text)
2515        .parse::<NaiveDate>()
2516        .ok()
2517}
2518
2519/// Parse a directive's `DATE` token. On success returns the
2520/// `NaiveDate`; on a token whose calendar values don't form a
2521/// real date (`2024-13-01`, Feb 29 in a non-leap year) emits
2522/// `InvalidDateValue` with the legacy parser's human-readable
2523/// message and returns `None`. This mirrors
2524/// `parser.rs:181-182` so the CST and legacy parsers surface the
2525/// same diagnostics for malformed dates in directive position.
2526fn parse_directive_date(
2527    date_tok: &ast::Date,
2528    errors: &mut Vec<crate::ParseError>,
2529    bom_offset: u32,
2530) -> Option<NaiveDate> {
2531    let text = date_tok.text();
2532    if let Some(d) = parse_date_token(text) {
2533        return Some(d);
2534    }
2535    let range = date_tok.syntax().text_range();
2536    let start: u32 = range.start().into();
2537    let end: u32 = range.end().into();
2538    let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2539    errors.push(crate::ParseError::new(
2540        crate::ParseErrorKind::InvalidDateValue(crate::diagnostics::describe_invalid_date(text)),
2541        span,
2542    ));
2543    None
2544}
2545
2546/// Decode a `STRING` token's text (with surrounding quotes) into its semantic
2547/// value: quotes stripped, escapes decoded (`\"`→`"`, `\\`→`\`, `\n`/`\t`/`\r`,
2548/// unknown escape drops the backslash). `None` if not a well-formed quoted
2549/// string. Text-based so both the red ([`ast::StringLit::text_decoded`]) and
2550/// green conversion paths share one source of truth.
2551pub(super) fn decode_string_token(text: &str) -> Option<String> {
2552    let bytes = text.as_bytes();
2553    if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
2554        return None;
2555    }
2556    let raw = &text[1..text.len() - 1];
2557    if !raw.contains('\\') {
2558        return Some(raw.to_string());
2559    }
2560    let mut out = String::with_capacity(raw.len());
2561    let mut chars = raw.chars();
2562    while let Some(c) = chars.next() {
2563        if c != '\\' {
2564            out.push(c);
2565            continue;
2566        }
2567        match chars.next() {
2568            Some('"') => out.push('"'),
2569            Some('\\') => out.push('\\'),
2570            Some('n') => out.push('\n'),
2571            Some('t') => out.push('\t'),
2572            Some('r') => out.push('\r'),
2573            Some(other) => out.push(other),
2574            None => {}
2575        }
2576    }
2577    Some(out)
2578}
2579
2580/// Parse a numeric token. Tolerates leading sign and thousands-
2581/// separator commas (legacy parser drops them).
2582pub(super) fn parse_decimal_token(text: &str) -> Option<Decimal> {
2583    use std::str::FromStr;
2584    let cleaned: String;
2585    let s = if text.contains(',') {
2586        cleaned = text.replace(',', "");
2587        cleaned.as_str()
2588    } else {
2589        text
2590    };
2591    Decimal::from_str(s).ok()
2592}
2593
2594/// Choose `Int` vs `Number` for a numeric metadata literal.
2595///
2596/// Beancount represents integer metadata (`key: 42`) as an int and decimal
2597/// metadata (`key: 42.0`) as a `Decimal`. `text` is the original (unsigned)
2598/// NUMBER token: a literal with no `.` or exponent that fits in `i64` becomes
2599/// `Int`; decimals, exponents, and out-of-range integers stay `Number`. `value`
2600/// is the parsed (and sign-applied) magnitude. (Thousands-separator commas are
2601/// irrelevant to integer-ness, so they aren't stripped before the check.)
2602pub(super) fn number_meta_value(text: &str, value: Decimal) -> MetaValue {
2603    use rust_decimal::prelude::ToPrimitive;
2604    if !text.contains('.')
2605        && !text.contains('e')
2606        && !text.contains('E')
2607        && let Some(i) = value.to_i64()
2608    {
2609        return MetaValue::Int(i);
2610    }
2611    MetaValue::Number(value)
2612}
2613
2614// ---- Span helpers ----------------------------------------------
2615
2616/// Convert a CST node's [`rowan::TextRange`] (relative to the
2617/// post-BOM source frame) into a [`Span`] in the original-source
2618/// frame.
2619fn node_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2620    let range = node.text_range();
2621    let start: u32 = range.start().into();
2622    let end: u32 = range.end().into();
2623    Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2624}
2625
2626/// Trivia kinds that don't count toward a span's start/end when
2627/// matching the legacy parser's span convention.
2628///
2629/// Covers WHITESPACE / NEWLINE plus EVERY comment-trivia kind
2630/// (`COMMENT`, `PERCENT_COMMENT`, `SHEBANG`, `EMACS_DIRECTIVE`)
2631/// so files with ledger-style `%` comments or org-mode
2632/// `#!`/`#+` lines have the same span/header-tracking behavior
2633/// as files with only `;` comments. Mirrors
2634/// `SyntaxKind::is_trivia()` minus `BOM` - a mid-file BOM byte
2635/// is an error to surface (`extract_inline_token_errors` /
2636/// `classify_recovery_error`), not trivia to silently skip.
2637pub(super) const fn is_trivia_kind(kind: crate::SyntaxKind) -> bool {
2638    matches!(
2639        kind,
2640        crate::SyntaxKind::WHITESPACE
2641            | crate::SyntaxKind::NEWLINE
2642            | crate::SyntaxKind::COMMENT
2643            | crate::SyntaxKind::PERCENT_COMMENT
2644            | crate::SyntaxKind::SHEBANG
2645            | crate::SyntaxKind::EMACS_DIRECTIVE
2646    )
2647}
2648
2649/// Span policy for `Posting`: the legacy parser ends the posting
2650/// span at the position just before the line's terminating
2651/// NEWLINE. The CST node's range INCLUDES the terminator
2652/// NEWLINE; trim it by using the NEWLINE token's start position.
2653/// We look at the FIRST direct-child NEWLINE token because
2654/// posting-attached metadata sub-lines (which have their own
2655/// inner NEWLINEs) come after the line terminator and shouldn't
2656/// extend the posting-line span.
2657fn posting_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2658    let range = node.text_range();
2659    let start: u32 = range.start().into();
2660    let end_raw: u32 = range.end().into();
2661    // Postings have no inter-directive leading trivia: their
2662    // first direct-child NEWLINE IS the terminator.
2663    let end = node
2664        .children_with_tokens()
2665        .filter_map(rowan::NodeOrToken::into_token)
2666        .find(|t| t.kind() == crate::SyntaxKind::NEWLINE)
2667        .map_or(end_raw, |t| u32::from(t.text_range().start()));
2668    Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2669}
2670
2671/// Span policy for non-Directive single-line constructs that
2672/// participate in inter-directive trivia attachment (Option,
2673/// Include, Plugin). Unlike Posting these may have leading
2674/// trivia (blank-line NEWLINEs, comments) inside the node from
2675/// the Directive-Terminator Rule. Start at the first non-trivia
2676/// content token; end at the first NEWLINE after that.
2677fn single_line_directive_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2678    let range = node.text_range();
2679    let start_raw: u32 = range.start().into();
2680    let end_raw: u32 = range.end().into();
2681    let mut content_start: Option<u32> = None;
2682    let mut terminator: Option<u32> = None;
2683    for t in node
2684        .children_with_tokens()
2685        .filter_map(rowan::NodeOrToken::into_token)
2686    {
2687        if content_start.is_none() {
2688            if !is_trivia_kind(t.kind()) {
2689                content_start = Some(u32::from(t.text_range().start()));
2690            }
2691        } else if t.kind() == crate::SyntaxKind::NEWLINE {
2692            terminator = Some(u32::from(t.text_range().start()));
2693            break;
2694        }
2695    }
2696    let start = content_start.unwrap_or(start_raw);
2697    let end = terminator.unwrap_or(end_raw);
2698    Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2699}
2700
2701/// Span policy for top-level directives: legacy directives start
2702/// at the first content character (skipping leading trivia from
2703/// the Directive-Terminator Rule) and extend through any
2704/// inter-directive trivia up to where the NEXT directive begins.
2705/// Computed in a post-pass since each directive's end depends on
2706/// the next one's start.
2707fn fixup_directive_spans(
2708    source_file: &SourceFile,
2709    bom_offset: u32,
2710    converted_nodes: &[crate::SyntaxNode],
2711    directives: &mut [Spanned<Directive>],
2712) {
2713    debug_assert_eq!(
2714        converted_nodes.len(),
2715        directives.len(),
2716        "converted_nodes and directives must be parallel arrays"
2717    );
2718
2719    // Walk EVERY top-level Directive-castable child (including
2720    // pushtag/poptag/pushmeta/popmeta that we filter out of the
2721    // ParseResult) so the "next directive's start" boundary used
2722    // for span end-fixup matches the legacy parser: there, each
2723    // visible directive's span ends at the next /input/
2724    // directive's start, regardless of whether that next
2725    // directive is preserved.
2726    let all_starts: Vec<(usize, usize)> = source_file
2727        .syntax()
2728        .children()
2729        .filter(|n| ast::Directive::can_cast(n.kind()))
2730        .map(|n| {
2731            let raw_start: u32 = n.text_range().start().into();
2732            let content_start = n
2733                .descendants_with_tokens()
2734                .filter_map(rowan::NodeOrToken::into_token)
2735                .find(|t| !is_trivia_kind(t.kind()))
2736                .map_or_else(
2737                    || (raw_start + bom_offset) as usize,
2738                    |t| (u32::from(t.text_range().start()) + bom_offset) as usize,
2739                );
2740            ((raw_start + bom_offset) as usize, content_start)
2741        })
2742        .collect();
2743
2744    let source_end: usize =
2745        (u32::from(source_file.syntax().text_range().end()) + bom_offset) as usize;
2746
2747    // For each converted directive, find its position in the all
2748    // list by raw_start (which is unique per CST node), then use
2749    // the NEXT all_starts content_start as its span end.
2750    //
2751    // INVARIANT: every node in `converted_nodes` was yielded by
2752    // `source_file.directives()`, which is the same iteration
2753    // `all_starts` filters from. So `position` always succeeds in
2754    // well-formed input. Falling back to the node's own
2755    // `text_range` rather than panicking keeps the parser usable
2756    // when a future change to the typed-AST surface ever de-syncs
2757    // those two enumerations - a `panic!()` reachable from user
2758    // input is a `#![forbid(unsafe_code)]`-class regression for an
2759    // LSP/WASM consumer.
2760    for (i, spanned) in directives.iter_mut().enumerate() {
2761        let node = &converted_nodes[i];
2762        let raw_start: usize = (u32::from(node.text_range().start()) + bom_offset) as usize;
2763        let node_end: usize = (u32::from(node.text_range().end()) + bom_offset) as usize;
2764        if let Some(pos) = all_starts.iter().position(|(rs, _)| *rs == raw_start) {
2765            let start = all_starts[pos].1;
2766            let end = all_starts
2767                .get(pos + 1)
2768                .map_or(source_end, |(_, content)| *content);
2769            spanned.span = Span::new(start, end);
2770        } else {
2771            // Defensive fallback: match the success-path
2772            // convention by also trimming leading trivia. Without
2773            // this trim the fallback span would underline blank
2774            // lines / column-0 comments above the directive when
2775            // LSP/miette renders the diagnostic, even though the
2776            // directive itself starts further down.
2777            let content_start = node
2778                .descendants_with_tokens()
2779                .filter_map(rowan::NodeOrToken::into_token)
2780                .find(|t| !is_trivia_kind(t.kind()))
2781                .map_or(raw_start, |t| {
2782                    (u32::from(t.text_range().start()) + bom_offset) as usize
2783                });
2784            spanned.span = Span::new(content_start, node_end);
2785        }
2786    }
2787}
2788
2789#[cfg(test)]
2790mod tests {
2791    use super::*;
2792
2793    fn assert_directive_count(result: &ParseResult, expected: usize) {
2794        assert_eq!(
2795            result.directives.len(),
2796            expected,
2797            "directive count mismatch: {:#?}",
2798            result.directives
2799        );
2800    }
2801
2802    #[test]
2803    fn open_directive_basic() {
2804        let src = "2024-01-15 open Assets:Cash\n";
2805        let result = parse_via_cst(src);
2806        assert_directive_count(&result, 1);
2807        let Directive::Open(open) = &result.directives[0].value else {
2808            panic!("expected Open, got {:?}", result.directives[0].value);
2809        };
2810        assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
2811        assert_eq!(open.account.as_str(), "Assets:Cash");
2812        assert!(open.currencies.is_empty());
2813        assert!(open.booking.is_none());
2814        assert!(open.meta.is_empty());
2815    }
2816
2817    #[test]
2818    fn open_directive_with_currencies_and_booking() {
2819        let src = "2024-01-15 open Assets:Brokerage USD,EUR \"STRICT\"\n";
2820        let result = parse_via_cst(src);
2821        assert_directive_count(&result, 1);
2822        let Directive::Open(open) = &result.directives[0].value else {
2823            panic!("expected Open");
2824        };
2825        let currencies: Vec<&str> = open.currencies.iter().map(Currency::as_str).collect();
2826        assert_eq!(currencies, vec!["USD", "EUR"]);
2827        assert_eq!(open.booking.as_deref(), Some("STRICT"));
2828    }
2829
2830    #[test]
2831    fn open_directive_with_metadata() {
2832        let src = "2024-01-15 open Assets:Cash\n  note: \"main checking\"\n  number: 42\n";
2833        let result = parse_via_cst(src);
2834        assert_directive_count(&result, 1);
2835        let Directive::Open(open) = &result.directives[0].value else {
2836            panic!("expected Open");
2837        };
2838        assert_eq!(
2839            open.meta.get("note"),
2840            Some(&MetaValue::String("main checking".to_string()))
2841        );
2842        assert_eq!(
2843            open.meta.get("number"),
2844            // Unquoted integer metadata is now `Int`, not `Number`.
2845            Some(&MetaValue::Int(42))
2846        );
2847    }
2848
2849    #[test]
2850    fn close_directive_basic() {
2851        let src = "2024-12-31 close Assets:Cash\n";
2852        let result = parse_via_cst(src);
2853        assert_directive_count(&result, 1);
2854        let Directive::Close(close) = &result.directives[0].value else {
2855            panic!("expected Close, got {:?}", result.directives[0].value);
2856        };
2857        assert_eq!(close.date, naive_date(2024, 12, 31).unwrap());
2858        assert_eq!(close.account.as_str(), "Assets:Cash");
2859    }
2860
2861    #[test]
2862    fn commodity_directive_basic() {
2863        let src = "2024-01-01 commodity HOOL\n";
2864        let result = parse_via_cst(src);
2865        assert_directive_count(&result, 1);
2866        let Directive::Commodity(c) = &result.directives[0].value else {
2867            panic!("expected Commodity");
2868        };
2869        assert_eq!(c.currency.as_str(), "HOOL");
2870    }
2871
2872    #[test]
2873    fn bom_offset_is_included_in_spans() {
2874        let src = "\u{FEFF}2024-01-15 open Assets:Cash\n";
2875        let result = parse_via_cst(src);
2876        assert!(result.has_leading_bom);
2877        let span = result.directives[0].span;
2878        assert_eq!(span.start, 3, "span should include BOM offset");
2879    }
2880
2881    #[test]
2882    fn note_directive_basic() {
2883        let src = "2024-01-15 note Assets:Cash \"deposit received\"\n";
2884        let result = parse_via_cst(src);
2885        assert_directive_count(&result, 1);
2886        let Directive::Note(note) = &result.directives[0].value else {
2887            panic!("expected Note");
2888        };
2889        assert_eq!(note.date, naive_date(2024, 1, 15).unwrap());
2890        assert_eq!(note.account.as_str(), "Assets:Cash");
2891        assert_eq!(note.comment, "deposit received");
2892    }
2893
2894    #[test]
2895    fn document_directive_basic() {
2896        let src = "2024-01-15 document Assets:Cash \"/path/to/file.pdf\"\n";
2897        let result = parse_via_cst(src);
2898        assert_directive_count(&result, 1);
2899        let Directive::Document(d) = &result.directives[0].value else {
2900            panic!("expected Document");
2901        };
2902        assert_eq!(d.account.as_str(), "Assets:Cash");
2903        assert_eq!(d.path, "/path/to/file.pdf");
2904        // tags/links currently unimplemented - pin as empty.
2905        assert!(d.tags.is_empty());
2906        assert!(d.links.is_empty());
2907    }
2908
2909    #[test]
2910    fn event_directive_basic() {
2911        let src = "2024-01-15 event \"location\" \"Berlin\"\n";
2912        let result = parse_via_cst(src);
2913        assert_directive_count(&result, 1);
2914        let Directive::Event(e) = &result.directives[0].value else {
2915            panic!("expected Event");
2916        };
2917        assert_eq!(e.event_type, "location");
2918        assert_eq!(e.value, "Berlin");
2919    }
2920
2921    #[test]
2922    fn query_directive_basic() {
2923        let src = "2024-01-15 query \"income\" \"SELECT account, sum(position)\"\n";
2924        let result = parse_via_cst(src);
2925        assert_directive_count(&result, 1);
2926        let Directive::Query(q) = &result.directives[0].value else {
2927            panic!("expected Query");
2928        };
2929        assert_eq!(q.name, "income");
2930        assert_eq!(q.query, "SELECT account, sum(position)");
2931    }
2932
2933    #[test]
2934    fn price_directive_basic() {
2935        let src = "2024-01-15 price USD 1.10 EUR\n";
2936        let result = parse_via_cst(src);
2937        assert_directive_count(&result, 1);
2938        let Directive::Price(p) = &result.directives[0].value else {
2939            panic!("expected Price");
2940        };
2941        assert_eq!(p.currency.as_str(), "USD");
2942        assert_eq!(p.amount.number, Decimal::new(110, 2));
2943        assert_eq!(p.amount.currency.as_str(), "EUR");
2944    }
2945
2946    #[test]
2947    fn balance_directive_basic() {
2948        let src = "2024-06-30 balance Assets:Cash 100.00 USD\n";
2949        let result = parse_via_cst(src);
2950        assert_directive_count(&result, 1);
2951        let Directive::Balance(b) = &result.directives[0].value else {
2952            panic!("expected Balance");
2953        };
2954        assert_eq!(b.account.as_str(), "Assets:Cash");
2955        assert_eq!(b.amount.number, Decimal::new(10000, 2));
2956        assert_eq!(b.amount.currency.as_str(), "USD");
2957        assert!(b.tolerance.is_none());
2958    }
2959
2960    #[test]
2961    fn balance_directive_with_explicit_tolerance() {
2962        let src = "2024-06-30 balance Assets:Cash 100.00 ~ 0.05 USD\n";
2963        let result = parse_via_cst(src);
2964        assert_directive_count(&result, 1);
2965        let Directive::Balance(b) = &result.directives[0].value else {
2966            panic!("expected Balance");
2967        };
2968        assert_eq!(b.amount.number, Decimal::new(10000, 2));
2969        assert_eq!(b.tolerance, Some(Decimal::new(5, 2)));
2970    }
2971
2972    #[test]
2973    fn pad_directive_basic() {
2974        let src = "2024-01-01 pad Assets:Cash Equity:Opening-Balances\n";
2975        let result = parse_via_cst(src);
2976        assert_directive_count(&result, 1);
2977        let Directive::Pad(p) = &result.directives[0].value else {
2978            panic!("expected Pad");
2979        };
2980        assert_eq!(p.account.as_str(), "Assets:Cash");
2981        assert_eq!(p.source_account.as_str(), "Equity:Opening-Balances");
2982    }
2983
2984    #[test]
2985    fn custom_directive_basic() {
2986        let src = "2024-01-01 custom \"budget\" \"food\" 500 USD\n";
2987        let result = parse_via_cst(src);
2988        assert_directive_count(&result, 1);
2989        let Directive::Custom(c) = &result.directives[0].value else {
2990            panic!("expected Custom");
2991        };
2992        assert_eq!(c.custom_type, "budget");
2993        assert_eq!(c.values.len(), 2);
2994        assert_eq!(c.values[0], MetaValue::String("food".to_string()));
2995        // 500 USD becomes an Amount (NUMBER + CURRENCY adjacent).
2996        let MetaValue::Amount(amt) = &c.values[1] else {
2997            panic!("expected Amount, got {:?}", c.values[1]);
2998        };
2999        assert_eq!(amt.number, Decimal::from(500));
3000        assert_eq!(amt.currency.as_str(), "USD");
3001    }
3002
3003    #[test]
3004    fn custom_directive_heterogeneous_values() {
3005        let src = "2024-01-01 custom \"test\" Assets:Cash TRUE 42 2024-06-15\n";
3006        let result = parse_via_cst(src);
3007        let Directive::Custom(c) = &result.directives[0].value else {
3008            panic!("expected Custom");
3009        };
3010        assert_eq!(c.values.len(), 4);
3011        assert!(matches!(c.values[0], MetaValue::Account(_)));
3012        assert_eq!(c.values[1], MetaValue::Bool(true));
3013        assert_eq!(c.values[2], MetaValue::Int(42));
3014        assert!(matches!(c.values[3], MetaValue::Date(_)));
3015    }
3016
3017    #[test]
3018    fn number_meta_value_int_vs_decimal_discriminator() {
3019        use rust_decimal_macros::dec;
3020        // Integer literals -> Int (the token text is unsigned; `value` carries
3021        // the sign, e.g. `precision: -1` parses the token "1" with value -1).
3022        assert_eq!(number_meta_value("42", dec!(42)), MetaValue::Int(42));
3023        assert_eq!(number_meta_value("0", dec!(0)), MetaValue::Int(0));
3024        assert_eq!(number_meta_value("1", dec!(-1)), MetaValue::Int(-1));
3025        // Decimal point -> Number.
3026        assert_eq!(
3027            number_meta_value("42.0", dec!(42.0)),
3028            MetaValue::Number(dec!(42.0))
3029        );
3030        // Exponent -> Number. The lexer doesn't currently emit exponent NUMBER
3031        // tokens, so this isn't reachable from real input today; it pins the
3032        // helper's `e`/`E` guard against a future lexer that does.
3033        assert_eq!(
3034            number_meta_value("1e3", dec!(1000)),
3035            MetaValue::Number(dec!(1000))
3036        );
3037        // i64 overflow stays Number (within Decimal's range).
3038        let huge = "99999999999999999999999999";
3039        let huge_dec = Decimal::from_str_exact(huge).unwrap();
3040        assert_eq!(
3041            number_meta_value(huge, huge_dec),
3042            MetaValue::Number(huge_dec)
3043        );
3044    }
3045
3046    #[test]
3047    fn option_directive_populates_options_field() {
3048        let src = "option \"title\" \"My Ledger\"\n";
3049        let result = parse_via_cst(src);
3050        assert_directive_count(&result, 0);
3051        assert_eq!(result.options.len(), 1);
3052        assert_eq!(result.options[0].0, "title");
3053        assert_eq!(result.options[0].1, "My Ledger");
3054    }
3055
3056    #[test]
3057    fn include_directive_populates_includes_field() {
3058        let src = "include \"shared.beancount\"\n";
3059        let result = parse_via_cst(src);
3060        assert_directive_count(&result, 0);
3061        assert_eq!(result.includes.len(), 1);
3062        assert_eq!(result.includes[0].0, "shared.beancount");
3063    }
3064
3065    #[test]
3066    fn plugin_directive_with_config() {
3067        let src = "plugin \"my.plugin\" \"cfg\"\n";
3068        let result = parse_via_cst(src);
3069        assert_directive_count(&result, 0);
3070        assert_eq!(result.plugins.len(), 1);
3071        assert_eq!(result.plugins[0].0, "my.plugin");
3072        assert_eq!(result.plugins[0].1.as_deref(), Some("cfg"));
3073    }
3074
3075    #[test]
3076    fn plugin_directive_without_config() {
3077        let src = "plugin \"my.plugin\"\n";
3078        let result = parse_via_cst(src);
3079        assert_eq!(result.plugins.len(), 1);
3080        assert_eq!(result.plugins[0].0, "my.plugin");
3081        assert!(result.plugins[0].1.is_none());
3082    }
3083
3084    // ---- Transaction converter tests ------------------------------
3085
3086    #[test]
3087    fn transaction_basic_two_postings() {
3088        let src = "2024-01-15 * \"Coffee Shop\" \"Morning coffee\"\n  \
3089                   Expenses:Food:Coffee  5.00 USD\n  \
3090                   Assets:Cash\n";
3091        let result = parse_via_cst(src);
3092        assert_directive_count(&result, 1);
3093        let Directive::Transaction(t) = &result.directives[0].value else {
3094            panic!("expected Transaction");
3095        };
3096        assert_eq!(t.date, naive_date(2024, 1, 15).unwrap());
3097        assert_eq!(t.flag, '*');
3098        assert_eq!(
3099            t.payee.as_ref().map(InternedStr::as_str),
3100            Some("Coffee Shop")
3101        );
3102        assert_eq!(t.narration.as_str(), "Morning coffee");
3103        assert_eq!(t.postings.len(), 2);
3104
3105        let p0 = &t.postings[0].value;
3106        assert_eq!(p0.account.as_str(), "Expenses:Food:Coffee");
3107        let Some(IncompleteAmount::Complete(amt)) = &p0.units else {
3108            panic!("expected complete units, got {:?}", p0.units);
3109        };
3110        assert_eq!(amt.number, Decimal::new(500, 2));
3111        assert_eq!(amt.currency.as_str(), "USD");
3112
3113        let p1 = &t.postings[1].value;
3114        assert_eq!(p1.account.as_str(), "Assets:Cash");
3115        assert!(p1.units.is_none(), "auto-posting has no units");
3116    }
3117
3118    #[test]
3119    fn transaction_narration_only_no_payee() {
3120        let src = "2024-01-15 ! \"Pending\"\n  Assets:Cash  -5 USD\n";
3121        let result = parse_via_cst(src);
3122        let Directive::Transaction(t) = &result.directives[0].value else {
3123            panic!("expected Transaction");
3124        };
3125        assert_eq!(t.flag, '!');
3126        assert!(t.payee.is_none());
3127        assert_eq!(t.narration.as_str(), "Pending");
3128    }
3129
3130    #[test]
3131    fn transaction_three_plus_header_strings_surface_last_as_narration() {
3132        // A header with 3+ strings is ambiguous (the grammar caps payee+narration
3133        // at two); the lossless CST still keeps all of them, so the typed surface
3134        // drops the payee and surfaces only the LAST string as narration. Locks
3135        // the `(Some, Some, Some(c)) => it.last().unwrap_or(c)` arm.
3136        let src = "2024-01-15 * \"a\" \"b\" \"c\"\n  Assets:Cash  -5 USD\n";
3137        let result = parse_via_cst(src);
3138        let Directive::Transaction(t) = &result.directives[0].value else {
3139            panic!("expected Transaction");
3140        };
3141        assert!(t.payee.is_none(), "3+ strings drop the payee");
3142        assert_eq!(t.narration.as_str(), "c", "last string becomes narration");
3143    }
3144
3145    #[test]
3146    fn transaction_implied_flag_via_leading_string() {
3147        let src = "2024-01-15 \"Implied\"\n  Assets:Cash  -5 USD\n";
3148        let result = parse_via_cst(src);
3149        let Directive::Transaction(t) = &result.directives[0].value else {
3150            panic!("expected Transaction");
3151        };
3152        assert_eq!(t.flag, '*', "implied flag defaults to *");
3153    }
3154
3155    #[test]
3156    fn transaction_with_tags_and_links() {
3157        let src = "2024-01-15 * \"Coffee\" #daily ^trip1\n  Assets:Cash  -5 USD\n";
3158        let result = parse_via_cst(src);
3159        let Directive::Transaction(t) = &result.directives[0].value else {
3160            panic!("expected Transaction");
3161        };
3162        assert_eq!(t.tags.len(), 1);
3163        assert_eq!(t.tags[0].as_str(), "daily");
3164        assert_eq!(t.links.len(), 1);
3165        assert_eq!(t.links[0].as_str(), "trip1");
3166    }
3167
3168    #[test]
3169    fn transaction_with_signed_amount() {
3170        let src = "2024-01-15 * \"x\"\n  Assets:Cash  -5.00 USD\n";
3171        let result = parse_via_cst(src);
3172        let Directive::Transaction(t) = &result.directives[0].value else {
3173            panic!("expected Transaction");
3174        };
3175        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3176            panic!("expected complete units");
3177        };
3178        assert_eq!(amt.number, Decimal::new(-500, 2));
3179    }
3180
3181    #[test]
3182    fn transaction_with_posting_flag() {
3183        let src = "2024-01-15 * \"x\"\n  ! Assets:Cash  -5 USD\n";
3184        let result = parse_via_cst(src);
3185        let Directive::Transaction(t) = &result.directives[0].value else {
3186            panic!("expected Transaction");
3187        };
3188        assert_eq!(t.postings[0].value.flag, Some('!'));
3189    }
3190
3191    #[test]
3192    fn transaction_with_cost_spec_per_unit() {
3193        let src = "2024-01-15 * \"buy\"\n  \
3194                   Assets:Inv  10 HOOL {500.00 USD}\n  \
3195                   Assets:Cash\n";
3196        let result = parse_via_cst(src);
3197        let Directive::Transaction(t) = &result.directives[0].value else {
3198            panic!("expected Transaction");
3199        };
3200        let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
3201        assert!(!cost.merge);
3202        let Some(CostNumber::PerUnit { value }) = &cost.number else {
3203            panic!("expected PerUnit");
3204        };
3205        assert_eq!(*value, Decimal::new(50000, 2));
3206        assert_eq!(cost.currency.as_ref().unwrap().as_str(), "USD");
3207    }
3208
3209    #[test]
3210    fn transaction_with_cost_spec_total() {
3211        let src = "2024-01-15 * \"buy\"\n  \
3212                   Assets:Inv  10 HOOL {{5000 USD}}\n  \
3213                   Assets:Cash\n";
3214        let result = parse_via_cst(src);
3215        let Directive::Transaction(t) = &result.directives[0].value else {
3216            panic!("expected Transaction");
3217        };
3218        let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
3219        let Some(CostNumber::Total { value }) = &cost.number else {
3220            panic!("expected Total");
3221        };
3222        assert_eq!(*value, Decimal::from(5000));
3223    }
3224
3225    #[test]
3226    fn transaction_with_price_annotation_unit() {
3227        let src = "2024-01-15 * \"buy\"\n  \
3228                   Assets:Inv  10 HOOL @ 510 USD\n  \
3229                   Assets:Cash\n";
3230        let result = parse_via_cst(src);
3231        let Directive::Transaction(t) = &result.directives[0].value else {
3232            panic!("expected Transaction");
3233        };
3234        let price = t.postings[0]
3235            .value
3236            .price
3237            .as_ref()
3238            .expect("price annotation");
3239        assert!(price.is_unit());
3240        let Some(IncompleteAmount::Complete(amt)) = &price.amount else {
3241            panic!("expected complete price amount");
3242        };
3243        assert_eq!(amt.number, Decimal::from(510));
3244        assert_eq!(amt.currency.as_str(), "USD");
3245    }
3246
3247    #[test]
3248    fn transaction_with_price_annotation_total() {
3249        let src = "2024-01-15 * \"buy\"\n  \
3250                   Assets:Inv  10 HOOL @@ 5100 USD\n  \
3251                   Assets:Cash\n";
3252        let result = parse_via_cst(src);
3253        let Directive::Transaction(t) = &result.directives[0].value else {
3254            panic!("expected Transaction");
3255        };
3256        let price = t.postings[0]
3257            .value
3258            .price
3259            .as_ref()
3260            .expect("price annotation");
3261        assert!(!price.is_unit(), "@@ is total form");
3262    }
3263
3264    // ---- regression tests for review findings (#1281) ----------
3265
3266    #[test]
3267    fn document_directive_preserves_tags_and_links() {
3268        // Finding 1: convert_document was filling tags/links empty
3269        // unconditionally. Legacy parse_document_directive collects
3270        // trailing `#tag` / `^link` tokens after the path STRING.
3271        let src = "2024-06-01 document Assets:Bank \"stmt.pdf\" #quarter1 ^scan42 #urgent\n";
3272        let result = parse_via_cst(src);
3273        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3274        let Directive::Document(doc) = &result.directives[0].value else {
3275            panic!("expected Document");
3276        };
3277        let tags: Vec<&str> = doc.tags.iter().map(Tag::as_str).collect();
3278        let links: Vec<&str> = doc.links.iter().map(Link::as_str).collect();
3279        assert_eq!(tags, vec!["quarter1", "urgent"]);
3280        assert_eq!(links, vec!["scan42"]);
3281    }
3282
3283    #[test]
3284    fn open_directive_rejects_invalid_booking_method() {
3285        // Finding 2: convert_open accepted any booking string; legacy
3286        // validates against [FIFO, STRICT, STRICT_WITH_SIZE, LIFO,
3287        // HIFO, NONE, AVERAGE] and on mismatch drops the directive
3288        // AND emits InvalidBookingMethod.
3289        let src = "2024-01-01 open Assets:Bank USD \"GARBAGE\"\n";
3290        let result = parse_via_cst(src);
3291        assert_eq!(result.directives.len(), 0, "directive should be dropped");
3292        assert_eq!(result.errors.len(), 1);
3293        let err = &result.errors[0];
3294        assert!(
3295            matches!(
3296                &err.kind,
3297                crate::ParseErrorKind::InvalidBookingMethod(s) if s == "GARBAGE"
3298            ),
3299            "expected InvalidBookingMethod, got {:?}",
3300            err.kind,
3301        );
3302    }
3303
3304    #[test]
3305    fn open_directive_accepts_all_valid_booking_methods() {
3306        for method in VALID_BOOKING_METHODS {
3307            let src = format!("2024-01-01 open Assets:Bank USD \"{method}\"\n");
3308            let result = parse_via_cst(&src);
3309            assert!(
3310                result.errors.is_empty(),
3311                "{method} rejected: {:?}",
3312                result.errors
3313            );
3314            let Directive::Open(open) = &result.directives[0].value else {
3315                panic!("{method}: expected Open");
3316            };
3317            assert_eq!(open.booking.as_deref(), Some(*method));
3318        }
3319    }
3320
3321    #[test]
3322    fn unclosed_pushtag_at_eof_emits_diagnostic() {
3323        // Finding 3: legacy emits one UnclosedPushtag per leftover
3324        // tag at EOF, pointing at the originating push directive.
3325        let src = "pushtag #active\n2024-01-01 open Assets:Bank USD\n";
3326        let result = parse_via_cst(src);
3327        let unclosed: Vec<_> = result
3328            .errors
3329            .iter()
3330            .filter_map(|e| match &e.kind {
3331                crate::ParseErrorKind::UnclosedPushtag(t) => Some(t.clone()),
3332                _ => None,
3333            })
3334            .collect();
3335        assert_eq!(unclosed, vec!["active".to_string()]);
3336    }
3337
3338    #[test]
3339    fn unclosed_pushmeta_at_eof_emits_diagnostic() {
3340        // Finding 4: same as pushtag, for pushmeta.
3341        let src = "pushmeta location: \"NYC\"\n2024-01-01 open Assets:Bank USD\n";
3342        let result = parse_via_cst(src);
3343        let unclosed: Vec<_> = result
3344            .errors
3345            .iter()
3346            .filter_map(|e| match &e.kind {
3347                crate::ParseErrorKind::UnclosedPushmeta(k) => Some(k.clone()),
3348                _ => None,
3349            })
3350            .collect();
3351        assert_eq!(unclosed, vec!["location".to_string()]);
3352    }
3353
3354    #[test]
3355    fn invalid_poptag_on_mismatch_emits_diagnostic() {
3356        // Finding 5: poptag for a tag never pushed should error,
3357        // not silently no-op.
3358        let src = "pushtag #foo\npoptag #bar\npoptag #foo\n";
3359        let result = parse_via_cst(src);
3360        let mismatches: Vec<_> = result
3361            .errors
3362            .iter()
3363            .filter_map(|e| match &e.kind {
3364                crate::ParseErrorKind::InvalidPoptag(t) => Some(t.clone()),
3365                _ => None,
3366            })
3367            .collect();
3368        assert_eq!(mismatches, vec!["bar".to_string()]);
3369        // and the matching #foo poptag should leave NO unclosed
3370        // diagnostic - i.e. the stack is empty after the matched pop.
3371        let leftover: Vec<_> = result
3372            .errors
3373            .iter()
3374            .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushtag(_)))
3375            .collect();
3376        assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
3377    }
3378
3379    #[test]
3380    fn invalid_popmeta_on_mismatch_emits_diagnostic() {
3381        // Finding 6: popmeta for a key never pushed should error,
3382        // not silently no-op. Also checks Vec-stack shadow semantics:
3383        // pushmeta x: 1; pushmeta x: 2; popmeta x leaves x=1 active.
3384        let src = "pushmeta location: \"NYC\"\npopmeta nope:\npopmeta location:\n";
3385        let result = parse_via_cst(src);
3386        let mismatches: Vec<_> = result
3387            .errors
3388            .iter()
3389            .filter_map(|e| match &e.kind {
3390                crate::ParseErrorKind::InvalidPopmeta(k) => Some(k.clone()),
3391                _ => None,
3392            })
3393            .collect();
3394        assert_eq!(mismatches, vec!["nope".to_string()]);
3395        let leftover: Vec<_> = result
3396            .errors
3397            .iter()
3398            .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushmeta(_)))
3399            .collect();
3400        assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
3401    }
3402
3403    #[test]
3404    fn pushmeta_shadow_pop_restores_prior_value() {
3405        // Vec-stack semantics (the reason meta_stack isn't a HashMap):
3406        // shadow-pop must restore the prior value, not delete the key.
3407        let src = "pushmeta loc: \"NYC\"\n\
3408                   pushmeta loc: \"LDN\"\n\
3409                   popmeta loc:\n\
3410                   2024-01-01 open Assets:Bank USD\n\
3411                   popmeta loc:\n";
3412        let result = parse_via_cst(src);
3413        let Directive::Open(open) = &result.directives[0].value else {
3414            panic!("expected Open");
3415        };
3416        assert_eq!(
3417            open.meta.get("loc"),
3418            Some(&MetaValue::String("NYC".to_string())),
3419            "shadow pop should restore NYC, got {:?}",
3420            open.meta.get("loc"),
3421        );
3422    }
3423
3424    #[test]
3425    fn error_recovery_classifies_bom_in_directive_body() {
3426        // Finding 7: error-recovery path should distinguish BOM-in-
3427        // line from a generic SyntaxError so users see the
3428        // BOM-removal hint instead of "unexpected input".
3429        let src = "garbage\u{FEFF}content\n";
3430        let result = parse_via_cst(src);
3431        let bom_errors: Vec<_> = result
3432            .errors
3433            .iter()
3434            .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3435            .collect();
3436        assert_eq!(bom_errors.len(), 1, "errors: {:?}", result.errors);
3437        assert!(
3438            bom_errors[0].hint.is_some(),
3439            "BomInDirectiveBody should carry BOM_REMOVAL_HINT",
3440        );
3441    }
3442
3443    #[test]
3444    fn error_recovery_emits_both_invalid_account_and_bom_for_dual_line() {
3445        // Round-2 finding: legacy `parser.rs:2258-2263` emits a
3446        // SECONDARY `BomInDirectiveBody` whenever the line ALSO
3447        // contains a BOM byte and the primary diagnostic isn't
3448        // BOM itself. Without this, a Windows-exported file with
3449        // a Unicode account AND an internal BOM loses the BOM
3450        // hint entirely.
3451        let src = "garbage Assets:Café\u{FEFF}content\n";
3452        let result = parse_via_cst(src);
3453        let invalid_account_count = result
3454            .errors
3455            .iter()
3456            .filter(|e| matches!(e.kind, crate::ParseErrorKind::InvalidAccount(_)))
3457            .count();
3458        let bom_count = result
3459            .errors
3460            .iter()
3461            .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3462            .count();
3463        assert_eq!(
3464            invalid_account_count, 1,
3465            "expected one InvalidAccount: {:?}",
3466            result.errors
3467        );
3468        assert_eq!(
3469            bom_count, 1,
3470            "expected secondary BomInDirectiveBody: {:?}",
3471            result.errors
3472        );
3473        // The secondary BOM diagnostic must carry the hint so
3474        // miette renders the remediation step.
3475        let bom_err = result
3476            .errors
3477            .iter()
3478            .find(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3479            .unwrap();
3480        assert!(bom_err.hint.is_some());
3481    }
3482
3483    #[test]
3484    fn error_recovery_classifies_unicode_account() {
3485        // Finding 7: a Unicode-character account name (Assets:Café)
3486        // should surface as InvalidAccount, not generic SyntaxError.
3487        // We embed it in a malformed line so the parser routes to
3488        // the error-recovery path.
3489        let src = "garbage Assets:Café content\n";
3490        let result = parse_via_cst(src);
3491        let unicode_errors: Vec<_> = result
3492            .errors
3493            .iter()
3494            .filter_map(|e| match &e.kind {
3495                crate::ParseErrorKind::InvalidAccount(s) => Some(s.clone()),
3496                _ => None,
3497            })
3498            .collect();
3499        assert_eq!(unicode_errors, vec!["Assets:Café".to_string()]);
3500    }
3501
3502    #[test]
3503    fn transaction_with_pipe_emits_deprecated_pipe_symbol() {
3504        // Finding 7 (transaction path): legacy emits
3505        // DeprecatedPipeSymbol when a `|` separates payee/narration.
3506        let src = "2024-01-15 * \"Acme\" | \"invoice\"\n  Assets:Cash  -5 USD\n  Expenses:X\n";
3507        let result = parse_via_cst(src);
3508        let pipe_count = result
3509            .errors
3510            .iter()
3511            .filter(|e| matches!(e.kind, crate::ParseErrorKind::DeprecatedPipeSymbol))
3512            .count();
3513        assert_eq!(pipe_count, 1, "errors: {:?}", result.errors);
3514        // and the transaction itself is kept (legacy behavior).
3515        assert_eq!(result.directives.len(), 1);
3516    }
3517
3518    #[test]
3519    fn transaction_trailing_comments_after_final_posting() {
3520        // Finding 8: comments that appear AFTER the last posting
3521        // but inside the transaction body belong to
3522        // Transaction::trailing_comments, not lost.
3523        let src = "2024-01-15 * \"x\"\n  \
3524                   Assets:Cash  -5 USD\n  \
3525                   Expenses:X\n  \
3526                   ; trailing one\n  \
3527                   ; trailing two\n";
3528        let result = parse_via_cst(src);
3529        let Directive::Transaction(t) = &result.directives[0].value else {
3530            panic!("expected Transaction");
3531        };
3532        assert_eq!(
3533            t.trailing_comments.len(),
3534            2,
3535            "got: {:?}",
3536            t.trailing_comments
3537        );
3538        assert!(t.trailing_comments[0].contains("trailing one"));
3539        assert!(t.trailing_comments[1].contains("trailing two"));
3540    }
3541
3542    // ---- arithmetic AMOUNT evaluation (phase 3.7 flip blocker) -
3543
3544    #[test]
3545    fn posting_amount_evaluates_division() {
3546        // Regression for `test_arithmetic_expressions_consistency`:
3547        // `120 / 3 USD` must evaluate to 40 USD so the transaction
3548        // balances. Without this the CST flip breaks every ledger
3549        // using arithmetic split syntax.
3550        let src = "2024-01-15 * \"split\"\n  \
3551                   Expenses:Food   120 / 3 USD\n  \
3552                   Assets:Bank    -40 USD\n";
3553        let result = parse_via_cst(src);
3554        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3555        let Directive::Transaction(t) = &result.directives[0].value else {
3556            panic!("expected Transaction");
3557        };
3558        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3559            panic!("expected complete amount on posting 0");
3560        };
3561        assert_eq!(amt.number, Decimal::from(40));
3562        assert_eq!(amt.currency.as_str(), "USD");
3563    }
3564
3565    #[test]
3566    fn posting_amount_evaluates_addition_and_multiplication_precedence() {
3567        // `2 + 3 * 4 USD` = 14 USD (standard precedence).
3568        let src = "2024-01-15 * \"x\"\n  \
3569                   Expenses:X   2 + 3 * 4 USD\n  \
3570                   Assets:Y   -14 USD\n";
3571        let result = parse_via_cst(src);
3572        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3573        let Directive::Transaction(t) = &result.directives[0].value else {
3574            panic!("expected Transaction");
3575        };
3576        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3577            panic!("expected complete amount");
3578        };
3579        assert_eq!(amt.number, Decimal::from(14));
3580    }
3581
3582    #[test]
3583    fn posting_amount_evaluates_parens_override_precedence() {
3584        // `(2 + 3) * 4 USD` = 20 USD.
3585        let src = "2024-01-15 * \"x\"\n  \
3586                   Expenses:X   (2 + 3) * 4 USD\n  \
3587                   Assets:Y   -20 USD\n";
3588        let result = parse_via_cst(src);
3589        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3590        let Directive::Transaction(t) = &result.directives[0].value else {
3591            panic!("expected Transaction");
3592        };
3593        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3594            panic!("expected complete amount");
3595        };
3596        assert_eq!(amt.number, Decimal::from(20));
3597    }
3598
3599    #[test]
3600    fn posting_amount_evaluates_subtraction_left_associative() {
3601        // `10 - 3 - 2 USD` = 5 USD (left-associative, not 9).
3602        let src = "2024-01-15 * \"x\"\n  \
3603                   Expenses:X   10 - 3 - 2 USD\n  \
3604                   Assets:Y   -5 USD\n";
3605        let result = parse_via_cst(src);
3606        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3607        let Directive::Transaction(t) = &result.directives[0].value else {
3608            panic!("expected Transaction");
3609        };
3610        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3611            panic!("expected complete amount");
3612        };
3613        assert_eq!(amt.number, Decimal::from(5));
3614    }
3615
3616    #[test]
3617    fn posting_amount_division_by_zero_drops_number() {
3618        // `5 / 0 USD` - legacy returns parse error; we return None
3619        // from the evaluator, which degrades to CurrencyOnly here.
3620        // The transaction won't balance and downstream validation
3621        // surfaces that as the user-facing error.
3622        let src = "2024-01-15 * \"x\"\n  \
3623                   Expenses:X   5 / 0 USD\n  \
3624                   Assets:Y\n";
3625        let result = parse_via_cst(src);
3626        let Directive::Transaction(t) = &result.directives[0].value else {
3627            panic!("expected Transaction");
3628        };
3629        // Either the units degrade to CurrencyOnly (number lost)
3630        // or to None - both are acceptable since the input is
3631        // semantically invalid. The strict assertion is that we
3632        // DON'T silently return 5 (the first NUMBER) as the value.
3633        match &t.postings[0].value.units {
3634            None | Some(IncompleteAmount::CurrencyOnly(_)) => {}
3635            other => panic!("div-by-zero leaked: {other:?}"),
3636        }
3637    }
3638
3639    // ---- round-8 final compat regressions (#1282 flip) ---------
3640
3641    #[test]
3642    fn indented_top_level_directive_emits_error() {
3643        // A top-level directive that starts at column N>0 is a
3644        // syntax error per the Beancount spec; the CST grammar
3645        // accepts it silently, so the converter has to surface
3646        // the diagnostic at directive-content-start position.
3647        let src = "2020-07-28 open Assets:Foo\n  2020-07-28 open Assets:Bar\n";
3648        let result = parse_via_cst(src);
3649        let indent_errs = result
3650            .errors
3651            .iter()
3652            .filter(|e| match &e.kind {
3653                crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
3654                _ => false,
3655            })
3656            .count();
3657        assert_eq!(
3658            indent_errs, 1,
3659            "expected one column-0 diagnostic, got: {:?}",
3660            result.errors
3661        );
3662    }
3663
3664    #[test]
3665    fn indented_directive_after_blank_line_still_emits_error() {
3666        // Same as above but with a blank line between the
3667        // first directive and the indented one - the blank line
3668        // shouldn't mask the indentation error.
3669        let src = "2020-07-28 open Assets:Foo\n\n  2020-07-28 open Assets:Bar\n";
3670        let result = parse_via_cst(src);
3671        let indent_errs = result
3672            .errors
3673            .iter()
3674            .filter(|e| match &e.kind {
3675                crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
3676                _ => false,
3677            })
3678            .count();
3679        assert_eq!(indent_errs, 1, "errors: {:?}", result.errors);
3680    }
3681
3682    #[test]
3683    fn top_level_directive_at_column_0_no_diagnostic() {
3684        // Sanity: well-formed top-level directives must NOT
3685        // trigger the indent diagnostic.
3686        let src = "2020-07-28 open Assets:Foo\n2020-07-28 open Assets:Bar\n";
3687        let result = parse_via_cst(src);
3688        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3689    }
3690
3691    #[test]
3692    fn custom_directive_with_bare_currency_emits_error() {
3693        // `bean-check` rejects bare currency literals in custom
3694        // value position; the CST converter mirrors that.
3695        let src = "2025-01-01 custom \"x\" 10 USD \"y\" NZD\n";
3696        let result = parse_via_cst(src);
3697        let bare_curr_errs = result
3698            .errors
3699            .iter()
3700            .filter(|e| match &e.kind {
3701                crate::ParseErrorKind::SyntaxError(s) => s.contains("bare currency"),
3702                _ => false,
3703            })
3704            .count();
3705        assert_eq!(
3706            bare_curr_errs, 1,
3707            "expected one bare-currency diagnostic, got: {:?}",
3708            result.errors
3709        );
3710    }
3711
3712    #[test]
3713    fn custom_directive_with_amount_no_error() {
3714        // Sanity: `10 USD` (NUMBER + CURRENCY paired as Amount)
3715        // is a valid custom value and must NOT trigger the
3716        // bare-currency diagnostic.
3717        let src = "2025-01-01 custom \"x\" 10 USD\n";
3718        let result = parse_via_cst(src);
3719        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3720    }
3721
3722    // ---- round-7 compat regressions (#1282 flip) ---------------
3723
3724    #[test]
3725    fn balance_assertion_evaluates_arithmetic_value() {
3726        // PR #1282 compat regression: rledger emitted a balance
3727        // failure for `Assets:X  0.25+ 0.75 GBP` because only
3728        // the first NUMBER (0.25) was used as the assertion
3729        // target. CST converters for BALANCE/PRICE now evaluate
3730        // arithmetic the same way posting AMOUNTs do.
3731        let src = "2024-01-01 open Assets:X GBP\n\
3732                   2024-01-01 open Equity:Open GBP\n\
3733                   2024-01-02 * \"deposit\"\n  \
3734                   Assets:X         1.00 GBP\n  \
3735                   Equity:Open     -1.00 GBP\n\
3736                   2024-01-03 balance Assets:X  0.25 + 0.75 GBP\n";
3737        let result = parse_via_cst(src);
3738        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3739        let bal = result
3740            .directives
3741            .iter()
3742            .find_map(|d| match &d.value {
3743                Directive::Balance(b) => Some(b),
3744                _ => None,
3745            })
3746            .expect("expected a Balance directive");
3747        assert_eq!(bal.amount.number, Decimal::from(1));
3748        assert_eq!(bal.amount.currency.as_str(), "GBP");
3749    }
3750
3751    #[test]
3752    fn price_directive_evaluates_arithmetic_value() {
3753        let src = "2024-01-01 price USD  1/2 EUR\n";
3754        let result = parse_via_cst(src);
3755        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3756        let Directive::Price(p) = &result.directives[0].value else {
3757            panic!("expected Price");
3758        };
3759        assert_eq!(p.amount.number, Decimal::new(5, 1));
3760        assert_eq!(p.amount.currency.as_str(), "EUR");
3761    }
3762
3763    // ---- round-5 architecture review (#1281) -------------------
3764
3765    #[test]
3766    fn body_line_tag_does_not_drop_following_postings_comment() {
3767        // F2-bis: trailing TAG / LINK tokens on transaction body
3768        // lines are valid Beancount (extend the transaction's
3769        // tag/link set). Before the exemption was added, the
3770        // `pending.clear()` over-fired on the TAG and silently
3771        // dropped the preceding comment that semantically
3772        // belonged to the next posting.
3773        let src = "2024-01-01 * \"x\"\n  \
3774                   Assets:A   100 USD\n  \
3775                   ; comment-for-B\n  \
3776                   #late-tag\n  \
3777                   Assets:B   -100 USD\n";
3778        let result = parse_via_cst(src);
3779        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3780        let Directive::Transaction(t) = &result.directives[0].value else {
3781            panic!("expected Transaction");
3782        };
3783        // The trailing tag joins the transaction's tag set.
3784        assert!(
3785            t.tags.iter().any(|tag| tag.as_str() == "late-tag"),
3786            "expected #late-tag in tags: {:?}",
3787            t.tags,
3788        );
3789        // And the comment survives - attached to the next posting.
3790        let b = t.postings.last().expect("at least one posting");
3791        assert_eq!(b.value.account.as_str(), "Assets:B");
3792        assert!(
3793            b.value.comments.iter().any(|c| c.contains("comment-for-B")),
3794            "expected comment-for-B to survive on Assets:B: {:?}",
3795            b.value.comments,
3796        );
3797    }
3798
3799    #[test]
3800    fn oversized_number_in_amount_emits_diagnostic() {
3801        // F5-bis: the non-arithmetic NUMBER path is now symmetric
3802        // with the arithmetic-evaluation path. A NUMBER whose
3803        // text the lexer accepts but `Decimal::from_str` rejects
3804        // (e.g., 30+ digits, exceeding the 28-digit precision
3805        // ceiling) used to silently degrade to `CurrencyOnly`.
3806        let huge = "1".to_string() + &"2345678901234567890".repeat(2); // 39 digits
3807        let src = format!("2024-01-15 * \"big\"\n  Expenses:X   {huge} USD\n  Assets:Y\n");
3808        let result = parse_via_cst(&src);
3809        let invalid_num = result
3810            .errors
3811            .iter()
3812            .filter(|e| match &e.kind {
3813                crate::ParseErrorKind::SyntaxError(s) => s.contains("invalid number"),
3814                _ => false,
3815            })
3816            .count();
3817        assert_eq!(
3818            invalid_num, 1,
3819            "expected one invalid-number diagnostic, got: {:?}",
3820            result.errors
3821        );
3822    }
3823
3824    // ---- round-4 architecture review (#1281) -------------------
3825
3826    #[test]
3827    fn posting_with_two_amount_siblings_emits_error_and_keeps_first() {
3828        // F1: a posting like `Expenses:Food  5 USD + 3 USD` builds
3829        // two sibling AMOUNT nodes in the CST. `Posting::amount()`
3830        // only returns the first. Without an explicit guard the
3831        // second AMOUNT plus the joining `+` would be silently
3832        // dropped - the user's transaction would balance against
3833        // 5 USD instead of the intended 8 USD with no diagnostic.
3834        let src = "2024-01-15 * \"ambig\"\n  \
3835                   Expenses:Food   5 USD + 3 USD\n  \
3836                   Assets:Bank\n";
3837        let result = parse_via_cst(src);
3838        let trailing_count = result
3839            .errors
3840            .iter()
3841            .filter(|e| match &e.kind {
3842                crate::ParseErrorKind::SyntaxError(s) => s.contains("trailing tokens"),
3843                _ => false,
3844            })
3845            .count();
3846        assert_eq!(
3847            trailing_count, 1,
3848            "expected one trailing-tokens diagnostic, got: {:?}",
3849            result.errors
3850        );
3851        // The first AMOUNT is still surfaced so partial recovery
3852        // works for downstream tooling.
3853        let Directive::Transaction(t) = &result.directives[0].value else {
3854            panic!("expected Transaction");
3855        };
3856        let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3857            panic!("expected complete units from the first AMOUNT");
3858        };
3859        assert_eq!(amt.number, Decimal::from(5));
3860    }
3861
3862    #[test]
3863    fn comments_dont_leak_across_failed_posting() {
3864        // F2: when convert_posting returns None, the queue of
3865        // pending pre-posting comments must be CLEARED so they
3866        // don't migrate forward and attach to the next valid
3867        // posting. Without the clear, comments labelled for the
3868        // failed posting would silently re-attach to the wrong
3869        // account, visibly misleading the user.
3870        let src = "2024-01-15 * \"test\"\n  \
3871                   Assets:A   100 USD\n  \
3872                   ; comment-for-bad\n  \
3873                   ; another-comment\n  \
3874                   bogus_token_line_no_account\n  \
3875                   ; comment-for-good\n  \
3876                   Assets:B   -100 USD\n";
3877        let result = parse_via_cst(src);
3878        let Directive::Transaction(t) = &result.directives[0].value else {
3879            panic!("expected Transaction");
3880        };
3881        // Assets:B is the LAST successful posting; the only
3882        // comment that should attach to it is the one that
3883        // immediately precedes it (`; comment-for-good`). The
3884        // pre-failed-posting comments belong to the failed
3885        // posting and should be DROPPED with it.
3886        let b = t.postings.last().expect("at least one posting");
3887        assert_eq!(b.value.account.as_str(), "Assets:B");
3888        assert!(
3889            !b.value
3890                .comments
3891                .iter()
3892                .any(|c| c.contains("comment-for-bad")),
3893            "comment-for-bad leaked across failed posting onto Assets:B: {:?}",
3894            b.value.comments
3895        );
3896        assert!(
3897            !b.value
3898                .comments
3899                .iter()
3900                .any(|c| c.contains("another-comment")),
3901            "another-comment leaked: {:?}",
3902            b.value.comments
3903        );
3904    }
3905
3906    #[test]
3907    fn arithmetic_overflow_in_amount_emits_diagnostic() {
3908        // F5: when `is_arithmetic` is true but the evaluator
3909        // gives up (overflow, div-by-zero), the converter used
3910        // to silently produce CurrencyOnly. Now an explicit
3911        // SyntaxError fires so the user sees the actual root
3912        // cause instead of just a downstream "doesn't balance".
3913        // Decimal max is 28 digits - `9999999999999999999999999999 *
3914        // 9999999999999999999999999999` overflows.
3915        let huge = "9999999999999999999999999999 * 9999999999999999999999999999";
3916        let src = format!("2024-01-15 * \"big\"\n  Expenses:X   {huge} USD\n  Assets:Y\n");
3917        let result = parse_via_cst(&src);
3918        let arith_errs = result
3919            .errors
3920            .iter()
3921            .filter(|e| match &e.kind {
3922                crate::ParseErrorKind::SyntaxError(s) => s.contains("arithmetic"),
3923                _ => false,
3924            })
3925            .count();
3926        assert_eq!(
3927            arith_errs, 1,
3928            "expected one arithmetic-error diagnostic, got: {:?}",
3929            result.errors
3930        );
3931    }
3932
3933    // ---- 14 emission-gap regressions (#1281 round-3 review) ----
3934
3935    #[test]
3936    fn date_with_single_digit_month_parses() {
3937        let result = parse_via_cst("2024-1-15 open Assets:Checking\n");
3938        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3939        let Directive::Open(open) = &result.directives[0].value else {
3940            panic!("expected Open");
3941        };
3942        assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
3943    }
3944
3945    #[test]
3946    fn date_with_single_digit_day_parses() {
3947        let result = parse_via_cst("2024-01-5 open Assets:Cash USD\n");
3948        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3949        let Directive::Open(open) = &result.directives[0].value else {
3950            panic!("expected Open");
3951        };
3952        assert_eq!(open.date, naive_date(2024, 1, 5).unwrap());
3953    }
3954
3955    #[test]
3956    fn date_with_single_digit_month_and_day_parses() {
3957        let result = parse_via_cst("2024-1-1 open Assets:Cash USD\n");
3958        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3959        let Directive::Open(open) = &result.directives[0].value else {
3960            panic!("expected Open");
3961        };
3962        assert_eq!(open.date, naive_date(2024, 1, 1).unwrap());
3963    }
3964
3965    #[test]
3966    fn date_with_month_out_of_range_emits_invalid_date_value() {
3967        let result = parse_via_cst("2024-13-01 open Assets:Cash USD\n");
3968        let invalid_date: Vec<_> = result
3969            .errors
3970            .iter()
3971            .filter_map(|e| match &e.kind {
3972                crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
3973                _ => None,
3974            })
3975            .collect();
3976        assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
3977        let msg = &invalid_date[0];
3978        assert!(
3979            msg.contains("month") && msg.contains("out of range"),
3980            "msg: {msg}"
3981        );
3982    }
3983
3984    #[test]
3985    fn date_with_invalid_leap_year_emits_invalid_date_value() {
3986        let result = parse_via_cst("2023-02-29 open Assets:Cash USD\n");
3987        let invalid_date: Vec<_> = result
3988            .errors
3989            .iter()
3990            .filter_map(|e| match &e.kind {
3991                crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
3992                _ => None,
3993            })
3994            .collect();
3995        assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
3996        let msg = &invalid_date[0];
3997        assert!(
3998            msg.contains("day") && msg.contains("out of range") && msg.contains("2023-02"),
3999            "msg: {msg}"
4000        );
4001    }
4002
4003    #[test]
4004    fn date_with_completely_invalid_value_still_emits_error() {
4005        // `2024-13-45` has BOTH month and day out of range; any
4006        // error variant satisfies the original integration test's
4007        // `!result.errors.is_empty()` assertion.
4008        let result = parse_via_cst("2024-13-45 open Assets:Bank\n");
4009        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4010    }
4011
4012    #[test]
4013    fn open_directive_without_account_emits_error() {
4014        // `2024-01-01 open` with no account is rejected by legacy
4015        // via the top-level error-recovery path. CST emits the
4016        // catch-all `SyntaxError` from `parse_via_cst`'s
4017        // is_directive_producing/errors_before tracker.
4018        let result = parse_via_cst("2024-01-01 open\n");
4019        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4020    }
4021
4022    #[test]
4023    fn open_directive_with_lowercase_account_emits_error() {
4024        // `lowercase:invalid` doesn't match the ACCOUNT regex
4025        // (uppercase first letter required), so the open directive
4026        // has no ACCOUNT child. Same catch-all path as the no-
4027        // account case.
4028        let result = parse_via_cst("2024-01-01 open lowercase:invalid\n");
4029        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4030    }
4031
4032    #[test]
4033    fn incomplete_open_at_eof_emits_error() {
4034        // Regression for the PR #740 "incomplete-at-EOF" finding:
4035        // `2024-01-01 open` at EOF with no trailing newline must
4036        // not be silently dropped.
4037        let result = parse_via_cst("2024-01-01 open");
4038        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4039    }
4040
4041    #[test]
4042    fn balance_directive_without_amount_emits_error() {
4043        let result = parse_via_cst("2024-01-15 balance Assets:Checking\n");
4044        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4045    }
4046
4047    #[test]
4048    fn pad_directive_without_source_account_emits_error() {
4049        let result = parse_via_cst("2024-01-15 pad Assets:Checking\n");
4050        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4051    }
4052
4053    #[test]
4054    fn cost_spec_n_hash_t_parses_as_compound() {
4055        use rust_decimal_macros::dec;
4056        // This test previously pinned `{N # T}` -> Total{T} — the #1700
4057        // misparse (beancount's compound_amount weighs N*per + total,
4058        // so dropping the per-unit silently misweighed every compound
4059        // spec). It now pins the corrected as-written form.
4060        let src = "2024-01-01 open Assets:Stock\n\
4061                   2024-01-01 open Assets:Cash USD\n\
4062                   2024-01-15 *\n  \
4063                   Assets:Stock  10 STK {50 # 1500 USD}\n  \
4064                   Assets:Cash  -1500.00 USD\n";
4065        let result = parse_via_cst(src);
4066        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4067        let Directive::Transaction(txn) = &result.directives[2].value else {
4068            panic!("expected Transaction at index 2");
4069        };
4070        let cost = txn.postings[0]
4071            .value
4072            .cost
4073            .as_ref()
4074            .expect("cost spec present");
4075        assert_eq!(
4076            cost.number,
4077            Some(CostNumber::Compound {
4078                per_unit: dec!(50),
4079                total: dec!(1500)
4080            }),
4081            "the `{{N # T CCY}}` form must carry both components as written"
4082        );
4083    }
4084
4085    #[test]
4086    fn unclosed_cost_brace_emits_error() {
4087        let src = "2024-01-01 open Assets:Stock\n\
4088                   2024-01-01 open Assets:Cash USD\n\
4089                   2024-01-15 *\n  \
4090                   Assets:Stock 10 AAPL {150 USD\n  \
4091                   Assets:Cash -1500 USD\n";
4092        let result = parse_via_cst(src);
4093        let has_unclosed: bool = result
4094            .errors
4095            .iter()
4096            .any(|e| e.message().contains("unclosed cost"));
4097        assert!(
4098            has_unclosed,
4099            "expected 'unclosed cost' error, got: {:?}",
4100            result.errors
4101        );
4102    }
4103
4104    #[test]
4105    fn unclosed_cost_brace_at_eof_emits_error() {
4106        let src = "2024-01-01 open Assets:Stock\n\
4107                   2024-01-01 open Assets:Cash USD\n\
4108                   2024-01-15 *\n  \
4109                   Assets:Stock 10 AAPL {150 USD";
4110        let result = parse_via_cst(src);
4111        let has_unclosed: bool = result
4112            .errors
4113            .iter()
4114            .any(|e| e.message().contains("unclosed cost"));
4115        assert!(
4116            has_unclosed,
4117            "expected 'unclosed cost' error at EOF, got: {:?}",
4118            result.errors
4119        );
4120    }
4121
4122    #[test]
4123    fn leading_decimal_in_posting_amount_emits_error() {
4124        // `.50 USD` (no integer part before the decimal) must be
4125        // rejected by both parsers; valid `0.50 USD` still works
4126        // (covered by other tests).
4127        let src = "2024-01-15 * \"Test\"\n  \
4128                   Expenses:Food  .50 USD\n  \
4129                   Assets:Checking\n";
4130        let result = parse_via_cst(src);
4131        assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4132    }
4133
4134    #[test]
4135    fn transaction_with_metadata_on_directive_and_posting() {
4136        let src = "2024-01-15 * \"x\"\n  \
4137                   tag1: \"hello\"\n  \
4138                   Assets:Cash  -5 USD\n    \
4139                       receipt: \"abc123\"\n";
4140        let result = parse_via_cst(src);
4141        let Directive::Transaction(t) = &result.directives[0].value else {
4142            panic!("expected Transaction");
4143        };
4144        assert_eq!(
4145            t.meta.get("tag1"),
4146            Some(&MetaValue::String("hello".to_string()))
4147        );
4148        let p_meta = &t.postings[0].value.meta;
4149        assert_eq!(
4150            p_meta.get("receipt"),
4151            Some(&MetaValue::String("abc123".to_string()))
4152        );
4153    }
4154
4155    /// Pins the `ERROR_NODE` exclusion contract on
4156    /// `account_occurrences`. The rustdoc on `ParseResult::
4157    /// account_occurrences` distinguishes two failure modes:
4158    ///
4159    /// - **Typed-conversion failure** (e.g. `InvalidBookingMethod`
4160    ///   on an `open` whose booking string is garbage): the CST is
4161    ///   intact, the `ACCOUNT` node is NOT inside `ERROR_NODE`, so
4162    ///   the token IS tracked. The LSP rename can still hit it
4163    ///   during mid-edit.
4164    /// - **CST-recovery wrap**: a directive so garbled that the
4165    ///   CST wraps the region in `ERROR_NODE`. The `ACCOUNT` token
4166    ///   is inside `ERROR_NODE`, NOT tracked.
4167    ///
4168    /// The two policies are deliberate. This test pins both.
4169    #[test]
4170    fn account_occurrences_policy_for_failing_directives() {
4171        // Case A: typed-conversion failure. `open Assets:Bank
4172        // "GARBAGE"` parses syntactically but fails the booking-
4173        // method whitelist. The ACCOUNT token IS tracked.
4174        let src = "2024-01-01 open Assets:Bank \"GARBAGE\"\n";
4175        let r = parse_via_cst(src);
4176        assert!(
4177            r.account_occurrences
4178                .iter()
4179                .any(|o| o.value == "Assets:Bank"),
4180            "typed-conversion failure should keep the ACCOUNT token in \
4181             account_occurrences (got {:?}); rename mid-edit relies on this",
4182            r.account_occurrences,
4183        );
4184
4185        // Case B: CST-recovery wrap. `opn Assets:Bank USD` (typo
4186        // `opn`) is unrecognized at the directive position and the
4187        // recovery walker wraps it in ERROR_NODE. The ACCOUNT
4188        // token is excluded.
4189        let src = "2024-01-01 opn Assets:Bank USD\n";
4190        let r = parse_via_cst(src);
4191        assert!(
4192            !r.account_occurrences
4193                .iter()
4194                .any(|o| o.value == "Assets:Bank"),
4195            "ERROR_NODE-wrapped ACCOUNT should be EXCLUDED from \
4196             account_occurrences (got {:?}); rename should not hit garbled \
4197             mid-edit syntax",
4198            r.account_occurrences,
4199        );
4200    }
4201}