Skip to main content

rustledger_parser/cst/
format.rs

1//! Opinionated CST-backed formatter (phase 4.1 of #1262).
2//!
3//! [`format_source`] is a pure function `&str → String`: it
4//! reparses the input into a CST and emits text in one canonical
5//! form per AST shape. Two semantically-equivalent inputs produce
6//! byte-identical output; idempotence (`f(f(x)) == f(x)`) follows
7//! trivially.
8//!
9//! Replaces the pre-#1262 source-level formatter that took
10//! `(source, ParseResult, FormatConfig)` and re-emitted via the
11//! AST-driven `rustledger_core::format` path. Typed-directive
12//! synthesis (`rustledger_core::format::format_directives`) still
13//! lives in `rustledger-core` for callers that build a directive
14//! from scratch (e.g., `rledger add`, importer extract, FFI
15//! `format.entry`) — that's a different shape of input and is
16//! out of scope here.
17//!
18//! # Typed-directive emit: known coupling
19//!
20//! The typed-directive path is a two-pass shim: callers run
21//! `core::format::format_directives` to get bean-format-style text,
22//! then run that text back through [`format_source`] for the
23//! canonical pass. This keeps the FINAL byte sequence single-
24//! sourced (always emitted by this module), but it means
25//! `core::format` is permanently load-bearing as a parser-clean
26//! intermediate and every canonical-form rule needs the legacy
27//! emitter to produce SOMETHING the new parser accepts.
28//!
29//! Call sites (`rustledger-ffi-wasi::router::canonical_format_directives`,
30//! `rustledger::cmd::add_cmd::canonical_format_directive`,
31//! `rustledger::cmd::extract_cmd`) all guard the round-trip with
32//! an explicit `parse(&raw)` step that bails on parse errors, so a
33//! divergence between the two emitters surfaces as a hard error
34//! instead of silently dropping content.
35//!
36//! The eventual fix is a typed-directive emit path on this module
37//! (`format_directive(&Directive) -> String`) that bypasses the
38//! source-string round-trip. Tracked in a follow-up issue.
39//!
40//! # Canonical form (locked in the PR-decision comment on #1262)
41//!
42//! - Indent inside a directive body: 2 spaces. Tabs converted.
43//! - Blank lines between directives: preserved from the source
44//!   (#1325). Grouped directives (consecutive `open`s, a `price`
45//!   feed) stay grouped; the formatter does not insert or collapse
46//!   blank lines, matching Python `bean-format`.
47//! - Blank lines inside a directive: 0.
48//! - Number lexical form: thousands separators dropped; user
49//!   decimal-place count preserved.
50//! - Comment content: verbatim.
51//! - Comment positions: normalized to the attachment slot
52//!   (header-trailing / inter-directive / body-internal /
53//!   posting-trailing).
54//! - Cost spec spacing: `{cost CCY}` (no inner padding).
55//! - Tag/link order on a transaction header: source order, after
56//!   the strings.
57//! - Trailing newline at EOF: always exactly one.
58//! - Line endings: LF; CRLF inputs normalized.
59//! - Leading BOM: dropped.
60//!
61//! No `FormatConfig` parameter. One canonical form, no knobs.
62
63use crate::cst::ast::{self, AstNode, AstToken, MetaEntry, SourceFile};
64
65/// Pre-computed alignment data for a whole source file.
66///
67/// Bean-format-style two-axis alignment. The **number field** is a
68/// fixed-width slot starting at column `number_col` and `number_width`
69/// chars wide, into which each posting's number / arithmetic
70/// expression is right-justified. Shorter numbers are left-padded
71/// with spaces, so the currency column (right after the field) is
72/// uniform across the whole file even when individual numbers have
73/// different widths or signs.
74///
75/// - `number_col`   = INDENT + max(account width with optional `flag `) + 2
76/// - `number_width` = max rendered width of any posting's number /
77///   arithmetic expression (sign included)
78///
79/// `PostingAlignment` is `Copy` and `Default` (the all-zero state);
80/// the default is the alignment used for files that contain no
81/// postings (no transactions, or transactions with no AMOUNT).
82/// Marked `#[non_exhaustive]` so that a future column-derivation
83/// rule can add fields without breaking downstream consumers.
84///
85/// **Name choice.** The type is qualified by its semantic purpose
86/// (posting layout column widths) so the public path
87/// `rustledger_parser::format::PostingAlignment` doesn't compete
88/// with future generic "alignment" types (text justification,
89/// memory layout, etc.).
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
91#[non_exhaustive]
92pub struct PostingAlignment {
93    /// 0-indexed column at which the right-justified number field
94    /// starts.
95    pub number_col: usize,
96    /// Width of the number field; shorter numbers are left-padded
97    /// with spaces so the currency column stays uniform.
98    pub number_width: usize,
99}
100
101/// How numerals are grouped, resolved per currency.
102///
103/// `Copy` on purpose: it is threaded as a sibling of [`PostingAlignment`]
104/// through the width pre-pass and the emitters, which must agree about a
105/// numeral's width or the currency column drifts — the #1290 failure. It is
106/// NOT a field of `PostingAlignment`: that type is `pub`, `Eq` and cached on
107/// `ParseResult`, so it cannot carry a lifetime. The invariant that pairs a
108/// cached alignment with the style it was measured under is stated on
109/// `format_node_with_style` (crate-internal).
110#[derive(Debug, Clone, Copy, Default)]
111pub struct GroupingStyle<'a> {
112    ctx: Option<&'a rustledger_core::DisplayContext>,
113}
114
115impl<'a> GroupingStyle<'a> {
116    /// Grouping as declared by a ledger's display context.
117    ///
118    /// Returns the no-grouping style when the context groups nothing, so the
119    /// overwhelmingly common case costs no per-numeral lookups.
120    #[must_use]
121    pub fn from_context(ctx: &'a rustledger_core::DisplayContext) -> Self {
122        Self {
123            ctx: ctx.renders_any_commas().then_some(ctx),
124        }
125    }
126
127    /// Whether this style groups anything at all.
128    ///
129    /// Lets a caller keep the fast precomputed-alignment path when nothing is
130    /// declared, which is the overwhelming majority of ledgers.
131    #[must_use]
132    pub const fn groups_anything(self) -> bool {
133        self.ctx.is_some()
134    }
135
136    /// Whether a numeral in `currency` is grouped. `None` for numerals with no
137    /// currency in scope (metadata values, `custom` values), which take the
138    /// ledger-wide default.
139    #[must_use]
140    pub fn groups(self, currency: Option<&str>) -> bool {
141        match (self.ctx, currency) {
142            (None, _) => false,
143            (Some(c), Some(cur)) => c.render_commas_for(cur),
144            (Some(c), None) => c.render_commas(),
145        }
146    }
147}
148
149/// Two-space indent for directive bodies (postings, metadata).
150const INDENT: &str = "  ";
151
152/// Format a Beancount source file in opinionated canonical form.
153///
154/// Reparses internally — callers that already have a CST in hand
155/// and want to avoid the double-parse can use [`format_node`].
156///
157/// Returns canonical text; output always ends with exactly one
158/// trailing newline (even for an empty file, where the output is
159/// just `"\n"`).
160///
161/// **Line-ending normalization runs BEFORE parsing.** The lexer
162/// does not treat bare `\r` as a line terminator, so a classic-
163/// Mac-authored `directive\r…\rdirective\r` would otherwise parse
164/// as a single broken directive and the rest of the user's ledger
165/// would be silently dropped. We normalize `\r\n` and bare `\r`
166/// to `\n` first, then parse — matching the canonical-form
167/// promise that line endings are LF-only on output.
168#[must_use]
169pub fn format_source(source: &str) -> String {
170    format_source_grouped(source, GroupingStyle::default())
171}
172
173/// [`format_source`], with the thousands-separator rule supplied by the caller.
174///
175/// The style comes from the LEDGER, not from the tool: `option
176/// "render_commas"` and per-commodity `render_commas:`, resolved by whoever
177/// holds the options (the CLI after a load, the LSP from its ledger state, the
178/// FFI session from its own). That is what keeps this a canonical form rather
179/// than a knob — the output is still a function of the input, the declaration
180/// simply travels with it.
181///
182/// `format_source` passes [`GroupingStyle::default`], so every existing caller
183/// and every ledger that has not opted in is byte-for-byte unaffected.
184#[must_use]
185pub fn format_source_grouped(source: &str, style: GroupingStyle<'_>) -> String {
186    let (stripped, _had_bom) = crate::bom::strip_leading(source);
187    let normalized = crlf_to_lf_outside_strings(stripped);
188    let parsed = SourceFile::parse(&normalized);
189    format_node_grouped(parsed.syntax(), style)
190}
191
192/// Like [`format_source`] but reuses the caller's
193/// [`crate::ParseResult`] instead of re-parsing `source`.
194///
195/// Skips both expensive pre-passes the bare `format_source` runs
196/// every call: the lex+parse from `SourceFile::parse(&normalized)`,
197/// and the `O(N_postings)` `compute_alignment` walk. Both pieces
198/// are already on `parse_result` (in `syntax_root` and
199/// `alignment` respectively, populated by `parse_via_cst`). For
200/// any consumer that already holds a `ParseResult` — the LSP
201/// `format_document` handler, the FFI `format.source` endpoint,
202/// the WASM `ParsedLedger::format` bridge — this entry skips two
203/// redundant traversals of the file.
204///
205/// **Output equivalence with `format_source`.** Pinned by
206/// `parse_result_alignment_cache::format_source_with_parsed_matches_format_source_under_fallback`
207/// (the fallback exercises broken sources) and
208/// `cst::format::tests::format_source_with_parsed_matches_format_source`
209/// (the cache path exercises clean sources) across LF / CRLF /
210/// BOM / parse-error / mixed-line-ending fixtures. The cache-
211/// path equivalence holds because the formatter rebuilds output
212/// from each directive's typed values rather than echoing
213/// trivia, so the CRLF-vs-LF difference in the underlying CST
214/// trivia never reaches the output. The fallback path is
215/// byte-trivially equivalent (it IS `format_source`).
216///
217/// **CRLF re-injection is still the caller's responsibility.**
218/// Same as `format_source`: this function always returns LF;
219/// LSP consumers that need to preserve CRLF for Windows-
220/// authored files call [`lf_to_crlf_outside_strings`] on the
221/// returned text.
222///
223/// **Parse-error fallback.** When `parse_result.errors` is
224/// non-empty, this function delegates to `format_source(source)`
225/// — losing the cache benefit but preserving byte-identity for
226/// inputs whose CST diverges from what `format_source`'s
227/// pre-parse normalization would produce. Concretely: bare-`\r`
228/// (classic Mac) line terminators are normalized to LF by
229/// `format_source` before parsing, but `parse_via_cst` does NOT
230/// normalize them — so the cached CST treats them as broken
231/// content and `parse_result.errors` is non-empty. The fallback
232/// path keeps the byte-identity claim total instead of
233/// "holds-only-when-clean".
234///
235/// **Stale `parse_result` is the caller's responsibility.** The
236/// producer-side cache invariant (see
237/// [`crate::ParseResult::alignment`] rustdoc) says
238/// `parse_result` must come from a fresh `parse(source)` with
239/// the same `source`. A `debug_assert_eq!` compares the CST's
240/// text length against `source.len() - bom_offset` to catch the
241/// most common mismatched-pair class (different documents have
242/// different lengths) in debug builds; release builds skip the
243/// check. Identical-length mismatches still pass silently —
244/// the rustdoc-level contract remains the source of truth.
245///
246/// Formats under the DEFAULT grouping style, always: it reuses
247/// `ParseResult::alignment`, which is measured ungrouped, and the two
248/// must agree. A caller that needs a ledger's separators wants
249/// [`format_node_grouped`], which measures its own alignment.
250///
251/// # Panics
252///
253/// Panics if `parse_result.syntax_root` is not a `SOURCE_FILE`
254/// (always true for results produced by [`crate::parse`]).
255///
256/// In debug builds, panics on a `(parse_result, source)`
257/// length-mismatch via `debug_assert_eq!`. Release builds
258/// silently emit possibly-wrong output (pairing `source` with the
259/// `parse_result` it came from is the caller's responsibility).
260#[must_use]
261pub fn format_source_with_parsed(parse_result: &crate::ParseResult, source: &str) -> String {
262    // Parse-error fallback. See the function rustdoc for the
263    // rationale: `parse_via_cst` does not run the same input
264    // normalization `format_source` does (no CRLF/bare-CR
265    // normalize), so for sources containing bare-`\r` line
266    // terminators the cached CST is wrong-shaped and the cache
267    // path would diverge from `format_source`. Delegating
268    // preserves byte-identity unconditionally.
269    if !parse_result.errors.is_empty() {
270        return format_source(source);
271    }
272    let node = parse_result.syntax_node();
273    // Defensive length check (debug-only). Catches the most
274    // common form of `(parse_result, source)` mismatched pair —
275    // different documents with different lengths. The CST's
276    // text range is BOM-stripped, so we add back the BOM bytes
277    // if the parser saw one.
278    //
279    // Computed outside the `debug_assert_eq!` to avoid clippy's
280    // `debug_assert_with_mut_call` (`syntax_node()` does an Arc
281    // bump, which clippy treats as state mutation in a debug
282    // context).
283    let cst_len =
284        usize::from(node.text_range().len()) + if parse_result.has_leading_bom { 3 } else { 0 };
285    debug_assert_eq!(
286        cst_len,
287        source.len(),
288        "format_source_with_parsed called with a `source` whose length doesn't \
289         match the CST stored in `parse_result`. The two arguments came from \
290         different documents — the cache path will emit text for the wrong \
291         buffer.",
292    );
293    format_node_with_alignment(&node, parse_result.alignment())
294}
295
296/// Like [`format_source`], but returns the parse errors instead
297/// of silently formatting around them.
298///
299/// `format_source` is intentionally infallible — the canonical
300/// formatter must still emit *something* for a file the parser
301/// could only recover from. Tooling that wants to refuse to
302/// rewrite a file with parse errors (the `rledger format` CLI,
303/// the LSP `format` handler) previously had to call `parse`
304/// out-of-band, inspect `errors`, then call `format_source` on
305/// the SAME input — a contract two functions cooperated on
306/// implicitly, and the kind of pairing a future caller could
307/// easily forget. This helper makes the contract explicit.
308///
309/// Returns `Ok(formatted)` if and only if `parse(source).errors`
310/// would be empty. Otherwise returns the parse errors verbatim,
311/// in the same order the parser emitted them.
312///
313/// # Errors
314///
315/// Returns `Err(Vec<ParseError>)` containing every parse error
316/// the underlying [`parse`](crate::parse) call would surface for
317/// `source`. The caller decides whether to abort, render the
318/// errors, or fall back to a non-canonical pass.
319pub fn try_format_source(source: &str) -> Result<String, Vec<crate::ParseError>> {
320    try_format_source_grouped(source, GroupingStyle::default())
321}
322
323/// [`try_format_source`], with the grouping style supplied by the caller.
324///
325/// # Errors
326///
327/// As [`try_format_source`].
328pub fn try_format_source_grouped(
329    source: &str,
330    style: GroupingStyle<'_>,
331) -> Result<String, Vec<crate::ParseError>> {
332    let result = crate::parse(source);
333    if !result.errors.is_empty() {
334        return Err(result.errors);
335    }
336    // Reuse the parse we already produced for the error gate rather than
337    // letting `format_source_grouped` re-parse. The alignment cached on
338    // `ParseResult` is computed WITHOUT grouping, so it can only be reused for
339    // the default style — see `format_node_with_style`'s invariant.
340    if style.groups_anything() {
341        return Ok(format_node_grouped(&result.syntax_node(), style));
342    }
343    Ok(format_source_with_parsed(&result, source))
344}
345
346/// Convert every `\n` line terminator OUTSIDE string literals back
347/// to `\r\n`, leaving `\n` characters inside strings (and inside
348/// comments… see below) untouched.
349///
350/// The canonical form emitted by [`format_source`] is LF-only.
351/// Editors that round-trip Windows-authored files want to see CRLF
352/// echoed back on every line. This helper bridges the two by
353/// walking the canonical output with the shared `SourceState`
354/// state machine. The walker respects:
355///
356/// - String literals: bytes pass through verbatim. The user's
357///   original line endings inside a multi-line narration / note /
358///   document string are preserved.
359/// - Line comments (`;`, `%`, `#!`, `#+`): the comment's
360///   terminating newline IS a real structural line terminator, so
361///   it gets converted to CRLF; bytes inside the comment region
362///   (which can include arbitrary characters, notably stray `"`)
363///   pass through without flipping the in-string state. `#!` and
364///   `#+` open a comment at any column — the lexer's
365///   `SHEBANG` / `EMACS_DIRECTIVE` regexes carry no line-start
366///   anchor, and the state machine matches that classification.
367///
368/// The helper lives in this module rather than the LSP crate
369/// because its correctness depends on the lexer's `STRING` and
370/// comment rules. Keep it co-located with the formatter so a
371/// lexer change forces a co-evaluation here.
372#[must_use]
373pub fn lf_to_crlf_outside_strings(s: &str) -> String {
374    let mut out = String::with_capacity(s.len() + s.matches('\n').count());
375    // BOM is data, not classification input. We re-prepend it
376    // verbatim and let the body start fresh in Code state. The
377    // sibling crlf_to_lf_outside_strings does the same so the two
378    // walkers handle a leading-BOM file identically.
379    let (body, bom) = match s.strip_prefix('\u{FEFF}') {
380        Some(rest) => (rest, "\u{FEFF}"),
381        None => (s, ""),
382    };
383    out.push_str(bom);
384    let mut chars = body.chars().peekable();
385    let mut state = SourceState::Code;
386    let mut prev_was_backslash = false;
387    while let Some(ch) = chars.next() {
388        let peek = chars.peek().copied();
389        match state {
390            SourceState::InString => out.push(ch),
391            SourceState::InComment | SourceState::Code => {
392                if ch == '\n' {
393                    out.push_str("\r\n");
394                } else {
395                    out.push(ch);
396                }
397            }
398        }
399        state = advance_source_state(ch, peek, state, &mut prev_was_backslash);
400    }
401    out
402}
403
404/// Render typed Beancount `Directive`s in the canonical form
405/// emitted by [`format_source`].
406///
407/// Two-pass pipeline:
408///
409/// 1. Synthesize a source string via the typed-directive emitter
410///    in `rustledger_core::format::format_directives`. That
411///    emitter is `Directive → text`; its output is bean-format-
412///    style, parser-clean, and used here purely as an
413///    intermediate.
414/// 2. Re-parse the synthesized text. If the legacy emitter
415///    produced something the new parser cannot fully accept,
416///    return [`CanonicalizeError::ReparseFailed`] rather than
417///    silently emitting the recoverable subset — that silent-loss
418///    failure mode is what the older `crates/rustledger/tests/
419///    format_compat.rs` (deleted in phase 4.1, distinct from the
420///    phase 4.2 file-pair suite at `crates/rustledger-parser/
421///    tests/format_compat/`) used to guard against. The new file-
422///    pair suite exercises `format_source`, not this two-pass
423///    shim; a future change to `canonicalize_directives`'s error
424///    semantics needs its own dedicated regression test.
425/// 3. Run the re-parsed text through [`format_source`] for the
426///    canonical pass.
427///
428/// Single source of truth for the synthesize → canonicalize
429/// shim. Every consumer that builds a typed `Directive` in memory
430/// and wants canonical text — `rledger add`, `rledger extract`,
431/// the FFI `format.entry` / `format.entries` endpoints — should
432/// call this function instead of reinventing the pipeline.
433pub fn canonicalize_directives<'a, I>(
434    directives: I,
435    config: &rustledger_core::format::FormatConfig,
436) -> Result<String, CanonicalizeError>
437where
438    I: IntoIterator<Item = &'a rustledger_core::Directive>,
439    I::IntoIter: ExactSizeIterator,
440{
441    // Take the count off the ExactSizeIterator without
442    // collecting — the legacy emitter only walks the iterator
443    // once, so we don't need to materialize a Vec just to know
444    // how many directives the caller passed.
445    let iter = directives.into_iter();
446    let input_count = iter.len();
447    let raw = rustledger_core::format::format_directives(iter, config);
448    let parse_result = crate::parse(&raw);
449    if !parse_result.errors.is_empty() {
450        return Err(CanonicalizeError::ReparseFailed {
451            errors: parse_result
452                .errors
453                .iter()
454                .map(ToString::to_string)
455                .collect(),
456        });
457    }
458    // Count check covers the only Directive variants we have
459    // today (12, all of which surface on parse_result.directives).
460    // If a future `rustledger_core::Directive` variant is added
461    // that the parser routes to a different `ParseResult`
462    // collection (e.g., a typed Pushtag whose legacy text the
463    // parser puts on a `pragmas` field), this check needs to
464    // include that field too — otherwise a perfectly healthy
465    // round-trip would always report DirectiveCountMismatch. The
466    // compile-time `_directive_variant_fixture_coverage` match
467    // pins the variant set we're committed to here; any new
468    // variant breaks that match and surfaces this same
469    // maintenance need.
470    let reparsed_count = parse_result.directives.len();
471    if reparsed_count != input_count {
472        return Err(CanonicalizeError::DirectiveCountMismatch {
473            input: input_count,
474            reparsed: reparsed_count,
475        });
476    }
477    // The second pass re-canonicalizes LAYOUT over the core emitter's text.
478    // It must carry the same grouping rule, or it would strip the separators
479    // the first pass just wrote — which is exactly why this shim used to
480    // preserve precision but not grouping.
481    let style = config
482        .number_display
483        .as_ref()
484        .map_or_else(GroupingStyle::default, GroupingStyle::from_context);
485    Ok(format_source_grouped(&raw, style))
486}
487
488/// Error returned by [`canonicalize_directives`].
489///
490/// Marked `#[non_exhaustive]` so that adding a future variant
491/// (e.g. a `CanonicalizationTimeout` for an async path, or a new
492/// guard for a future canonical-form rule) does not become a
493/// SemVer-breaking change. Consumers must use a `_ => …` arm.
494#[derive(Debug, Clone)]
495#[non_exhaustive]
496pub enum CanonicalizeError {
497    /// The synthesized intermediate failed to re-parse cleanly.
498    /// Carries the rendered error messages so callers can surface
499    /// a diagnostic; the source text itself is not retained
500    /// because it's an internal intermediate the caller has no
501    /// control over.
502    ReparseFailed {
503        /// One rendered message per parse error from the
504        /// intermediate text. Capped at the parser's own error
505        /// limit so this field is bounded.
506        errors: Vec<String>,
507    },
508    /// The synthesized intermediate parsed cleanly but produced a
509    /// different directive count than the input. This indicates
510    /// the legacy emitter and the new parser disagree on what
511    /// constitutes a directive — typically a future
512    /// `rustledger_core::Directive` variant whose legacy text the
513    /// CST parser silently swallows as comments / error-recovery
514    /// trivia. Without this guard, the call would round-trip to
515    /// truncated text with no error returned.
516    DirectiveCountMismatch {
517        /// Number of directives the caller passed in.
518        input: usize,
519        /// Number of directives the parser recovered from the
520        /// synthesized text.
521        reparsed: usize,
522    },
523}
524
525impl std::fmt::Display for CanonicalizeError {
526    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527        match self {
528            Self::ReparseFailed { errors } => {
529                let preview: Vec<&str> = errors.iter().take(3).map(String::as_str).collect();
530                write!(
531                    f,
532                    "canonical formatter failed to re-parse the synthesized \
533                     directive text ({} error(s)): {}",
534                    errors.len(),
535                    preview.join("; ")
536                )
537            }
538            Self::DirectiveCountMismatch { input, reparsed } => write!(
539                f,
540                "the canonical formatter could not emit {input} directive(s) \
541                 without loss ({reparsed} survived the round-trip). This is \
542                 an rledger bug; please report it with the input directives.",
543            ),
544        }
545    }
546}
547
548impl std::error::Error for CanonicalizeError {}
549
550/// Replace CRLF and bare-CR line terminators with LF, but ONLY
551/// outside string literals.
552///
553/// String literals (`"…"`) can contain raw `\r` and `\n` per the
554/// lexer's `STRING` rule; folding CR inside a string would mutate
555/// the user's data. Uses the shared `SourceState` state machine
556/// to track string / comment boundaries.
557///
558/// Cheap fast path: if the input contains no `\r`, returns the
559/// source slice borrowed (no allocation). Used by
560/// [`format_source`] before parsing so the lexer never has to see
561/// legacy line endings. Exposed publicly under [`crlf_to_lf_outside_strings`]
562/// for tooling (CLI `--diff`, format-equivalence checks) that
563/// needs the same string-aware normalization.
564pub fn crlf_to_lf_outside_strings(src: &str) -> std::borrow::Cow<'_, str> {
565    if !src.contains('\r') {
566        return std::borrow::Cow::Borrowed(src);
567    }
568    // Re-prepend the BOM verbatim and let the body start fresh in
569    // Code state. The state machine no longer needs line-start
570    // tracking — the lexer's `SHEBANG` / `EMACS_DIRECTIVE` regexes
571    // have no line-start anchor, so `#!`/`#+` open a comment at
572    // any column, and the state machine mirrors that.
573    let (body, bom) = match src.strip_prefix('\u{FEFF}') {
574        Some(rest) => (rest, "\u{FEFF}"),
575        None => (src, ""),
576    };
577    let mut out = String::with_capacity(src.len());
578    out.push_str(bom);
579    let mut chars = body.chars().peekable();
580    let mut state = SourceState::Code;
581    let mut prev_was_backslash = false;
582    while let Some(ch) = chars.next() {
583        let peek = chars.peek().copied();
584        match state {
585            SourceState::InString => out.push(ch),
586            _ => {
587                if ch == '\r' {
588                    out.push('\n');
589                    if peek == Some('\n') {
590                        chars.next();
591                    }
592                } else {
593                    out.push(ch);
594                }
595            }
596        }
597        state = advance_source_state(ch, peek, state, &mut prev_was_backslash);
598    }
599    std::borrow::Cow::Owned(out)
600}
601
602/// `true` iff `src` contains at least one `\r` byte OUTSIDE a
603/// string literal — i.e. the byte sequence the canonical
604/// formatter would fold to `\n` via
605/// [`crlf_to_lf_outside_strings`].
606///
607/// This is the explicit predicate companion to the Cow return of
608/// [`crlf_to_lf_outside_strings`]. Tooling that only needs to
609/// know whether the fold would change bytes (the CLI `--diff`
610/// "CR-bearing line endings folded" cause line, the LSP
611/// did-the-formatter-touch-this guard) should call this instead
612/// of matching on `Cow::Owned`, which conflates allocation with
613/// semantic change. A future optimization that pre-allocated the
614/// Cow even on a no-op fold would silently invert that
615/// match-on-Cow guard; this predicate keeps the question
616/// answered by the bytes, not by allocation behavior.
617#[must_use]
618pub fn cr_outside_strings_present(src: &str) -> bool {
619    if !src.contains('\r') {
620        return false;
621    }
622    let body = src.strip_prefix('\u{FEFF}').unwrap_or(src);
623    let mut chars = body.chars().peekable();
624    let mut state = SourceState::Code;
625    let mut prev_was_backslash = false;
626    while let Some(ch) = chars.next() {
627        let peek = chars.peek().copied();
628        if matches!(state, SourceState::Code | SourceState::InComment) && ch == '\r' {
629            return true;
630        }
631        state = advance_source_state(ch, peek, state, &mut prev_was_backslash);
632    }
633    false
634}
635
636/// Per-character walker state for line-ending normalization passes
637/// that must respect string-literal and comment boundaries.
638///
639/// Used by both line-ending helpers: a flat `is_in_string` boolean
640/// is not enough because a quote character inside a `;`/`%` /
641/// `#!` / `#+` comment is data, not a string delimiter.
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643enum SourceState {
644    /// In normal code. `"` opens a string; `;` / `%` / `#!` /
645    /// `#+` opens a comment; everything else is just bytes.
646    Code,
647    /// Inside `"…"`. Bytes pass through; an unescaped `"` exits.
648    InString,
649    /// Inside `;…\n`, `%…\n`, `#!…\n`, or `#+…\n`. Bytes pass
650    /// through until LF/CR.
651    InComment,
652}
653
654/// One-step state transition shared by both line-ending helpers.
655///
656/// Returns the state AFTER consuming `ch`. The string-escape
657/// bookkeeping (`prev_was_backslash`) updates in place. Comment
658/// opener detection covers all four line-comment lexemes: `;` and
659/// `%` open a comment unconditionally; `#!` and `#+` open one at
660/// any column — the lexer's `#![^\n\r]*` / `#\+[^\n\r]*` regexes
661/// have NO line-start anchor, so a mid-line `#!` or `#+` is still
662/// a `SHEBANG` / `EMACS_DIRECTIVE` token. A `#` followed by
663/// anything else is a `TAG` / `HASH` token, not a comment.
664const fn advance_source_state(
665    ch: char,
666    peek: Option<char>,
667    state: SourceState,
668    prev_was_backslash: &mut bool,
669) -> SourceState {
670    match state {
671        SourceState::InString => {
672            let is_close = ch == '"' && !*prev_was_backslash;
673            *prev_was_backslash = ch == '\\' && !*prev_was_backslash;
674            if is_close {
675                SourceState::Code
676            } else {
677                SourceState::InString
678            }
679        }
680        SourceState::InComment => {
681            if matches!(ch, '\n' | '\r') {
682                SourceState::Code
683            } else {
684                SourceState::InComment
685            }
686        }
687        SourceState::Code => {
688            let is_hash_line_comment = ch == '#' && matches!(peek, Some('!' | '+'));
689            if ch == '"' {
690                *prev_was_backslash = false;
691                SourceState::InString
692            } else if matches!(ch, ';' | '%') || is_hash_line_comment {
693                SourceState::InComment
694            } else {
695                SourceState::Code
696            }
697        }
698    }
699}
700
701/// Format a `SOURCE_FILE` syntax node in opinionated canonical form.
702///
703/// The bare-node entry for callers that already parsed the CST
704/// (typically LSP formatting providers). Output rules are the
705/// same as [`format_source`].
706///
707/// Internally runs [`compute_alignment`] on `node` to derive the
708/// file-wide column targets. Hot paths that hold a precomputed
709/// `PostingAlignment` (e.g., via [`crate::ParseResult::alignment`]) should
710/// call [`format_node_with_alignment`] instead to skip the
711/// per-call walk. Equivalence pinned by
712/// `format_node_equals_format_node_with_alignment` in this file's
713/// tests.
714#[must_use]
715pub fn format_node(node: &crate::SyntaxNode) -> String {
716    // The SOURCE_FILE precondition is asserted by `format_node_grouped`.
717    format_node_grouped(node, GroupingStyle::default())
718}
719
720/// [`format_node`], with the thousands-separator rule supplied by the caller.
721#[must_use]
722pub fn format_node_grouped(node: &crate::SyntaxNode, style: GroupingStyle<'_>) -> String {
723    // Precondition as in `format_node`.
724    #[allow(clippy::expect_used)]
725    let source_file =
726        SourceFile::cast(node.clone()).expect("format_node_grouped called on non-SOURCE_FILE node");
727    let alignment = compute_alignment(&source_file, style);
728    format_node_with_style(node, alignment, style)
729}
730
731/// Like [`format_node`] but skips the per-call
732/// [`compute_alignment`] walk by accepting a precomputed
733/// `PostingAlignment`.
734///
735/// The cache pattern: parse → take `ParseResult::alignment` (the
736/// pre-computed file-wide alignment, populated by `parse_via_cst`)
737/// → call this function. Subsequent formatting calls on the same
738/// `ParseResult` pay only the per-call emit cost, not the
739/// `O(N_postings)` pre-pass.
740///
741/// `alignment` MUST match what `compute_alignment(&SourceFile::cast(node).unwrap())` would
742/// return for the given `node` — passing a mismatched alignment
743/// is allowed but produces output with non-canonical column
744/// widths. Use `PostingAlignment::default()` for files known to have no
745/// postings (no transactions, or transactions with no AMOUNT).
746///
747/// # Panics
748///
749/// Panics if `node`'s kind is not `SOURCE_FILE`.
750#[must_use]
751pub fn format_node_with_alignment(node: &crate::SyntaxNode, alignment: PostingAlignment) -> String {
752    format_node_with_style(node, alignment, GroupingStyle::default())
753}
754
755/// [`format_node_with_alignment`] with an explicit grouping style.
756///
757/// Deliberately NOT public. It is the one shape that can be called wrongly —
758/// see the invariant below — and every public entry point either measures the
759/// alignment itself (`format_node_grouped`) or is fixed to the default style,
760/// which is what `ParseResult::alignment` is measured under. Keeping this
761/// private makes the mismatch unstatable from outside the crate rather than
762/// merely documented.
763///
764/// INVARIANT: `alignment` must have been produced by [`compute_alignment`] with
765/// this same `style`. Grouping changes a numeral's width, so an alignment
766/// measured under a different style puts the currency column in the wrong place
767/// — the #1290 non-convergence, reintroduced. The `_grouped` entry points
768/// derive both from one value and cannot mismatch; a caller reusing a cached
769/// `ParseResult::alignment` (computed at parse time, before any options are
770/// known) must pass `GroupingStyle::default()`.
771#[must_use]
772fn format_node_with_style(
773    node: &crate::SyntaxNode,
774    alignment: PostingAlignment,
775    group: GroupingStyle<'_>,
776) -> String {
777    // Precondition check (debug-only). The bare `format_node`
778    // delegate already validated the kind via the
779    // `SourceFile::cast` it performs for `compute_alignment`, so
780    // for the most common call path (bare → with_alignment) the
781    // debug_assert is a redundant no-op in release. External
782    // direct callers of this entry point (FFI, future LSP
783    // handlers calling `format_node_with_alignment` with a
784    // `parse_result.alignment()` cache) get the panic in debug
785    // builds; in release, a wrong-kind `node` produces empty or
786    // malformed output rather than panicking — acceptable for
787    // a precondition that's guaranteed by the call's typed
788    // contract.
789    debug_assert_eq!(
790        node.kind(),
791        crate::SyntaxKind::SOURCE_FILE,
792        "format_node_with_alignment called on non-SOURCE_FILE node (got {:?})",
793        node.kind(),
794    );
795    let mut out = String::new();
796    // Walk every direct child in source order so file-level comments
797    // (file-leading per phase-2.0 trivia attachment, plus file-
798    // trailing) interleave correctly with directives. Inter-directive
799    // and same-line trailing comments live INSIDE the next/owning
800    // directive and surface from `emit_directive`'s leading-trivia
801    // pass.
802    //
803    // Blank-line policy at the top level: PRESERVE the author's blank
804    // lines between directives rather than normalizing to exactly one.
805    // Between two directives, emit as many blank lines as the source
806    // had — including zero, so deliberately grouped runs (consecutive
807    // `open`s, a dense `price` feed) stay grouped instead of being
808    // double-spaced (#1325). This matches Python `bean-format` and the
809    // rest of the beancount formatter lineage (fava,
810    // beancount-language-server, beancount-mode), all of which leave
811    // blank-line structure untouched and only realign amounts.
812    //
813    // Adjacent file-level comments still stay tight as a group (so a
814    // `; ====\n; HEADER\n; ====` section header keeps its visual
815    // grouping), and a comment group sitting against a directive on
816    // either side stays flush.
817    let mut prev_was_directive = false;
818    for el in node.children_with_tokens() {
819        match el {
820            rowan::NodeOrToken::Node(n) => {
821                if let Some(directive) = ast::Directive::cast(n.clone()) {
822                    if prev_was_directive {
823                        for _ in 0..leading_blank_lines(directive.syntax()) {
824                            out.push('\n');
825                        }
826                    }
827                    emit_directive(&directive, alignment, group, &mut out);
828                    prev_was_directive = true;
829                } else if n.kind() == crate::SyntaxKind::ERROR_NODE {
830                    // Preserve unparsable content verbatim (#1335): `format`
831                    // must never delete the author's text. Org-mode `*`
832                    // section headers (and any comments grouped with them)
833                    // parse into ERROR_NODEs; emit them as-is rather than
834                    // dropping them. Treated like a directive for spacing — an
835                    // ERROR_NODE is a top-level content block, so the author's
836                    // blank lines around it (before it, and before the next
837                    // directive) are preserved, not flushed.
838                    if prev_was_directive {
839                        for _ in 0..leading_blank_lines(&n) {
840                            out.push('\n');
841                        }
842                    }
843                    emit_error_node(&n, &mut out);
844                    prev_was_directive = true;
845                }
846                // Any other non-directive node: nothing to emit.
847            }
848            rowan::NodeOrToken::Token(t) => {
849                if matches!(
850                    t.kind(),
851                    crate::SyntaxKind::COMMENT
852                        | crate::SyntaxKind::PERCENT_COMMENT
853                        | crate::SyntaxKind::SHEBANG
854                        | crate::SyntaxKind::EMACS_DIRECTIVE
855                ) {
856                    out.push_str(t.text().trim_end_matches(['\n', '\r']));
857                    out.push('\n');
858                    prev_was_directive = false;
859                }
860            }
861        }
862    }
863    if !out.ends_with('\n') {
864        out.push('\n');
865    }
866    out
867}
868
869/// Format the subset of `node`'s top-level children that intersect
870/// `range`, returning the snapped byte range and the canonical-form
871/// replacement text.
872///
873/// This is the building block for the LSP `textDocument/rangeFormatting`
874/// provider: the client sends a `Range`, the server snaps it up to
875/// the smallest set of top-level structural nodes (directives or
876/// standalone comments) that intersect the selection, formats those
877/// nodes the same way [`format_node`] formats the whole file, and
878/// returns a single `TextEdit` replacing the snapped range. The
879/// alternative — formatting a substring of the source — would have
880/// to either invent a partial canonical form (creating a second
881/// truth alongside the whole-file canonical form, the failure mode
882/// that bit #1252) or refuse to format anything that crosses a
883/// structural boundary. Snapping up to top-level boundaries is the
884/// only choice that lets the same canonical-form rules apply.
885///
886/// **Frame.** `range` is in the *CST* byte frame — the same frame
887/// the syntax node's `TextRange`s use. The LSP handler is
888/// responsible for shifting `bom_offset` at the input/output
889/// boundary (mirrors the [`super::super::SyntaxNode`] /
890/// `selection_range` handler convention; see
891/// `ParseResult::syntax_root` rustdoc for the rationale).
892///
893/// **Behavior.**
894///
895/// - If `range` intersects no top-level Directive or standalone
896///   COMMENT/SHEBANG/EMACS token, returns `None`. The LSP handler
897///   surfaces `None` directly (serialized as `null` per LSP, not
898///   as `[]`); the client treats it as "nothing to format".
899/// - If the computed snap range would cover any top-level
900///   `ERROR_NODE` byte, returns `None`. **Range formatting refuses
901///   to delete user content the parser couldn't classify.** This
902///   diverges from [`format_node`], which silently drops
903///   `ERROR_NODE` children on the whole-file path; the rationale
904///   is the per-handler asymmetry the LSP exposes — the user
905///   pressing "Format Selection" expects either a clean
906///   reformat or a no-op, never a silent partial delete of an
907///   in-progress directive. Tooling that genuinely wants to drop
908///   broken regions can still call [`format_node`] on the same
909///   node.
910/// - Otherwise returns `Some((snap, text))` where `snap` is the
911///   union of the included children's text ranges (so it begins at
912///   the first included child's start and ends at the last
913///   included child's end, including each child's leading-trivia
914///   prefix per the phase-2.0 Directive-Terminator Rule) and
915///   `text` is the canonical-form replacement.
916/// - Cursor-only selection (`range.is_empty()`): the child at the
917///   cursor is included if the cursor is strictly inside it OR is
918///   exactly at the child's start. Boundary at the child's end
919///   belongs to the next child, not the previous one — matches
920///   the standard "end-of-line cursor is start-of-next-line"
921///   convention.
922///
923/// **Posting alignment.** The pre-pass uses the FULL `SourceFile`, not
924/// the selected subset. A selection that formats one transaction
925/// in a file with many other transactions inherits the file's
926/// alignment columns, so the formatted output stays visually
927/// aligned with un-formatted postings elsewhere. The opposite
928/// policy (per-selection alignment) would create a jarring
929/// visual jump every time the user re-formats a sub-range.
930///
931/// **Round-trip invariant.** For any `range` that contains every
932/// top-level child, the returned text equals the result of
933/// [`format_node`] on the same node. Pinned by
934/// `format_node_range_full_range_matches_format_node` in this
935/// file's test module.
936///
937/// Returns `None` if `node`'s kind is not `SOURCE_FILE` (the precondition is
938/// still that callers pass the parse root) or if `range` intersects no top-level
939/// child.
940#[must_use]
941pub fn format_node_range(
942    node: &crate::SyntaxNode,
943    range: rowan::TextRange,
944) -> Option<(rowan::TextRange, String)> {
945    // This returns `Option`, so a non-SOURCE_FILE node is a clean `None` rather
946    // than a panic (the precondition is still that callers pass the parse root).
947    let source_file = SourceFile::cast(node.clone())?;
948    // File-wide alignment pre-pass: see rustdoc above for the
949    // rationale. The selected subset always uses the full file's
950    // alignment columns. Hot paths with a precomputed `PostingAlignment`
951    // should call `format_node_range_with_alignment` instead.
952    let alignment = compute_alignment(&source_file, GroupingStyle::default());
953    format_node_range_with_alignment(node, range, alignment)
954}
955
956/// Like [`format_node_range`] but skips the per-call
957/// [`compute_alignment`] walk by accepting a precomputed
958/// `PostingAlignment`.
959///
960/// The cache pattern is identical to
961/// [`format_node_with_alignment`]: parse → take
962/// `ParseResult::alignment` → call this function. The hot path the
963/// cache addresses is the LSP `textDocument/rangeFormatting`
964/// fallback (CST-snap path that fires on parse-error files), which
965/// can be invoked per-keystroke through format-on-type clients.
966/// Without the cache the per-call cost is
967/// `O(N_postings_in_file)`; with the cache it's
968/// `O(N_cst_nodes covered by range)`.
969///
970/// `alignment` MUST match what `compute_alignment(&SourceFile::cast(node).unwrap())` would
971/// return for the given `node`; pinned by
972/// `format_node_range_matches_format_node_range_with_alignment`. Same
973/// `range` semantics, `ERROR_NODE` policy, snap rules, and
974/// `# Panics` precondition as [`format_node_range`].
975#[must_use]
976pub fn format_node_range_with_alignment(
977    node: &crate::SyntaxNode,
978    range: rowan::TextRange,
979    alignment: PostingAlignment,
980) -> Option<(rowan::TextRange, String)> {
981    format_node_range_with_style(node, range, alignment, GroupingStyle::default())
982}
983
984/// [`format_node_range`] under an explicit grouping style, measuring the
985/// alignment itself.
986///
987/// Prefer this over pairing a caller-supplied alignment with a
988/// alignment: the alignment MUST have been measured under the same style, and
989/// doing both here makes that unfalsifiable. Costs one `compute_alignment`
990/// walk, which is why the ungrouped hot path still uses the cached alignment.
991pub fn format_node_range_grouped(
992    node: &crate::SyntaxNode,
993    range: rowan::TextRange,
994    style: GroupingStyle<'_>,
995) -> Option<(rowan::TextRange, String)> {
996    let source_file = SourceFile::cast(node.clone())?;
997    let alignment = compute_alignment(&source_file, style);
998    format_node_range_with_style(node, range, alignment, style)
999}
1000
1001/// [`format_node_range_with_alignment`] with an explicit grouping style.
1002///
1003/// Same `alignment`/`style` agreement invariant as
1004/// `format_node_with_style`: `alignment` must have been measured by
1005/// `compute_alignment` under this same `style`, or the currency column will be
1006/// off by the separators' width. The cached `ParseResult::alignment` is
1007/// measured UNGROUPED, so it may only be paired with the default style.
1008fn format_node_range_with_style(
1009    node: &crate::SyntaxNode,
1010    range: rowan::TextRange,
1011    alignment: PostingAlignment,
1012    style: GroupingStyle<'_>,
1013) -> Option<(rowan::TextRange, String)> {
1014    // Precondition check (debug-only). Same rationale as
1015    // `format_node_with_alignment`: the bare delegate already
1016    // validated the kind, so the most common call path (bare →
1017    // with_alignment) gets no release-build cost from this
1018    // assert. External direct callers — the LSP range_formatting
1019    // fallback, FFI, future format-on-type — get a debug-build
1020    // panic; release-build wrong-kind input produces no output
1021    // (rather than panicking).
1022    debug_assert_eq!(
1023        node.kind(),
1024        crate::SyntaxKind::SOURCE_FILE,
1025        "format_node_range_with_alignment called on non-SOURCE_FILE node (got {:?})",
1026        node.kind(),
1027    );
1028
1029    // First pass: identify the included children and the snap range.
1030    // We pick:
1031    //   - Directive nodes whose `text_range` intersects `range`
1032    //   - top-level COMMENT/PERCENT_COMMENT/SHEBANG/EMACS_DIRECTIVE
1033    //     tokens whose range intersects `range`
1034    // ERROR_NODE and other non-Directive nodes are skipped (matches
1035    // `format_node`); a selection that lands only on them returns
1036    // None below.
1037    let mut snap_start: Option<rowan::TextSize> = None;
1038    let mut snap_end: Option<rowan::TextSize> = None;
1039    let mut any_included = false;
1040    for el in node.children_with_tokens() {
1041        let (kind, child_range) = (el.kind(), el.text_range());
1042        let is_formattable = match &el {
1043            rowan::NodeOrToken::Node(n) => ast::Directive::cast(n.clone()).is_some(),
1044            rowan::NodeOrToken::Token(_) => matches!(
1045                kind,
1046                crate::SyntaxKind::COMMENT
1047                    | crate::SyntaxKind::PERCENT_COMMENT
1048                    | crate::SyntaxKind::SHEBANG
1049                    | crate::SyntaxKind::EMACS_DIRECTIVE
1050            ),
1051        };
1052        if !is_formattable {
1053            continue;
1054        }
1055        if !range_intersects(child_range, range) {
1056            continue;
1057        }
1058        any_included = true;
1059        snap_start = Some(snap_start.map_or(child_range.start(), |s| s.min(child_range.start())));
1060        snap_end = Some(snap_end.map_or(child_range.end(), |e| e.max(child_range.end())));
1061    }
1062    if !any_included {
1063        return None;
1064    }
1065    // `any_included` guarantees both bounds were set in the loop above; bail
1066    // (return `None`) rather than `unwrap` if somehow not.
1067    let (Some(snap_start), Some(snap_end)) = (snap_start, snap_end) else {
1068        return None;
1069    };
1070    let snap = rowan::TextRange::new(snap_start, snap_end);
1071
1072    // ERROR_NODE intersection bail: if the snap range covers any
1073    // top-level ERROR_NODE byte, refuse to format and return None.
1074    // Range formatting must not silently delete content the parser
1075    // could not classify — without this guard, a selection
1076    // spanning two valid directives with an ERROR_NODE between
1077    // them would emit a TextEdit that replaces all three with
1078    // just the two formatted directives, deleting the user's
1079    // in-progress source bytes.
1080    //
1081    // This is the deliberate divergence from `format_node`'s
1082    // whole-file policy: the whole-file path runs on the
1083    // assumption that the caller (CLI / FFI / `try_format_source`)
1084    // has already decided to accept content loss; the per-handler
1085    // LSP path has no such opt-in. The cost is occasional
1086    // "format-selection did nothing" UX while a parse error sits
1087    // inside the snap; the benefit is no data loss.
1088    for el in node.children_with_tokens() {
1089        if !matches!(el.kind(), crate::SyntaxKind::ERROR_NODE) {
1090            continue;
1091        }
1092        let er = el.text_range();
1093        // Strict-overlap check: an ERROR_NODE whose end touches
1094        // snap.start (or start touches snap.end) is adjacent, not
1095        // overlapping — those are safe to emit alongside.
1096        if er.end() > snap.start() && er.start() < snap.end() {
1097            return None;
1098        }
1099    }
1100
1101    // Second pass: emit only the children whose range falls
1102    // inside `snap`. We re-walk rather than caching the first
1103    // pass because the second pass needs to maintain the
1104    // `prev_was_directive` blank-line state in source order, and
1105    // the child set is small enough that the second walk is
1106    // cheap. (Re-walking also keeps the data-flow obvious: snap
1107    // computation and emission are two distinct concerns.)
1108    let mut out = String::new();
1109    let mut prev_was_directive = false;
1110    for el in node.children_with_tokens() {
1111        let child_range = el.text_range();
1112        // Use the snap range (not the input `range`) so we emit
1113        // every child WITHIN the snap, even those that the
1114        // original selection didn't directly intersect but that
1115        // sit between two intersecting children. Without this,
1116        // ERROR_NODE-free trivia between two selected directives
1117        // would be re-formatted into our output (the comment
1118        // pass picks them up), which matches `format_node`.
1119        if child_range.end() <= snap.start() || child_range.start() >= snap.end() {
1120            continue;
1121        }
1122        match el {
1123            rowan::NodeOrToken::Node(n) => {
1124                // ERROR_NODEs never reach here: the range path bails out
1125                // above (returns None) when the snap covers one, so it
1126                // refuses to format rather than risk touching unparsable
1127                // content. Only the whole-file path preserves them verbatim.
1128                let Some(directive) = ast::Directive::cast(n) else {
1129                    continue;
1130                };
1131                // Preserve the author's inter-directive blank lines
1132                // (#1325), identically to `format_node_with_alignment`,
1133                // so range formatting and whole-file formatting agree.
1134                //
1135                // The FIRST directive emitted from the snap needs care:
1136                // its predecessor may sit OUTSIDE the selection, but the
1137                // blank lines between them are this directive's leading
1138                // trivia (the Directive-Terminator Rule), so they fall
1139                // INSIDE the snapped range. Dropping them would delete
1140                // the blank line above the selection. Emit them whenever
1141                // a directive precedes this one in the file — the same
1142                // condition the whole-file path expresses as
1143                // `prev_was_directive`. For the file's first directive
1144                // (no predecessor) there is nothing to preserve.
1145                let preceded_by_directive = prev_was_directive
1146                    || directive
1147                        .syntax()
1148                        .prev_sibling()
1149                        .and_then(ast::Directive::cast)
1150                        .is_some();
1151                if preceded_by_directive {
1152                    for _ in 0..leading_blank_lines(directive.syntax()) {
1153                        out.push('\n');
1154                    }
1155                }
1156                emit_directive(&directive, alignment, style, &mut out);
1157                prev_was_directive = true;
1158            }
1159            rowan::NodeOrToken::Token(t) => {
1160                if matches!(
1161                    t.kind(),
1162                    crate::SyntaxKind::COMMENT
1163                        | crate::SyntaxKind::PERCENT_COMMENT
1164                        | crate::SyntaxKind::SHEBANG
1165                        | crate::SyntaxKind::EMACS_DIRECTIVE
1166                ) {
1167                    out.push_str(t.text().trim_end_matches(['\n', '\r']));
1168                    out.push('\n');
1169                    prev_was_directive = false;
1170                }
1171            }
1172        }
1173    }
1174    if !out.ends_with('\n') {
1175        out.push('\n');
1176    }
1177    Some((snap, out))
1178}
1179
1180/// Whether `child` (a CST node's text range) intersects the
1181/// caller's selection. Zero-width selections (a cursor with no
1182/// extent) are handled specially: the cursor counts as "inside"
1183/// a child if the cursor is strictly inside the child's range or
1184/// is exactly at the child's start. Boundary at the child's end
1185/// is NOT a match — it belongs to the next child, matching
1186/// editors' "end-of-line cursor = start of next line" convention.
1187fn range_intersects(child: rowan::TextRange, sel: rowan::TextRange) -> bool {
1188    if sel.is_empty() {
1189        child.contains(sel.start()) || sel.start() == child.start()
1190    } else {
1191        child.start() < sel.end() && sel.start() < child.end()
1192    }
1193}
1194
1195/// Compute the file-wide alignment columns for a parsed `SourceFile`.
1196///
1197/// Walks every Transaction's postings once, takes the max LHS
1198/// width (account + optional `flag `) and max number-text width,
1199/// and derives the column targets from them.
1200///
1201/// **`O(N_postings)`.** Public so consumers can pre-compute the
1202/// alignment once (typically at parse time) and pass the cached
1203/// `PostingAlignment` into [`format_node_with_alignment`] or
1204/// [`format_node_range_with_alignment`] — eliminates the per-call
1205/// walk in hot formatting paths (LSP format-on-type through a
1206/// parse error, repeat-format scripts, etc.).
1207///
1208/// **Tree-shape precondition.** `sf` must be a `SourceFile` whose
1209/// CST was produced by `parse_structured` (directly or transitively
1210/// via `parse_via_cst` / `parse`). Hand-built partial trees (e.g.,
1211/// a `GreenNodeBuilder` invocation for snippet formatting) silently
1212/// return `PostingAlignment::default()` because their wrapping
1213/// nodes fail the `ast::Directive::Transaction::cast` check.
1214/// Likewise, transactions wrapped in `ERROR_NODE` by mid-edit
1215/// error recovery are excluded — see
1216/// `parse_result_alignment_cache::mid_transaction_error_node` for
1217/// the pinned behavior. The function never panics on a partial
1218/// tree; it just returns the all-zero alignment for the no-postings
1219/// case.
1220///
1221/// **Pinning the contract.** `ParseResult::alignment` is populated
1222/// by calling this function during `parse_via_cst`; the equivalence
1223/// between the cached value and a fresh call is guaranteed by the
1224/// `parse_result_alignment_cache::*` regression tests (7 fixtures) in
1225/// this module.
1226#[must_use]
1227pub fn compute_alignment(sf: &SourceFile, style: GroupingStyle<'_>) -> PostingAlignment {
1228    let mut max_lhs: usize = 0;
1229    let mut max_num: usize = 0;
1230    // Tracks postings that actually render a number — the only ones that
1231    // participate in alignment. A file whose postings render no numbers
1232    // gets `PostingAlignment::default()`, matching the type docs.
1233    let mut any_aligned_posting = false;
1234    for directive in sf.directives() {
1235        let ast::Directive::Transaction(t) = directive else {
1236            continue;
1237        };
1238        for child in t.syntax().children() {
1239            let Some(p) = ast::Posting::cast(child) else {
1240                continue;
1241            };
1242            let mut lhs = 0usize;
1243            if let Some(flag) = p.flag() {
1244                lhs += flag.text().chars().count() + 1; // `! ` etc.
1245            }
1246            if let Some(account) = p.account() {
1247                lhs += account.text().chars().count();
1248            }
1249
1250            // Only postings that render a number drive the alignment
1251            // column. `bean-format` computes the number column from the
1252            // prefixes of number-bearing lines only, so two kinds of
1253            // posting must NOT push the column right:
1254            //   - amount-less postings (the elided balancing leg, or a
1255            //     long account with no amount), and
1256            //   - currency-only amounts (`Assets:Cash USD`), which
1257            //     `emit_posting` prints with no number at all.
1258            // Counting either is why `rledger format` and `bean-format`
1259            // disagreed and round-tripping never converged (issue #1290).
1260            // `amount_number_text` is the shared predicate that keeps
1261            // this pre-pass in lockstep with `emit_posting`.
1262            if let Some(amt) = p.amount()
1263                && let Some(text) = amount_number_text(&amt, style)
1264            {
1265                any_aligned_posting = true;
1266                max_lhs = max_lhs.max(lhs);
1267                max_num = max_num.max(text.chars().count());
1268            }
1269        }
1270    }
1271    if !any_aligned_posting {
1272        return PostingAlignment::default();
1273    }
1274    // 2 spaces between the longest account end and the number field,
1275    // matching the conventional Beancount layout.
1276    PostingAlignment {
1277        number_col: INDENT.len() + max_lhs + 2,
1278        number_width: max_num,
1279    }
1280}
1281
1282/// The rendered number / arithmetic-expression text of an amount *if it
1283/// renders a number*, or `None` when it renders nothing (a currency-only
1284/// amount like `USD`, whose value text is empty). EXCLUDES the trailing
1285/// currency; sign (if any) is included.
1286///
1287/// This is the single source of truth for "does this posting line have a
1288/// number?". Both the file-wide alignment pre-pass ([`compute_alignment`])
1289/// and the emitter ([`emit_posting`]) consult it, so they can never
1290/// disagree about which postings participate in alignment — the bug
1291/// class behind #1290 (amount-less postings) and its currency-only
1292/// sibling.
1293fn amount_number_text(amt: &ast::Amount, group: GroupingStyle<'_>) -> Option<String> {
1294    let text = amount_value_text(amt, group);
1295    (!text.is_empty()).then_some(text)
1296}
1297
1298/// Render an amount's value portion (number or arithmetic
1299/// expression) as a string, EXCLUDING the trailing currency.
1300/// Mirrors the value half of [`format_amount`].
1301fn amount_value_text(amt: &ast::Amount, group: GroupingStyle<'_>) -> String {
1302    let mut buf = String::new();
1303    if amt.is_arithmetic() {
1304        emit_amount_subnode_expression(amt.syntax(), group, &mut buf);
1305        return buf;
1306    }
1307    if let Some(sign) = amt.sign()
1308        && sign.is_minus()
1309    {
1310        buf.push('-');
1311    }
1312    if let Some(n) = amt.number() {
1313        // `amt.currency()` is a CST child lookup, and `groups()` ignores the
1314        // currency entirely under the default style — so resolving it first
1315        // costs a tree walk to answer a question already settled. That is per
1316        // posting, per PARSE, because `compute_alignment` runs on every parse
1317        // (see `convert.rs`), long before anything asks to be formatted. It
1318        // measured 1.35% of the load pipeline's instructions.
1319        //
1320        // Same short-circuit `emit_amount_expression` already applies to
1321        // `run_currency`; these two sites were missed when that was added.
1322        let grouped = group.groups_anything()
1323            && group.groups(amt.currency().as_ref().map(ast::CurrencyName::text));
1324        buf.push_str(&canonical_number(n.text(), grouped));
1325    }
1326    buf
1327}
1328
1329fn emit_directive(
1330    d: &ast::Directive,
1331    align: PostingAlignment,
1332    group: GroupingStyle<'_>,
1333    out: &mut String,
1334) {
1335    // Leading inter-directive trivia: COMMENT tokens that sit
1336    // BEFORE the directive's first content token. Per phase-2.0
1337    // trivia attachment, these live inside the directive's syntax
1338    // node — emit them as their own lines BEFORE the canonical
1339    // content.
1340    emit_leading_comments(d.syntax(), out);
1341
1342    // Capture an optional same-line trailing comment so we can
1343    // splice it back in immediately before the directive's
1344    // terminating NEWLINE — see the comment-aware emit loop at
1345    // the bottom of this function.
1346    let trailing = collect_trailing_comment(d.syntax());
1347
1348    let len_before = out.len();
1349    match d {
1350        ast::Directive::Open(d) => emit_open(d, group, out),
1351        ast::Directive::Close(d) => emit_close(d, group, out),
1352        ast::Directive::Commodity(d) => emit_commodity(d, group, out),
1353        ast::Directive::Note(d) => emit_note(d, group, out),
1354        ast::Directive::Event(d) => emit_event(d, group, out),
1355        ast::Directive::Query(d) => emit_query(d, group, out),
1356        ast::Directive::Pad(d) => emit_pad(d, group, out),
1357        ast::Directive::Document(d) => emit_document(d, group, out),
1358        ast::Directive::Price(d) => emit_price(d, group, out),
1359        ast::Directive::Balance(d) => emit_balance(d, group, out),
1360        ast::Directive::Custom(d) => emit_custom(d, group, out),
1361        ast::Directive::Option(d) => emit_option(d, out),
1362        ast::Directive::Include(d) => emit_include(d, out),
1363        ast::Directive::Plugin(d) => emit_plugin(d, out),
1364        ast::Directive::Pushtag(d) => emit_pushtag(d, out),
1365        ast::Directive::Poptag(d) => emit_poptag(d, out),
1366        ast::Directive::Pushmeta(d) => emit_pushmeta(d, group, out),
1367        ast::Directive::Popmeta(d) => emit_popmeta(d, out),
1368        ast::Directive::Transaction(d) => emit_transaction(d, align, group, out),
1369    }
1370    // Splice the same-line trailing comment in: find the FIRST '\n'
1371    // after `len_before` (= end of the directive's header line in
1372    // the emitted bytes) and insert `" ; comment"` before it. For
1373    // single-line directives the first '\n' is also the only one
1374    // and this lands the comment on the directive line. For multi-
1375    // line transactions it lands the comment on the header line
1376    // (where the source had it), not after the body.
1377    if let Some(c) = trailing
1378        && let Some(newline_rel) = out[len_before..].find('\n')
1379    {
1380        let insert_at = len_before + newline_rel;
1381        let mut splice = String::with_capacity(c.len() + 1);
1382        splice.push(' ');
1383        splice.push_str(&c);
1384        out.insert_str(insert_at, &splice);
1385    }
1386}
1387
1388/// Emit an `ERROR_NODE`'s text verbatim, so `format` never deletes content it
1389/// could not parse (#1335) — chiefly org-mode `*` section headers and the
1390/// comments grouped with them. Only trailing whitespace per line is stripped
1391/// (the formatter's no-trailing-space policy) and the node's trailing newlines
1392/// are collapsed to one; everything else — including blank lines, comments and
1393/// the unparsable lines themselves — is preserved exactly as written.
1394fn emit_error_node(node: &crate::SyntaxNode, out: &mut String) {
1395    let text = node.text().to_string();
1396    // Trim leading AND trailing blank lines: the caller emits the leading
1397    // blank lines (via `leading_blank_lines`) so emitting them here too would
1398    // double-count them and break idempotence. Internal blank lines and the
1399    // content (org headers, grouped comments) are preserved.
1400    for line in text.trim_matches(['\n', '\r']).split('\n') {
1401        out.push_str(line.trim_end());
1402        out.push('\n');
1403    }
1404}
1405
1406/// Number of blank lines the author left immediately before this
1407/// directive's first visible line (its leading comment, if any, else
1408/// its content). Each NEWLINE in the leading trivia that precedes the
1409/// first comment / content token is exactly one blank line: the
1410/// previous directive owns its own terminator NEWLINE (the Directive-
1411/// Terminator Rule), so this node's leading NEWLINEs are purely the
1412/// blank gap, with no off-by-one. WHITESPACE-only "blank" lines count
1413/// too (the NEWLINE that ends them is included). Scanning stops at the
1414/// first comment or content token, so a blank line sitting *between* a
1415/// leading comment and the directive's content is not counted here
1416/// (that gap is collapsed by `emit_leading_comments`, as before).
1417fn leading_blank_lines(node: &crate::SyntaxNode) -> usize {
1418    let mut blanks = 0;
1419    for el in node.children_with_tokens() {
1420        let rowan::NodeOrToken::Token(t) = el else {
1421            break;
1422        };
1423        match t.kind() {
1424            crate::SyntaxKind::NEWLINE => blanks += 1,
1425            crate::SyntaxKind::WHITESPACE => {}
1426            // First comment or content token — past the leading gap.
1427            _ => break,
1428        }
1429    }
1430    blanks
1431}
1432
1433/// Walk the directive's direct-child tokens until the first
1434/// non-trivia token, emitting each `COMMENT` (and `PERCENT_COMMENT`)
1435/// on its own line. Whitespace and newlines in the leading region
1436/// are ignored — the canonical form controls inter-directive
1437/// blank-line spacing separately.
1438fn emit_leading_comments(node: &crate::SyntaxNode, out: &mut String) {
1439    for el in node.children_with_tokens() {
1440        let rowan::NodeOrToken::Token(t) = el else {
1441            break;
1442        };
1443        match t.kind() {
1444            crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT => {
1445                out.push_str(t.text().trim_end_matches(['\n', '\r']));
1446                out.push('\n');
1447            }
1448            crate::SyntaxKind::WHITESPACE | crate::SyntaxKind::NEWLINE => {}
1449            _ => break,
1450        }
1451    }
1452}
1453
1454/// Return the directive's same-line trailing comment (if any) —
1455/// the COMMENT token that appears between the LAST non-trivia
1456/// content token and the directive-terminating NEWLINE on the
1457/// header line. Returns the verbatim comment text (no trailing
1458/// newline).
1459fn collect_trailing_comment(node: &crate::SyntaxNode) -> Option<String> {
1460    // Find the directive-header terminating NEWLINE: the FIRST
1461    // direct-child NEWLINE that follows at least one non-trivia
1462    // content token. (For single-line directives there's only one
1463    // NEWLINE; for transactions the header line is the first
1464    // NEWLINE, after which postings/metadata follow.)
1465    let mut header_nl_idx: Option<usize> = None;
1466    let mut saw_content = false;
1467    let tokens: Vec<crate::SyntaxToken> = node
1468        .children_with_tokens()
1469        .filter_map(rowan::NodeOrToken::into_token)
1470        .collect();
1471    for (i, t) in tokens.iter().enumerate() {
1472        let k = t.kind();
1473        if k == crate::SyntaxKind::NEWLINE && saw_content {
1474            header_nl_idx = Some(i);
1475            break;
1476        }
1477        if !matches!(
1478            k,
1479            crate::SyntaxKind::WHITESPACE
1480                | crate::SyntaxKind::NEWLINE
1481                | crate::SyntaxKind::COMMENT
1482                | crate::SyntaxKind::PERCENT_COMMENT
1483        ) {
1484            saw_content = true;
1485        }
1486    }
1487    // EOF-without-newline fallback: if there is no header-
1488    // terminating NEWLINE, the directive runs to the end of the
1489    // file. Scan from the LAST token instead. A `?` early-return
1490    // here previously dropped same-line trailing comments at the
1491    // final line of a file that lacked a trailing newline, e.g.
1492    // `2024-01-15 open Assets:A ; trailing` (no `\n`). The
1493    // canonical formatter restores the trailing newline, but the
1494    // comment was already gone.
1495    let nl_idx = header_nl_idx.unwrap_or(tokens.len());
1496    // Scan backwards from the header NEWLINE (or EOF): the
1497    // trailing comment is the last COMMENT before the NEWLINE
1498    // separated only by WHITESPACE.
1499    for i in (0..nl_idx).rev() {
1500        let k = tokens[i].kind();
1501        if matches!(
1502            k,
1503            crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT
1504        ) {
1505            return Some(tokens[i].text().to_string());
1506        }
1507        if k != crate::SyntaxKind::WHITESPACE {
1508            return None;
1509        }
1510    }
1511    None
1512}
1513
1514// ---- Single-line directives ------------------------------------
1515
1516fn emit_open(d: &ast::OpenDirective, group: GroupingStyle<'_>, out: &mut String) {
1517    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1518    let account = d
1519        .account()
1520        .map(|t| t.text().to_string())
1521        .unwrap_or_default();
1522    out.push_str(&date);
1523    out.push_str(" open ");
1524    out.push_str(&account);
1525    // The currency constraint list is comma-separated (`USD,EUR`), not
1526    // space-separated — emitting spaces produces invalid beancount (#1405).
1527    for (i, currency) in d.currencies().enumerate() {
1528        out.push_str(if i == 0 { " " } else { "," });
1529        out.push_str(currency.text());
1530    }
1531    if let Some(booking) = d.booking_method() {
1532        // `booking.text()` includes the surrounding quotes.
1533        out.push(' ');
1534        out.push_str(booking.text());
1535    }
1536    out.push('\n');
1537    emit_meta_entries_of(d.syntax(), group, out);
1538}
1539
1540fn emit_close(d: &ast::CloseDirective, group: GroupingStyle<'_>, out: &mut String) {
1541    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1542    let account = d
1543        .account()
1544        .map(|t| t.text().to_string())
1545        .unwrap_or_default();
1546    out.push_str(&date);
1547    out.push_str(" close ");
1548    out.push_str(&account);
1549    out.push('\n');
1550    emit_meta_entries_of(d.syntax(), group, out);
1551}
1552
1553fn emit_commodity(d: &ast::CommodityDirective, group: GroupingStyle<'_>, out: &mut String) {
1554    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1555    let currency = d
1556        .currency()
1557        .map(|t| t.text().to_string())
1558        .unwrap_or_default();
1559    out.push_str(&date);
1560    out.push_str(" commodity ");
1561    out.push_str(&currency);
1562    out.push('\n');
1563    emit_meta_entries_of(d.syntax(), group, out);
1564}
1565
1566fn emit_note(d: &ast::NoteDirective, group: GroupingStyle<'_>, out: &mut String) {
1567    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1568    let account = d
1569        .account()
1570        .map(|t| t.text().to_string())
1571        .unwrap_or_default();
1572    let text = d.text().map(|s| s.text().to_string()).unwrap_or_default();
1573    out.push_str(&date);
1574    out.push_str(" note ");
1575    out.push_str(&account);
1576    out.push(' ');
1577    out.push_str(&text);
1578    // beancount v3 accepts tags and links on a note header, and dropping them
1579    // here silently deleted user data on every `rledger format` run (#2184).
1580    emit_header_tags_and_links(d.syntax(), out);
1581    out.push('\n');
1582    emit_meta_entries_of(d.syntax(), group, out);
1583}
1584
1585fn emit_event(d: &ast::EventDirective, group: GroupingStyle<'_>, out: &mut String) {
1586    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1587    let event_type = d
1588        .event_type()
1589        .map(|s| s.text().to_string())
1590        .unwrap_or_default();
1591    let value = d.value().map(|s| s.text().to_string()).unwrap_or_default();
1592    out.push_str(&date);
1593    out.push_str(" event ");
1594    out.push_str(&event_type);
1595    out.push(' ');
1596    out.push_str(&value);
1597    out.push('\n');
1598    emit_meta_entries_of(d.syntax(), group, out);
1599}
1600
1601fn emit_query(d: &ast::QueryDirective, group: GroupingStyle<'_>, out: &mut String) {
1602    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1603    let name = d.name().map(|s| s.text().to_string()).unwrap_or_default();
1604    let query = d.query().map(|s| s.text().to_string()).unwrap_or_default();
1605    out.push_str(&date);
1606    out.push_str(" query ");
1607    out.push_str(&name);
1608    out.push(' ');
1609    out.push_str(&query);
1610    out.push('\n');
1611    emit_meta_entries_of(d.syntax(), group, out);
1612}
1613
1614fn emit_pad(d: &ast::PadDirective, group: GroupingStyle<'_>, out: &mut String) {
1615    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1616    let target = d
1617        .target_account()
1618        .map(|t| t.text().to_string())
1619        .unwrap_or_default();
1620    let source = d
1621        .source_account()
1622        .map(|t| t.text().to_string())
1623        .unwrap_or_default();
1624    out.push_str(&date);
1625    out.push_str(" pad ");
1626    out.push_str(&target);
1627    out.push(' ');
1628    out.push_str(&source);
1629    out.push('\n');
1630    emit_meta_entries_of(d.syntax(), group, out);
1631}
1632
1633/// Re-emit the trailing `#tag` / `^link` tokens of a directive header.
1634///
1635/// The typed AST has no accessor for them, so this walks direct-child tokens.
1636/// Two things make that harder than it sounds, and both are bugs that have
1637/// been fixed here before:
1638///
1639/// - Skip LEADING trivia. A blank line before a non-first directive attaches
1640///   its NEWLINE inside the node, so a walk that stopped at "the first
1641///   NEWLINE" would stop before the header and drop every tag (#1321 in the
1642///   transaction path, and #2189 in the converter, which had the same shape).
1643/// - Skip leading COMMENT lines too, not just whitespace. A comment before a
1644///   non-first directive attaches inside this node (Directive-Terminator
1645///   Rule); treating it as content would flip `seen_content`, break at the
1646///   comment's own NEWLINE, and drop the real header tags.
1647///
1648/// `document` carried this walk alone while `note` had none, so formatting a
1649/// note deleted its tags and links (#2184). Sharing it is what keeps the two
1650/// from drifting apart again.
1651fn emit_header_tags_and_links(node: &crate::SyntaxNode, out: &mut String) {
1652    let mut seen_content = false;
1653    for el in node.children_with_tokens() {
1654        let rowan::NodeOrToken::Token(t) = el else {
1655            // A child NODE is header content, not a reason to stop. For today's
1656            // two callers it can only be a META_ENTRY, which sits past the
1657            // header newline the walk has already broken on -- but stopping
1658            // here would mean a header that ever gains a child node silently
1659            // loses the tags after it, and nothing would say so.
1660            seen_content = true;
1661            continue;
1662        };
1663        match t.kind() {
1664            crate::SyntaxKind::TAG | crate::SyntaxKind::LINK => {
1665                out.push(' ');
1666                out.push_str(t.text());
1667                seen_content = true;
1668            }
1669            crate::SyntaxKind::NEWLINE if seen_content => break,
1670            k if k.is_trivia() => {}
1671            _ => seen_content = true,
1672        }
1673    }
1674}
1675
1676fn emit_document(d: &ast::DocumentDirective, group: GroupingStyle<'_>, out: &mut String) {
1677    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1678    let account = d
1679        .account()
1680        .map(|t| t.text().to_string())
1681        .unwrap_or_default();
1682    let path = d.path().map(|s| s.text().to_string()).unwrap_or_default();
1683    out.push_str(&date);
1684    out.push_str(" document ");
1685    out.push_str(&account);
1686    out.push(' ');
1687    out.push_str(&path);
1688    emit_header_tags_and_links(d.syntax(), out);
1689    out.push('\n');
1690    emit_meta_entries_of(d.syntax(), group, out);
1691}
1692
1693fn emit_price(d: &ast::PriceDirective, group: GroupingStyle<'_>, out: &mut String) {
1694    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1695    let base = d
1696        .base_currency()
1697        .map(|t| t.text().to_string())
1698        .unwrap_or_default();
1699    let quote = d
1700        .quote_currency()
1701        .map(|t| t.text().to_string())
1702        .unwrap_or_default();
1703    out.push_str(&date);
1704    out.push_str(" price ");
1705    out.push_str(&base);
1706    out.push(' ');
1707    emit_amount_expression(d.syntax(), group, out);
1708    out.push(' ');
1709    out.push_str(&quote);
1710    out.push('\n');
1711    emit_meta_entries_of(d.syntax(), group, out);
1712}
1713
1714fn emit_balance(d: &ast::BalanceDirective, group: GroupingStyle<'_>, out: &mut String) {
1715    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1716    let account = d
1717        .account()
1718        .map(|t| t.text().to_string())
1719        .unwrap_or_default();
1720    let currency = d
1721        .currency()
1722        .map(|t| t.text().to_string())
1723        .unwrap_or_default();
1724    out.push_str(&date);
1725    out.push_str(" balance ");
1726    out.push_str(&account);
1727    out.push(' ');
1728    emit_amount_expression(d.syntax(), group, out);
1729    // `balance ACCOUNT AMOUNT [~ TOLERANCE] CURRENCY` — ONE currency, trailing,
1730    // covering both numbers. The tolerance's own `CURRENCY` token (if the source
1731    // repeated it) is deliberately dropped: emitting it as well produced
1732    // `0.00 USD ~ 1234.5 USD`, which is not the beancount form.
1733    if let Some((tolerance, _tol_currency)) = balance_tolerance(d.syntax(), group) {
1734        out.push_str(" ~ ");
1735        out.push_str(&tolerance);
1736    }
1737    out.push(' ');
1738    out.push_str(&currency);
1739    out.push('\n');
1740    emit_meta_entries_of(d.syntax(), group, out);
1741}
1742
1743fn emit_custom(d: &ast::CustomDirective, group: GroupingStyle<'_>, out: &mut String) {
1744    // `custom` / `pushmeta` values are not denominated in anything, so
1745    // they take the ledger-wide default.
1746    let run_group = group.groups(None);
1747    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1748    let custom_type = d
1749        .custom_type()
1750        .map(|s| s.text().to_string())
1751        .unwrap_or_default();
1752    out.push_str(&date);
1753    out.push_str(" custom ");
1754    out.push_str(&custom_type);
1755    // Walk raw tokens after the type STRING and emit each value
1756    // with single-space separation. NUMBER + CURRENCY adjacent
1757    // counts as an Amount; emitted together with one space.
1758    let tokens: Vec<crate::SyntaxToken> = d
1759        .syntax()
1760        .children_with_tokens()
1761        .filter_map(rowan::NodeOrToken::into_token)
1762        .filter(|t| !is_trivia_kind(t.kind()))
1763        .collect();
1764    // `seen_type` skips the leading DATE + CUSTOM_KW + type-STRING
1765    // tokens (already emitted above as the directive header); once
1766    // it flips true, every subsequent non-trivia token is a value
1767    // argument and gets emitted with single-space separation. An
1768    // adjacent NUMBER + CURRENCY pair is glued with a single space
1769    // (canonical Amount shape); the CURRENCY is NOT eaten as a
1770    // standalone arg next iteration.
1771    //
1772    // Beancount custom directives accept any mix of value kinds
1773    // including DATE — a `custom "type" 2024-06-15 100.00 USD`
1774    // shape has a DATE in value position. The previous version
1775    // skipped every DATE after seen_type, silently dropping such
1776    // user-provided date arguments.
1777    let mut seen_type = false;
1778    let mut i = 0;
1779    while i < tokens.len() {
1780        let t = &tokens[i];
1781        if !seen_type {
1782            if t.kind() == crate::SyntaxKind::STRING {
1783                seen_type = true;
1784            }
1785            i += 1;
1786            continue;
1787        }
1788        out.push(' ');
1789        if t.kind() == crate::SyntaxKind::NUMBER {
1790            out.push_str(&canonical_number(t.text(), run_group));
1791            if matches!(
1792                tokens.get(i + 1).map(rowan::SyntaxToken::kind),
1793                Some(crate::SyntaxKind::CURRENCY)
1794            ) {
1795                out.push(' ');
1796                out.push_str(tokens[i + 1].text());
1797                i += 2;
1798                continue;
1799            }
1800        } else {
1801            out.push_str(t.text());
1802        }
1803        i += 1;
1804    }
1805    out.push('\n');
1806    emit_meta_entries_of(d.syntax(), group, out);
1807}
1808
1809// ---- Top-level non-dated directives -----------------------------
1810
1811fn emit_option(d: &ast::OptionDirective, out: &mut String) {
1812    let key = d.key().map(|s| s.text().to_string()).unwrap_or_default();
1813    let value = d.value().map(|s| s.text().to_string()).unwrap_or_default();
1814    out.push_str("option ");
1815    out.push_str(&key);
1816    out.push(' ');
1817    out.push_str(&value);
1818    out.push('\n');
1819}
1820
1821fn emit_include(d: &ast::IncludeDirective, out: &mut String) {
1822    let path = d.path().map(|s| s.text().to_string()).unwrap_or_default();
1823    out.push_str("include ");
1824    out.push_str(&path);
1825    out.push('\n');
1826}
1827
1828fn emit_plugin(d: &ast::PluginDirective, out: &mut String) {
1829    let module = d.module().map(|s| s.text().to_string()).unwrap_or_default();
1830    out.push_str("plugin ");
1831    out.push_str(&module);
1832    if let Some(config) = d.config() {
1833        out.push(' ');
1834        out.push_str(config.text());
1835    }
1836    out.push('\n');
1837}
1838
1839// ---- State directives (no metadata) -----------------------------
1840
1841fn emit_pushtag(d: &ast::PushtagDirective, out: &mut String) {
1842    let tag = d.tag().map(|t| t.text().to_string()).unwrap_or_default();
1843    out.push_str("pushtag ");
1844    out.push_str(&tag);
1845    out.push('\n');
1846}
1847
1848fn emit_poptag(d: &ast::PoptagDirective, out: &mut String) {
1849    let tag = d.tag().map(|t| t.text().to_string()).unwrap_or_default();
1850    out.push_str("poptag ");
1851    out.push_str(&tag);
1852    out.push('\n');
1853}
1854
1855fn emit_pushmeta(d: &ast::PushmetaDirective, group: GroupingStyle<'_>, out: &mut String) {
1856    // `custom` / `pushmeta` values are not denominated in anything, so
1857    // they take the ledger-wide default.
1858    let run_group = group.groups(None);
1859    let key = d.key().map(|t| t.text().to_string()).unwrap_or_default();
1860    out.push_str("pushmeta ");
1861    out.push_str(&key);
1862    // Walk the value tokens after META_KEY, single-space separated.
1863    let mut past_key = false;
1864    for el in d.syntax().children_with_tokens() {
1865        let rowan::NodeOrToken::Token(t) = el else {
1866            continue;
1867        };
1868        if !past_key {
1869            if t.kind() == crate::SyntaxKind::META_KEY {
1870                past_key = true;
1871            }
1872            continue;
1873        }
1874        if is_trivia_kind(t.kind()) {
1875            continue;
1876        }
1877        out.push(' ');
1878        if t.kind() == crate::SyntaxKind::NUMBER {
1879            out.push_str(&canonical_number(t.text(), run_group));
1880        } else {
1881            out.push_str(t.text());
1882        }
1883    }
1884    out.push('\n');
1885}
1886
1887fn emit_popmeta(d: &ast::PopmetaDirective, out: &mut String) {
1888    let key = d.key().map(|t| t.text().to_string()).unwrap_or_default();
1889    out.push_str("popmeta ");
1890    out.push_str(&key);
1891    out.push('\n');
1892}
1893
1894// ---- Transaction + Posting --------------------------------------
1895
1896fn emit_transaction(
1897    d: &ast::Transaction,
1898    align: PostingAlignment,
1899    group: GroupingStyle<'_>,
1900    out: &mut String,
1901) {
1902    let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1903    out.push_str(&date);
1904    out.push(' ');
1905    out.push_str(&transaction_flag_string(d));
1906    if let Some(payee) = d.payee() {
1907        out.push(' ');
1908        out.push_str(payee.text());
1909    }
1910    if let Some(narration) = d.narration() {
1911        out.push(' ');
1912        out.push_str(narration.text());
1913    }
1914    // Header-region tags/links — emitted in source order
1915    // (typed `.tags()` / `.links()` accessors return each kind
1916    // grouped, which loses interleaving like `#a ^l #b`). Walk
1917    // direct-child tokens, stopping at the header-terminating
1918    // NEWLINE.
1919    //
1920    // `seen_content` guards against LEADING trivia: for any directive
1921    // after the first, the preceding blank line's NEWLINE attaches
1922    // inside this node before the date (the Directive-Terminator Rule).
1923    // The header terminator is the first NEWLINE *after* the date, not
1924    // a leading one — otherwise this loop would break immediately and
1925    // emit no header tags (#1321).
1926    let mut seen_content = false;
1927    for el in d.syntax().children_with_tokens() {
1928        let rowan::NodeOrToken::Token(t) = el else {
1929            break;
1930        };
1931        match t.kind() {
1932            crate::SyntaxKind::TAG | crate::SyntaxKind::LINK => {
1933                out.push(' ');
1934                out.push_str(t.text());
1935                seen_content = true;
1936            }
1937            crate::SyntaxKind::NEWLINE if seen_content => break,
1938            // Leading trivia before the date: whitespace, blank-line
1939            // NEWLINEs, AND comment lines (a comment before a non-first
1940            // directive attaches inside this node per the Directive-
1941            // Terminator Rule). Skipping only WHITESPACE/NEWLINE would
1942            // let a leading comment flip `seen_content`, break at the
1943            // comment's NEWLINE, and drop the real header tags/links.
1944            k if k.is_trivia() => {}
1945            // DATE / flag / STRING etc. — header content has begun.
1946            _ => seen_content = true,
1947        }
1948    }
1949    out.push('\n');
1950    // Body: a single source-order walk over the transaction's children,
1951    // emitting — in the order they appear — POSTING / META_ENTRY nodes, any
1952    // body-internal COMMENT lines (#1332: the formatter must not delete the
1953    // author's comments), and trailing body-line TAG / LINK continuation
1954    // tokens (valid Beancount per the body-line exemption).
1955    //
1956    // `seen_content` / `past_header` skip the header region exactly as the
1957    // header loop above does, so the header-trailing comment (spliced onto
1958    // the header line by `emit_directive`) and the header tags/links (already
1959    // emitted inline above) are not duplicated here. A leading blank-line
1960    // NEWLINE for any directive past the first is trivia and must not flip
1961    // `past_header` early (#1321).
1962    let mut past_header = false;
1963    let mut seen_content = false;
1964    for el in d.syntax().children_with_tokens() {
1965        match el {
1966            rowan::NodeOrToken::Node(n) => {
1967                // A POSTING / META_ENTRY node is definitively past the header.
1968                past_header = true;
1969                if let Some(p) = ast::Posting::cast(n.clone()) {
1970                    emit_posting(&p, align, group, out);
1971                } else if let Some(m) = ast::MetaEntry::cast(n) {
1972                    emit_meta_entry(&m, INDENT, group, out);
1973                }
1974            }
1975            rowan::NodeOrToken::Token(t) => {
1976                if !past_header {
1977                    match t.kind() {
1978                        crate::SyntaxKind::NEWLINE if seen_content => past_header = true,
1979                        k if k.is_trivia() => {}
1980                        // DATE / flag / STRING / header TAG / LINK: still header.
1981                        _ => seen_content = true,
1982                    }
1983                    continue;
1984                }
1985                // Body tokens: preserve comment-only lines and emit
1986                // continuation tags/links, each on its own indented line.
1987                match t.kind() {
1988                    crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT => {
1989                        out.push_str(INDENT);
1990                        out.push_str(t.text().trim_end_matches(['\n', '\r']));
1991                        out.push('\n');
1992                    }
1993                    crate::SyntaxKind::TAG | crate::SyntaxKind::LINK => {
1994                        out.push_str(INDENT);
1995                        out.push_str(t.text());
1996                        out.push('\n');
1997                    }
1998                    _ => {}
1999                }
2000            }
2001        }
2002    }
2003}
2004
2005fn transaction_flag_string(d: &ast::Transaction) -> String {
2006    use crate::cst::ast::TransactionFlagKind;
2007    match d.flag() {
2008        None => "*".to_string(),
2009        Some(f) => match f.classify() {
2010            TransactionFlagKind::Star | TransactionFlagKind::Txn => "*".to_string(),
2011            TransactionFlagKind::Pending => "!".to_string(),
2012            TransactionFlagKind::Hash => "#".to_string(),
2013            TransactionFlagKind::Letter | TransactionFlagKind::CurrencyLetter => {
2014                f.text().to_string()
2015            }
2016        },
2017    }
2018}
2019
2020fn emit_posting(
2021    p: &ast::Posting,
2022    align: PostingAlignment,
2023    group: GroupingStyle<'_>,
2024    out: &mut String,
2025) {
2026    // Posting-trailing comment (same-line, before the posting-line
2027    // NEWLINE) — capture upfront so we can splice it back in just
2028    // before that NEWLINE, preserving the user's attachment intent.
2029    let trailing = collect_trailing_comment(p.syntax());
2030    let posting_start = out.len();
2031
2032    out.push_str(INDENT);
2033    let mut col = INDENT.len();
2034    if let Some(flag) = p.flag() {
2035        out.push_str(flag.text());
2036        out.push(' ');
2037        col += flag.text().chars().count() + 1;
2038    }
2039    let account_text = p
2040        .account()
2041        .map(|a| a.text().to_string())
2042        .unwrap_or_default();
2043    out.push_str(&account_text);
2044    col += account_text.chars().count();
2045
2046    if let Some(amt) = p.amount() {
2047        // `amount_number_text` is the shared "does this render a number?"
2048        // predicate (see `compute_alignment`); a currency-only amount
2049        // returns `None` and prints no number.
2050        if let Some(value) = amount_number_text(&amt, group) {
2051            // Two stages of padding:
2052            //   1) Account end → start of number field (`number_col`).
2053            //      Fall back to 2 spaces when the LHS already exceeds
2054            //      the file-wide max (over-long account name).
2055            //   2) Inside the number field, left-pad to right-justify
2056            //      to `number_width`. Effect: the currency column
2057            //      lands at a single uniform position file-wide even
2058            //      when numbers have different widths or signs.
2059            let field_pad = align.number_col.saturating_sub(col).max(2);
2060            let justify_pad = align.number_width.saturating_sub(value.chars().count());
2061            for _ in 0..(field_pad + justify_pad) {
2062                out.push(' ');
2063            }
2064            out.push_str(&value);
2065            if let Some(c) = amt.currency() {
2066                out.push(' ');
2067                out.push_str(c.text());
2068            }
2069            if let Some(cs) = p.cost_spec() {
2070                out.push(' ');
2071                out.push_str(&format_cost_spec(&cs, group));
2072            }
2073            if let Some(pa) = p.price_annotation() {
2074                out.push(' ');
2075                out.push_str(&format_price_annotation(&pa, group));
2076            }
2077        } else {
2078            // No NUMBER, but the posting may still carry a currency, a cost
2079            // spec or a price. `Assets:Other USD` is valid beancount that
2080            // constrains interpolation to USD, and a units-less posting with
2081            // a cost or price is malformed but is still what the author
2082            // wrote. Emitting nothing here deleted all of it (#2142).
2083            //
2084            // Two spaces rather than the aligned number column: there is no
2085            // number to align, and `compute_alignment` deliberately excludes
2086            // these postings from the column width so a long currency-only
2087            // account cannot widen the file.
2088            for part in [
2089                amt.currency().map(|c| c.text().to_string()),
2090                p.cost_spec().map(|cs| format_cost_spec(&cs, group)),
2091                p.price_annotation()
2092                    .map(|pa| format_price_annotation(&pa, group)),
2093            ]
2094            .into_iter()
2095            .flatten()
2096            {
2097                out.push_str("  ");
2098                out.push_str(&part);
2099            }
2100        }
2101    } else {
2102        // No amount node at all, yet a cost spec or price annotation parsed:
2103        // the CST attaches them after the account whether or not an AMOUNT
2104        // was recognized. Same rule as above, keep whatever is there.
2105        //
2106        // An earlier version of this fix handled only the cost spec, so
2107        // `Assets:MSFT @@ 2000.00 USD` still lost its price. Both are listed
2108        // here so neither can be forgotten again.
2109        for part in [
2110            p.cost_spec().map(|cs| format_cost_spec(&cs, group)),
2111            p.price_annotation()
2112                .map(|pa| format_price_annotation(&pa, group)),
2113        ]
2114        .into_iter()
2115        .flatten()
2116        {
2117            out.push_str("  ");
2118            out.push_str(&part);
2119        }
2120    }
2121    out.push('\n');
2122    // Splice the trailing comment in BEFORE the posting-line
2123    // NEWLINE (the first '\n' in the emitted posting region).
2124    if let Some(c) = trailing
2125        && let Some(rel) = out[posting_start..].find('\n')
2126    {
2127        let mut splice = String::with_capacity(c.len() + 1);
2128        splice.push(' ');
2129        splice.push_str(&c);
2130        out.insert_str(posting_start + rel, &splice);
2131    }
2132    // Posting body: emit attached metadata AND posting-internal comment
2133    // lines in source order, indented 4 (deeper than the posting's 2).
2134    // Comment-only lines inside a posting attach as COMMENT tokens of the
2135    // POSTING node; walking children-with-tokens preserves them (#1337)
2136    // instead of dropping them. The posting's own header line is skipped via
2137    // the seen_content/past_header guard, so the same-line trailing comment
2138    // (spliced above) is not duplicated here.
2139    let mut past_header = false;
2140    let mut seen_content = false;
2141    for el in p.syntax().children_with_tokens() {
2142        match el {
2143            rowan::NodeOrToken::Node(n) => {
2144                // Header child nodes (AMOUNT / COST_SPEC / PRICE_ANNOTATION)
2145                // are emitted inline above and must NOT flip `past_header` —
2146                // only the posting-line NEWLINE does. Otherwise the same-line
2147                // trailing comment, which follows the AMOUNT node, would be
2148                // re-emitted here as a body comment. META_ENTRY nodes only
2149                // appear in the body, after `past_header` is already set.
2150                if let Some(m) = ast::MetaEntry::cast(n) {
2151                    emit_meta_entry(&m, "    ", group, out);
2152                }
2153            }
2154            rowan::NodeOrToken::Token(t) => {
2155                if !past_header {
2156                    match t.kind() {
2157                        crate::SyntaxKind::NEWLINE if seen_content => past_header = true,
2158                        k if k.is_trivia() => {}
2159                        _ => seen_content = true,
2160                    }
2161                    continue;
2162                }
2163                if matches!(
2164                    t.kind(),
2165                    crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT
2166                ) {
2167                    out.push_str("    ");
2168                    out.push_str(t.text().trim_end_matches(['\n', '\r']));
2169                    out.push('\n');
2170                }
2171            }
2172        }
2173    }
2174}
2175
2176/// Format an `AMOUNT` (units + currency) in canonical form. For
2177/// arithmetic shapes, emits the expression with single-space
2178/// separators (parens tight); for plain shapes, emits
2179/// `NUMBER CURRENCY` with thousands separators stripped.
2180fn format_amount(amt: &ast::Amount, group: GroupingStyle<'_>) -> String {
2181    let mut out = String::new();
2182    if amt.is_arithmetic() {
2183        emit_amount_subnode_expression(amt.syntax(), group, &mut out);
2184        if let Some(c) = amt.currency() {
2185            if !out.is_empty() {
2186                out.push(' ');
2187            }
2188            out.push_str(c.text());
2189        }
2190        return out;
2191    }
2192    if let Some(sign) = amt.sign()
2193        && sign.is_minus()
2194    {
2195        out.push('-');
2196    }
2197    if let Some(n) = amt.number() {
2198        // `amt.currency()` is a CST child lookup, and `groups()` ignores the
2199        // currency entirely under the default style — so resolving it first
2200        // costs a tree walk to answer a question already settled. That is per
2201        // posting, per PARSE, because `compute_alignment` runs on every parse
2202        // (see `convert.rs`), long before anything asks to be formatted. It
2203        // measured 1.35% of the load pipeline's instructions.
2204        //
2205        // Same short-circuit `emit_amount_expression` already applies to
2206        // `run_currency`; these two sites were missed when that was added.
2207        let grouped = group.groups_anything()
2208            && group.groups(amt.currency().as_ref().map(ast::CurrencyName::text));
2209        out.push_str(&canonical_number(n.text(), grouped));
2210    }
2211    if let Some(c) = amt.currency() {
2212        if !out.is_empty() && !out.ends_with('-') {
2213            out.push(' ');
2214        }
2215        out.push_str(c.text());
2216    }
2217    out
2218}
2219
2220/// Canonical form for cost specs: `{cost CCY}` (single-brace
2221/// per-unit), `{{cost CCY}}` (double-brace total), `{# cost CCY}`
2222/// (per-unit + total via opener), or the in-brace `{N # T CCY}`
2223/// shape preserved as-is with single-space normalization.
2224///
2225/// Commas separating cost components (`{N CCY, DATE, "label"}`)
2226/// stay tight against the preceding token; every other adjacent
2227/// token pair is joined with a single space.
2228fn format_cost_spec(cs: &ast::CostSpec, group: GroupingStyle<'_>) -> String {
2229    let (open, close) = if cs.is_total() {
2230        ("{{", "}}")
2231    } else if cs.is_per_unit_plus_total() {
2232        ("{#", "}")
2233    } else {
2234        ("{", "}")
2235    };
2236    // Collect inner content tokens (skip opener/closer/whitespace),
2237    // then route through write_canonical_token_sequence so the spacing rule
2238    // is identical to balance/price/AMOUNT-subnode arithmetic — most
2239    // importantly, unary `+`/`-` stays tight (`{-500 USD}`, not
2240    // `{- 500 USD}`) and COMMA stays tight.
2241    let inner_tokens: Vec<crate::SyntaxToken> = cs
2242        .syntax()
2243        .children_with_tokens()
2244        .filter_map(rowan::NodeOrToken::into_token)
2245        .filter(|t| {
2246            !matches!(
2247                t.kind(),
2248                crate::SyntaxKind::L_BRACE
2249                    | crate::SyntaxKind::R_BRACE
2250                    | crate::SyntaxKind::L_DOUBLE_BRACE
2251                    | crate::SyntaxKind::R_DOUBLE_BRACE
2252                    | crate::SyntaxKind::L_BRACE_HASH
2253                    | crate::SyntaxKind::WHITESPACE
2254                    | crate::SyntaxKind::NEWLINE
2255            )
2256        })
2257        .collect();
2258    let mut inner = String::new();
2259    write_canonical_token_sequence(&inner_tokens, group, &mut inner);
2260    // The `{#` opener is a two-character marker; canonical form
2261    // separates it from the first inner token with a single space
2262    // (matching the rendering in this function's rustdoc). `{` and
2263    // `{{` don't get inner padding per the canonical-form spec.
2264    if cs.is_per_unit_plus_total() && !inner.is_empty() {
2265        format!("{open} {inner}{close}")
2266    } else {
2267        format!("{open}{inner}{close}")
2268    }
2269}
2270
2271/// Canonical price annotation: `@ amount` (per-unit) or
2272/// `@@ amount` (total).
2273fn format_price_annotation(pa: &ast::PriceAnnotation, group: GroupingStyle<'_>) -> String {
2274    let op = if pa.is_total() { "@@" } else { "@" };
2275    match pa.amount() {
2276        Some(a) => format!("{op} {}", format_amount(&a, group)),
2277        None => op.to_string(),
2278    }
2279}
2280
2281// ---- Helpers ---------------------------------------------------
2282
2283/// True for tokens that don't contribute content to the canonical
2284/// form: whitespace, newlines, every comment kind, and the
2285/// leading-file `BOM` token.
2286const fn is_trivia_kind(kind: crate::SyntaxKind) -> bool {
2287    matches!(
2288        kind,
2289        crate::SyntaxKind::WHITESPACE
2290            | crate::SyntaxKind::NEWLINE
2291            | crate::SyntaxKind::COMMENT
2292            | crate::SyntaxKind::PERCENT_COMMENT
2293            | crate::SyntaxKind::SHEBANG
2294            | crate::SyntaxKind::EMACS_DIRECTIVE
2295            | crate::SyntaxKind::BOM
2296    )
2297}
2298
2299/// Render a `NUMBER` token in canonical form: the user's decimal-place count
2300/// is preserved, and digit grouping is imposed by `group` rather than by what
2301/// the source happened to contain.
2302///
2303/// `group == false` → `1,000.00` becomes `1000.00`. `group == true` → the
2304/// reverse: `1000.00` becomes `1,000.00`. Either way this is a TOTAL rewrite of
2305/// the grouping, so the formatter still yields one form per value — the rule
2306/// changes, not the guarantee.
2307///
2308/// Groups are always three digits, because that is the only shape the lexer
2309/// accepts (`(\d{1,3}(,\d{3})*|\d+)(\.\d*)?`). Anything else — Indian lakh
2310/// grouping, say — would emit text this parser then REJECTS, so widening this
2311/// needs a lexer change first, not just a formatter one.
2312fn canonical_number(text: &str, group: bool) -> std::borrow::Cow<'_, str> {
2313    // The overwhelmingly common numeral is already canonical: no separators to
2314    // strip and no grouping requested. Borrow it rather than allocating a copy
2315    // per numeral on the default formatter path.
2316    if !group && !text.contains(',') {
2317        return std::borrow::Cow::Borrowed(text);
2318    }
2319    let bare = text.replace(',', "");
2320    if !group {
2321        return std::borrow::Cow::Owned(bare);
2322    }
2323    let (int_part, frac) = match bare.split_once('.') {
2324        Some((i, f)) => (i, Some(f)),
2325        None => (bare.as_str(), None),
2326    };
2327    // Defensive: a non-digit integer part is not ours to regroup. Unreachable
2328    // for a lexed NUMBER, whose sign is a separate token.
2329    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
2330        return std::borrow::Cow::Owned(bare);
2331    }
2332    let n = int_part.len();
2333    let mut out = String::with_capacity(bare.len() + n / 3);
2334    for (i, c) in int_part.chars().enumerate() {
2335        if i > 0 && (n - i) % 3 == 0 {
2336            out.push(',');
2337        }
2338        out.push(c);
2339    }
2340    if let Some(f) = frac {
2341        out.push('.');
2342        out.push_str(f);
2343    }
2344    std::borrow::Cow::Owned(out)
2345}
2346
2347/// Emit the arithmetic expression of a `PRICE` / `BALANCE`
2348/// directive: tokens from the first expression-starting token
2349/// (`NUMBER`, unary `+`/`-`, or `(`) up to (but not including) the
2350/// first `CURRENCY` at paren-depth 0. Spacing rules per
2351/// [`write_canonical_token_sequence`].
2352///
2353/// **Why the predicate must allow `PLUS` / `MINUS` / `L_PAREN`,
2354/// not just `NUMBER`.** A previous version skipped tokens until
2355/// it hit a `NUMBER`, which silently dropped leading unary signs
2356/// and opening parens — flipping the sign on inputs like
2357/// `2024-01-15 price USD -1.00 EUR` (formatted to `1.00 EUR`) and
2358/// corrupting parenthesized expressions like
2359/// `2024-01-15 balance Assets:A (1 + 2) USD` (formatted to
2360/// `1 + 2) USD USD`). Sign drift in BALANCE / PRICE is silent data
2361/// corruption — a balance assertion that previously asserted a
2362/// debit would assert a credit after a round-trip.
2363fn emit_amount_expression(node: &crate::SyntaxNode, group: GroupingStyle<'_>, out: &mut String) {
2364    let raw: Vec<crate::SyntaxToken> = node
2365        .children_with_tokens()
2366        .filter_map(rowan::NodeOrToken::into_token)
2367        .filter(|t| !is_trivia_kind(t.kind()))
2368        .skip_while(|t| {
2369            !matches!(
2370                t.kind(),
2371                crate::SyntaxKind::NUMBER
2372                    | crate::SyntaxKind::PLUS
2373                    | crate::SyntaxKind::MINUS
2374                    | crate::SyntaxKind::L_PAREN
2375            )
2376        })
2377        .collect();
2378    let mut depth: i32 = 0;
2379    let mut first_currency_idx: Option<usize> = None;
2380    for (i, t) in raw.iter().enumerate() {
2381        match t.kind() {
2382            crate::SyntaxKind::L_PAREN => depth += 1,
2383            crate::SyntaxKind::R_PAREN => depth -= 1,
2384            // A `~ tolerance` clause ENDS the asserted amount. `emit_balance`
2385            // emits it separately via `balance_tolerance`, so running past the
2386            // tilde here emitted it twice: `0.00 ~ 1234.5 USD` came out as
2387            // `0.00 ~ 1234.5 USD ~ 1234.5 USD`. Stable but wrong, and very
2388            // likely not valid beancount — its balance grammar takes at most
2389            // one tolerance.
2390            crate::SyntaxKind::TILDE if depth == 0 && first_currency_idx.is_none() => {
2391                first_currency_idx = Some(i);
2392            }
2393            crate::SyntaxKind::CURRENCY if depth == 0 && first_currency_idx.is_none() => {
2394                first_currency_idx = Some(i);
2395            }
2396            _ => {}
2397        }
2398    }
2399    let end = first_currency_idx.unwrap_or(raw.len());
2400    write_canonical_token_sequence(&raw[..end], group, out);
2401}
2402
2403/// Emit an `AMOUNT` subnode's expression region: every non-trivia
2404/// token minus the trailing `CURRENCY` (caller re-emits the
2405/// currency itself). Used by [`format_amount`] for arithmetic
2406/// posting amounts like `-(1.00 + 2.00) USD`.
2407fn emit_amount_subnode_expression(
2408    node: &crate::SyntaxNode,
2409    group: GroupingStyle<'_>,
2410    out: &mut String,
2411) {
2412    let mut tokens: Vec<crate::SyntaxToken> = node
2413        .children_with_tokens()
2414        .filter_map(rowan::NodeOrToken::into_token)
2415        .filter(|t| !is_trivia_kind(t.kind()))
2416        .collect();
2417    if let Some(last) = tokens.last()
2418        && last.kind() == crate::SyntaxKind::CURRENCY
2419    {
2420        tokens.pop();
2421    }
2422    write_canonical_token_sequence(&tokens, group, out);
2423}
2424
2425/// Single dispatcher for the canonical spacing rules used by EVERY
2426/// token-sequence emit path: balance / price arithmetic, AMOUNT
2427/// subnodes, cost-spec interiors, and metadata values. There is no
2428/// separate path; each call site collects the relevant non-trivia
2429/// tokens and routes them through here so the rules cannot drift
2430/// between contexts.
2431///
2432/// Rules:
2433///
2434/// - single space between adjacent operands / binary operators
2435/// - no space after `(` or before `)` (parens stay tight)
2436/// - no space after a unary `+` / `-` (one that opens the run
2437///   or follows `(` or another operator)
2438/// - no space before `,` (commas in cost-spec component lists
2439///   stay tight against the preceding token)
2440///
2441/// **Adding a new `SyntaxKind` to the formatter implies thinking
2442/// about its effect on every call site of this function.** A new
2443/// operator-like kind added to `is_op` will silently change cost-
2444/// spec and metadata spacing too; a new bracket-like kind needs
2445/// its own rule. The corpus-level idempotence test
2446/// (`idempotence_corpus_sweep`) is the safety net that catches
2447/// drifts.
2448/// The currency a token run is denominated in: the LAST `CURRENCY` token in it.
2449///
2450/// A cost spec (`{1234.56 USD}`) and a balance tolerance carry their own
2451/// currency, and it is the one whose declaration governs their numerals. Taking
2452/// the ledger default instead is how `{1,234,567.89 USD}` came out grouped
2453/// while a plain `1234567.89 USD` posting in the same file stayed bare — USD
2454/// had declared `render_commas: FALSE` and only one of the two honored it.
2455fn run_currency(tokens: &[crate::SyntaxToken]) -> Option<&str> {
2456    tokens
2457        .iter()
2458        .rev()
2459        .find(|t| t.kind() == crate::SyntaxKind::CURRENCY)
2460        .map(rowan::SyntaxToken::text)
2461}
2462
2463fn write_canonical_token_sequence(
2464    tokens: &[crate::SyntaxToken],
2465    group: GroupingStyle<'_>,
2466    out: &mut String,
2467) {
2468    // `&&` short-circuits, so a ledger that declares no grouping never pays
2469    // for the currency scan. `format` walks whole ledgers; see the
2470    // `profile_format` example.
2471    let run_group = group.groups_anything() && group.groups(run_currency(tokens));
2472    let is_op = |k: crate::SyntaxKind| {
2473        matches!(
2474            k,
2475            crate::SyntaxKind::PLUS
2476                | crate::SyntaxKind::MINUS
2477                | crate::SyntaxKind::STAR
2478                | crate::SyntaxKind::SLASH
2479        )
2480    };
2481    let mut prev_kind: Option<crate::SyntaxKind> = None;
2482    let mut prev_was_unary = false;
2483    for t in tokens {
2484        let kind = t.kind();
2485        let is_unary = is_op(kind)
2486            && match prev_kind {
2487                None => true,
2488                Some(p) => p == crate::SyntaxKind::L_PAREN || is_op(p),
2489            };
2490        let need_space = match prev_kind {
2491            None => false,
2492            Some(prev) => {
2493                prev != crate::SyntaxKind::L_PAREN
2494                    && kind != crate::SyntaxKind::R_PAREN
2495                    && kind != crate::SyntaxKind::COMMA
2496                    && !prev_was_unary
2497            }
2498        };
2499        if need_space {
2500            out.push(' ');
2501        }
2502        if kind == crate::SyntaxKind::NUMBER {
2503            out.push_str(&canonical_number(t.text(), run_group));
2504        } else {
2505            out.push_str(t.text());
2506        }
2507        prev_kind = Some(kind);
2508        prev_was_unary = is_unary;
2509    }
2510}
2511
2512/// Extract a balance directive's optional tolerance — the
2513/// `NUMBER` after the first `TILDE`, plus an optional trailing
2514/// `CURRENCY` at paren-depth 0.
2515fn balance_tolerance(
2516    node: &crate::SyntaxNode,
2517    group: GroupingStyle<'_>,
2518) -> Option<(String, Option<String>)> {
2519    // `balance Assets:A 100.00 ~ 0.05 USD` — one currency covers the asserted
2520    // amount and the tolerance, and it trails both, so resolve it up front
2521    // rather than mid-walk.
2522    let run_group = group.groups_anything() && {
2523        // Only materialized when something groups — see above.
2524        let toks: Vec<crate::SyntaxToken> = node
2525            .children_with_tokens()
2526            .filter_map(rowan::NodeOrToken::into_token)
2527            .collect();
2528        group.groups(run_currency(&toks))
2529    };
2530    // The tolerance is an EXPRESSION, not a number: `~ 0.005 + 0.005 USD` and
2531    // `~ 0.005 * 2 USD` are both legal and both mean 0.010. Keeping only the
2532    // first NUMBER -- which this did -- rewrote them as `~ 0.005 USD`, halving
2533    // the tolerance. That reparsed cleanly and asserted something else, so
2534    // `rledger format` could turn a passing ledger into a failing one with no
2535    // diagnostic on either side. Same bug as #1944 in the parser's
2536    // `extract_balance_tolerance`, which was fixed there and left here.
2537    let mut past_tilde = false;
2538    let mut expr: Vec<String> = Vec::new();
2539    let mut currency: Option<String> = None;
2540    for el in node.children_with_tokens() {
2541        let rowan::NodeOrToken::Token(t) = el else {
2542            continue;
2543        };
2544        if !past_tilde {
2545            if t.kind() == crate::SyntaxKind::TILDE {
2546                past_tilde = true;
2547            }
2548            continue;
2549        }
2550        match t.kind() {
2551            crate::SyntaxKind::NUMBER => {
2552                expr.push(canonical_number(t.text(), run_group).into_owned());
2553            }
2554            crate::SyntaxKind::PLUS
2555            | crate::SyntaxKind::MINUS
2556            | crate::SyntaxKind::STAR
2557            | crate::SyntaxKind::SLASH => expr.push(t.text().to_string()),
2558            // Parens bind to their neighbors rather than taking spaces, so
2559            // they are joined below rather than pushed as operands.
2560            crate::SyntaxKind::L_PAREN => expr.push("(".to_string()),
2561            crate::SyntaxKind::R_PAREN => expr.push(")".to_string()),
2562            crate::SyntaxKind::CURRENCY if !expr.is_empty() && currency.is_none() => {
2563                currency = Some(t.text().to_string());
2564            }
2565            _ => {}
2566        }
2567    }
2568    if expr.is_empty() {
2569        return None;
2570    }
2571    // A `+`/`-` is UNARY when nothing that can end an operand precedes it, and
2572    // a unary sign binds to its number: `~ -0.01`, not `~ - 0.01`. The amount
2573    // on the same line already renders `-1.00` tight, so spacing it here made
2574    // one directive disagree with itself (`-1.00 ~ - 0.01 USD`).
2575    let ends_operand = |t: &str| t == ")" || t.starts_with(|c: char| c.is_ascii_digit());
2576    let mut rendered = String::new();
2577    let mut tight_next = false;
2578    for (i, tok) in expr.iter().enumerate() {
2579        let prev = i.checked_sub(1).and_then(|j| expr.get(j));
2580        let unary = matches!(tok.as_str(), "+" | "-") && prev.is_none_or(|p| !ends_operand(p));
2581        let needs_space = i > 0 && tok != ")" && !tight_next && prev.is_none_or(|p| p != "(");
2582        if needs_space {
2583            rendered.push(' ');
2584        }
2585        rendered.push_str(tok);
2586        tight_next = unary;
2587    }
2588    Some((rendered, currency))
2589}
2590
2591// ---- Metadata --------------------------------------------------
2592
2593/// Walk a directive's direct-child `META_ENTRY` nodes and emit
2594/// each on its own indented line in canonical form (`indent + KEY:
2595/// value\n`). Most directive types don't have a `.meta_entries()`
2596/// accessor on their typed wrapper; we walk the syntax node
2597/// directly to stay uniform.
2598fn emit_meta_entries_of(node: &crate::SyntaxNode, group: GroupingStyle<'_>, out: &mut String) {
2599    // Source-order walk so body-internal COMMENT lines are preserved
2600    // alongside the metadata entries (#1332). The header region (up to and
2601    // including the header-terminating NEWLINE) is skipped so the
2602    // header-trailing comment — spliced onto the header line by
2603    // `emit_directive` — is not duplicated here.
2604    let mut past_header = false;
2605    let mut seen_content = false;
2606    for el in node.children_with_tokens() {
2607        match el {
2608            rowan::NodeOrToken::Node(n) => {
2609                past_header = true;
2610                if let Some(entry) = MetaEntry::cast(n) {
2611                    emit_meta_entry(&entry, INDENT, group, out);
2612                }
2613            }
2614            rowan::NodeOrToken::Token(t) => {
2615                if !past_header {
2616                    match t.kind() {
2617                        crate::SyntaxKind::NEWLINE if seen_content => past_header = true,
2618                        k if k.is_trivia() => {}
2619                        _ => seen_content = true,
2620                    }
2621                    continue;
2622                }
2623                if matches!(
2624                    t.kind(),
2625                    crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT
2626                ) {
2627                    out.push_str(INDENT);
2628                    out.push_str(t.text().trim_end_matches(['\n', '\r']));
2629                    out.push('\n');
2630                }
2631            }
2632        }
2633    }
2634}
2635
2636/// Canonical emit for a single `META_ENTRY`. Walks non-trivia
2637/// tokens, prints them with single-space separation, and
2638/// normalizes numbers via [`canonical_number`]. The `META_KEY`
2639/// token already includes the trailing colon (e.g. `note:`); the
2640/// value side gets the same NUMBER + CURRENCY gluing rule the
2641/// rest of the formatter uses elsewhere.
2642///
2643/// Two semantically-equivalent inputs (e.g. `foo: "bar"` and
2644/// `foo:    "bar"`) produce byte-identical output — the
2645/// gofmt-style invariant the file rustdoc promises.
2646fn emit_meta_entry(m: &MetaEntry, indent: &str, group: GroupingStyle<'_>, out: &mut String) {
2647    out.push_str(indent);
2648    // Split the META_ENTRY's non-trivia tokens into [META_KEY,
2649    // value*]. The META_KEY token already includes the trailing
2650    // colon (e.g. `note:`); the value tokens go through
2651    // write_canonical_token_sequence so the spacing rules — unary +/-
2652    // tight, COMMA tight, paren-tight, NUMBER canonicalized — are
2653    // shared with the balance/price/cost-spec/posting-amount paths.
2654    let content: Vec<crate::SyntaxToken> = m
2655        .syntax()
2656        .children_with_tokens()
2657        .filter_map(rowan::NodeOrToken::into_token)
2658        .filter(|t| {
2659            !matches!(
2660                t.kind(),
2661                crate::SyntaxKind::WHITESPACE | crate::SyntaxKind::NEWLINE
2662            )
2663        })
2664        .collect();
2665    let mut iter = content.iter();
2666    if let Some(key) = iter.next() {
2667        out.push_str(key.text());
2668    }
2669    let value_tokens: Vec<crate::SyntaxToken> = iter.cloned().collect();
2670    if !value_tokens.is_empty() {
2671        out.push(' ');
2672        write_canonical_token_sequence(&value_tokens, group, out);
2673    }
2674    out.push('\n');
2675}
2676
2677#[cfg(test)]
2678mod tests {
2679    use super::*;
2680
2681    #[test]
2682    fn empty_input_yields_single_newline() {
2683        assert_eq!(format_source(""), "\n");
2684    }
2685
2686    #[test]
2687    fn open_directive_canonical() {
2688        let src = "2024-01-15   open    Assets:Cash\n";
2689        assert_eq!(format_source(src), "2024-01-15 open Assets:Cash\n");
2690    }
2691
2692    #[test]
2693    fn open_with_currencies_and_booking_canonical() {
2694        // The currency constraint list is comma-separated; emitting spaces
2695        // produced invalid beancount (#1405).
2696        let src = "2024-01-15 open Assets:Brokerage USD,EUR \"STRICT\"\n";
2697        assert_eq!(
2698            format_source(src),
2699            "2024-01-15 open Assets:Brokerage USD,EUR \"STRICT\"\n"
2700        );
2701    }
2702
2703    /// Regression for #1405: `format` must keep the open currency list
2704    /// comma-separated, not rewrite it space-separated (invalid syntax), and
2705    /// the result must be idempotent.
2706    #[test]
2707    fn open_currency_list_stays_comma_separated() {
2708        let src = "2026-01-01 open Assets:Wallet USD,EUR\n";
2709        let once = format_source(src);
2710        assert_eq!(once, "2026-01-01 open Assets:Wallet USD,EUR\n");
2711        assert_eq!(format_source(&once), once, "format must be idempotent");
2712    }
2713
2714    #[test]
2715    fn close_directive_canonical() {
2716        let src = "2024-12-31 close Assets:Cash\n";
2717        assert_eq!(format_source(src), "2024-12-31 close Assets:Cash\n");
2718    }
2719
2720    #[test]
2721    fn commodity_directive_canonical() {
2722        let src = "2024-01-01 commodity HOOL\n";
2723        assert_eq!(format_source(src), "2024-01-01 commodity HOOL\n");
2724    }
2725
2726    #[test]
2727    fn blank_lines_between_directives_preserved() {
2728        // #1325: the formatter preserves the author's inter-directive
2729        // blank lines rather than normalizing to exactly one (matching
2730        // Python bean-format and the rest of the beancount lineage).
2731
2732        // Grouped (no blank in source) stays grouped — not double-spaced.
2733        let grouped = "2024-01-01 open Assets:A\n2024-01-02 open Assets:B\n";
2734        assert_eq!(format_source(grouped), grouped);
2735
2736        // One blank is preserved as one.
2737        let one = "2024-01-01 open Assets:A\n\n2024-01-02 open Assets:B\n";
2738        assert_eq!(format_source(one), one);
2739
2740        // Two blanks are preserved as two (not collapsed).
2741        let two = "2024-01-01 open Assets:A\n\n\n2024-01-02 open Assets:B\n";
2742        assert_eq!(format_source(two), two);
2743
2744        // A whitespace-only "blank" line still counts as one blank line
2745        // (its trailing whitespace is stripped, leaving an empty line).
2746        let ws_blank = "2024-01-01 open Assets:A\n   \n2024-01-02 open Assets:B\n";
2747        assert_eq!(
2748            format_source(ws_blank),
2749            "2024-01-01 open Assets:A\n\n2024-01-02 open Assets:B\n"
2750        );
2751    }
2752
2753    #[test]
2754    fn trailing_newline_always_present() {
2755        let src = "2024-01-01 open Assets:A";
2756        let formatted = format_source(src);
2757        assert!(formatted.ends_with('\n'));
2758        assert!(!formatted.ends_with("\n\n"));
2759    }
2760
2761    #[test]
2762    fn idempotent_on_canonical_input() {
2763        let src = "2024-01-01 open Assets:A\n\n2024-01-02 close Assets:A\n";
2764        let once = format_source(src);
2765        let twice = format_source(&once);
2766        assert_eq!(once, twice);
2767    }
2768
2769    #[test]
2770    fn note_canonical() {
2771        let src = "2024-01-15   note   Assets:Cash   \"a note\"\n";
2772        assert_eq!(
2773            format_source(src),
2774            "2024-01-15 note Assets:Cash \"a note\"\n"
2775        );
2776    }
2777
2778    #[test]
2779    fn event_canonical() {
2780        let src = "2024-01-15  event  \"location\"   \"NYC\"\n";
2781        assert_eq!(
2782            format_source(src),
2783            "2024-01-15 event \"location\" \"NYC\"\n"
2784        );
2785    }
2786
2787    #[test]
2788    fn query_canonical() {
2789        let src = "2024-01-15 query \"q1\" \"SELECT account\"\n";
2790        assert_eq!(
2791            format_source(src),
2792            "2024-01-15 query \"q1\" \"SELECT account\"\n"
2793        );
2794    }
2795
2796    #[test]
2797    fn pad_canonical() {
2798        let src = "2024-01-15  pad   Assets:A   Equity:Opening\n";
2799        assert_eq!(
2800            format_source(src),
2801            "2024-01-15 pad Assets:A Equity:Opening\n"
2802        );
2803    }
2804
2805    #[test]
2806    fn document_with_tags_and_links_canonical() {
2807        let src = "2024-06-01 document Assets:Bank \"stmt.pdf\" #q1 ^scan42 #urgent\n";
2808        assert_eq!(
2809            format_source(src),
2810            "2024-06-01 document Assets:Bank \"stmt.pdf\" #q1 ^scan42 #urgent\n"
2811        );
2812    }
2813
2814    #[test]
2815    fn issue_1321_document_tags_links_idempotent_across_directives() {
2816        // Same class as the transaction case, in `document` directives:
2817        // the 2nd+ document's trailing tags/links were dropped on a
2818        // reformat (found by the #1323 corpus idempotence check). Assert
2819        // the fixed-point property: re-formatting must not change (and
2820        // must not drop the tags/links of the second document).
2821        let src = "\
28222013-05-18 document Assets:Bank \"/a.pdf\" #tag1 ^link1
28232013-05-19 document Assets:Bank \"/b.pdf\" #tag2 ^link2
2824";
2825        let once = format_source(src);
2826        assert_eq!(format_source(&once), once, "format must be idempotent");
2827        assert!(
2828            once.contains("#tag2") && once.contains("^link2"),
2829            "the second document's tags/links must survive formatting; got:\n{once}"
2830        );
2831    }
2832
2833    #[test]
2834    fn issue_1321_header_tags_links_idempotent_across_transactions() {
2835        // Header tags/links must stay on the header line for EVERY
2836        // transaction, not just the first. Regression for #1321 where
2837        // the 2nd+ transaction's header tags/links got migrated to
2838        // continuation lines.
2839        let src = "\
28402024-01-15 * \"x\" #tag1 ^link1 #tag2 ^link2
2841  Assets:Cash    -1.00 USD
2842  Expenses:Misc   1.00 USD
2843
28442024-01-16 * \"x\" #tag1 ^link1 #tag2 ^link2
2845  Assets:Cash    -1.00 USD
2846  Expenses:Misc   1.00 USD
2847";
2848        assert_eq!(
2849            format_source(src),
2850            src,
2851            "format must be a no-op (idempotent)"
2852        );
2853    }
2854
2855    #[test]
2856    fn issue_1321_comment_before_transaction_keeps_header_tags() {
2857        // A comment line before a transaction is leading trivia attached
2858        // inside the transaction node (Directive-Terminator Rule), exactly
2859        // like a blank line. Skipping only WHITESPACE/NEWLINE let the
2860        // comment flip `seen_content`, break at the comment's NEWLINE, and
2861        // migrate the real header tags/links to continuation lines. The
2862        // header tags/links must stay on the header line. (Found by the
2863        // Copilot review of the #1321 fix.)
2864        let src = "\
28652024-01-15 * \"first\" #h1 ^l1
2866  Assets:Cash    -1.00 USD
2867  Expenses:Misc   1.00 USD
2868
2869; a comment before the second transaction
28702024-01-16 * \"second\" #tag1 ^link1
2871  Assets:Cash    -2.00 USD
2872  Expenses:Misc   2.00 USD
2873";
2874        assert_eq!(
2875            format_source(src),
2876            src,
2877            "a leading comment must not migrate header tags/links to continuation lines"
2878        );
2879    }
2880
2881    #[test]
2882    fn issue_1321_comment_before_document_keeps_tags() {
2883        // Document-directive variant of the comment-trivia case above.
2884        let src = "\
28852013-05-18 document Assets:Bank \"/a.pdf\" #tag1 ^link1
2886; a comment before the second document
28872013-05-19 document Assets:Bank \"/b.pdf\" #tag2 ^link2
2888";
2889        let once = format_source(src);
2890        assert_eq!(format_source(&once), once, "format must be idempotent");
2891        assert!(
2892            once.contains("\"/b.pdf\" #tag2 ^link2"),
2893            "the second document's tags/links must stay on its header line; got:\n{once}"
2894        );
2895    }
2896
2897    #[test]
2898    fn issue_1332_body_comments_in_metadata_preserved() {
2899        // The formatter must NOT delete comment-only lines inside a
2900        // directive body (#1332). Here two commented-out `; price:` lines
2901        // sit between metadata entries in a `commodity` body; they must
2902        // survive, interleaved in source order, and the result is idempotent.
2903        let src = "\
29042023-06-04 commodity EAM-VEUR ; cSpell: word VEUR
2905  name: \"Vanguard FTSE Developed Europe UCITS ETF EUR Dist\"
2906  ; price: \"EUR:alphavantage/price:VEUR.AS:EUR\"
2907  ; price: \"EUR:yahoo/VEUR.AS\"
2908  price: \"EUR:pricehist.beanprice.yahoo/VEUR.AS\"
2909";
2910        assert_eq!(
2911            format_source(src),
2912            src,
2913            "body comments must be preserved verbatim"
2914        );
2915        assert_eq!(format_source(&format_source(src)), format_source(src));
2916    }
2917
2918    #[test]
2919    fn issue_1332_body_comments_between_postings_preserved() {
2920        // Same class, inside a transaction body: a comment-only line between
2921        // postings must survive (in source order, 2-space indent). Asserted
2922        // via preservation + idempotence rather than an exact match, since
2923        // amount alignment is also canonicalized.
2924        let src = "\
29252024-01-15 * \"Cafe\" \"Latte\"
2926  Expenses:Coffee   4.50 USD
2927  ; was 5.00 before the discount
2928  Assets:Checking
2929";
2930        let out = format_source(src);
2931        assert!(
2932            out.contains("\n  ; was 5.00 before the discount\n"),
2933            "the body comment must be preserved on its own indented line; got:\n{out}"
2934        );
2935        // Order: the comment stays between the two postings.
2936        let coffee = out.find("Expenses:Coffee").unwrap();
2937        let comment = out.find("; was 5.00").unwrap();
2938        let checking = out.find("Assets:Checking").unwrap();
2939        assert!(
2940            coffee < comment && comment < checking,
2941            "comment must stay between postings:\n{out}"
2942        );
2943        assert_eq!(format_source(&out), out, "format must be idempotent");
2944    }
2945
2946    #[test]
2947    fn issue_1335_org_headers_and_grouped_comments_preserved() {
2948        // The formatter must not delete unparsable content (#1335).
2949        // Org-mode `*` section headers parse into ERROR_NODEs, and comments
2950        // grouped with them get swallowed into the same node — previously all
2951        // dropped. They must survive, and the result must be idempotent.
2952        let src = "\
2953* Section A
2954;; comment between headers
2955;; second line
2956* Section B
29572013-01-01 open Assets:X
2958";
2959        let out = format_source(src);
2960        // Use the exact `;;` needles: a single-`;` substring would still match
2961        // `;; ...` even if one `;` were dropped, weakening the regression.
2962        for needle in [
2963            "* Section A",
2964            ";; comment between headers",
2965            ";; second line",
2966            "* Section B",
2967            "2013-01-01 open Assets:X",
2968        ] {
2969            assert!(
2970                out.contains(needle),
2971                "lost {needle:?} on format; got:\n{out}"
2972            );
2973        }
2974        assert_eq!(format_source(&out), out, "format must be idempotent");
2975    }
2976
2977    #[test]
2978    fn issue_1335_org_header_then_directive_keeps_header() {
2979        // A lone org header before a directive: the header is an ERROR_NODE
2980        // and must be kept (the comment here attaches to the directive and
2981        // was already preserved).
2982        let src = "* Accounts\n2013-01-01 open Assets:X\n";
2983        let out = format_source(src);
2984        assert!(
2985            out.contains("* Accounts"),
2986            "org header dropped; got:\n{out}"
2987        );
2988        assert_eq!(format_source(&out), out);
2989    }
2990
2991    #[test]
2992    fn issue_1335_blank_lines_around_org_header_preserved() {
2993        // An ERROR_NODE is a top-level content block: the author's blank line
2994        // between an org header and the following directive is preserved (it
2995        // is not flushed), and the result is idempotent.
2996        let src = "* Accounts\n\n2013-01-01 open Assets:X\n";
2997        assert_eq!(
2998            format_source(src),
2999            src,
3000            "blank around org header must be kept"
3001        );
3002        assert_eq!(format_source(&format_source(src)), format_source(src));
3003    }
3004
3005    #[test]
3006    fn issue_1337_posting_internal_comments_preserved() {
3007        // A comment on its own line inside a posting attaches as a COMMENT
3008        // token of the POSTING node; it must be preserved (#1337), not
3009        // dropped, and stay between its posting and the next.
3010        let src = "\
30112024-01-15 * \"x\"
3012  Assets:A   1.00 USD
3013    ; posting-internal note
3014  Assets:B
3015";
3016        let out = format_source(src);
3017        assert!(
3018            out.contains("; posting-internal note"),
3019            "posting-internal comment dropped; got:\n{out}"
3020        );
3021        let a = out.find("Assets:A").unwrap();
3022        let c = out.find("; posting-internal note").unwrap();
3023        let b = out.find("Assets:B").unwrap();
3024        assert!(a < c && c < b, "comment must stay between postings:\n{out}");
3025        assert_eq!(format_source(&out), out, "format must be idempotent");
3026    }
3027
3028    #[test]
3029    fn price_canonical_strips_thousands_separators() {
3030        let src = "2024-01-15 price USD  1,234.56 EUR\n";
3031        assert_eq!(format_source(src), "2024-01-15 price USD 1234.56 EUR\n");
3032    }
3033
3034    #[test]
3035    fn price_arithmetic_canonicalizes_spacing() {
3036        let src = "2024-01-15 price USD 1/2 EUR\n";
3037        assert_eq!(format_source(src), "2024-01-15 price USD 1 / 2 EUR\n");
3038    }
3039
3040    #[test]
3041    fn balance_canonical() {
3042        let src = "2024-01-15  balance  Assets:Cash   100.00  USD\n";
3043        assert_eq!(
3044            format_source(src),
3045            "2024-01-15 balance Assets:Cash 100.00 USD\n"
3046        );
3047    }
3048
3049    #[test]
3050    fn balance_with_tolerance_canonical() {
3051        // Beancount's form is `AMOUNT ~ TOLERANCE CURRENCY` — ONE trailing
3052        // currency covering both numbers, per its Precision & Tolerances docs
3053        // (`319.020 ~ 0.002 RGAGX`). This test previously asserted
3054        // `100.00 USD ~ 0.01 USD`, repeating the currency; that is not the
3055        // beancount form, and the emitter produced it by running the amount
3056        // expression past the tilde and then emitting the tolerance again.
3057        // Input that repeats the currency now normalizes to the canonical form.
3058        let src = "2024-01-15 balance Assets:Cash 100.00 USD ~ 0.01 USD\n";
3059        assert_eq!(
3060            format_source(src),
3061            "2024-01-15 balance Assets:Cash 100.00 ~ 0.01 USD\n"
3062        );
3063    }
3064
3065    #[test]
3066    fn balance_arithmetic_canonical() {
3067        let src = "2024-01-15 balance Assets:Cash  0.25 + 0.75  USD\n";
3068        assert_eq!(
3069            format_source(src),
3070            "2024-01-15 balance Assets:Cash 0.25 + 0.75 USD\n"
3071        );
3072    }
3073
3074    #[test]
3075    fn custom_canonical() {
3076        let src = "2024-01-01 custom \"budget\" Expenses:Food 500.00 USD\n";
3077        assert_eq!(
3078            format_source(src),
3079            "2024-01-01 custom \"budget\" Expenses:Food 500.00 USD\n"
3080        );
3081    }
3082
3083    #[test]
3084    fn option_canonical() {
3085        let src = "option   \"title\"   \"My Ledger\"\n";
3086        assert_eq!(format_source(src), "option \"title\" \"My Ledger\"\n");
3087    }
3088
3089    #[test]
3090    fn include_canonical() {
3091        let src = "include  \"other.beancount\"\n";
3092        assert_eq!(format_source(src), "include \"other.beancount\"\n");
3093    }
3094
3095    #[test]
3096    fn plugin_canonical_with_config() {
3097        let src = "plugin  \"beancount.plugins.unrealized\"  \"Unrealized\"\n";
3098        assert_eq!(
3099            format_source(src),
3100            "plugin \"beancount.plugins.unrealized\" \"Unrealized\"\n"
3101        );
3102    }
3103
3104    #[test]
3105    fn plugin_canonical_without_config() {
3106        let src = "plugin   \"my.plugin\"\n";
3107        assert_eq!(format_source(src), "plugin \"my.plugin\"\n");
3108    }
3109
3110    #[test]
3111    fn pushtag_poptag_canonical() {
3112        // No blank line in the source — preserved as grouped (#1325).
3113        let src = "pushtag  #active\npoptag  #active\n";
3114        assert_eq!(format_source(src), "pushtag #active\npoptag #active\n");
3115    }
3116
3117    #[test]
3118    fn pushmeta_popmeta_canonical() {
3119        // No blank line in the source — preserved as grouped (#1325).
3120        let src = "pushmeta location: \"NYC\"\npopmeta location:\n";
3121        assert_eq!(
3122            format_source(src),
3123            "pushmeta location: \"NYC\"\npopmeta location:\n"
3124        );
3125    }
3126
3127    // ---- Transaction tests ------------------------------------
3128
3129    #[test]
3130    fn transaction_minimal_two_postings_aligns_amounts() {
3131        let src = "\
31322024-01-15 * \"Coffee\"
3133  Assets:Cash       -5.00 USD
3134  Expenses:Coffee    5.00 USD
3135";
3136        // max LHS = 15 (Expenses:Coffee); number_col = 17.
3137        // max number width = 6 (`-5.00`); number_width = 6.
3138        // Posting 1: account end at col 13, pad 4 → `-5.00` (width 6,
3139        //   no left-pad) → currency at col 24.
3140        // Posting 2: account end at col 17, pad 2 → ` 5.00` (width
3141        //   5 left-padded by 1) → currency at col 24.
3142        let expected = "\
31432024-01-15 * \"Coffee\"
3144  Assets:Cash      -5.00 USD
3145  Expenses:Coffee   5.00 USD
3146";
3147        assert_eq!(format_source(src), expected);
3148    }
3149
3150    /// Regression for #1290: an amount-less posting (the common elided
3151    /// balancing leg) must NOT widen the number column, even when its
3152    /// account is longer than every amount-bearing account. `bean-format`
3153    /// computes the column only from number-bearing lines, so counting
3154    /// `Expenses:Food` here would make `rledger format` and `bean-format`
3155    /// disagree and never converge on round-trip.
3156    #[test]
3157    fn transaction_elided_posting_does_not_widen_amount_column() {
3158        let src = "\
31592024-01-15 * \"Coffee\"
3160  Assets:Cash  -5.00 USD
3161  Expenses:Food
3162";
3163        // Only Assets:Cash (11) bears an amount; Expenses:Food (13) is
3164        // elided and is ignored for alignment. number_col = 2+11+2 = 15.
3165        let expected = "\
31662024-01-15 * \"Coffee\"
3167  Assets:Cash  -5.00 USD
3168  Expenses:Food
3169";
3170        assert_eq!(format_source(src), expected);
3171        // Idempotent: re-formatting the output is a no-op.
3172        assert_eq!(format_source(expected), expected);
3173    }
3174
3175    /// Regression for #1290 using the reporter's exact fixture: a long
3176    /// elided account (`Expenses:Thingamabobs`) alongside a short
3177    /// amount-bearing one (`Assets:Money`). Pre-fix the number was
3178    /// pushed right to clear the long account; `bean-format` keeps it
3179    /// two spaces after `Assets:Money`. Also confirms the thousands
3180    /// separator is stripped.
3181    #[test]
3182    fn transaction_long_elided_account_matches_bean_format() {
3183        let src = "\
31842024-07-20 * \"Commas should stay\"
3185  Assets:Money  -1,024 USD
3186  Expenses:Thingamabobs
3187";
3188        let expected = "\
31892024-07-20 * \"Commas should stay\"
3190  Assets:Money  -1024 USD
3191  Expenses:Thingamabobs
3192";
3193        assert_eq!(format_source(src), expected);
3194        assert_eq!(format_source(expected), expected);
3195    }
3196
3197    /// Regression for the currency-only gap (#1307, found in review): a
3198    /// currency-only posting (`... USD`, no number) renders no number,
3199    /// so — like an elided posting — it must not widen the alignment
3200    /// column even when its account is the longest. Only `Assets:Bank`
3201    /// bears a number here, so the number stays two spaces after it. The
3202    /// assertion checks the numbered line directly, independent of how
3203    /// the currency-only line itself renders.
3204    #[test]
3205    fn transaction_currency_only_posting_does_not_widen_amount_column() {
3206        let out = format_source(
3207            "2024-01-15 * \"x\"\n  Assets:Bank  -5.00 USD\n  Assets:LongCashReserve USD\n",
3208        );
3209        assert!(
3210            out.contains("  Assets:Bank  -5.00 USD"),
3211            "number column must align to the numbered posting, not the longer \
3212             currency-only one; got:\n{out}"
3213        );
3214    }
3215
3216    /// A posting with no NUMBER keeps its currency, cost and price.
3217    ///
3218    /// `emit_posting` renders the amount only when `amount_number_text`
3219    /// yields a number, and everything else was inside that branch, so a
3220    /// posting without one lost its currency, its cost spec and its price
3221    /// annotation. The tokens were not reformatted, they were deleted, and
3222    /// what the author wrote could not be recovered from the output (#2142).
3223    ///
3224    /// `Assets:Other USD` is VALID beancount that constrains interpolation to
3225    /// USD, so this was data loss on correct input, not only on malformed
3226    /// input. The malformed shapes matter too: `format` runs on editor save,
3227    /// so mistyping a units number and saving deleted the rest of the line.
3228    #[test]
3229    fn issue_2142_numberless_posting_keeps_currency_cost_and_price() {
3230        // Each expectation names the WHOLE posting line, not a fragment.
3231        // An earlier version asserted only `"USD"` for the currency-only
3232        // case, which the OTHER posting (`-5.00 USD`) satisfies, so the test
3233        // would have passed while the currency-only posting still lost its
3234        // currency. A test written to prove content survives must not be
3235        // satisfiable by unrelated content.
3236        for (name, src, must_contain) in [
3237            (
3238                "currency only",
3239                "2024-01-15 * \"x\"\n  Assets:Bank  -5.00 USD\n  Assets:Other USD\n",
3240                "Assets:Other  USD",
3241            ),
3242            (
3243                "price with no amount node at all",
3244                "2013-05-18 * \"x\"\n  Assets:MSFT @@ 2000.00 USD\n  Assets:Cash  -2000.00 USD\n",
3245                "Assets:MSFT  @@ 2000.00 USD",
3246            ),
3247            (
3248                "cost with no amount node at all",
3249                "2013-05-18 * \"x\"\n  Assets:MSFT {12.00 USD}\n  Assets:Cash  -120.00 USD\n",
3250                "Assets:MSFT  {12.00 USD}",
3251            ),
3252            (
3253                "total price without units",
3254                "2013-05-18 * \"x\"\n  Assets:MSFT  MSFT @@ 2000.00 USD\n  Assets:Cash  -2000.00 USD\n",
3255                "Assets:MSFT  MSFT  @@ 2000.00 USD",
3256            ),
3257            (
3258                "cost without units",
3259                "2013-05-18 * \"x\"\n  Assets:MSFT  MSFT {12.00 USD}\n  Assets:Cash  -120.00 USD\n",
3260                "Assets:MSFT  MSFT  {12.00 USD}",
3261            ),
3262            (
3263                "per-unit price without units",
3264                "2013-05-18 * \"x\"\n  Assets:MSFT  MSFT @ 12.00 USD\n  Assets:Cash  -120.00 USD\n",
3265                "Assets:MSFT  MSFT  @ 12.00 USD",
3266            ),
3267        ] {
3268            let out = format_source(src);
3269            assert!(
3270                out.contains(must_contain),
3271                "{name}: formatting deleted {must_contain:?}; got:\n{out}"
3272            );
3273            // And formatting the result again must not change it further: a
3274            // fix that re-emitted the tokens in a shape it could not reparse
3275            // would show up here rather than in the assertion above.
3276            assert_eq!(
3277                format_source(&out),
3278                out,
3279                "{name}: not idempotent; got:\n{out}"
3280            );
3281        }
3282    }
3283
3284    #[test]
3285    fn transaction_payee_and_narration() {
3286        let src =
3287            "2024-01-15 * \"Starbucks\" \"Coffee\"\n  Assets:Cash -5.00 USD\n  Expenses:Coffee\n";
3288        let out = format_source(src);
3289        assert!(
3290            out.contains("2024-01-15 * \"Starbucks\" \"Coffee\"\n"),
3291            "got: {out}"
3292        );
3293    }
3294
3295    #[test]
3296    fn transaction_pending_flag() {
3297        let src = "2024-01-15 ! \"Pending\"\n  Assets:Cash -5.00 USD\n  Expenses:Misc\n";
3298        let out = format_source(src);
3299        assert!(out.starts_with("2024-01-15 ! \"Pending\"\n"), "got: {out}");
3300    }
3301
3302    #[test]
3303    fn transaction_txn_keyword_normalized_to_star() {
3304        // The `txn` keyword form is canonical-form equivalent to `*`.
3305        let src = "2024-01-15 txn \"x\"\n  Assets:Cash -1.00 USD\n  Expenses:Misc\n";
3306        let out = format_source(src);
3307        assert!(out.starts_with("2024-01-15 * \"x\"\n"), "got: {out}");
3308    }
3309
3310    #[test]
3311    fn transaction_header_tags_and_links() {
3312        let src =
3313            "2024-01-15 * \"x\" #tag1 ^link1 #tag2\n  Assets:Cash -1.00 USD\n  Expenses:Misc\n";
3314        let out = format_source(src);
3315        assert!(
3316            out.starts_with("2024-01-15 * \"x\" #tag1 ^link1 #tag2\n"),
3317            "got: {out}"
3318        );
3319    }
3320
3321    #[test]
3322    fn transaction_auto_balance_posting_no_amount() {
3323        let src = "2024-01-15 * \"x\"\n  Assets:Cash  -5.00 USD\n  Expenses:Misc\n";
3324        let out = format_source(src);
3325        // The auto-balance posting has no amount; should just be
3326        // the indented account name.
3327        assert!(out.contains("\n  Expenses:Misc\n"), "got: {out}");
3328    }
3329
3330    #[test]
3331    fn transaction_posting_with_cost_spec() {
3332        let src = "2024-01-15 * \"buy\"\n  Assets:Brokerage  10 HOOL {500.00 USD}\n  Assets:Cash  -5000.00 USD\n";
3333        let out = format_source(src);
3334        assert!(out.contains("10 HOOL {500.00 USD}"), "got: {out}");
3335    }
3336
3337    #[test]
3338    fn transaction_posting_with_total_cost_spec() {
3339        let src = "2024-01-15 * \"buy\"\n  Assets:Brokerage  10 HOOL {{5000.00 USD}}\n  Assets:Cash  -5000.00 USD\n";
3340        let out = format_source(src);
3341        assert!(out.contains("10 HOOL {{5000.00 USD}}"), "got: {out}");
3342    }
3343
3344    #[test]
3345    fn transaction_posting_with_per_unit_price() {
3346        let src = "2024-01-15 * \"buy\"\n  Assets:Brokerage  10 HOOL @ 500.00 USD\n  Assets:Cash  -5000.00 USD\n";
3347        let out = format_source(src);
3348        assert!(out.contains("10 HOOL @ 500.00 USD"), "got: {out}");
3349    }
3350
3351    #[test]
3352    fn transaction_posting_with_total_price() {
3353        let src = "2024-01-15 * \"buy\"\n  Assets:Brokerage  10 HOOL @@ 5000.00 USD\n  Assets:Cash  -5000.00 USD\n";
3354        let out = format_source(src);
3355        assert!(out.contains("10 HOOL @@ 5000.00 USD"), "got: {out}");
3356    }
3357
3358    #[test]
3359    fn transaction_posting_with_flag() {
3360        let src = "2024-01-15 * \"x\"\n  ! Assets:Cash  -5.00 USD\n  Expenses:Misc  5.00 USD\n";
3361        let out = format_source(src);
3362        assert!(out.contains("\n  ! Assets:Cash"), "got: {out}");
3363    }
3364
3365    #[test]
3366    fn transaction_negative_amount() {
3367        let src = "2024-01-15 * \"x\"\n  Assets:Cash -5.00 USD\n  Expenses:Misc 5.00 USD\n";
3368        let out = format_source(src);
3369        assert!(out.contains("-5.00 USD"), "got: {out}");
3370        assert!(out.contains(" 5.00 USD"), "got: {out}");
3371    }
3372
3373    #[test]
3374    fn transaction_strips_thousands_separators_in_postings() {
3375        let src = "2024-01-15 * \"x\"\n  Assets:Cash -1,000.00 USD\n  Expenses:Misc 1,000.00 USD\n";
3376        let out = format_source(src);
3377        assert!(out.contains("-1000.00 USD"), "got: {out}");
3378        assert!(!out.contains("1,000"), "got: {out}");
3379    }
3380
3381    #[test]
3382    fn transaction_arithmetic_amount() {
3383        let src =
3384            "2024-01-15 * \"x\"\n  Assets:Cash  -(1.00 + 2.00) USD\n  Expenses:Misc 3.00 USD\n";
3385        let out = format_source(src);
3386        // The arithmetic expression should render with single
3387        // spaces around binary ops and tight parens.
3388        assert!(
3389            out.contains("(1.00 + 2.00) USD") || out.contains("-(1.00 + 2.00) USD"),
3390            "got: {out}"
3391        );
3392    }
3393
3394    #[test]
3395    fn transaction_idempotent() {
3396        let src = "\
33972024-01-15 * \"Coffee\"
3398  Assets:Cash       -5.00 USD
3399  Expenses:Coffee    5.00 USD
3400";
3401        let once = format_source(src);
3402        let twice = format_source(&once);
3403        assert_eq!(once, twice);
3404    }
3405
3406    #[test]
3407    fn transaction_file_wide_alignment_across_transactions() {
3408        let src = "\
34092024-01-15 * \"x\"
3410  Assets:Cash -5.00 USD
3411  Expenses:Misc 5.00 USD
3412
34132024-01-16 * \"y\"
3414  Liabilities:CreditCard:Visa  -100.00 USD
3415  Expenses:Big  100.00 USD
3416";
3417        let out = format_source(src);
3418        // Cross-posting invariant: the currency column (USD here)
3419        // lands at the same column on every posting line, even when
3420        // individual numbers differ in width or sign. The number
3421        // field is right-justified so the currency column is uniform.
3422        let usd_cols: Vec<usize> = out
3423            .lines()
3424            .filter(|l| l.starts_with("  ") && l.contains(" USD"))
3425            .filter_map(|l| l.find("USD"))
3426            .collect();
3427        assert!(
3428            usd_cols.len() >= 4,
3429            "expected ≥4 posting lines, got {usd_cols:?} in {out}"
3430        );
3431        let first = usd_cols[0];
3432        assert!(
3433            usd_cols.iter().all(|&c| c == first),
3434            "expected USD column uniform at {first}, got {usd_cols:?} in:\n{out}"
3435        );
3436    }
3437
3438    #[test]
3439    fn transaction_posting_metadata_indented_four() {
3440        let src =
3441            "2024-01-15 * \"x\"\n  Assets:Cash -5.00 USD\n    foo: \"bar\"\n  Expenses:Misc\n";
3442        let out = format_source(src);
3443        assert!(out.contains("\n    foo: \"bar\"\n"), "got: {out}");
3444    }
3445
3446    // ---- Code-review regression tests -----------------------------
3447    //
3448    // Each test pins a bug surfaced by the high-effort code review of
3449    // PR #1284 and verified at runtime against the unfixed formatter.
3450
3451    #[test]
3452    fn cost_spec_per_unit_plus_total_opener_preserved() {
3453        // Bug: format_cost_spec only branched on is_total() and emitted
3454        // `{` for the `{#` opener too, dropping the `#` marker and
3455        // changing semantics from per-unit-plus-total to plain
3456        // per-unit cost.
3457        let src = "2024-01-01 * \"buy\"\n  Assets:Brokerage 10 HOOL {# 500.00 USD}\n  Assets:Cash -5000.00 USD\n";
3458        let out = format_source(src);
3459        assert!(
3460            out.contains("{# 500.00 USD}"),
3461            "expected `{{#` opener preserved; got:\n{out}"
3462        );
3463        assert!(!out.contains("{500.00 USD}"), "got:\n{out}");
3464    }
3465
3466    #[test]
3467    fn cost_spec_comma_stays_tight_to_prev_token() {
3468        // Bug: format_cost_spec's catch-all arm inserted a space
3469        // before every non-trivia token including COMMA, producing
3470        // `{500.00 USD , 2024-01-15}` instead of the canonical
3471        // `{500.00 USD, 2024-01-15}`.
3472        let src = "2024-01-01 * \"buy\"\n  Assets:Brokerage 10 HOOL {500.00 USD, 2024-01-15}\n  Assets:Cash -5000.00 USD\n";
3473        let out = format_source(src);
3474        assert!(
3475            out.contains("{500.00 USD, 2024-01-15}"),
3476            "comma must stay tight to USD; got:\n{out}"
3477        );
3478        assert!(
3479            !out.contains("USD ,"),
3480            "no space allowed before comma; got:\n{out}"
3481        );
3482    }
3483
3484    #[test]
3485    fn custom_directive_preserves_date_value_arguments() {
3486        // Bug: emit_custom's post-seen_type match skipped every DATE
3487        // token, silently dropping legitimate date-typed value
3488        // arguments. The leading directive date is already skipped
3489        // via the seen_type=false phase.
3490        let src = "2024-01-01 custom \"budget\" \"name\" 2024-06-15 100.00 USD\n";
3491        let out = format_source(src);
3492        assert!(
3493            out.contains("2024-06-15"),
3494            "value-position DATE must survive; got: {out}"
3495        );
3496    }
3497
3498    #[test]
3499    fn file_level_adjacent_comments_stay_tight() {
3500        // Bug: format_node's top-level walk inserted a blank `\n`
3501        // separator before every emitted item including comments,
3502        // breaking section-header blocks like `; ====\n; HEADER\n; ====`
3503        // by injecting blanks between every adjacent comment line.
3504        let src = "; ====\n; HEADER\n; ====\n2024-01-01 open Assets:A\n";
3505        let expected = "; ====\n; HEADER\n; ====\n2024-01-01 open Assets:A\n";
3506        assert_eq!(format_source(src), expected);
3507    }
3508
3509    #[test]
3510    fn metadata_internal_whitespace_normalized() {
3511        // Bug: emit_meta_entries_of passed META_ENTRY source text
3512        // through verbatim, so `foo: "bar"` and `foo:    "bar"` —
3513        // identical typed ASTs — produced different formatter
3514        // output, violating the gofmt-style invariant the rustdoc
3515        // declares.
3516        let a = "2024-01-01 open Assets:Bank\n  starting: \"foo\"\n";
3517        let b = "2024-01-01 open Assets:Bank\n  starting:    \"foo\"\n";
3518        assert_eq!(format_source(a), format_source(b));
3519    }
3520
3521    #[test]
3522    fn metadata_number_thousands_separator_stripped() {
3523        // Same invariant: numbers inside metadata values share the
3524        // canonical thousands-separator policy with posting numbers
3525        // (otherwise the same file would emit inconsistent numeric
3526        // forms in postings vs. metadata).
3527        let src = "2024-01-01 open Assets:Bank\n  starting_balance: 1,000.00 USD\n";
3528        let out = format_source(src);
3529        assert!(
3530            out.contains("1000.00 USD"),
3531            "thousands-sep should strip in metadata too; got: {out}"
3532        );
3533        assert!(!out.contains("1,000"), "got: {out}");
3534    }
3535
3536    #[test]
3537    fn bare_cr_line_endings_normalized_to_lf_before_parse() {
3538        // Bug: the lexer doesn't treat bare CR as a line terminator,
3539        // so a classic-Mac-authored `directive\r…\rdirective\r`
3540        // parsed as one broken directive and the rest were silently
3541        // dropped. format_source normalizes line endings BEFORE
3542        // parsing so bare CR (and CRLF) are treated as LF.
3543        let src = "2024-01-01 open Assets:A\r2024-01-02 open Assets:B\r";
3544        let out = format_source(src);
3545        assert!(
3546            out.contains("2024-01-01 open Assets:A"),
3547            "first directive lost: {out:?}"
3548        );
3549        assert!(
3550            out.contains("2024-01-02 open Assets:B"),
3551            "second directive lost on bare-CR input: {out:?}"
3552        );
3553    }
3554
3555    #[test]
3556    fn crlf_input_canonicalizes_to_lf() {
3557        // CRLF and bare CR both fold to LF on the way through the
3558        // canonical pass (the canonical form is LF-only).
3559        let src = "2024-01-01 open Assets:A\r\n2024-01-02 open Assets:B\r\n";
3560        let out = format_source(src);
3561        assert!(
3562            !out.contains('\r'),
3563            "canonical output must be LF-only: {out:?}"
3564        );
3565        assert!(out.contains("2024-01-01 open Assets:A\n"), "got: {out:?}");
3566        assert!(out.contains("2024-01-02 open Assets:B\n"), "got: {out:?}");
3567    }
3568
3569    #[test]
3570    fn metadata_value_with_unary_minus_stays_tight() {
3571        // Bug: emit_meta_entry's tokenized walk inserted a space
3572        // after a unary `+`/`-`, breaking `key: -5.00 USD` →
3573        // `key: - 5.00 USD`. Routed through write_canonical_token_sequence
3574        // so unary detection matches the balance/price/posting paths.
3575        let src = "2024-01-01 open Assets:Bank\n  threshold: -5.00 USD\n";
3576        let out = format_source(src);
3577        assert!(
3578            out.contains("threshold: -5.00 USD"),
3579            "unary minus must stay tight in metadata; got: {out}"
3580        );
3581        assert!(
3582            !out.contains("- 5.00"),
3583            "no space after unary minus; got: {out}"
3584        );
3585    }
3586
3587    #[test]
3588    fn metadata_value_with_unary_plus_stays_tight() {
3589        let src = "2024-01-01 open Assets:Bank\n  min: +1.00 USD\n";
3590        let out = format_source(src);
3591        assert!(out.contains("min: +1.00 USD"), "got: {out}");
3592        assert!(!out.contains("+ 1.00"), "got: {out}");
3593    }
3594
3595    #[test]
3596    fn cost_spec_negative_cost_stays_tight() {
3597        // Bug: format_cost_spec catch-all had no unary-operator
3598        // handling. `{-500 USD}` formatted to `{- 500 USD}`. Now
3599        // routes through write_canonical_token_sequence.
3600        let src = "2024-01-01 * \"x\"\n  Assets:Brokerage 10 HOOL {-500 USD}\n  Assets:Cash -5000.00 USD\n";
3601        let out = format_source(src);
3602        assert!(
3603            out.contains("{-500 USD}"),
3604            "negative cost spec must stay tight; got:\n{out}"
3605        );
3606        assert!(!out.contains("{- "), "got:\n{out}");
3607    }
3608
3609    #[test]
3610    fn cost_spec_arithmetic_with_unary_stays_tight() {
3611        // `{500 * -2 USD}` formerly emitted `{500 * - 2 USD}` because
3612        // the cost-spec catch-all didn't understand unary +/-.
3613        let src = "2024-01-01 * \"x\"\n  Assets:Brokerage 10 HOOL {500 * -2 USD}\n  Assets:Cash -1000.00 USD\n";
3614        let out = format_source(src);
3615        assert!(
3616            out.contains("{500 * -2 USD}"),
3617            "cost-spec arithmetic unary must stay tight; got:\n{out}"
3618        );
3619    }
3620
3621    // ---- Property tests -------------------------------------------
3622    //
3623    // Two invariants the rustdoc's gofmt-style promise depends on,
3624    // pinned over a hand-curated input matrix:
3625    //
3626    // - **Idempotence:** `format_source(format_source(x)) == format_source(x)`.
3627    // - **Round-trip stability for canonicalize_directives:** the
3628    //   synthesize-then-canonicalize shim produces text that, when
3629    //   parsed back, yields the same Directive count and zero parse
3630    //   errors.
3631    //
3632    // The matrix covers every directive kind plus the high-risk
3633    // edge cases the prior reviews surfaced (unary +/- in metadata,
3634    // cost-spec arithmetic, CRLF, bare CR, multi-line strings,
3635    // comments containing quotes, non-Latin accounts). When the
3636    // upstream compatibility corpus is fetched into
3637    // `tests/compatibility/files/` the per-file sweep at the bottom
3638    // also runs; otherwise the file-based test is skipped.
3639
3640    const IDEMPOTENCE_MATRIX: &[(&str, &str)] = &[
3641        ("empty", ""),
3642        ("only_comment", "; header comment\n"),
3643        ("only_directive", "2024-01-01 open Assets:Cash\n"),
3644        (
3645            "two_open_directives",
3646            "2024-01-01 open Assets:A\n2024-01-02 open Assets:B\n",
3647        ),
3648        (
3649            "transaction_with_cost_and_price",
3650            "2024-01-15 * \"buy\"\n  Assets:Brokerage 10 HOOL {500.00 USD} @ 510.00 USD\n  Assets:Cash -5000.00 USD\n",
3651        ),
3652        (
3653            "transaction_with_per_unit_plus_total_cost",
3654            "2024-01-15 * \"x\"\n  Assets:Brokerage 10 HOOL {# 500.00 USD}\n  Assets:Cash -5000.00 USD\n",
3655        ),
3656        (
3657            "transaction_with_arithmetic_amount",
3658            "2024-01-15 * \"x\"\n  Assets:Cash  -(1.00 + 2.00) USD\n  Expenses:Misc 3.00 USD\n",
3659        ),
3660        (
3661            "balance_with_arithmetic_and_tolerance",
3662            "2024-01-15 balance Assets:Cash 0.25 + 0.75 USD ~ 0.01 USD\n",
3663        ),
3664        // Regression for Copilot #2: a previous emit_amount_expression
3665        // skipped tokens until the first NUMBER, which dropped a
3666        // leading unary `-` and silently flipped the sign — a
3667        // balance assertion that asserted a debit would assert a
3668        // credit after a round-trip. These fixtures pin the
3669        // sign / paren preservation explicitly.
3670        (
3671            "balance_leading_unary_minus",
3672            "2024-01-15 balance Assets:A -1.00 USD\n",
3673        ),
3674        (
3675            "balance_leading_parenthesized_expression",
3676            "2024-01-15 balance Assets:A (1 + 2) USD\n",
3677        ),
3678        (
3679            "price_leading_unary_minus",
3680            "2024-01-15 price USD -1.00 EUR\n",
3681        ),
3682        (
3683            "price_with_thousands_separator",
3684            "2024-01-15 price USD 1,234.56 EUR\n",
3685        ),
3686        (
3687            "metadata_unary_minus",
3688            "2024-01-01 open Assets:Bank\n  threshold: -5.00 USD\n",
3689        ),
3690        (
3691            "metadata_arithmetic",
3692            "2024-01-01 open Assets:Bank\n  total: 1000 + 500 USD\n",
3693        ),
3694        (
3695            "cost_spec_with_comma_and_date",
3696            "2024-01-15 * \"x\"\n  Assets:Brokerage 10 HOOL {500.00 USD, 2024-01-15}\n  Assets:Cash -5000.00 USD\n",
3697        ),
3698        (
3699            "cost_spec_with_negative",
3700            "2024-01-15 * \"x\"\n  Assets:Brokerage 10 HOOL {-500 USD}\n  Assets:Cash 5000.00 USD\n",
3701        ),
3702        (
3703            "transaction_with_tags_and_links",
3704            "2024-01-15 * \"x\" #tag1 ^link1 #tag2\n  Assets:Cash -1.00 USD\n  Expenses:Misc 1.00 USD\n",
3705        ),
3706        (
3707            "custom_with_date_value",
3708            "2024-01-01 custom \"budget\" \"name\" 2024-06-15 100.00 USD\n",
3709        ),
3710        (
3711            "non_latin_account_name",
3712            "2024-01-15 * \"x\"\n  Активы:Банк -5.00 USD\n  Expenses:Misc 5.00 USD\n",
3713        ),
3714        (
3715            "section_header_comments",
3716            "; ====\n; HEADER\n; ====\n2024-01-01 open Assets:A\n",
3717        ),
3718        (
3719            "multiline_note_string",
3720            "2024-01-15 note Assets:Bank \"line 1\nline 2\"\n",
3721        ),
3722        (
3723            "comment_containing_quote",
3724            "; comment with \"a quote\n2024-01-01 open Assets:A\n",
3725        ),
3726        (
3727            "crlf_input",
3728            "2024-01-01 open Assets:A\r\n2024-01-02 open Assets:B\r\n",
3729        ),
3730        (
3731            "bare_cr_input",
3732            "2024-01-01 open Assets:A\r2024-01-02 open Assets:B\r",
3733        ),
3734        (
3735            "file_with_trailing_newlines",
3736            "2024-01-01 open Assets:A\n\n\n",
3737        ),
3738        ("file_without_trailing_newline", "2024-01-01 open Assets:A"),
3739        // Regression for Copilot #1: collect_trailing_comment
3740        // previously returned None for a directive with no
3741        // header-terminating NEWLINE token, which silently dropped
3742        // a same-line trailing comment at EOF when the file lacked
3743        // a trailing newline. The canonical formatter restores the
3744        // trailing newline, but the dropped comment was already
3745        // gone.
3746        (
3747            "trailing_comment_no_final_newline",
3748            "2024-01-15 open Assets:A ; trailing",
3749        ),
3750        (
3751            "posting_with_trailing_comment",
3752            "2024-01-15 * \"x\"\n  Assets:Cash -5.00 USD ; pocket\n  Expenses:Misc 5.00 USD\n",
3753        ),
3754        (
3755            "balance_assertion_with_meta",
3756            "2024-01-15 balance Assets:Cash 100.00 USD\n  source: \"bank\"\n",
3757        ),
3758        (
3759            "options_and_includes",
3760            "option \"title\" \"My Ledger\"\ninclude \"sub.beancount\"\nplugin \"my.plugin\" \"cfg\"\n",
3761        ),
3762        // ---- per-variant coverage ---------------------------------
3763        ("close_directive", "2024-12-31 close Assets:Cash\n"),
3764        ("commodity_directive", "2024-01-01 commodity HOOL\n"),
3765        // Tagged and linked on purpose. Untagged, this fixture could not see
3766        // a note losing its tags -- which is exactly what happened: the
3767        // emitters dropped them and every harness in this file passed
3768        // (#2184).
3769        (
3770            "note_directive",
3771            "2024-01-15 note Assets:Cash \"a note\" #n1 ^l1\n",
3772        ),
3773        ("event_directive", "2024-01-15 event \"location\" \"NYC\"\n"),
3774        (
3775            "query_directive",
3776            "2024-01-15 query \"q1\" \"SELECT account\"\n",
3777        ),
3778        ("pad_directive", "2024-01-15 pad Assets:A Equity:Opening\n"),
3779        (
3780            "document_directive",
3781            "2024-06-01 document Assets:Bank \"stmt.pdf\" #q1 ^d1\n",
3782        ),
3783        // Note: `#!` and `#+` anywhere on a line, not just at
3784        // line start, open the lexer's SHEBANG / EMACS_DIRECTIVE
3785        // tokens. The fixture places `#+` mid-line and tails it
3786        // with an unbalanced `"`: an incorrect state machine that
3787        // gated the opener on `at_line_start` would stay in Code
3788        // when it hit the `#+`, then flip to InString on the next
3789        // `"` and trap there for the remainder of the file. The
3790        // lexer-agreement property test catches that divergence,
3791        // and the round-trip body runs too because the parser
3792        // treats the mid-line EMACS_DIRECTIVE as same-line
3793        // trailing trivia under the directive-terminator rule.
3794        (
3795            "emacs_directive_mid_line_with_quote",
3796            "2024-01-15 open Assets:A #+stray \"q\n",
3797        ),
3798        ("pushtag_directive", "pushtag #active\n"),
3799        ("poptag_directive", "poptag #active\n"),
3800        ("pushmeta_directive", "pushmeta location: \"NYC\"\n"),
3801        ("popmeta_directive", "popmeta location:\n"),
3802    ];
3803
3804    /// Number of fixtures in [`IDEMPOTENCE_MATRIX`] that legitimately
3805    /// produce zero typed directives — comment-only / empty /
3806    /// pragma-only inputs. The round-trip property test skips these
3807    /// (they have nothing to emit), but every OTHER fixture MUST
3808    /// exercise the body. Bumping this constant when adding such a
3809    /// fixture is the only manual maintenance the coverage floor
3810    /// needs; otherwise the floor (`IDEMPOTENCE_MATRIX.len() -
3811    /// ROUNDTRIP_KNOWN_ZERO_DIRECTIVE_FIXTURES`) tracks the matrix
3812    /// automatically.
3813    ///
3814    /// Today's zero-directive fixtures (skipped by the round-trip
3815    /// body), verified by an exhaustive probe against the live
3816    /// parser:
3817    ///
3818    /// - `empty`, `only_comment` — no directives at all.
3819    /// - `bare_cr_input` — the parser does not recognize bare CR
3820    ///   (without a following LF) as a directive terminator, so
3821    ///   the file's two would-be directives never surface as
3822    ///   structured tokens. The fixture's purpose is the
3823    ///   line-ending state-machine pass, not the round-trip body.
3824    /// - `pushtag_directive`, `poptag_directive`,
3825    ///   `pushmeta_directive`, `popmeta_directive` — pragma
3826    ///   directives don't surface as `Directive` variants on the
3827    ///   typed-AST side (the parser also rejects them today, so
3828    ///   they produce parse errors and the skip-on-errors guard
3829    ///   triggers).
3830    /// - `options_and_includes` — option / include / plugin lines
3831    ///   live on separate `ParseResult` collections, not on
3832    ///   `.directives`.
3833    ///
3834    /// Note: `comment_containing_quote` and
3835    /// `emacs_directive_mid_line_with_quote` BOTH exercise the
3836    /// body — each is paired with a parseable directive on the
3837    /// same line or an adjacent line, and the trivia token
3838    /// (comment / `EMACS_DIRECTIVE`) attaches as same-line or
3839    /// inter-directive trivia under the directive-terminator
3840    /// rule. Their purpose is the state-machine / lexer agreement
3841    /// property on a comment with an unbalanced `"`, not the
3842    /// zero-directive case.
3843    const ROUNDTRIP_KNOWN_ZERO_DIRECTIVE_FIXTURES: usize = 8;
3844
3845    #[test]
3846    fn lf_to_crlf_outside_strings_preserves_string_interior() {
3847        // Bug: a flat in_string-only state machine would re-inject
3848        // CRLF inside multi-line strings, mutating the user's bytes.
3849        let s = "2024-01-15 note Assets:Bank \"line 1\nline 2\"\n";
3850        let out = lf_to_crlf_outside_strings(s);
3851        assert!(out.contains("line 1\nline 2"), "got: {out:?}");
3852        assert!(out.ends_with("\r\n"), "got: {out:?}");
3853    }
3854
3855    #[test]
3856    fn lf_to_crlf_outside_strings_handles_comment_with_quote() {
3857        // Bug: an unbalanced `"` inside a `;` comment formerly flipped
3858        // in_string=true for the rest of the file, leaving every
3859        // subsequent newline as LF.
3860        let s = "; comment with \"a quote\n2024-01-01 open Assets:A\n";
3861        let out = lf_to_crlf_outside_strings(s);
3862        assert_eq!(
3863            out,
3864            "; comment with \"a quote\r\n2024-01-01 open Assets:A\r\n",
3865        );
3866    }
3867
3868    #[test]
3869    fn lf_to_crlf_outside_strings_handles_percent_comment_with_quote() {
3870        let s = "% percent \"quote\n2024-01-01 open Assets:A\n";
3871        let out = lf_to_crlf_outside_strings(s);
3872        assert_eq!(out, "% percent \"quote\r\n2024-01-01 open Assets:A\r\n");
3873    }
3874
3875    #[test]
3876    fn crlf_to_lf_preserves_crlf_inside_strings() {
3877        // Bug fix mirror: a Windows-authored multi-line string had
3878        // its CRLF folded to LF by the pre-parse normalizer too,
3879        // which silently mutated the user's bytes.
3880        let s = "2024-01-15 note Assets:Bank \"line1\r\nline2\"\r\n";
3881        let normalized = crlf_to_lf_outside_strings(s);
3882        // Outside the string, the trailing CRLF folds to LF; inside
3883        // the string, CRLF stays CRLF (user's bytes preserved).
3884        assert!(
3885            normalized.contains("\"line1\r\nline2\""),
3886            "got: {:?}",
3887            &*normalized
3888        );
3889        assert!(normalized.ends_with('\n') && !normalized.ends_with("\r\n"));
3890    }
3891
3892    /// Formatting must not change what the file MEANS.
3893    ///
3894    /// The idempotence matrix next door checks that formatting twice equals
3895    /// formatting once. That property cannot see data loss: dropping a note's
3896    /// tags is perfectly idempotent, and it passed for as long as the bug
3897    /// lived (#2184). Stability is not fidelity.
3898    ///
3899    /// So: parse, format, parse again, and compare the directive models. Spans
3900    /// move and are not compared; everything the model holds must survive.
3901    ///
3902    /// Known blind spot, worth stating so a future reader does not over-trust
3903    /// this: a model comparison cannot see data the model never held. When the
3904    /// PARSER drops something, both sides are equally lossy and compare equal.
3905    /// `balance X 1.00 USD ~ 0.01 EUR` formats to `1.00 ~ 0.01 USD` -- the EUR
3906    /// is erased from the file -- and this test passes, because the parser
3907    /// discarded it before either comparison (#2193). That is why the
3908    /// note-tag test next door also asserts on output TEXT.
3909    ///
3910    /// This could not land with #2184: it failed on
3911    /// `balance_with_arithmetic_and_tolerance`, where the formatter emitted a
3912    /// balance that no longer parsed. That was a parser inconsistency (#2191),
3913    /// fixed in the commit this test arrives with -- the harness found it, and
3914    /// waited for it rather than hiding it behind an allow-list.
3915    #[test]
3916    fn formatting_preserves_the_parsed_directives() {
3917        let mut checked = 0;
3918        for (name, src) in IDEMPOTENCE_MATRIX {
3919            let before = crate::parse(src);
3920            if !before.errors.is_empty() {
3921                continue; // error fixtures pin diagnostics, not round-trips
3922            }
3923            let formatted = format_source(src);
3924            let after = crate::parse(&formatted);
3925
3926            assert!(
3927                after.errors.is_empty(),
3928                "{name}: formatter produced output that no longer parses: {:?}\n{formatted}",
3929                after.errors,
3930            );
3931            let b: Vec<_> = before.directives.iter().map(|d| &d.value).collect();
3932            let a: Vec<_> = after.directives.iter().map(|d| &d.value).collect();
3933            assert_eq!(
3934                b, a,
3935                "{name}: formatting changed the directives it parsed from\n\
3936                 --- source ---\n{src}\n--- formatted ---\n{formatted}",
3937            );
3938            checked += 1;
3939        }
3940        // A matrix that stopped yielding clean fixtures would make every
3941        // assertion above vacuous.
3942        // 37 of 42 round-trip today; the rest are error fixtures. The floor
3943        // is just under that rather than a round number a third of the way
3944        // down: at `> 20` a change that made ten fixtures stop parsing would
3945        // slip through, and skipping is exactly how this test goes quiet.
3946        assert!(
3947            checked >= 35,
3948            "only {checked} fixtures round-tripped; this test is not covering \
3949             what it claims to"
3950        );
3951    }
3952
3953    /// Every fixture must survive the TYPED emitter, not just `format_source`.
3954    ///
3955    /// `canonicalize_directives` is the path `rledger add`, `rledger extract`
3956    /// and the FFI `format.entry` endpoints take, and it renders through
3957    /// `rustledger_core::format::format_directives` -- a different emitter
3958    /// with its own set of fields it might forget. It forgot a note's and a
3959    /// document's tags and links, and the fidelity test next door could not
3960    /// see it because that one exercises `format_source`.
3961    ///
3962    /// Same shape as that test, aimed at the other emitter: parse, render,
3963    /// parse, compare models.
3964    #[test]
3965    fn canonicalizing_every_fixture_preserves_the_model() {
3966        let cfg = rustledger_core::format::FormatConfig::default();
3967        let mut checked = 0;
3968        for (name, src) in IDEMPOTENCE_MATRIX {
3969            let before = crate::parse(src);
3970            if !before.errors.is_empty() || before.directives.is_empty() {
3971                continue;
3972            }
3973            let ds: Vec<_> = before.directives.iter().map(|d| d.value.clone()).collect();
3974            let out = match canonicalize_directives(ds.iter(), &cfg) {
3975                Ok(out) => out,
3976                // The shim reports a re-parse failure rather than emitting a
3977                // recoverable subset; that is its contract, not a silent loss.
3978                Err(e) => panic!("{name}: canonicalize failed: {e:?}"),
3979            };
3980            let after = crate::parse(&out);
3981            assert!(
3982                after.errors.is_empty(),
3983                "{name}: typed emitter produced unparsable text: {:?}\n{out}",
3984                after.errors,
3985            );
3986            let a: Vec<_> = after.directives.iter().map(|d| &d.value).collect();
3987            let b: Vec<_> = ds.iter().collect();
3988            assert_eq!(
3989                b, a,
3990                "{name}: the typed emitter changed the directives\n\
3991                 --- source ---\n{src}\n--- rendered ---\n{out}",
3992            );
3993            checked += 1;
3994        }
3995        // Fixtures that parse to no directive at all (comments, options) are
3996        // skipped above; the floor keeps that from quietly becoming all of them.
3997        assert!(
3998            checked >= 30,
3999            "only {checked} fixtures reached the typed emitter"
4000        );
4001    }
4002
4003    /// The typed-directive emitter must not delete tags either.
4004    ///
4005    /// There are TWO live formatters. `format_source` is what `rledger
4006    /// format` runs; `canonicalize_directives` is what `rledger add`,
4007    /// `rledger extract` and the FFI `format.entry` endpoints run, and it
4008    /// synthesizes its intermediate text through
4009    /// `rustledger_core::format::format_directives`.
4010    ///
4011    /// Fixing only the first left the second deleting the same data -- and
4012    /// deleting MORE of it, since the CST emitter at least kept a document's
4013    /// tags while the typed one dropped those too. A round-trip through this
4014    /// shim is what a caller building directives in memory actually gets.
4015    #[test]
4016    fn canonicalizing_typed_directives_keeps_tags_and_links() {
4017        let src = "2024-01-05 note Assets:A \"n\" #ntag ^nlink\n\
4018                   2024-01-06 document Assets:A \"/x.pdf\" #dtag ^dlink\n\
4019                   2024-01-07 * \"t\" #ttag ^tlink\n\
4020                  \x20 Assets:A  1 USD\n\
4021                  \x20 Equity:O\n";
4022        let parsed = crate::parse(src);
4023        assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
4024        let directives: Vec<_> = parsed.directives.iter().map(|d| d.value.clone()).collect();
4025
4026        let out = canonicalize_directives(
4027            directives.iter(),
4028            &rustledger_core::format::FormatConfig::default(),
4029        )
4030        .expect("fixture must canonicalize");
4031
4032        for marker in ["#ntag", "^nlink", "#dtag", "^dlink", "#ttag", "^tlink"] {
4033            assert!(
4034                out.contains(marker),
4035                "typed emitter dropped {marker}\n{out}"
4036            );
4037        }
4038
4039        // And the text it produced still means the same thing.
4040        let after = crate::parse(&out);
4041        assert!(after.errors.is_empty(), "{:?}\n{out}", after.errors);
4042        let a: Vec<_> = after.directives.iter().map(|d| &d.value).collect();
4043        let b: Vec<_> = directives.iter().collect();
4044        assert_eq!(b, a, "canonicalizing changed the directives\n{out}");
4045    }
4046
4047    /// Formatting a note must not change what it means.
4048    ///
4049    /// The idempotence matrix next door cannot catch this class: dropping a
4050    /// note's tags is perfectly idempotent, and it passed for as long as
4051    /// #2184 lived. Stability is not fidelity, so this parses, formats,
4052    /// parses again, and compares the models.
4053    ///
4054    /// Narrower than the matrix tests above on purpose: those compare models,
4055    /// and a model comparison is satisfied by two equally-empty sides. This
4056    /// one names the tags it expects in the output TEXT.
4057    #[test]
4058    fn formatting_preserves_a_notes_tags_and_links() {
4059        // A note past the first, after a blank line and after a comment:
4060        // both attach trivia inside the directive node, which is what broke
4061        // the equivalent walk in the converter (#2189).
4062        let src = "2024-01-01 open Assets:A USD\n\
4063                   \n\
4064                   2024-01-05 note Assets:A \"first\" #n1 ^l1\n\
4065                   \n\
4066                   2024-01-06 note Assets:A \"after blank\" #n2 ^l2\n\
4067                   ; a comment line\n\
4068                   2024-01-07 note Assets:A \"after comment\" #n3\n\
4069                   2024-01-08 note Assets:A \"with meta\" #n4\n\
4070                  \x20 key: \"v\"\n\
4071                   \n\
4072                   2024-01-09 document Assets:A \"/x.pdf\" #d1 ^l3\n";
4073
4074        let before = crate::parse(src);
4075        assert!(before.errors.is_empty(), "{:?}", before.errors);
4076        let formatted = format_source(src);
4077        let after = crate::parse(&formatted);
4078        assert!(
4079            after.errors.is_empty(),
4080            "formatted output no longer parses: {:?}\n{formatted}",
4081            after.errors,
4082        );
4083
4084        let b: Vec<_> = before.directives.iter().map(|d| &d.value).collect();
4085        let a: Vec<_> = after.directives.iter().map(|d| &d.value).collect();
4086        assert_eq!(
4087            b, a,
4088            "formatting changed the directives it parsed from\n\
4089             --- source ---\n{src}\n--- formatted ---\n{formatted}",
4090        );
4091
4092        // The model comparison above is NOT sufficient on its own, and this
4093        // is not a hypothetical: run it against a tree whose CONVERTER also
4094        // drops these tags (#2189) and both sides come back tagless and
4095        // equal, while every tag in the file has been deleted. Assert on the
4096        // formatter's own output text, which is the thing under change here
4097        // and cannot be satisfied by a matching pair of empty models.
4098        for tag in ["#n1", "#n2", "#n3", "#n4", "#d1"] {
4099            assert!(
4100                formatted.contains(tag),
4101                "formatter dropped {tag}\n{formatted}"
4102            );
4103        }
4104        for link in ["^l1", "^l2", "^l3"] {
4105            assert!(
4106                formatted.contains(link),
4107                "formatter dropped {link}\n{formatted}"
4108            );
4109            // Every one of these is already canonical, so formatting must be a
4110            // no-op on the text too. Without this the value assertion above
4111            // would accept any spelling that happens to evaluate the same.
4112            assert_eq!(formatted, src, "already-canonical input was rewritten");
4113        }
4114
4115        // A unary sign binds to its number; a binary one keeps its spaces.
4116        // Rendering `~ - 0.01` evaluates the same but makes one directive
4117        // disagree with itself, since the amount renders `-1.00` tight.
4118        assert_eq!(
4119            format_source("2024-01-15 balance Assets:C -1.00 ~ -0.01 USD\n"),
4120            "2024-01-15 balance Assets:C -1.00 ~ -0.01 USD\n",
4121        );
4122        assert_eq!(
4123            format_source("2024-01-15 balance Assets:C 1.00 ~ (-0.005 + 0.015) USD\n"),
4124            "2024-01-15 balance Assets:C 1.00 ~ (-0.005 + 0.015) USD\n",
4125        );
4126    }
4127
4128    #[test]
4129    fn idempotence_matrix() {
4130        // The gofmt invariant in the file rustdoc: f(f(x)) == f(x)
4131        // on every accepted input. Each fixture below covers one
4132        // axis of the canonical-form spec; together they exercise
4133        // every directive kind and every spacing rule shared via
4134        // write_canonical_token_sequence.
4135        for (name, src) in IDEMPOTENCE_MATRIX {
4136            let once = format_source(src);
4137            let twice = format_source(&once);
4138            assert_eq!(
4139                once, twice,
4140                "idempotence broken on fixture `{name}`\n--- once ---\n{once}\n--- twice ---\n{twice}",
4141            );
4142        }
4143    }
4144
4145    /// The number-display context (#1766) threads through the
4146    /// two-pass canonicalize shim: precision pads. Thousands
4147    /// separators are deliberately absent — canonical ledger text has
4148    /// none (this canonicalizer strips them by definition, and
4149    /// `render_number` agrees so direct emitters match the shim).
4150    #[test]
4151    fn canonicalize_directives_honors_number_display_context() {
4152        use rustledger_core::format::FormatConfig;
4153
4154        let source = "2024-01-15 balance Assets:Bank 1234.5 USD\n";
4155        let parsed = crate::parse(source);
4156        assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
4157
4158        let mut ctx = rustledger_core::DisplayContext::new();
4159        ctx.set_fixed_precision("USD", 2);
4160        ctx.set_render_commas(true);
4161        let config = FormatConfig {
4162            number_display: Some(ctx),
4163            ..FormatConfig::default()
4164        };
4165        let out = canonicalize_directives(parsed.directives.iter().map(|d| &d.value), &config)
4166            .expect("comma-grouped canonical text must survive the reparse");
4167        // REVERSED from "precision pads, no separators". The shim's second
4168        // pass now carries the grouping rule, so a context that asks for
4169        // separators gets them in ledger text — matching beancount, whose
4170        // `render_commas` is documented to affect its PRINT command. The
4171        // machine boundary is the parser, and the grammar admits grouped
4172        // numerals; csv/json (whose consumers have no grammar) are unaffected.
4173        assert!(
4174            out.contains("1,234.50 USD"),
4175            "precision AND grouping both flow through the shim: {out}"
4176        );
4177
4178        // And the default config stays byte-faithful to the value's scale.
4179        let out = canonicalize_directives(
4180            parsed.directives.iter().map(|d| &d.value),
4181            &FormatConfig::default(),
4182        )
4183        .expect("canonicalizes");
4184        assert!(out.contains("1234.5 USD"), "own scale preserved: {out}");
4185    }
4186
4187    /// Padded COST and PRICE-annotation numbers survive the shim's
4188    /// pass 2 (the CST re-canonicalization preserves trailing zeros),
4189    /// and the pass-1 emitter and the shim agree on every rendered
4190    /// number — the cross-surface drift guard the `render_number` doc
4191    /// claims (deep review of #1807).
4192    #[test]
4193    fn canonicalize_pads_costs_and_prices_and_agrees_with_pass_one() {
4194        use rustledger_core::format::FormatConfig;
4195
4196        let source = "2024-01-10 * \"buy\"\n  Assets:Broker  2 AAPL {150 USD} @ 155.5 USD\n  Assets:Cash  -310.00 USD\n";
4197        let parsed = crate::parse(source);
4198        assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
4199
4200        let mut ctx = rustledger_core::DisplayContext::new();
4201        ctx.set_fixed_precision("USD", 2);
4202        let config = FormatConfig {
4203            number_display: Some(ctx),
4204            ..FormatConfig::default()
4205        };
4206
4207        let pass_one = rustledger_core::format::format_directives(
4208            parsed.directives.iter().map(|d| &d.value),
4209            &config,
4210        );
4211        let canonical =
4212            canonicalize_directives(parsed.directives.iter().map(|d| &d.value), &config)
4213                .expect("padded cost/price text must survive the reparse");
4214
4215        for padded in ["{150.00 USD}", "@ 155.50 USD"] {
4216            assert!(
4217                pass_one.contains(padded),
4218                "pass-1 emitter pads {padded}: {pass_one}"
4219            );
4220            assert!(
4221                canonical.contains(padded),
4222                "the shim preserves the padded {padded}: {canonical}"
4223            );
4224        }
4225    }
4226
4227    #[test]
4228    fn canonicalize_directives_roundtrips_every_synthesized_directive() {
4229        // For each canonical-form fixture: parse → take the typed
4230        // directives → run them through canonicalize_directives →
4231        // re-parse the canonical text → assert the parser reports
4232        // zero errors and the directive count is preserved.
4233        //
4234        // This is the proper end-to-end test of the two-pass shim
4235        // the FFI format.entry and rledger add/extract commands all
4236        // depend on. Without it, a future Directive variant added
4237        // to rustledger-core without matching coverage in
4238        // cst::format would silently round-trip to truncated text.
4239        //
4240        // Counter + assertion guards against silent-skip: if the
4241        // guard at the top of the loop ever filters too many
4242        // fixtures (e.g. a parser regression that drops directives
4243        // from previously-clean fixtures), the test fails instead
4244        // of silently passing with zero coverage.
4245        use rustledger_core::format::FormatConfig;
4246        let cfg = FormatConfig::default();
4247        let mut exercised = 0usize;
4248        for (name, src) in IDEMPOTENCE_MATRIX {
4249            let parsed = crate::parse(src);
4250            if parsed.errors.is_empty() && !parsed.directives.is_empty() {
4251                let dirs: Vec<&rustledger_core::Directive> =
4252                    parsed.directives.iter().map(|s| &s.value).collect();
4253                let formatted = super::canonicalize_directives(dirs.iter().copied(), &cfg)
4254                    .unwrap_or_else(|e| {
4255                        panic!("canonicalize_directives error on fixture `{name}`: {e}")
4256                    });
4257                let reparsed = crate::parse(&formatted);
4258                assert!(
4259                    reparsed.errors.is_empty(),
4260                    "round-trip parse errors on fixture `{name}`:\n--- formatted ---\n{formatted}\n--- errors ---\n{:?}",
4261                    reparsed.errors,
4262                );
4263                assert_eq!(
4264                    parsed.directives.len(),
4265                    reparsed.directives.len(),
4266                    "directive count drifted on fixture `{name}`\n--- formatted ---\n{formatted}",
4267                );
4268                exercised += 1;
4269            }
4270        }
4271        let expected = IDEMPOTENCE_MATRIX
4272            .len()
4273            .saturating_sub(ROUNDTRIP_KNOWN_ZERO_DIRECTIVE_FIXTURES);
4274        assert!(
4275            exercised >= expected,
4276            "only {exercised} fixtures exercised the round-trip body, \
4277             expected at least {expected} (= IDEMPOTENCE_MATRIX.len() - \
4278             {ROUNDTRIP_KNOWN_ZERO_DIRECTIVE_FIXTURES}). A parser \
4279             regression or a broken fixture is silently dropping coverage."
4280        );
4281    }
4282
4283    /// `SHEBANG` / `EMACS_DIRECTIVE` lines (`#!…` / `#+…` at line
4284    /// start) also count as comments for the LSP-CRLF state
4285    /// machine. A stray quote inside such a line used to flip
4286    /// `in_string=true` for the rest of the file just like the
4287    /// `;` / `%` comment case the round-3 fix covered.
4288    #[test]
4289    fn lf_to_crlf_outside_strings_handles_emacs_directive_with_quote() {
4290        let s = "#+title: \"My Book\n2024-01-01 open Assets:A\n";
4291        let out = lf_to_crlf_outside_strings(s);
4292        assert_eq!(out, "#+title: \"My Book\r\n2024-01-01 open Assets:A\r\n");
4293    }
4294
4295    #[test]
4296    fn lf_to_crlf_outside_strings_handles_shebang_with_quote() {
4297        let s = "#!shebang \"quote\n2024-01-01 open Assets:A\n";
4298        let out = lf_to_crlf_outside_strings(s);
4299        assert_eq!(out, "#!shebang \"quote\r\n2024-01-01 open Assets:A\r\n");
4300    }
4301
4302    /// `#` NOT at line start is a TAG / HASH token; the state
4303    /// machine must NOT treat it as a comment opener.
4304    #[test]
4305    fn lf_to_crlf_outside_strings_hash_mid_line_is_not_comment() {
4306        let s = "2024-01-15 * \"x\" #tag1\n  Assets:A 1 USD\n";
4307        let out = lf_to_crlf_outside_strings(s);
4308        // Every LF outside strings becomes CRLF — including the
4309        // one ending the tag-bearing line.
4310        assert!(out.contains("#tag1\r\n"), "got: {out:?}");
4311        assert!(out.ends_with("\r\n"), "got: {out:?}");
4312    }
4313
4314    /// Regression for Copilot #2 inline review on PR #1284: a
4315    /// previous `emit_amount_expression` dropped leading unary
4316    /// signs and parens, flipping the sign on
4317    /// `2024-01-15 balance Assets:A
4318    /// -1.00 USD` to `1.00 USD` — silent data corruption (a debit
4319    /// asserted as a credit). Byte-exact pins on every shape.
4320    #[test]
4321    fn balance_price_preserve_leading_unary_and_parens() {
4322        // Bare leading minus on balance.
4323        let src = "2024-01-15 balance Assets:A -1.00 USD\n";
4324        assert_eq!(
4325            format_source(src),
4326            "2024-01-15 balance Assets:A -1.00 USD\n"
4327        );
4328
4329        // Bare leading minus on price (sign flip would change
4330        // every quote on the user's commodity).
4331        let src = "2024-01-15 price USD -1.00 EUR\n";
4332        assert_eq!(format_source(src), "2024-01-15 price USD -1.00 EUR\n");
4333
4334        // Leading parenthesized expression. The previous code
4335        // dropped the `(`, which made the trailing `)` unbalanced
4336        // AND made the first-CURRENCY scan find the wrong token.
4337        let src = "2024-01-15 balance Assets:A (1 + 2) USD\n";
4338        assert_eq!(
4339            format_source(src),
4340            "2024-01-15 balance Assets:A (1 + 2) USD\n"
4341        );
4342
4343        // Leading minus on a parenthesized arithmetic expression.
4344        let src = "2024-01-15 balance Assets:A -(1 + 2) USD\n";
4345        assert_eq!(
4346            format_source(src),
4347            "2024-01-15 balance Assets:A -(1 + 2) USD\n"
4348        );
4349    }
4350
4351    /// Regression for Copilot #1 inline review on PR #1284:
4352    /// `collect_trailing_comment` used `?` on the header-terminating
4353    /// NEWLINE, silently dropping same-line trailing comments at
4354    /// EOF when the file had no final newline. The canonical
4355    /// formatter restores the trailing newline, but the dropped
4356    /// comment was already gone — a real-world case for editors
4357    /// that don't insert a trailing newline on save.
4358    #[test]
4359    fn trailing_comment_preserved_at_eof_without_newline() {
4360        let src = "2024-01-15 open Assets:A ; trailing";
4361        assert_eq!(format_source(src), "2024-01-15 open Assets:A ; trailing\n");
4362    }
4363
4364    #[test]
4365    fn try_format_source_returns_ok_on_clean_input() {
4366        let src = "2024-01-15 open Assets:Cash\n";
4367        let out = super::try_format_source(src).expect("clean input should format");
4368        assert_eq!(out, super::format_source(src));
4369    }
4370
4371    #[test]
4372    fn try_format_source_returns_err_on_parse_error() {
4373        // Bare `unparsable` text triggers parser errors. The
4374        // helper must surface them instead of silently emitting
4375        // canonical text around a broken file.
4376        let src = "this is not a directive at all\n";
4377        let err = super::try_format_source(src).expect_err("garbage should error");
4378        assert!(!err.is_empty(), "errors must not be empty");
4379    }
4380
4381    #[test]
4382    fn cr_outside_strings_present_distinguishes_in_string_cr() {
4383        // CR inside a multi-line string literal must NOT count —
4384        // the formatter wouldn't fold it.
4385        let in_string_only = "2024-01-15 note Assets:Bank \"line1\r\nline2\"\n";
4386        assert!(!super::cr_outside_strings_present(in_string_only));
4387
4388        // CR outside any string literal (CRLF line terminator)
4389        // counts — that's what crlf_to_lf_outside_strings would
4390        // fold.
4391        let crlf_terminator = "2024-01-01 open Assets:A\r\n";
4392        assert!(super::cr_outside_strings_present(crlf_terminator));
4393
4394        // No `\r` at all — fast path.
4395        let lf_only = "2024-01-01 open Assets:A\n";
4396        assert!(!super::cr_outside_strings_present(lf_only));
4397
4398        // CR inside a `;` comment is outside any string and counts.
4399        // (Beancount lexer's comment regex excludes the newline, so
4400        // the comment region ends at `\r`; either way, the predicate
4401        // says "yes, the formatter would fold this byte".)
4402        let comment_with_cr = "; comment with \"quote\rstuff\n";
4403        assert!(super::cr_outside_strings_present(comment_with_cr));
4404    }
4405
4406    #[test]
4407    fn canonicalize_directives_directive_count_mismatch_is_reported() {
4408        // Drive the new DirectiveCountMismatch error variant.
4409        // Today's Directive variants all round-trip with matching
4410        // counts, so this test pins the Display rendering of the
4411        // variant (the user-facing message). The positive-count-
4412        // match path is exercised by
4413        // `canonicalize_directives_positive_count_check` below.
4414        let err = super::CanonicalizeError::DirectiveCountMismatch {
4415            input: 3,
4416            reparsed: 2,
4417        };
4418        let msg = format!("{err}");
4419        assert!(msg.contains("3 directive(s)"), "got: {msg}");
4420        assert!(msg.contains("2 survived"), "got: {msg}");
4421        assert!(msg.contains("rledger bug"), "got: {msg}");
4422    }
4423
4424    /// Single source of truth for the variant → fixture mapping
4425    /// used by both the compile-time exhaustiveness check
4426    /// ([`_directive_variant_fixture_coverage`]) and the runtime
4427    /// semantic check
4428    /// ([`directive_variant_fixture_names_resolve_in_matrix`]).
4429    ///
4430    /// Each tuple is `(VariantName, fixture_name)`. The
4431    /// `VariantName` half is the string the runtime check uses to
4432    /// confirm the fixture parses to that variant; the
4433    /// `fixture_name` half is what the compile-time match returns
4434    /// for the same variant. A future `Directive::Hedge` variant
4435    /// only ships with canonical-form coverage if BOTH a new
4436    /// arm is added to the compile-time match AND a row here
4437    /// names a fixture that actually produces a `Hedge` on parse.
4438    const DIRECTIVE_VARIANT_FIXTURE_MAP: &[(&str, &str)] = &[
4439        ("Transaction", "transaction_with_cost_and_price"),
4440        ("Balance", "balance_with_arithmetic_and_tolerance"),
4441        ("Open", "only_directive"),
4442        ("Close", "close_directive"),
4443        ("Commodity", "commodity_directive"),
4444        ("Pad", "pad_directive"),
4445        ("Event", "event_directive"),
4446        ("Query", "query_directive"),
4447        ("Note", "note_directive"),
4448        ("Document", "document_directive"),
4449        ("Price", "price_with_thousands_separator"),
4450        ("Custom", "custom_with_date_value"),
4451    ];
4452
4453    /// Lookup helper: variant tag string → fixture name. Used by
4454    /// the compile-time match below. Panics if the variant is not
4455    /// in the map (which would be an internal-consistency bug, not
4456    /// a user-facing case).
4457    const fn fixture_for_variant(tag: &str) -> &'static str {
4458        let mut i = 0;
4459        while i < DIRECTIVE_VARIANT_FIXTURE_MAP.len() {
4460            let (v, f) = DIRECTIVE_VARIANT_FIXTURE_MAP[i];
4461            // const_str equality: compare byte slices.
4462            let v_bytes = v.as_bytes();
4463            let t_bytes = tag.as_bytes();
4464            if v_bytes.len() == t_bytes.len() {
4465                let mut k = 0;
4466                let mut eq = true;
4467                while k < v_bytes.len() {
4468                    if v_bytes[k] != t_bytes[k] {
4469                        eq = false;
4470                        break;
4471                    }
4472                    k += 1;
4473                }
4474                if eq {
4475                    return f;
4476                }
4477            }
4478            i += 1;
4479        }
4480        panic!("DIRECTIVE_VARIANT_FIXTURE_MAP missing entry for variant tag");
4481    }
4482
4483    /// Compile-time check that every `rustledger_core::Directive`
4484    /// variant has at least one source-text fixture in
4485    /// [`IDEMPOTENCE_MATRIX`] exercising its emit path. The
4486    /// function NEVER runs — its body is an exhaustive `match` over
4487    /// the `Directive` enum. Adding a new variant breaks
4488    /// compilation unless the author adds a match arm referencing
4489    /// `fixture_for_variant("NewVariantName")`, AND adds a row to
4490    /// [`DIRECTIVE_VARIANT_FIXTURE_MAP`] naming the fixture. The
4491    /// runtime test then confirms the fixture parses to a directive
4492    /// of that variant.
4493    ///
4494    /// The non-`Directive` pragma-style directives (Pushtag,
4495    /// Poptag, Pushmeta, Popmeta, options, includes, plugins)
4496    /// don't appear in the typed `Directive` enum; they're covered
4497    /// by separate fixtures whose names map directly into
4498    /// `IDEMPOTENCE_MATRIX`.
4499    #[allow(dead_code)]
4500    fn _directive_variant_fixture_coverage(d: &rustledger_core::Directive) -> &'static str {
4501        match d {
4502            rustledger_core::Directive::Transaction(_) => fixture_for_variant("Transaction"),
4503            rustledger_core::Directive::Balance(_) => fixture_for_variant("Balance"),
4504            rustledger_core::Directive::Open(_) => fixture_for_variant("Open"),
4505            rustledger_core::Directive::Close(_) => fixture_for_variant("Close"),
4506            rustledger_core::Directive::Commodity(_) => fixture_for_variant("Commodity"),
4507            rustledger_core::Directive::Pad(_) => fixture_for_variant("Pad"),
4508            rustledger_core::Directive::Event(_) => fixture_for_variant("Event"),
4509            rustledger_core::Directive::Query(_) => fixture_for_variant("Query"),
4510            rustledger_core::Directive::Note(_) => fixture_for_variant("Note"),
4511            rustledger_core::Directive::Document(_) => fixture_for_variant("Document"),
4512            rustledger_core::Directive::Price(_) => fixture_for_variant("Price"),
4513            rustledger_core::Directive::Custom(_) => fixture_for_variant("Custom"),
4514        }
4515    }
4516
4517    #[test]
4518    fn directive_variant_fixture_names_resolve_in_matrix() {
4519        // Runtime mirror of the compile-time match above:
4520        //
4521        //   (1) every fixture name appears in IDEMPOTENCE_MATRIX;
4522        //   (2) parsing that fixture produces AT LEAST one
4523        //       directive of the variant the map row names.
4524        //
4525        // Without check (2) the compile-time match is satisfied by
4526        // any fixture-name string — a future contributor adding
4527        // a row `("Hedge", "only_comment")` would compile, the
4528        // lookup would resolve, and Hedge would ship with zero
4529        // canonical-form coverage. The semantic check rejects that
4530        // by parsing the named fixture and inspecting the
4531        // directive variant.
4532        use rustledger_core::Directive;
4533        fn matches_variant(d: &Directive, expected: &str) -> bool {
4534            matches!(
4535                (d, expected),
4536                (Directive::Transaction(_), "Transaction")
4537                    | (Directive::Balance(_), "Balance")
4538                    | (Directive::Open(_), "Open")
4539                    | (Directive::Close(_), "Close")
4540                    | (Directive::Commodity(_), "Commodity")
4541                    | (Directive::Pad(_), "Pad")
4542                    | (Directive::Event(_), "Event")
4543                    | (Directive::Query(_), "Query")
4544                    | (Directive::Note(_), "Note")
4545                    | (Directive::Document(_), "Document")
4546                    | (Directive::Price(_), "Price")
4547                    | (Directive::Custom(_), "Custom")
4548            )
4549        }
4550        for (variant, name) in DIRECTIVE_VARIANT_FIXTURE_MAP {
4551            let (_, src) = IDEMPOTENCE_MATRIX
4552                .iter()
4553                .find(|(n, _)| *n == *name)
4554                .unwrap_or_else(|| {
4555                    panic!(
4556                        "fixture `{name}` is named by \
4557                     DIRECTIVE_VARIANT_FIXTURE_MAP but missing from \
4558                     IDEMPOTENCE_MATRIX"
4559                    )
4560                });
4561            let parsed = crate::parse(src);
4562            let found = parsed
4563                .directives
4564                .iter()
4565                .any(|s| matches_variant(&s.value, variant));
4566            assert!(
4567                found,
4568                "fixture `{name}` is mapped to `Directive::{variant}` by \
4569                 DIRECTIVE_VARIANT_FIXTURE_MAP, but parsing it produced \
4570                 no directive of that variant (got {:?}). This silently \
4571                 leaves the variant without canonical-form coverage.",
4572                parsed
4573                    .directives
4574                    .iter()
4575                    .map(|s| std::mem::discriminant(&s.value))
4576                    .collect::<Vec<_>>()
4577            );
4578        }
4579    }
4580
4581    /// Coverage-mirror check: every `matrix_name` half of the
4582    /// `MIRROR_PAIRS` table in the file-pair integration test
4583    /// (`crates/rustledger-parser/tests/format_compat.rs`) must
4584    /// exist as an entry in [`IDEMPOTENCE_MATRIX`]. The
4585    /// integration test asserts the symmetric half (every
4586    /// `file_pair_name` exists as a directory under `cases/`).
4587    /// Together the two checks guarantee that retiring a
4588    /// bug-class fixture from EITHER side forces an edit to
4589    /// `MIRROR_PAIRS` - which surfaces in review and prevents
4590    /// the silent one-sided drop the README's "two audience" split
4591    /// design would otherwise admit.
4592    ///
4593    /// Hand-maintained copy of the matrix half of the table.
4594    /// Editing `MIRROR_PAIRS` in the integration test requires
4595    /// editing this list too; the test below fires otherwise.
4596    #[test]
4597    fn idempotence_matrix_mirrors_format_compat_pairs() {
4598        const MIRROR_PAIRS_MATRIX_HALF: &[&str] = &[
4599            "balance_leading_unary_minus",
4600            "balance_leading_parenthesized_expression",
4601            "price_leading_unary_minus",
4602            "cost_spec_with_negative",
4603            "cost_spec_with_comma_and_date",
4604            "transaction_with_per_unit_plus_total_cost",
4605            "metadata_unary_minus",
4606            "metadata_arithmetic",
4607            "non_latin_account_name",
4608            "posting_with_trailing_comment",
4609            "multiline_note_string",
4610            "comment_containing_quote",
4611            "transaction_with_tags_and_links",
4612            "custom_with_date_value",
4613            "options_and_includes",
4614            "balance_assertion_with_meta",
4615            "crlf_input",
4616        ];
4617        let matrix_names: std::collections::BTreeSet<&str> =
4618            IDEMPOTENCE_MATRIX.iter().map(|(name, _)| *name).collect();
4619        let missing: Vec<&&str> = MIRROR_PAIRS_MATRIX_HALF
4620            .iter()
4621            .filter(|name| !matrix_names.contains(*name))
4622            .collect();
4623        assert!(
4624            missing.is_empty(),
4625            "IDEMPOTENCE_MATRIX is missing the matrix-half of MIRROR_PAIRS: {missing:?}. \
4626             Either re-add the entry to IDEMPOTENCE_MATRIX, or edit MIRROR_PAIRS in \
4627             tests/format_compat.rs to retire the pair from BOTH sides.",
4628        );
4629    }
4630
4631    /// Property test: the `SourceState` classification used by the
4632    /// line-ending helpers must agree with the lexer's
4633    /// classification on every byte of a corpus of fixtures.
4634    ///
4635    /// Concretely: for every byte offset in every fixture, the
4636    /// state machine's `InString` periods MUST line up with the
4637    /// lexer's STRING token spans, and its `InComment` periods MUST
4638    /// line up with the union of COMMENT / SHEBANG /
4639    /// `EMACS_DIRECTIVE` token spans. A divergence — e.g. the lexer
4640    /// gains a new comment lexeme that the state machine treats as
4641    /// code — fails this test instead of silently mutating user
4642    /// bytes inside the new lexeme on a line-ending round-trip.
4643    #[test]
4644    fn source_state_classification_agrees_with_lexer() {
4645        use crate::logos_lexer::{Token, tokenize_lossless};
4646
4647        for (name, src) in IDEMPOTENCE_MATRIX {
4648            // Run the lexer to get authoritative classification of
4649            // each token. Build a per-byte map of expected state.
4650            let tokens = tokenize_lossless(src);
4651            let mut expected = vec![SourceState::Code; src.len()];
4652            for (token, span) in &tokens {
4653                let classify = match token {
4654                    Token::String(_) => Some(SourceState::InString),
4655                    Token::Comment(_) | Token::Shebang(_) | Token::EmacsDirective(_) => {
4656                        Some(SourceState::InComment)
4657                    }
4658                    _ => None,
4659                };
4660                if let Some(state) = classify {
4661                    for byte in &mut expected[span.start..span.end] {
4662                        *byte = state;
4663                    }
4664                }
4665            }
4666
4667            // Run the state-machine classifier and compare per
4668            // byte. We skip ONLY the exact bytes where a
4669            // transition fires — the lexer includes those bytes
4670            // inside the resulting token while the state machine
4671            // tags them with the PRE-transition state (the
4672            // 'opener' is still Code, the closing LF is still
4673            // InComment). Tracking the transition indices
4674            // explicitly (rather than skipping every `"`/`;`/`%`
4675            // / newline byte) means a state-machine bug at any
4676            // non-transition `"`/`;`/`%` byte — e.g. inside a
4677            // comment or string — surfaces as a real failure
4678            // instead of being silently masked.
4679            let (actual, transitions) = classify_source_bytes_with_transitions(src);
4680
4681            for (i, (&want, &got)) in expected.iter().zip(actual.iter()).enumerate() {
4682                if transitions.contains(&i) {
4683                    continue;
4684                }
4685                assert_eq!(
4686                    want,
4687                    got,
4688                    "state-machine / lexer disagreement on fixture `{name}` \
4689                     at byte {i} ({:?}): lexer said {want:?}, state machine said {got:?}",
4690                    src.as_bytes()[i] as char
4691                );
4692            }
4693        }
4694    }
4695
4696    /// Walk `s` through the same state-machine logic the
4697    /// line-ending helpers use, returning a per-byte classification
4698    /// AND the set of byte indices where a state transition
4699    /// fired. The transition indices are the ONLY bytes where the
4700    /// state machine and the lexer can legitimately disagree (the
4701    /// off-by-one at opener / closer / terminator); callers
4702    /// comparing against the lexer should skip exactly those
4703    /// indices and assert agreement everywhere else.
4704    fn classify_source_bytes_with_transitions(
4705        s: &str,
4706    ) -> (Vec<SourceState>, std::collections::HashSet<usize>) {
4707        let (body, bom_len) = match s.strip_prefix('\u{FEFF}') {
4708            Some(rest) => (rest, '\u{FEFF}'.len_utf8()),
4709            None => (s, 0),
4710        };
4711        let mut out: Vec<SourceState> = vec![SourceState::Code; s.len()];
4712        let mut transitions = std::collections::HashSet::new();
4713        let mut chars = body.char_indices().peekable();
4714        let mut state = SourceState::Code;
4715        let mut prev_was_backslash = false;
4716        while let Some((rel_i, ch)) = chars.next() {
4717            let i = bom_len + rel_i;
4718            let peek = chars.peek().map(|&(_, c)| c);
4719            // Classify THIS byte under the state BEFORE advancing.
4720            for byte in &mut out[i..i + ch.len_utf8()] {
4721                *byte = state;
4722            }
4723            let prev_state = state;
4724            let next_state = advance_source_state(ch, peek, state, &mut prev_was_backslash);
4725            // Record only OPENING transitions and the comment-
4726            // closing newline, where the state machine and lexer
4727            // legitimately disagree on this single byte:
4728            //   - Code → InString : opening `"` is Code-side but
4729            //     the lexer puts it inside the STRING token.
4730            //   - Code → InComment: opening `;` / `%` / `#!` /
4731            //     `#+` is Code-side but the lexer puts it inside
4732            //     the COMMENT / SHEBANG / EMACS_DIRECTIVE token.
4733            //   - InComment → Code: the `\n` ending the comment is
4734            //     classified InComment by the state machine but
4735            //     sits OUTSIDE the comment token (the lexer's
4736            //     `[^\n\r]*` excludes it).
4737            // The InString → Code transition (closing `"`) is NOT
4738            // a disagreement: the state machine still tags that
4739            // byte as InString (pre-transition), and the lexer
4740            // includes the closing `"` inside the STRING token.
4741            // Skipping it would silently mask a real bug.
4742            if next_state != state {
4743                let opening = matches!(prev_state, SourceState::Code)
4744                    && matches!(next_state, SourceState::InString | SourceState::InComment);
4745                let comment_close = matches!(prev_state, SourceState::InComment)
4746                    && matches!(next_state, SourceState::Code);
4747                if opening || comment_close {
4748                    transitions.insert(i);
4749                    // For a `#!` or `#+` opener the lexer's token
4750                    // span begins at the `#`, so the second byte
4751                    // (`!` / `+`) is also a "before the lexer's
4752                    // token start" byte the state machine tags as
4753                    // Code. Record it too.
4754                    if matches!(ch, '#') && matches!(peek, Some('!' | '+')) {
4755                        transitions.insert(i + 1);
4756                    }
4757                }
4758            }
4759            state = next_state;
4760        }
4761        (out, transitions)
4762    }
4763
4764    #[test]
4765    fn canonicalize_directives_positive_count_check() {
4766        // Pin the success path of the count check: pass a real
4767        // multi-directive input through canonicalize_directives and
4768        // assert that the output round-trips to the SAME directive
4769        // count. Without this test, a regression that always
4770        // returned CountMismatch (e.g. `==` instead of `!=` on the
4771        // count comparison) would be caught only on production
4772        // calls, not in CI. Together with the Display test above,
4773        // this gives coverage of both arms of the count guard.
4774        use rustledger_core::format::FormatConfig;
4775        let cfg = FormatConfig::default();
4776        let src = "2024-01-01 open Assets:Cash\n2024-01-02 open Assets:Bank\n2024-01-03 close Assets:Cash\n";
4777        let parsed = crate::parse(src);
4778        assert_eq!(
4779            parsed.directives.len(),
4780            3,
4781            "fixture must parse to 3 directives"
4782        );
4783        let dirs: Vec<&rustledger_core::Directive> =
4784            parsed.directives.iter().map(|s| &s.value).collect();
4785        let formatted = super::canonicalize_directives(dirs.iter().copied(), &cfg)
4786            .expect("canonicalize_directives should succeed on this input");
4787        let reparsed = crate::parse(&formatted);
4788        assert_eq!(
4789            reparsed.directives.len(),
4790            3,
4791            "count check accepted but round-trip dropped directives: {formatted}"
4792        );
4793    }
4794
4795    // ---- format_node_range -----------------------------------------
4796
4797    /// Parse `source` via the same pipeline `format_source` uses
4798    /// so the resulting `SyntaxNode`'s `TextRange`s are in the
4799    /// same byte frame `format_node_range`'s `range` argument
4800    /// is expected to use (post-BOM-strip, post-CRLF-to-LF).
4801    /// Returns the syntax node + the normalized source text so
4802    /// tests can compute byte offsets by `.find()`.
4803    fn parse_for_range(source: &str) -> (crate::SyntaxNode, String) {
4804        let (stripped, _bom) = crate::bom::strip_leading(source);
4805        let normalized = crlf_to_lf_outside_strings(stripped).to_string();
4806        let sf = SourceFile::parse(&normalized);
4807        (sf.syntax().clone(), normalized)
4808    }
4809
4810    fn ts(n: usize) -> rowan::TextSize {
4811        rowan::TextSize::try_from(n).expect("offset fits TextSize")
4812    }
4813
4814    /// For any selection covering the whole file, the result text
4815    /// equals `format_node(node)`. Pins the round-trip invariant
4816    /// the design rests on: range formatting is the whole-file
4817    /// formatter restricted to a range, not a parallel canonical
4818    /// form.
4819    #[test]
4820    fn format_node_range_full_range_matches_format_node() {
4821        let source = "\
48222024-01-01 open Assets:Bank USD
48232024-01-15 * \"Coffee\"
4824  Assets:Bank  -5.00 USD
4825  Expenses:Food
48262024-01-31 close Assets:Bank
4827";
4828        let (node, src) = parse_for_range(source);
4829        let full = rowan::TextRange::new(ts(0), ts(src.len()));
4830        let (snap, formatted) =
4831            format_node_range(&node, full).expect("full range must include all directives");
4832        assert_eq!(
4833            snap,
4834            rowan::TextRange::new(ts(0), ts(src.len())),
4835            "snap range should be the whole file's textual span"
4836        );
4837        assert_eq!(formatted, format_node(&node));
4838    }
4839
4840    /// A selection that hits only inter-directive whitespace
4841    /// (no directive intersected, no top-level comment
4842    /// intersected) returns `None` — the caller surfaces this
4843    /// as an empty `Vec<TextEdit>`.
4844    #[test]
4845    fn format_node_range_trivia_only_returns_none() {
4846        // The phase-2.0 Directive-Terminator Rule puts every
4847        // inter-directive blank line on the next directive's
4848        // leading trivia, so any byte index between two
4849        // directives is INSIDE the next directive's text_range.
4850        // The only way to reach a truly trivia-only selection
4851        // is a source that has no directives at all (file is
4852        // pure whitespace). That is the case worth pinning —
4853        // the LSP handler maps `None` to an empty
4854        // `Vec<TextEdit>`, which is exactly the right "nothing
4855        // to format" response for a whitespace-only buffer.
4856        let (empty, _) = parse_for_range("\n\n\n");
4857        let sel = rowan::TextRange::new(ts(0), ts(3));
4858        assert!(format_node_range(&empty, sel).is_none());
4859    }
4860
4861    /// Selecting only the first directive's content (the
4862    /// transaction) snaps to that directive and the second
4863    /// directive is left out of both the snap and the output.
4864    #[test]
4865    fn format_node_range_single_directive() {
4866        let source = "\
48672024-01-01 open Assets:Bank USD
48682024-01-15 * \"Coffee\"
4869  Assets:Bank  -5.00 USD
4870  Expenses:Food
4871";
4872        let (node, src) = parse_for_range(source);
4873        // Position the selection inside the `open` line. Use
4874        // the byte offset of the word `open` so the test is
4875        // robust to whitespace changes in the fixture.
4876        let open_byte = src.find("open").expect("fixture contains 'open'");
4877        let sel = rowan::TextRange::new(ts(open_byte), ts(open_byte + "open".len()));
4878        let (snap, formatted) = format_node_range(&node, sel).expect("intersects 1 directive");
4879
4880        // Snap should start at byte 0 (the open directive's
4881        // text_range starts at the file's start) and end at
4882        // the open directive's terminating newline.
4883        let open_end = src.find('\n').expect("first directive has terminator") + 1;
4884        assert_eq!(snap.start(), ts(0));
4885        assert_eq!(snap.end(), ts(open_end));
4886        // Output is exactly the open directive's canonical form
4887        // + its `\n` terminator. No second-directive content.
4888        assert_eq!(formatted, "2024-01-01 open Assets:Bank USD\n");
4889    }
4890
4891    /// Multi-directive selection: the author's inter-directive
4892    /// blank lines are preserved (a blank stays a blank; grouped
4893    /// stays grouped), matching whole-file formatting (#1325).
4894    #[test]
4895    fn format_node_range_multi_directive_preserves_blank_lines() {
4896        // #1325: range formatting preserves the author's inter-directive
4897        // blank lines, identically to whole-file formatting. A source
4898        // with a blank between the two directives keeps it...
4899        let spaced = "\
49002024-01-01 open Assets:Bank USD
4901
49022024-01-31 close Assets:Bank
4903";
4904        let (node, src) = parse_for_range(spaced);
4905        let sel = rowan::TextRange::new(ts(0), ts(src.len()));
4906        let (snap, formatted) = format_node_range(&node, sel).expect("intersects 2 directives");
4907        assert_eq!(snap, rowan::TextRange::new(ts(0), ts(src.len())));
4908        assert_eq!(formatted, spaced, "the blank separator must be preserved");
4909
4910        // ...and a grouped source (no blank) stays grouped, rather than
4911        // having a separator inserted.
4912        let grouped = "\
49132024-01-01 open Assets:Bank USD
49142024-01-31 close Assets:Bank
4915";
4916        let (node2, src2) = parse_for_range(grouped);
4917        let sel2 = rowan::TextRange::new(ts(0), ts(src2.len()));
4918        let (_, formatted2) = format_node_range(&node2, sel2).expect("intersects 2 directives");
4919        assert_eq!(formatted2, grouped, "grouped directives must stay grouped");
4920    }
4921
4922    #[test]
4923    fn format_node_range_first_directive_in_snap_keeps_leading_blank() {
4924        // Regression (Copilot review of #1325): when the selection
4925        // covers only the SECOND directive, its predecessor sits outside
4926        // the snap, but the blank line between them is the second
4927        // directive's leading trivia and therefore inside the snapped
4928        // range. Range formatting must re-emit it, not silently delete
4929        // the blank line above the selection.
4930        let source = "2024-01-01 open Assets:Bank USD\n\n2024-01-31 close Assets:Bank\n";
4931        let (node, src) = parse_for_range(source);
4932        // Cursor inside the second (close) directive only.
4933        let close_byte = src.find("close").expect("fixture has 'close'");
4934        let cursor = rowan::TextRange::new(ts(close_byte), ts(close_byte));
4935        let (snap, formatted) = format_node_range(&node, cursor).expect("intersects close");
4936        // The leading blank is preserved in the replacement text...
4937        assert_eq!(formatted, "\n2024-01-31 close Assets:Bank\n");
4938        // ...so applying the edit leaves the blank line intact.
4939        let mut result = src;
4940        result.replace_range(
4941            usize::from(snap.start())..usize::from(snap.end()),
4942            &formatted,
4943        );
4944        assert_eq!(
4945            result, source,
4946            "range-formatting the second directive must not delete the blank above it"
4947        );
4948    }
4949
4950    /// Cursor-only (zero-width) selection inside a directive
4951    /// snaps to that directive. The cursor convention: inside
4952    /// or at the directive's start byte counts as inside;
4953    /// boundary at the directive's end belongs to the next
4954    /// child.
4955    #[test]
4956    fn format_node_range_cursor_inside_directive() {
4957        let source = "\
49582024-01-01 open Assets:Bank USD
49592024-01-31 close Assets:Bank
4960";
4961        let (node, src) = parse_for_range(source);
4962        // Cursor on the `c` of `close` (line 2 of the fixture).
4963        let close_byte = src.find("close").expect("fixture has 'close'");
4964        let cursor = rowan::TextRange::new(ts(close_byte), ts(close_byte));
4965        let (snap, formatted) = format_node_range(&node, cursor).expect("intersects close");
4966        // Snap starts at the close directive's text_range start.
4967        // Per Directive-Terminator Rule the second directive
4968        // OWNS the leading inter-directive trivia — so snap
4969        // starts immediately after the first directive's
4970        // terminator newline.
4971        let close_dir_start = src
4972            .find("\n2024-01-31")
4973            .map(|n| n + 1)
4974            .expect("close directive starts on its own line");
4975        assert_eq!(snap.start(), ts(close_dir_start));
4976        assert_eq!(snap.end(), ts(src.len()));
4977        assert_eq!(formatted, "2024-01-31 close Assets:Bank\n");
4978    }
4979
4980    /// Cursor exactly at the start of a directive snaps to
4981    /// that directive (start-boundary inclusion rule).
4982    #[test]
4983    fn format_node_range_cursor_at_directive_start_includes_directive() {
4984        let source = "\
49852024-01-01 open Assets:Bank USD
49862024-01-31 close Assets:Bank
4987";
4988        let (node, _src) = parse_for_range(source);
4989        // Cursor at byte 0 = start of first directive.
4990        let cursor = rowan::TextRange::new(ts(0), ts(0));
4991        let (_snap, formatted) = format_node_range(&node, cursor).expect("intersects open");
4992        // Only the OPEN should be formatted, not the close.
4993        assert!(formatted.starts_with("2024-01-01 open"));
4994        assert!(!formatted.contains("close"));
4995    }
4996
4997    /// Selection containing a top-level standalone comment
4998    /// (file-leading or between-directive comment that the
4999    /// trivia attachment policy puts on `SOURCE_FILE`) includes
5000    /// the comment in both the snap and the output.
5001    #[test]
5002    fn format_node_range_includes_top_level_comments() {
5003        let source = "\
5004; header
50052024-01-01 open Assets:Bank USD
5006";
5007        let (node, src) = parse_for_range(source);
5008        let sel = rowan::TextRange::new(ts(0), ts(src.len()));
5009        let (snap, formatted) = format_node_range(&node, sel).expect("intersects both");
5010        assert_eq!(snap, rowan::TextRange::new(ts(0), ts(src.len())));
5011        // Header comment, then directive on the next line. No
5012        // canonical blank between a file-level comment group
5013        // and a directive (matches format_node's policy).
5014        assert_eq!(formatted, "; header\n2024-01-01 open Assets:Bank USD\n");
5015    }
5016
5017    /// A selection that lands entirely inside an `ERROR_NODE`
5018    /// (no Directive intersected) returns None. Matches
5019    /// `format_node`'s policy of skipping `ERROR_NODE` children
5020    /// at the top level.
5021    #[test]
5022    fn format_node_range_error_node_only_returns_none() {
5023        // `}}}` at top level isn't a directive — the parser
5024        // wraps it in an ERROR_NODE.
5025        let source = "}}}\n";
5026        let (node, src) = parse_for_range(source);
5027        let sel = rowan::TextRange::new(ts(0), ts(src.len()));
5028        assert!(format_node_range(&node, sel).is_none());
5029    }
5030
5031    /// Past-EOF selection still works: the snap clamps to the
5032    /// last child that intersects within the file. (rowan's
5033    /// `TextRange` is bounded by usize but `format_node_range`
5034    /// doesn't validate `range` against file length — bytes past
5035    /// EOF can never intersect any child, so the rule is
5036    /// degenerate but well-defined.)
5037    #[test]
5038    fn format_node_range_past_eof_clamps() {
5039        let source = "2024-01-01 open Assets:Bank USD\n";
5040        let (node, src) = parse_for_range(source);
5041        let past_eof = rowan::TextRange::new(ts(src.len()), ts(src.len() + 1000));
5042        // The cursor / range is past EOF — no child intersects.
5043        assert!(format_node_range(&node, past_eof).is_none());
5044        // But a range that STRADDLES EOF still snaps to the
5045        // last intersecting directive.
5046        let straddle = rowan::TextRange::new(ts(0), ts(src.len() + 1000));
5047        let (snap, formatted) = format_node_range(&node, straddle).expect("intersects open");
5048        assert_eq!(snap, rowan::TextRange::new(ts(0), ts(src.len())));
5049        assert_eq!(formatted, "2024-01-01 open Assets:Bank USD\n");
5050    }
5051
5052    /// A cursor inside a posting (sub-directive position) snaps
5053    /// up to the enclosing transaction — the design pins
5054    /// "round to top-level directive boundaries, no finer."
5055    #[test]
5056    fn format_node_range_cursor_in_posting_snaps_to_transaction() {
5057        let source = "\
50582024-01-15 * \"Coffee\"
5059  Assets:Bank  -5.00 USD
5060  Expenses:Food
5061";
5062        let (node, src) = parse_for_range(source);
5063        // Position the cursor on the `B` of `Bank` in the
5064        // first posting.
5065        let bank_byte = src.find("Bank").expect("fixture has Bank");
5066        let cursor = rowan::TextRange::new(ts(bank_byte), ts(bank_byte));
5067        let (snap, _formatted) = format_node_range(&node, cursor).expect("intersects transaction");
5068        // Snap covers the WHOLE transaction (start of file
5069        // through final posting's newline).
5070        assert_eq!(snap.start(), ts(0));
5071        assert_eq!(snap.end(), ts(src.len()));
5072    }
5073
5074    /// Selection straddling an `ERROR_NODE` between two valid
5075    /// directives: snap range would cover the union (including
5076    /// `ERROR_NODE` bytes), so `format_node_range` returns
5077    /// `None` instead of silently deleting the error content.
5078    ///
5079    /// This is the deliberate divergence from `format_node`'s
5080    /// whole-file policy. `format_source(broken_source)` does
5081    /// drop `ERROR_NODE` content — but that path's callers
5082    /// (`rledger format` CLI, FFI `format.entry`) opt into
5083    /// content loss by invoking the canonical-form pipeline. The
5084    /// per-handler LSP `textDocument/rangeFormatting` path has no
5085    /// such opt-in, so it refuses to delete user content the
5086    /// parser couldn't classify. See the function's rustdoc for
5087    /// the per-handler asymmetry rationale.
5088    #[test]
5089    fn format_node_range_bails_when_snap_covers_error_node() {
5090        let source = "\
50912024-01-01 open Assets:Bank USD
5092}}}garbage{{{
50932024-01-31 close Assets:Bank
5094";
5095        let (node, src) = parse_for_range(source);
5096        let sel = rowan::TextRange::new(ts(0), ts(src.len()));
5097        assert!(
5098            format_node_range(&node, sel).is_none(),
5099            "selection covering both directives + ERROR_NODE between them must bail \
5100             to avoid silently deleting the garbage line — got Some output",
5101        );
5102    }
5103
5104    /// Selection that intersects only the FIRST valid directive
5105    /// in a broken file (no `ERROR_NODE` byte in the snap range)
5106    /// still formats. Pins that the `ERROR_NODE` bail is precisely
5107    /// scoped to the snap range, not to "the file has any
5108    /// `ERROR_NODE` at all".
5109    #[test]
5110    fn format_node_range_formats_directive_when_snap_does_not_cover_error_node() {
5111        let source = "\
51122024-01-01 open Assets:Bank USD
5113}}}garbage{{{
51142024-01-31 close Assets:Bank
5115";
5116        let (node, src) = parse_for_range(source);
5117        // Selection covers ONLY the open directive (first line +
5118        // its terminator). The ERROR_NODE on line 1 sits at byte
5119        // offset == open_end (length of first line including \n)
5120        // onward, OUTSIDE the snap range.
5121        let open_end = src.find('\n').expect("first directive has newline") + 1;
5122        let sel = rowan::TextRange::new(ts(0), ts(open_end));
5123        let (snap, formatted) =
5124            format_node_range(&node, sel).expect("selection covers only the open");
5125        assert_eq!(snap.start(), ts(0));
5126        assert_eq!(snap.end(), ts(open_end));
5127        assert_eq!(formatted, "2024-01-01 open Assets:Bank USD\n");
5128    }
5129
5130    /// `format_node_with_alignment(node, compute_alignment(sf))` is
5131    /// byte-identical to `format_node(node)`. Pins the cache
5132    /// contract: passing the correct alignment is a pure
5133    /// optimization, NOT a behavior change.
5134    #[test]
5135    fn format_node_equals_format_node_with_alignment() {
5136        let fixtures: &[(&str, &str)] = &[
5137            ("empty", ""),
5138            ("open only", "2024-01-01 open Assets:Bank USD\n"),
5139            (
5140                "single txn",
5141                "\
51422024-01-15 * \"Coffee\"
5143  Assets:Bank  -5.00 USD
5144  Expenses:Food
5145",
5146            ),
5147            (
5148                "multi txn varying widths",
5149                "\
51502024-01-15 * \"A\"
5151  Assets:Bank  -5.00 USD
5152  Expenses:Food
51532024-02-15 * \"B\"
5154  Assets:Investment:Long:Path  -123456.78 USD
5155  Expenses:Tax  100.00 USD
5156",
5157            ),
5158        ];
5159        for (label, source) in fixtures {
5160            let (node, _src) = parse_for_range(source);
5161            let source_file = SourceFile::cast(node.clone()).unwrap();
5162            let alignment = compute_alignment(&source_file, GroupingStyle::default());
5163            assert_eq!(
5164                format_node(&node),
5165                format_node_with_alignment(&node, alignment),
5166                "format_node_with_alignment must match format_node for {label}",
5167            );
5168        }
5169    }
5170
5171    /// `format_node_range_with_alignment(node, range, compute_alignment(sf))`
5172    /// matches `format_node_range(node, range)` byte-identically.
5173    /// Same shape as the previous test, for the range path.
5174    #[test]
5175    fn format_node_range_matches_format_node_range_with_alignment() {
5176        let source = "\
51772024-01-15 * \"A\"
5178  Assets:Bank  -5.00 USD
5179  Expenses:Food
51802024-02-15 * \"B\"
5181  Assets:Investment:Long:Path  -123456.78 USD
5182  Expenses:Tax  100.00 USD
5183";
5184        let (node, src) = parse_for_range(source);
5185        let source_file = SourceFile::cast(node.clone()).unwrap();
5186        let alignment = compute_alignment(&source_file, GroupingStyle::default());
5187        // Pin the equivalence on three ranges: whole file,
5188        // cursor inside the first transaction, cursor inside the
5189        // second.
5190        let sels = [
5191            rowan::TextRange::new(ts(0), ts(src.len())),
5192            rowan::TextRange::new(ts(0), ts(10)),
5193            rowan::TextRange::new(ts(src.len() - 10), ts(src.len())),
5194        ];
5195        for sel in sels {
5196            let uncached = format_node_range(&node, sel);
5197            let cached = format_node_range_with_alignment(&node, sel, alignment);
5198            assert_eq!(
5199                uncached, cached,
5200                "format_node_range_with_alignment must match \
5201                 format_node_range for range {sel:?}",
5202            );
5203        }
5204    }
5205
5206    /// The cached [`crate::ParseResult::alignment`] value matches what
5207    /// `format_node` would compute on the parsed tree. End-to-end
5208    /// regression: an LSP caller passing `parse_result.alignment()`
5209    /// to `format_node_with_alignment` produces the same output
5210    /// as the bare `format_node` (uncached path).
5211    #[test]
5212    fn parse_result_alignment_drives_identical_format_output() {
5213        let source = "\
52142024-01-15 * \"Coffee\"
5215  Assets:Bank  -5.00 USD
5216  Expenses:Food
5217";
5218        let parse_result = crate::parse(source);
5219        let node = parse_result.syntax_node();
5220        assert_eq!(
5221            format_node(&node),
5222            format_node_with_alignment(&node, parse_result.alignment()),
5223            "ParseResult::alignment must drive identical format output to format_node",
5224        );
5225    }
5226
5227    /// `format_source_with_parsed(parse(s), s) == format_source(s)`
5228    /// byte-identical across a representative fixture set including
5229    /// CRLF and BOM-prefixed sources. This is the load-bearing
5230    /// equivalence for the LSP `format_document` / FFI
5231    /// `format.source` / WASM `ParsedLedger::format` migrations:
5232    /// they swap `format_source(source)` for
5233    /// `format_source_with_parsed(parse_result, source)` on the
5234    /// assumption that the two produce the same output. Without
5235    /// this test, a future converter or formatter change that
5236    /// silently diverged the two paths would break canonical-form
5237    /// expectations in production.
5238    #[test]
5239    fn format_source_with_parsed_matches_format_source() {
5240        let fixtures: &[(&str, &str)] = &[
5241            ("empty", ""),
5242            ("comment only", "; hello\n"),
5243            (
5244                "single transaction LF",
5245                "\
52462024-01-15 * \"Coffee\"
5247  Assets:Bank  -5.00 USD
5248  Expenses:Food
5249",
5250            ),
5251            (
5252                "multi transaction varying widths LF",
5253                "\
52542024-01-15 * \"A\"
5255  Assets:Bank  -5.00 USD
5256  Expenses:Food
52572024-02-15 * \"B\"
5258  Assets:Investment:Long:Path  -123456.78 USD
5259  Expenses:Tax  100.00 USD
5260",
5261            ),
5262            (
5263                "arithmetic amounts LF",
5264                "\
52652024-01-15 * \"Split\"
5266  Assets:Bank  -10.00 + 5.00 USD
5267  Expenses:Misc
5268",
5269            ),
5270            (
5271                "CRLF source",
5272                "2024-01-15 * \"Coffee\"\r\n  Assets:Bank  -5.00 USD\r\n  Expenses:Food\r\n",
5273            ),
5274            ("BOM-prefixed", "\u{FEFF}2024-01-01 open Assets:Bank USD\n"),
5275            // BOM + CRLF — Windows-authored ledger with a BOM
5276            // prefix. `format_source` BOM-strips + CRLF→LF
5277            // normalizes before parsing. The cache path consumes
5278            // a CST that's BOM-stripped but NOT CRLF-normalized.
5279            // Byte-identity holds because the formatter rebuilds
5280            // canonical output from typed values (no trivia
5281            // passthrough).
5282            (
5283                "BOM + CRLF combination",
5284                "\u{FEFF}2024-01-15 * \"Coffee\"\r\n  Assets:Bank  -5.00 USD\r\n  Expenses:Food\r\n",
5285            ),
5286            // Parse-error file — exercises the fallback. Without
5287            // the `errors.is_empty()` guard, the cache path would
5288            // emit text for ERROR_NODE-wrapped content while
5289            // `format_source` would drop those bytes; identity
5290            // would fail. The fallback delegates to
5291            // `format_source(source)` so identity holds.
5292            (
5293                "parse errors (exercises fallback)",
5294                "2024-01-15 * \"x\"\n  Assets:Bank  -5.00 USD\n}}}garbage\n",
5295            ),
5296            // Bare-`\r` (classic Mac) line terminators. The
5297            // `format_source` path normalizes bare-CR to LF via
5298            // `crlf_to_lf_outside_strings`, then parses cleanly.
5299            // `parse_via_cst` does NOT normalize bare-CR, so the
5300            // CST sees broken syntax and `parse_result.errors`
5301            // is non-empty — the fallback fires. Byte-identity
5302            // holds via the same `format_source` delegation.
5303            (
5304                "bare CR line terminators (exercises fallback)",
5305                "2024-01-01 open Assets:Bank USD\r2024-01-02 open Assets:Cash USD\r",
5306            ),
5307        ];
5308        for (label, source) in fixtures {
5309            let parse_result = crate::parse(source);
5310            let baseline = format_source(source);
5311            let cached = format_source_with_parsed(&parse_result, source);
5312            assert_eq!(
5313                cached, baseline,
5314                "format_source_with_parsed must match format_source for {label}: \
5315                 baseline {baseline:?}, cached {cached:?}",
5316            );
5317        }
5318    }
5319
5320    /// Mismatched-pair safety: in debug builds, passing a
5321    /// length-mismatched `(parse_result, source)` pair panics via
5322    /// the `debug_assert_eq!`. Release builds silently emit text
5323    /// for the wrong buffer — pairing the two arguments is the
5324    /// caller's responsibility, per this function's rustdoc.
5325    #[cfg(debug_assertions)]
5326    #[test]
5327    #[should_panic(expected = "source` whose length doesn't match")]
5328    fn format_source_with_parsed_panics_on_length_mismatch() {
5329        let parse_result = crate::parse("2024-01-01 open Assets:Bank USD\n");
5330        // Different length — debug_assert fires.
5331        let _ = format_source_with_parsed(&parse_result, "different");
5332    }
5333
5334    /// A grouping style imposes separators, and the alignment pre-pass measures
5335    /// the GROUPED width — so the currency column still lines up.
5336    ///
5337    /// They agree because `compute_alignment` and the emitters are handed the
5338    /// SAME `GroupingStyle`. Measuring under one style and emitting under
5339    /// another is the mismatch that would reproduce #1290, which is why the two
5340    /// entry points that could express it are crate-private and every public
5341    /// one either measures its own alignment or is fixed to the default style.
5342    #[test]
5343    fn grouped_formatting_aligns_and_is_idempotent() {
5344        let src = "\
53452020-01-02 * \"mixed magnitudes\"
5346  Assets:Bank                        1234567.89 USD
5347  Assets:VeryLongAccountName:Nested       12.00 USD
5348  Income:Sales                      -1234579.89 USD
5349";
5350        let grouped = format_source_grouped(src, grouped_style(&all_commas()));
5351        assert!(
5352            grouped.contains("1,234,567.89") && grouped.contains("-1,234,579.89"),
5353            "grouping must be imposed regardless of the source form:\n{grouped}"
5354        );
5355        // Currency column uniform => every ` USD` starts at the same column.
5356        let cols: Vec<usize> = grouped
5357            .lines()
5358            .filter(|l| l.contains(" USD"))
5359            .map(|l| l.find("USD").expect("USD"))
5360            .collect();
5361        assert!(
5362            cols.windows(2).all(|w| w[0] == w[1]),
5363            "currency column must stay uniform under grouping: {cols:?}\n{grouped}"
5364        );
5365        assert_eq!(
5366            format_source_grouped(&grouped, grouped_style(&all_commas())),
5367            grouped,
5368            "grouped formatting must be idempotent"
5369        );
5370    }
5371
5372    /// Grouping is a TOTAL rewrite, not a preserve: it converges from either
5373    /// direction, so a file whose numerals are inconsistent still normalizes.
5374    /// That is the property `preserve` would have given up.
5375    #[test]
5376    fn grouping_converges_from_either_direction() {
5377        let mixed = "\
53782020-01-02 * \"inconsistent source\"
5379  Assets:A  1,234,567.89 USD
5380  Assets:B     -1234567.89 USD
5381";
5382        let on = format_source_grouped(mixed, grouped_style(&all_commas()));
5383        assert_eq!(on.matches(',').count(), 4, "both numerals grouped:\n{on}");
5384        let off = format_source_grouped(mixed, GroupingStyle::default());
5385        assert!(!off.contains(','), "both numerals bare:\n{off}");
5386        // And each is a fixed point of its own rule.
5387        assert_eq!(format_source_grouped(&on, grouped_style(&all_commas())), on);
5388        assert_eq!(format_source_grouped(&off, GroupingStyle::default()), off);
5389    }
5390
5391    /// The default entry point is untouched: every existing caller, and every
5392    /// ledger that has not opted in, gets byte-identical output.
5393    #[test]
5394    fn default_formatting_still_strips_separators() {
5395        let src = "2020-01-02 balance Assets:A  1,234.50 USD\n";
5396        assert_eq!(
5397            format_source(src),
5398            "2020-01-02 balance Assets:A 1234.50 USD\n"
5399        );
5400        assert_eq!(
5401            format_source(src),
5402            format_source_grouped(src, GroupingStyle::default())
5403        );
5404    }
5405
5406    /// Grouped output must re-parse to the SAME values — the formatter may not
5407    /// emit text its own lexer rejects. Groups are three digits because that is
5408    /// all `(\d{1,3}(,\d{3})*|\d+)` admits.
5409    #[test]
5410    fn grouped_output_reparses_to_the_same_values() {
5411        for n in [
5412            "1",
5413            "12",
5414            "123",
5415            "1234",
5416            "1234567",
5417            "1234567.891",
5418            "0.5",
5419            "1000000",
5420        ] {
5421            let src = format!("2020-01-02 balance Assets:A  {n} USD\n");
5422            let grouped = format_source_grouped(&src, grouped_style(&all_commas()));
5423            let reparsed = crate::parse(&grouped);
5424            assert!(
5425                reparsed.errors.is_empty(),
5426                "grouped `{n}` -> `{}` must re-parse: {:?}",
5427                grouped.trim(),
5428                reparsed.errors
5429            );
5430            // And round-trips to the identical value.
5431            let before = crate::parse(&src);
5432            let val = |r: &crate::ParseResult| match &r.directives[0].value {
5433                rustledger_core::Directive::Balance(b) => b.amount.number,
5434                _ => panic!("balance"),
5435            };
5436            assert_eq!(val(&before), val(&reparsed), "value changed for `{n}`");
5437        }
5438    }
5439
5440    /// A ledger-wide context used by the grouping tests.
5441    fn all_commas() -> rustledger_core::DisplayContext {
5442        let mut c = rustledger_core::DisplayContext::new();
5443        c.set_render_commas(true);
5444        c
5445    }
5446
5447    fn grouped_style(ctx: &rustledger_core::DisplayContext) -> GroupingStyle<'_> {
5448        GroupingStyle::from_context(ctx)
5449    }
5450
5451    /// A commodity may opt OUT of the ledger-wide default, so a 4000:1 currency
5452    /// can be grouped without also grouping two-digit USD amounts — the reason
5453    /// a single global boolean was not enough (#1892).
5454    #[test]
5455    fn grouping_is_resolved_per_commodity() {
5456        let mut ctx = rustledger_core::DisplayContext::new();
5457        ctx.set_render_commas(true);
5458        ctx.set_render_commas_for("USD", false);
5459
5460        let src = "\
54612020-01-02 * \"two currencies\"
5462  Assets:Local   1234567.89 IQD
5463  Assets:Dollars 1234567.89 USD
5464";
5465        let out = format_source_grouped(src, GroupingStyle::from_context(&ctx));
5466        assert!(
5467            out.contains("1,234,567.89 IQD"),
5468            "the ledger default applies to IQD:\n{out}"
5469        );
5470        assert!(
5471            out.contains("1234567.89 USD") && !out.contains("1,234,567.89 USD"),
5472            "USD opted out and must stay bare:\n{out}"
5473        );
5474    }
5475
5476    /// The inverse: grouping declared on ONE commodity while the ledger default
5477    /// is off. This is the shape a user with a single hyperinflated currency
5478    /// actually wants.
5479    #[test]
5480    fn a_single_commodity_can_opt_in() {
5481        let mut ctx = rustledger_core::DisplayContext::new();
5482        ctx.set_render_commas_for("IQD", true);
5483
5484        let src = "\
54852020-01-02 * \"two currencies\"
5486  Assets:Local   1234567.89 IQD
5487  Assets:Dollars 1234567.89 USD
5488";
5489        let out = format_source_grouped(src, GroupingStyle::from_context(&ctx));
5490        assert!(out.contains("1,234,567.89 IQD"), "IQD opted in:\n{out}");
5491        assert!(
5492            out.contains("1234567.89 USD") && !out.contains("1,234,567.89 USD"),
5493            "USD keeps the (off) default:\n{out}"
5494        );
5495    }
5496
5497    /// A context that groups nothing must produce the no-lookup style, so the
5498    /// overwhelming majority of ledgers pay nothing per numeral.
5499    #[test]
5500    fn a_context_that_groups_nothing_yields_the_default_style() {
5501        let mut ctx = rustledger_core::DisplayContext::new();
5502        ctx.set_fixed_precision("USD", 2);
5503        ctx.set_render_commas_for("USD", false);
5504        assert!(!ctx.renders_any_commas());
5505        let src = "2020-01-02 balance Assets:A  1234.50 USD\n";
5506        assert_eq!(
5507            format_source_grouped(src, GroupingStyle::from_context(&ctx)),
5508            format_source(src),
5509            "no declared grouping must be byte-identical to the default path"
5510        );
5511    }
5512
5513    /// Formatting must not change what a balance ASSERTS.
5514    ///
5515    /// The tolerance is an expression: `~ 0.005 + 0.005 USD` and
5516    /// `~ 0.005 * 2 USD` both mean 0.010. `balance_tolerance` kept only the
5517    /// first NUMBER and rewrote them as `~ 0.005 USD`, halving the tolerance.
5518    /// The output reparsed cleanly and asserted something else, so `rledger
5519    /// format` could turn a passing ledger into a failing one with nothing to
5520    /// show for it -- the same truncation #1944 fixed in the parser and left
5521    /// standing here.
5522    ///
5523    /// Asserted on the parsed TOLERANCE VALUE rather than the text, because a
5524    /// text comparison would pass on any spelling that happens to round-trip
5525    /// while still meaning something new.
5526    #[test]
5527    fn formatting_preserves_the_tolerance_value() {
5528        for (src, want) in [
5529            ("2024-01-15 balance Assets:C 1.00 ~ 0.01 USD\n", "0.01"),
5530            (
5531                "2024-01-15 balance Assets:C 1.00 ~ 0.005 + 0.005 USD\n",
5532                "0.010",
5533            ),
5534            (
5535                "2024-01-15 balance Assets:C 1.00 ~ (0.005 + 0.005) USD\n",
5536                "0.010",
5537            ),
5538            (
5539                "2024-01-15 balance Assets:C 1.00 ~ 0.005 * 2 USD\n",
5540                "0.010",
5541            ),
5542            // main dropped this MINUS entirely, so `~ -0.01` formatted to
5543            // `~ 0.01` -- a second changed value in the same function.
5544            ("2024-01-15 balance Assets:C 1.00 ~ -0.01 USD\n", "-0.01"),
5545            (
5546                "2024-01-15 balance Assets:C 1.00 ~ 0.02 - 0.01 USD\n",
5547                "0.01",
5548            ),
5549            (
5550                "2024-01-15 balance Assets:C 1.00 ~ -0.005 + 0.015 USD\n",
5551                "0.010",
5552            ),
5553        ] {
5554            let tolerance = |text: &str| {
5555                crate::parse(text)
5556                    .directives
5557                    .iter()
5558                    .find_map(|d| match &d.value {
5559                        rustledger_core::Directive::Balance(b) => Some(b.tolerance),
5560                        _ => None,
5561                    })
5562                    .flatten()
5563                    .map(|t| t.to_string())
5564            };
5565            assert_eq!(
5566                tolerance(src).as_deref(),
5567                Some(want),
5568                "fixture itself must parse to {want}: {src}"
5569            );
5570            let formatted = format_source(src);
5571            assert_eq!(
5572                tolerance(&formatted).as_deref(),
5573                Some(want),
5574                "formatting changed the asserted tolerance\n  {src}  -> {formatted}"
5575            );
5576            // Every one of these is already canonical, so formatting must be a
5577            // no-op on the TEXT too. Without this the value assertions accept
5578            // any spelling that happens to evaluate the same -- and one did:
5579            // rendering a unary minus as `~ - 0.01` keeps the value and makes
5580            // the directive disagree with itself, since the amount on the same
5581            // line renders `-1.00` tight.
5582            assert_eq!(formatted, src, "already-canonical input was rewritten");
5583        }
5584    }
5585
5586    /// `format` must not duplicate a balance tolerance.
5587    ///
5588    /// `emit_amount_expression` ran from the first NUMBER to the first
5589    /// CURRENCY, which SWALLOWED the `~ tolerance` clause; `emit_balance` then
5590    /// emitted it again via `balance_tolerance`. So
5591    /// `balance Assets:A 0.00 ~ 1234.5 USD` was rewritten as
5592    /// `... 0.00 ~ 1234.5 USD ~ 1234.5 USD` — stable across reformats, but
5593    /// almost certainly not valid beancount, whose balance grammar takes at
5594    /// most one tolerance. `--check` reported the ORIGINAL as unformatted, so a
5595    /// CI gate pushed users into the corrupted form.
5596    ///
5597    /// Pre-existing on main; unrelated to grouping.
5598    #[test]
5599    fn balance_tolerance_is_emitted_exactly_once() {
5600        for (src, want) in [
5601            (
5602                "2020-01-04 balance Assets:A 0.00 ~ 1234.5 USD\n",
5603                "2020-01-04 balance Assets:A 0.00 ~ 1234.5 USD\n",
5604            ),
5605            // A source that repeats the currency collapses to the one-currency
5606            // beancount form rather than keeping both.
5607            (
5608                "2020-01-04 balance Assets:A 0.00 USD ~ 0.05 USD\n",
5609                "2020-01-04 balance Assets:A 0.00 ~ 0.05 USD\n",
5610            ),
5611            // No tolerance: unchanged.
5612            (
5613                "2020-01-04 balance Assets:A 1234.50 USD\n",
5614                "2020-01-04 balance Assets:A 1234.50 USD\n",
5615            ),
5616            // Arithmetic still terminates at the currency, not the tilde.
5617            (
5618                "2020-01-04 balance Assets:A (1 + 5) / 2 USD\n",
5619                "2020-01-04 balance Assets:A (1 + 5) / 2 USD\n",
5620            ),
5621        ] {
5622            let out = format_source(src);
5623            assert_eq!(out, want, "formatting {src:?}");
5624            assert_eq!(format_source(&out), out, "not idempotent for {src:?}");
5625            // And the output must still parse — a formatter may not emit text
5626            // its own parser rejects.
5627            assert!(
5628                crate::parse(&out).errors.is_empty(),
5629                "output must re-parse: {out}"
5630            );
5631        }
5632    }
5633}