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}