Skip to main content

rustledger_parser/cst/
convert.rs

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