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(¤cy);
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 out.push('\n');
1579 emit_meta_entries_of(d.syntax(), group, out);
1580}
1581
1582fn emit_event(d: &ast::EventDirective, group: GroupingStyle<'_>, out: &mut String) {
1583 let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1584 let event_type = d
1585 .event_type()
1586 .map(|s| s.text().to_string())
1587 .unwrap_or_default();
1588 let value = d.value().map(|s| s.text().to_string()).unwrap_or_default();
1589 out.push_str(&date);
1590 out.push_str(" event ");
1591 out.push_str(&event_type);
1592 out.push(' ');
1593 out.push_str(&value);
1594 out.push('\n');
1595 emit_meta_entries_of(d.syntax(), group, out);
1596}
1597
1598fn emit_query(d: &ast::QueryDirective, group: GroupingStyle<'_>, out: &mut String) {
1599 let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1600 let name = d.name().map(|s| s.text().to_string()).unwrap_or_default();
1601 let query = d.query().map(|s| s.text().to_string()).unwrap_or_default();
1602 out.push_str(&date);
1603 out.push_str(" query ");
1604 out.push_str(&name);
1605 out.push(' ');
1606 out.push_str(&query);
1607 out.push('\n');
1608 emit_meta_entries_of(d.syntax(), group, out);
1609}
1610
1611fn emit_pad(d: &ast::PadDirective, group: GroupingStyle<'_>, out: &mut String) {
1612 let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1613 let target = d
1614 .target_account()
1615 .map(|t| t.text().to_string())
1616 .unwrap_or_default();
1617 let source = d
1618 .source_account()
1619 .map(|t| t.text().to_string())
1620 .unwrap_or_default();
1621 out.push_str(&date);
1622 out.push_str(" pad ");
1623 out.push_str(&target);
1624 out.push(' ');
1625 out.push_str(&source);
1626 out.push('\n');
1627 emit_meta_entries_of(d.syntax(), group, out);
1628}
1629
1630fn emit_document(d: &ast::DocumentDirective, group: GroupingStyle<'_>, out: &mut String) {
1631 let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1632 let account = d
1633 .account()
1634 .map(|t| t.text().to_string())
1635 .unwrap_or_default();
1636 let path = d.path().map(|s| s.text().to_string()).unwrap_or_default();
1637 out.push_str(&date);
1638 out.push_str(" document ");
1639 out.push_str(&account);
1640 out.push(' ');
1641 out.push_str(&path);
1642 // Trailing TAG / LINK tokens — typed AST has no accessor, so
1643 // walk direct-child tokens. Skip LEADING trivia (a blank line
1644 // before a non-first directive attaches its NEWLINE inside the
1645 // node) and stop at the first NEWLINE *after* the header content
1646 // begins; otherwise the tags/links are dropped when reformatting
1647 // any document past the first — the same bug as #1321 in the
1648 // transaction path.
1649 let mut seen_content = false;
1650 for el in d.syntax().children_with_tokens() {
1651 let rowan::NodeOrToken::Token(t) = el else {
1652 break;
1653 };
1654 match t.kind() {
1655 crate::SyntaxKind::TAG | crate::SyntaxKind::LINK => {
1656 out.push(' ');
1657 out.push_str(t.text());
1658 seen_content = true;
1659 }
1660 crate::SyntaxKind::NEWLINE if seen_content => break,
1661 // Leading trivia before the date: whitespace, blank-line
1662 // NEWLINEs, AND comment lines. A comment before a non-first
1663 // directive attaches inside this node (Directive-Terminator
1664 // Rule); skipping only WHITESPACE/NEWLINE would let it flip
1665 // `seen_content`, break at the comment's NEWLINE, and drop
1666 // the real header tags/links.
1667 k if k.is_trivia() => {}
1668 _ => seen_content = true,
1669 }
1670 }
1671 out.push('\n');
1672 emit_meta_entries_of(d.syntax(), group, out);
1673}
1674
1675fn emit_price(d: &ast::PriceDirective, group: GroupingStyle<'_>, out: &mut String) {
1676 let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1677 let base = d
1678 .base_currency()
1679 .map(|t| t.text().to_string())
1680 .unwrap_or_default();
1681 let quote = d
1682 .quote_currency()
1683 .map(|t| t.text().to_string())
1684 .unwrap_or_default();
1685 out.push_str(&date);
1686 out.push_str(" price ");
1687 out.push_str(&base);
1688 out.push(' ');
1689 emit_amount_expression(d.syntax(), group, out);
1690 out.push(' ');
1691 out.push_str("e);
1692 out.push('\n');
1693 emit_meta_entries_of(d.syntax(), group, out);
1694}
1695
1696fn emit_balance(d: &ast::BalanceDirective, group: GroupingStyle<'_>, out: &mut String) {
1697 let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1698 let account = d
1699 .account()
1700 .map(|t| t.text().to_string())
1701 .unwrap_or_default();
1702 let currency = d
1703 .currency()
1704 .map(|t| t.text().to_string())
1705 .unwrap_or_default();
1706 out.push_str(&date);
1707 out.push_str(" balance ");
1708 out.push_str(&account);
1709 out.push(' ');
1710 emit_amount_expression(d.syntax(), group, out);
1711 // `balance ACCOUNT AMOUNT [~ TOLERANCE] CURRENCY` — ONE currency, trailing,
1712 // covering both numbers. The tolerance's own `CURRENCY` token (if the source
1713 // repeated it) is deliberately dropped: emitting it as well produced
1714 // `0.00 USD ~ 1234.5 USD`, which is not the beancount form.
1715 if let Some((tolerance, _tol_currency)) = balance_tolerance(d.syntax(), group) {
1716 out.push_str(" ~ ");
1717 out.push_str(&tolerance);
1718 }
1719 out.push(' ');
1720 out.push_str(¤cy);
1721 out.push('\n');
1722 emit_meta_entries_of(d.syntax(), group, out);
1723}
1724
1725fn emit_custom(d: &ast::CustomDirective, group: GroupingStyle<'_>, out: &mut String) {
1726 // `custom` / `pushmeta` values are not denominated in anything, so
1727 // they take the ledger-wide default.
1728 let run_group = group.groups(None);
1729 let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1730 let custom_type = d
1731 .custom_type()
1732 .map(|s| s.text().to_string())
1733 .unwrap_or_default();
1734 out.push_str(&date);
1735 out.push_str(" custom ");
1736 out.push_str(&custom_type);
1737 // Walk raw tokens after the type STRING and emit each value
1738 // with single-space separation. NUMBER + CURRENCY adjacent
1739 // counts as an Amount; emitted together with one space.
1740 let tokens: Vec<crate::SyntaxToken> = d
1741 .syntax()
1742 .children_with_tokens()
1743 .filter_map(rowan::NodeOrToken::into_token)
1744 .filter(|t| !is_trivia_kind(t.kind()))
1745 .collect();
1746 // `seen_type` skips the leading DATE + CUSTOM_KW + type-STRING
1747 // tokens (already emitted above as the directive header); once
1748 // it flips true, every subsequent non-trivia token is a value
1749 // argument and gets emitted with single-space separation. An
1750 // adjacent NUMBER + CURRENCY pair is glued with a single space
1751 // (canonical Amount shape); the CURRENCY is NOT eaten as a
1752 // standalone arg next iteration.
1753 //
1754 // Beancount custom directives accept any mix of value kinds
1755 // including DATE — a `custom "type" 2024-06-15 100.00 USD`
1756 // shape has a DATE in value position. The previous version
1757 // skipped every DATE after seen_type, silently dropping such
1758 // user-provided date arguments.
1759 let mut seen_type = false;
1760 let mut i = 0;
1761 while i < tokens.len() {
1762 let t = &tokens[i];
1763 if !seen_type {
1764 if t.kind() == crate::SyntaxKind::STRING {
1765 seen_type = true;
1766 }
1767 i += 1;
1768 continue;
1769 }
1770 out.push(' ');
1771 if t.kind() == crate::SyntaxKind::NUMBER {
1772 out.push_str(&canonical_number(t.text(), run_group));
1773 if matches!(
1774 tokens.get(i + 1).map(rowan::SyntaxToken::kind),
1775 Some(crate::SyntaxKind::CURRENCY)
1776 ) {
1777 out.push(' ');
1778 out.push_str(tokens[i + 1].text());
1779 i += 2;
1780 continue;
1781 }
1782 } else {
1783 out.push_str(t.text());
1784 }
1785 i += 1;
1786 }
1787 out.push('\n');
1788 emit_meta_entries_of(d.syntax(), group, out);
1789}
1790
1791// ---- Top-level non-dated directives -----------------------------
1792
1793fn emit_option(d: &ast::OptionDirective, out: &mut String) {
1794 let key = d.key().map(|s| s.text().to_string()).unwrap_or_default();
1795 let value = d.value().map(|s| s.text().to_string()).unwrap_or_default();
1796 out.push_str("option ");
1797 out.push_str(&key);
1798 out.push(' ');
1799 out.push_str(&value);
1800 out.push('\n');
1801}
1802
1803fn emit_include(d: &ast::IncludeDirective, out: &mut String) {
1804 let path = d.path().map(|s| s.text().to_string()).unwrap_or_default();
1805 out.push_str("include ");
1806 out.push_str(&path);
1807 out.push('\n');
1808}
1809
1810fn emit_plugin(d: &ast::PluginDirective, out: &mut String) {
1811 let module = d.module().map(|s| s.text().to_string()).unwrap_or_default();
1812 out.push_str("plugin ");
1813 out.push_str(&module);
1814 if let Some(config) = d.config() {
1815 out.push(' ');
1816 out.push_str(config.text());
1817 }
1818 out.push('\n');
1819}
1820
1821// ---- State directives (no metadata) -----------------------------
1822
1823fn emit_pushtag(d: &ast::PushtagDirective, out: &mut String) {
1824 let tag = d.tag().map(|t| t.text().to_string()).unwrap_or_default();
1825 out.push_str("pushtag ");
1826 out.push_str(&tag);
1827 out.push('\n');
1828}
1829
1830fn emit_poptag(d: &ast::PoptagDirective, out: &mut String) {
1831 let tag = d.tag().map(|t| t.text().to_string()).unwrap_or_default();
1832 out.push_str("poptag ");
1833 out.push_str(&tag);
1834 out.push('\n');
1835}
1836
1837fn emit_pushmeta(d: &ast::PushmetaDirective, group: GroupingStyle<'_>, out: &mut String) {
1838 // `custom` / `pushmeta` values are not denominated in anything, so
1839 // they take the ledger-wide default.
1840 let run_group = group.groups(None);
1841 let key = d.key().map(|t| t.text().to_string()).unwrap_or_default();
1842 out.push_str("pushmeta ");
1843 out.push_str(&key);
1844 // Walk the value tokens after META_KEY, single-space separated.
1845 let mut past_key = false;
1846 for el in d.syntax().children_with_tokens() {
1847 let rowan::NodeOrToken::Token(t) = el else {
1848 continue;
1849 };
1850 if !past_key {
1851 if t.kind() == crate::SyntaxKind::META_KEY {
1852 past_key = true;
1853 }
1854 continue;
1855 }
1856 if is_trivia_kind(t.kind()) {
1857 continue;
1858 }
1859 out.push(' ');
1860 if t.kind() == crate::SyntaxKind::NUMBER {
1861 out.push_str(&canonical_number(t.text(), run_group));
1862 } else {
1863 out.push_str(t.text());
1864 }
1865 }
1866 out.push('\n');
1867}
1868
1869fn emit_popmeta(d: &ast::PopmetaDirective, out: &mut String) {
1870 let key = d.key().map(|t| t.text().to_string()).unwrap_or_default();
1871 out.push_str("popmeta ");
1872 out.push_str(&key);
1873 out.push('\n');
1874}
1875
1876// ---- Transaction + Posting --------------------------------------
1877
1878fn emit_transaction(
1879 d: &ast::Transaction,
1880 align: PostingAlignment,
1881 group: GroupingStyle<'_>,
1882 out: &mut String,
1883) {
1884 let date = d.date().map(|t| t.text().to_string()).unwrap_or_default();
1885 out.push_str(&date);
1886 out.push(' ');
1887 out.push_str(&transaction_flag_string(d));
1888 if let Some(payee) = d.payee() {
1889 out.push(' ');
1890 out.push_str(payee.text());
1891 }
1892 if let Some(narration) = d.narration() {
1893 out.push(' ');
1894 out.push_str(narration.text());
1895 }
1896 // Header-region tags/links — emitted in source order
1897 // (typed `.tags()` / `.links()` accessors return each kind
1898 // grouped, which loses interleaving like `#a ^l #b`). Walk
1899 // direct-child tokens, stopping at the header-terminating
1900 // NEWLINE.
1901 //
1902 // `seen_content` guards against LEADING trivia: for any directive
1903 // after the first, the preceding blank line's NEWLINE attaches
1904 // inside this node before the date (the Directive-Terminator Rule).
1905 // The header terminator is the first NEWLINE *after* the date, not
1906 // a leading one — otherwise this loop would break immediately and
1907 // emit no header tags (#1321).
1908 let mut seen_content = false;
1909 for el in d.syntax().children_with_tokens() {
1910 let rowan::NodeOrToken::Token(t) = el else {
1911 break;
1912 };
1913 match t.kind() {
1914 crate::SyntaxKind::TAG | crate::SyntaxKind::LINK => {
1915 out.push(' ');
1916 out.push_str(t.text());
1917 seen_content = true;
1918 }
1919 crate::SyntaxKind::NEWLINE if seen_content => break,
1920 // Leading trivia before the date: whitespace, blank-line
1921 // NEWLINEs, AND comment lines (a comment before a non-first
1922 // directive attaches inside this node per the Directive-
1923 // Terminator Rule). Skipping only WHITESPACE/NEWLINE would
1924 // let a leading comment flip `seen_content`, break at the
1925 // comment's NEWLINE, and drop the real header tags/links.
1926 k if k.is_trivia() => {}
1927 // DATE / flag / STRING etc. — header content has begun.
1928 _ => seen_content = true,
1929 }
1930 }
1931 out.push('\n');
1932 // Body: a single source-order walk over the transaction's children,
1933 // emitting — in the order they appear — POSTING / META_ENTRY nodes, any
1934 // body-internal COMMENT lines (#1332: the formatter must not delete the
1935 // author's comments), and trailing body-line TAG / LINK continuation
1936 // tokens (valid Beancount per the body-line exemption).
1937 //
1938 // `seen_content` / `past_header` skip the header region exactly as the
1939 // header loop above does, so the header-trailing comment (spliced onto
1940 // the header line by `emit_directive`) and the header tags/links (already
1941 // emitted inline above) are not duplicated here. A leading blank-line
1942 // NEWLINE for any directive past the first is trivia and must not flip
1943 // `past_header` early (#1321).
1944 let mut past_header = false;
1945 let mut seen_content = false;
1946 for el in d.syntax().children_with_tokens() {
1947 match el {
1948 rowan::NodeOrToken::Node(n) => {
1949 // A POSTING / META_ENTRY node is definitively past the header.
1950 past_header = true;
1951 if let Some(p) = ast::Posting::cast(n.clone()) {
1952 emit_posting(&p, align, group, out);
1953 } else if let Some(m) = ast::MetaEntry::cast(n) {
1954 emit_meta_entry(&m, INDENT, group, out);
1955 }
1956 }
1957 rowan::NodeOrToken::Token(t) => {
1958 if !past_header {
1959 match t.kind() {
1960 crate::SyntaxKind::NEWLINE if seen_content => past_header = true,
1961 k if k.is_trivia() => {}
1962 // DATE / flag / STRING / header TAG / LINK: still header.
1963 _ => seen_content = true,
1964 }
1965 continue;
1966 }
1967 // Body tokens: preserve comment-only lines and emit
1968 // continuation tags/links, each on its own indented line.
1969 match t.kind() {
1970 crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT => {
1971 out.push_str(INDENT);
1972 out.push_str(t.text().trim_end_matches(['\n', '\r']));
1973 out.push('\n');
1974 }
1975 crate::SyntaxKind::TAG | crate::SyntaxKind::LINK => {
1976 out.push_str(INDENT);
1977 out.push_str(t.text());
1978 out.push('\n');
1979 }
1980 _ => {}
1981 }
1982 }
1983 }
1984 }
1985}
1986
1987fn transaction_flag_string(d: &ast::Transaction) -> String {
1988 use crate::cst::ast::TransactionFlagKind;
1989 match d.flag() {
1990 None => "*".to_string(),
1991 Some(f) => match f.classify() {
1992 TransactionFlagKind::Star | TransactionFlagKind::Txn => "*".to_string(),
1993 TransactionFlagKind::Pending => "!".to_string(),
1994 TransactionFlagKind::Hash => "#".to_string(),
1995 TransactionFlagKind::Letter | TransactionFlagKind::CurrencyLetter => {
1996 f.text().to_string()
1997 }
1998 },
1999 }
2000}
2001
2002fn emit_posting(
2003 p: &ast::Posting,
2004 align: PostingAlignment,
2005 group: GroupingStyle<'_>,
2006 out: &mut String,
2007) {
2008 // Posting-trailing comment (same-line, before the posting-line
2009 // NEWLINE) — capture upfront so we can splice it back in just
2010 // before that NEWLINE, preserving the user's attachment intent.
2011 let trailing = collect_trailing_comment(p.syntax());
2012 let posting_start = out.len();
2013
2014 out.push_str(INDENT);
2015 let mut col = INDENT.len();
2016 if let Some(flag) = p.flag() {
2017 out.push_str(flag.text());
2018 out.push(' ');
2019 col += flag.text().chars().count() + 1;
2020 }
2021 let account_text = p
2022 .account()
2023 .map(|a| a.text().to_string())
2024 .unwrap_or_default();
2025 out.push_str(&account_text);
2026 col += account_text.chars().count();
2027
2028 if let Some(amt) = p.amount() {
2029 // `amount_number_text` is the shared "does this render a number?"
2030 // predicate (see `compute_alignment`); a currency-only amount
2031 // returns `None` and prints no number.
2032 if let Some(value) = amount_number_text(&amt, group) {
2033 // Two stages of padding:
2034 // 1) Account end → start of number field (`number_col`).
2035 // Fall back to 2 spaces when the LHS already exceeds
2036 // the file-wide max (over-long account name).
2037 // 2) Inside the number field, left-pad to right-justify
2038 // to `number_width`. Effect: the currency column
2039 // lands at a single uniform position file-wide even
2040 // when numbers have different widths or signs.
2041 let field_pad = align.number_col.saturating_sub(col).max(2);
2042 let justify_pad = align.number_width.saturating_sub(value.chars().count());
2043 for _ in 0..(field_pad + justify_pad) {
2044 out.push(' ');
2045 }
2046 out.push_str(&value);
2047 if let Some(c) = amt.currency() {
2048 out.push(' ');
2049 out.push_str(c.text());
2050 }
2051 if let Some(cs) = p.cost_spec() {
2052 out.push(' ');
2053 out.push_str(&format_cost_spec(&cs, group));
2054 }
2055 if let Some(pa) = p.price_annotation() {
2056 out.push(' ');
2057 out.push_str(&format_price_annotation(&pa, group));
2058 }
2059 }
2060 }
2061 out.push('\n');
2062 // Splice the trailing comment in BEFORE the posting-line
2063 // NEWLINE (the first '\n' in the emitted posting region).
2064 if let Some(c) = trailing
2065 && let Some(rel) = out[posting_start..].find('\n')
2066 {
2067 let mut splice = String::with_capacity(c.len() + 1);
2068 splice.push(' ');
2069 splice.push_str(&c);
2070 out.insert_str(posting_start + rel, &splice);
2071 }
2072 // Posting body: emit attached metadata AND posting-internal comment
2073 // lines in source order, indented 4 (deeper than the posting's 2).
2074 // Comment-only lines inside a posting attach as COMMENT tokens of the
2075 // POSTING node; walking children-with-tokens preserves them (#1337)
2076 // instead of dropping them. The posting's own header line is skipped via
2077 // the seen_content/past_header guard, so the same-line trailing comment
2078 // (spliced above) is not duplicated here.
2079 let mut past_header = false;
2080 let mut seen_content = false;
2081 for el in p.syntax().children_with_tokens() {
2082 match el {
2083 rowan::NodeOrToken::Node(n) => {
2084 // Header child nodes (AMOUNT / COST_SPEC / PRICE_ANNOTATION)
2085 // are emitted inline above and must NOT flip `past_header` —
2086 // only the posting-line NEWLINE does. Otherwise the same-line
2087 // trailing comment, which follows the AMOUNT node, would be
2088 // re-emitted here as a body comment. META_ENTRY nodes only
2089 // appear in the body, after `past_header` is already set.
2090 if let Some(m) = ast::MetaEntry::cast(n) {
2091 emit_meta_entry(&m, " ", group, out);
2092 }
2093 }
2094 rowan::NodeOrToken::Token(t) => {
2095 if !past_header {
2096 match t.kind() {
2097 crate::SyntaxKind::NEWLINE if seen_content => past_header = true,
2098 k if k.is_trivia() => {}
2099 _ => seen_content = true,
2100 }
2101 continue;
2102 }
2103 if matches!(
2104 t.kind(),
2105 crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT
2106 ) {
2107 out.push_str(" ");
2108 out.push_str(t.text().trim_end_matches(['\n', '\r']));
2109 out.push('\n');
2110 }
2111 }
2112 }
2113 }
2114}
2115
2116/// Format an `AMOUNT` (units + currency) in canonical form. For
2117/// arithmetic shapes, emits the expression with single-space
2118/// separators (parens tight); for plain shapes, emits
2119/// `NUMBER CURRENCY` with thousands separators stripped.
2120fn format_amount(amt: &ast::Amount, group: GroupingStyle<'_>) -> String {
2121 let mut out = String::new();
2122 if amt.is_arithmetic() {
2123 emit_amount_subnode_expression(amt.syntax(), group, &mut out);
2124 if let Some(c) = amt.currency() {
2125 if !out.is_empty() {
2126 out.push(' ');
2127 }
2128 out.push_str(c.text());
2129 }
2130 return out;
2131 }
2132 if let Some(sign) = amt.sign()
2133 && sign.is_minus()
2134 {
2135 out.push('-');
2136 }
2137 if let Some(n) = amt.number() {
2138 // `amt.currency()` is a CST child lookup, and `groups()` ignores the
2139 // currency entirely under the default style — so resolving it first
2140 // costs a tree walk to answer a question already settled. That is per
2141 // posting, per PARSE, because `compute_alignment` runs on every parse
2142 // (see `convert.rs`), long before anything asks to be formatted. It
2143 // measured 1.35% of the load pipeline's instructions.
2144 //
2145 // Same short-circuit `emit_amount_expression` already applies to
2146 // `run_currency`; these two sites were missed when that was added.
2147 let grouped = group.groups_anything()
2148 && group.groups(amt.currency().as_ref().map(ast::CurrencyName::text));
2149 out.push_str(&canonical_number(n.text(), grouped));
2150 }
2151 if let Some(c) = amt.currency() {
2152 if !out.is_empty() && !out.ends_with('-') {
2153 out.push(' ');
2154 }
2155 out.push_str(c.text());
2156 }
2157 out
2158}
2159
2160/// Canonical form for cost specs: `{cost CCY}` (single-brace
2161/// per-unit), `{{cost CCY}}` (double-brace total), `{# cost CCY}`
2162/// (per-unit + total via opener), or the in-brace `{N # T CCY}`
2163/// shape preserved as-is with single-space normalization.
2164///
2165/// Commas separating cost components (`{N CCY, DATE, "label"}`)
2166/// stay tight against the preceding token; every other adjacent
2167/// token pair is joined with a single space.
2168fn format_cost_spec(cs: &ast::CostSpec, group: GroupingStyle<'_>) -> String {
2169 let (open, close) = if cs.is_total() {
2170 ("{{", "}}")
2171 } else if cs.is_per_unit_plus_total() {
2172 ("{#", "}")
2173 } else {
2174 ("{", "}")
2175 };
2176 // Collect inner content tokens (skip opener/closer/whitespace),
2177 // then route through write_canonical_token_sequence so the spacing rule
2178 // is identical to balance/price/AMOUNT-subnode arithmetic — most
2179 // importantly, unary `+`/`-` stays tight (`{-500 USD}`, not
2180 // `{- 500 USD}`) and COMMA stays tight.
2181 let inner_tokens: Vec<crate::SyntaxToken> = cs
2182 .syntax()
2183 .children_with_tokens()
2184 .filter_map(rowan::NodeOrToken::into_token)
2185 .filter(|t| {
2186 !matches!(
2187 t.kind(),
2188 crate::SyntaxKind::L_BRACE
2189 | crate::SyntaxKind::R_BRACE
2190 | crate::SyntaxKind::L_DOUBLE_BRACE
2191 | crate::SyntaxKind::R_DOUBLE_BRACE
2192 | crate::SyntaxKind::L_BRACE_HASH
2193 | crate::SyntaxKind::WHITESPACE
2194 | crate::SyntaxKind::NEWLINE
2195 )
2196 })
2197 .collect();
2198 let mut inner = String::new();
2199 write_canonical_token_sequence(&inner_tokens, group, &mut inner);
2200 // The `{#` opener is a two-character marker; canonical form
2201 // separates it from the first inner token with a single space
2202 // (matching the rendering in this function's rustdoc). `{` and
2203 // `{{` don't get inner padding per the canonical-form spec.
2204 if cs.is_per_unit_plus_total() && !inner.is_empty() {
2205 format!("{open} {inner}{close}")
2206 } else {
2207 format!("{open}{inner}{close}")
2208 }
2209}
2210
2211/// Canonical price annotation: `@ amount` (per-unit) or
2212/// `@@ amount` (total).
2213fn format_price_annotation(pa: &ast::PriceAnnotation, group: GroupingStyle<'_>) -> String {
2214 let op = if pa.is_total() { "@@" } else { "@" };
2215 match pa.amount() {
2216 Some(a) => format!("{op} {}", format_amount(&a, group)),
2217 None => op.to_string(),
2218 }
2219}
2220
2221// ---- Helpers ---------------------------------------------------
2222
2223/// True for tokens that don't contribute content to the canonical
2224/// form: whitespace, newlines, every comment kind, and the
2225/// leading-file `BOM` token.
2226const fn is_trivia_kind(kind: crate::SyntaxKind) -> bool {
2227 matches!(
2228 kind,
2229 crate::SyntaxKind::WHITESPACE
2230 | crate::SyntaxKind::NEWLINE
2231 | crate::SyntaxKind::COMMENT
2232 | crate::SyntaxKind::PERCENT_COMMENT
2233 | crate::SyntaxKind::SHEBANG
2234 | crate::SyntaxKind::EMACS_DIRECTIVE
2235 | crate::SyntaxKind::BOM
2236 )
2237}
2238
2239/// Render a `NUMBER` token in canonical form: the user's decimal-place count
2240/// is preserved, and digit grouping is imposed by `group` rather than by what
2241/// the source happened to contain.
2242///
2243/// `group == false` → `1,000.00` becomes `1000.00`. `group == true` → the
2244/// reverse: `1000.00` becomes `1,000.00`. Either way this is a TOTAL rewrite of
2245/// the grouping, so the formatter still yields one form per value — the rule
2246/// changes, not the guarantee.
2247///
2248/// Groups are always three digits, because that is the only shape the lexer
2249/// accepts (`(\d{1,3}(,\d{3})*|\d+)(\.\d*)?`). Anything else — Indian lakh
2250/// grouping, say — would emit text this parser then REJECTS, so widening this
2251/// needs a lexer change first, not just a formatter one.
2252fn canonical_number(text: &str, group: bool) -> std::borrow::Cow<'_, str> {
2253 // The overwhelmingly common numeral is already canonical: no separators to
2254 // strip and no grouping requested. Borrow it rather than allocating a copy
2255 // per numeral on the default formatter path.
2256 if !group && !text.contains(',') {
2257 return std::borrow::Cow::Borrowed(text);
2258 }
2259 let bare = text.replace(',', "");
2260 if !group {
2261 return std::borrow::Cow::Owned(bare);
2262 }
2263 let (int_part, frac) = match bare.split_once('.') {
2264 Some((i, f)) => (i, Some(f)),
2265 None => (bare.as_str(), None),
2266 };
2267 // Defensive: a non-digit integer part is not ours to regroup. Unreachable
2268 // for a lexed NUMBER, whose sign is a separate token.
2269 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
2270 return std::borrow::Cow::Owned(bare);
2271 }
2272 let n = int_part.len();
2273 let mut out = String::with_capacity(bare.len() + n / 3);
2274 for (i, c) in int_part.chars().enumerate() {
2275 if i > 0 && (n - i) % 3 == 0 {
2276 out.push(',');
2277 }
2278 out.push(c);
2279 }
2280 if let Some(f) = frac {
2281 out.push('.');
2282 out.push_str(f);
2283 }
2284 std::borrow::Cow::Owned(out)
2285}
2286
2287/// Emit the arithmetic expression of a `PRICE` / `BALANCE`
2288/// directive: tokens from the first expression-starting token
2289/// (`NUMBER`, unary `+`/`-`, or `(`) up to (but not including) the
2290/// first `CURRENCY` at paren-depth 0. Spacing rules per
2291/// [`write_canonical_token_sequence`].
2292///
2293/// **Why the predicate must allow `PLUS` / `MINUS` / `L_PAREN`,
2294/// not just `NUMBER`.** A previous version skipped tokens until
2295/// it hit a `NUMBER`, which silently dropped leading unary signs
2296/// and opening parens — flipping the sign on inputs like
2297/// `2024-01-15 price USD -1.00 EUR` (formatted to `1.00 EUR`) and
2298/// corrupting parenthesized expressions like
2299/// `2024-01-15 balance Assets:A (1 + 2) USD` (formatted to
2300/// `1 + 2) USD USD`). Sign drift in BALANCE / PRICE is silent data
2301/// corruption — a balance assertion that previously asserted a
2302/// debit would assert a credit after a round-trip.
2303fn emit_amount_expression(node: &crate::SyntaxNode, group: GroupingStyle<'_>, out: &mut String) {
2304 let raw: Vec<crate::SyntaxToken> = node
2305 .children_with_tokens()
2306 .filter_map(rowan::NodeOrToken::into_token)
2307 .filter(|t| !is_trivia_kind(t.kind()))
2308 .skip_while(|t| {
2309 !matches!(
2310 t.kind(),
2311 crate::SyntaxKind::NUMBER
2312 | crate::SyntaxKind::PLUS
2313 | crate::SyntaxKind::MINUS
2314 | crate::SyntaxKind::L_PAREN
2315 )
2316 })
2317 .collect();
2318 let mut depth: i32 = 0;
2319 let mut first_currency_idx: Option<usize> = None;
2320 for (i, t) in raw.iter().enumerate() {
2321 match t.kind() {
2322 crate::SyntaxKind::L_PAREN => depth += 1,
2323 crate::SyntaxKind::R_PAREN => depth -= 1,
2324 // A `~ tolerance` clause ENDS the asserted amount. `emit_balance`
2325 // emits it separately via `balance_tolerance`, so running past the
2326 // tilde here emitted it twice: `0.00 ~ 1234.5 USD` came out as
2327 // `0.00 ~ 1234.5 USD ~ 1234.5 USD`. Stable but wrong, and very
2328 // likely not valid beancount — its balance grammar takes at most
2329 // one tolerance.
2330 crate::SyntaxKind::TILDE if depth == 0 && first_currency_idx.is_none() => {
2331 first_currency_idx = Some(i);
2332 }
2333 crate::SyntaxKind::CURRENCY if depth == 0 && first_currency_idx.is_none() => {
2334 first_currency_idx = Some(i);
2335 }
2336 _ => {}
2337 }
2338 }
2339 let end = first_currency_idx.unwrap_or(raw.len());
2340 write_canonical_token_sequence(&raw[..end], group, out);
2341}
2342
2343/// Emit an `AMOUNT` subnode's expression region: every non-trivia
2344/// token minus the trailing `CURRENCY` (caller re-emits the
2345/// currency itself). Used by [`format_amount`] for arithmetic
2346/// posting amounts like `-(1.00 + 2.00) USD`.
2347fn emit_amount_subnode_expression(
2348 node: &crate::SyntaxNode,
2349 group: GroupingStyle<'_>,
2350 out: &mut String,
2351) {
2352 let mut tokens: Vec<crate::SyntaxToken> = node
2353 .children_with_tokens()
2354 .filter_map(rowan::NodeOrToken::into_token)
2355 .filter(|t| !is_trivia_kind(t.kind()))
2356 .collect();
2357 if let Some(last) = tokens.last()
2358 && last.kind() == crate::SyntaxKind::CURRENCY
2359 {
2360 tokens.pop();
2361 }
2362 write_canonical_token_sequence(&tokens, group, out);
2363}
2364
2365/// Single dispatcher for the canonical spacing rules used by EVERY
2366/// token-sequence emit path: balance / price arithmetic, AMOUNT
2367/// subnodes, cost-spec interiors, and metadata values. There is no
2368/// separate path; each call site collects the relevant non-trivia
2369/// tokens and routes them through here so the rules cannot drift
2370/// between contexts.
2371///
2372/// Rules:
2373///
2374/// - single space between adjacent operands / binary operators
2375/// - no space after `(` or before `)` (parens stay tight)
2376/// - no space after a unary `+` / `-` (one that opens the run
2377/// or follows `(` or another operator)
2378/// - no space before `,` (commas in cost-spec component lists
2379/// stay tight against the preceding token)
2380///
2381/// **Adding a new `SyntaxKind` to the formatter implies thinking
2382/// about its effect on every call site of this function.** A new
2383/// operator-like kind added to `is_op` will silently change cost-
2384/// spec and metadata spacing too; a new bracket-like kind needs
2385/// its own rule. The corpus-level idempotence test
2386/// (`idempotence_corpus_sweep`) is the safety net that catches
2387/// drifts.
2388/// The currency a token run is denominated in: the LAST `CURRENCY` token in it.
2389///
2390/// A cost spec (`{1234.56 USD}`) and a balance tolerance carry their own
2391/// currency, and it is the one whose declaration governs their numerals. Taking
2392/// the ledger default instead is how `{1,234,567.89 USD}` came out grouped
2393/// while a plain `1234567.89 USD` posting in the same file stayed bare — USD
2394/// had declared `render_commas: FALSE` and only one of the two honored it.
2395fn run_currency(tokens: &[crate::SyntaxToken]) -> Option<&str> {
2396 tokens
2397 .iter()
2398 .rev()
2399 .find(|t| t.kind() == crate::SyntaxKind::CURRENCY)
2400 .map(rowan::SyntaxToken::text)
2401}
2402
2403fn write_canonical_token_sequence(
2404 tokens: &[crate::SyntaxToken],
2405 group: GroupingStyle<'_>,
2406 out: &mut String,
2407) {
2408 // `&&` short-circuits, so a ledger that declares no grouping never pays
2409 // for the currency scan. `format` walks whole ledgers; see the
2410 // `profile_format` example.
2411 let run_group = group.groups_anything() && group.groups(run_currency(tokens));
2412 let is_op = |k: crate::SyntaxKind| {
2413 matches!(
2414 k,
2415 crate::SyntaxKind::PLUS
2416 | crate::SyntaxKind::MINUS
2417 | crate::SyntaxKind::STAR
2418 | crate::SyntaxKind::SLASH
2419 )
2420 };
2421 let mut prev_kind: Option<crate::SyntaxKind> = None;
2422 let mut prev_was_unary = false;
2423 for t in tokens {
2424 let kind = t.kind();
2425 let is_unary = is_op(kind)
2426 && match prev_kind {
2427 None => true,
2428 Some(p) => p == crate::SyntaxKind::L_PAREN || is_op(p),
2429 };
2430 let need_space = match prev_kind {
2431 None => false,
2432 Some(prev) => {
2433 prev != crate::SyntaxKind::L_PAREN
2434 && kind != crate::SyntaxKind::R_PAREN
2435 && kind != crate::SyntaxKind::COMMA
2436 && !prev_was_unary
2437 }
2438 };
2439 if need_space {
2440 out.push(' ');
2441 }
2442 if kind == crate::SyntaxKind::NUMBER {
2443 out.push_str(&canonical_number(t.text(), run_group));
2444 } else {
2445 out.push_str(t.text());
2446 }
2447 prev_kind = Some(kind);
2448 prev_was_unary = is_unary;
2449 }
2450}
2451
2452/// Extract a balance directive's optional tolerance — the
2453/// `NUMBER` after the first `TILDE`, plus an optional trailing
2454/// `CURRENCY` at paren-depth 0.
2455fn balance_tolerance(
2456 node: &crate::SyntaxNode,
2457 group: GroupingStyle<'_>,
2458) -> Option<(String, Option<String>)> {
2459 // `balance Assets:A 100.00 ~ 0.05 USD` — one currency covers the asserted
2460 // amount and the tolerance, and it trails both, so resolve it up front
2461 // rather than mid-walk.
2462 let run_group = group.groups_anything() && {
2463 // Only materialized when something groups — see above.
2464 let toks: Vec<crate::SyntaxToken> = node
2465 .children_with_tokens()
2466 .filter_map(rowan::NodeOrToken::into_token)
2467 .collect();
2468 group.groups(run_currency(&toks))
2469 };
2470 let mut past_tilde = false;
2471 let mut number: Option<String> = None;
2472 let mut currency: Option<String> = None;
2473 for el in node.children_with_tokens() {
2474 let rowan::NodeOrToken::Token(t) = el else {
2475 continue;
2476 };
2477 if !past_tilde {
2478 if t.kind() == crate::SyntaxKind::TILDE {
2479 past_tilde = true;
2480 }
2481 continue;
2482 }
2483 match t.kind() {
2484 crate::SyntaxKind::NUMBER if number.is_none() => {
2485 number = Some(canonical_number(t.text(), run_group).into_owned());
2486 }
2487 crate::SyntaxKind::CURRENCY if number.is_some() && currency.is_none() => {
2488 currency = Some(t.text().to_string());
2489 }
2490 _ => {}
2491 }
2492 }
2493 number.map(|n| (n, currency))
2494}
2495
2496// ---- Metadata --------------------------------------------------
2497
2498/// Walk a directive's direct-child `META_ENTRY` nodes and emit
2499/// each on its own indented line in canonical form (`indent + KEY:
2500/// value\n`). Most directive types don't have a `.meta_entries()`
2501/// accessor on their typed wrapper; we walk the syntax node
2502/// directly to stay uniform.
2503fn emit_meta_entries_of(node: &crate::SyntaxNode, group: GroupingStyle<'_>, out: &mut String) {
2504 // Source-order walk so body-internal COMMENT lines are preserved
2505 // alongside the metadata entries (#1332). The header region (up to and
2506 // including the header-terminating NEWLINE) is skipped so the
2507 // header-trailing comment — spliced onto the header line by
2508 // `emit_directive` — is not duplicated here.
2509 let mut past_header = false;
2510 let mut seen_content = false;
2511 for el in node.children_with_tokens() {
2512 match el {
2513 rowan::NodeOrToken::Node(n) => {
2514 past_header = true;
2515 if let Some(entry) = MetaEntry::cast(n) {
2516 emit_meta_entry(&entry, INDENT, group, out);
2517 }
2518 }
2519 rowan::NodeOrToken::Token(t) => {
2520 if !past_header {
2521 match t.kind() {
2522 crate::SyntaxKind::NEWLINE if seen_content => past_header = true,
2523 k if k.is_trivia() => {}
2524 _ => seen_content = true,
2525 }
2526 continue;
2527 }
2528 if matches!(
2529 t.kind(),
2530 crate::SyntaxKind::COMMENT | crate::SyntaxKind::PERCENT_COMMENT
2531 ) {
2532 out.push_str(INDENT);
2533 out.push_str(t.text().trim_end_matches(['\n', '\r']));
2534 out.push('\n');
2535 }
2536 }
2537 }
2538 }
2539}
2540
2541/// Canonical emit for a single `META_ENTRY`. Walks non-trivia
2542/// tokens, prints them with single-space separation, and
2543/// normalizes numbers via [`canonical_number`]. The `META_KEY`
2544/// token already includes the trailing colon (e.g. `note:`); the
2545/// value side gets the same NUMBER + CURRENCY gluing rule the
2546/// rest of the formatter uses elsewhere.
2547///
2548/// Two semantically-equivalent inputs (e.g. `foo: "bar"` and
2549/// `foo: "bar"`) produce byte-identical output — the
2550/// gofmt-style invariant the file rustdoc promises.
2551fn emit_meta_entry(m: &MetaEntry, indent: &str, group: GroupingStyle<'_>, out: &mut String) {
2552 out.push_str(indent);
2553 // Split the META_ENTRY's non-trivia tokens into [META_KEY,
2554 // value*]. The META_KEY token already includes the trailing
2555 // colon (e.g. `note:`); the value tokens go through
2556 // write_canonical_token_sequence so the spacing rules — unary +/-
2557 // tight, COMMA tight, paren-tight, NUMBER canonicalized — are
2558 // shared with the balance/price/cost-spec/posting-amount paths.
2559 let content: Vec<crate::SyntaxToken> = m
2560 .syntax()
2561 .children_with_tokens()
2562 .filter_map(rowan::NodeOrToken::into_token)
2563 .filter(|t| {
2564 !matches!(
2565 t.kind(),
2566 crate::SyntaxKind::WHITESPACE | crate::SyntaxKind::NEWLINE
2567 )
2568 })
2569 .collect();
2570 let mut iter = content.iter();
2571 if let Some(key) = iter.next() {
2572 out.push_str(key.text());
2573 }
2574 let value_tokens: Vec<crate::SyntaxToken> = iter.cloned().collect();
2575 if !value_tokens.is_empty() {
2576 out.push(' ');
2577 write_canonical_token_sequence(&value_tokens, group, out);
2578 }
2579 out.push('\n');
2580}
2581
2582#[cfg(test)]
2583mod tests {
2584 use super::*;
2585
2586 #[test]
2587 fn empty_input_yields_single_newline() {
2588 assert_eq!(format_source(""), "\n");
2589 }
2590
2591 #[test]
2592 fn open_directive_canonical() {
2593 let src = "2024-01-15 open Assets:Cash\n";
2594 assert_eq!(format_source(src), "2024-01-15 open Assets:Cash\n");
2595 }
2596
2597 #[test]
2598 fn open_with_currencies_and_booking_canonical() {
2599 // The currency constraint list is comma-separated; emitting spaces
2600 // produced invalid beancount (#1405).
2601 let src = "2024-01-15 open Assets:Brokerage USD,EUR \"STRICT\"\n";
2602 assert_eq!(
2603 format_source(src),
2604 "2024-01-15 open Assets:Brokerage USD,EUR \"STRICT\"\n"
2605 );
2606 }
2607
2608 /// Regression for #1405: `format` must keep the open currency list
2609 /// comma-separated, not rewrite it space-separated (invalid syntax), and
2610 /// the result must be idempotent.
2611 #[test]
2612 fn open_currency_list_stays_comma_separated() {
2613 let src = "2026-01-01 open Assets:Wallet USD,EUR\n";
2614 let once = format_source(src);
2615 assert_eq!(once, "2026-01-01 open Assets:Wallet USD,EUR\n");
2616 assert_eq!(format_source(&once), once, "format must be idempotent");
2617 }
2618
2619 #[test]
2620 fn close_directive_canonical() {
2621 let src = "2024-12-31 close Assets:Cash\n";
2622 assert_eq!(format_source(src), "2024-12-31 close Assets:Cash\n");
2623 }
2624
2625 #[test]
2626 fn commodity_directive_canonical() {
2627 let src = "2024-01-01 commodity HOOL\n";
2628 assert_eq!(format_source(src), "2024-01-01 commodity HOOL\n");
2629 }
2630
2631 #[test]
2632 fn blank_lines_between_directives_preserved() {
2633 // #1325: the formatter preserves the author's inter-directive
2634 // blank lines rather than normalizing to exactly one (matching
2635 // Python bean-format and the rest of the beancount lineage).
2636
2637 // Grouped (no blank in source) stays grouped — not double-spaced.
2638 let grouped = "2024-01-01 open Assets:A\n2024-01-02 open Assets:B\n";
2639 assert_eq!(format_source(grouped), grouped);
2640
2641 // One blank is preserved as one.
2642 let one = "2024-01-01 open Assets:A\n\n2024-01-02 open Assets:B\n";
2643 assert_eq!(format_source(one), one);
2644
2645 // Two blanks are preserved as two (not collapsed).
2646 let two = "2024-01-01 open Assets:A\n\n\n2024-01-02 open Assets:B\n";
2647 assert_eq!(format_source(two), two);
2648
2649 // A whitespace-only "blank" line still counts as one blank line
2650 // (its trailing whitespace is stripped, leaving an empty line).
2651 let ws_blank = "2024-01-01 open Assets:A\n \n2024-01-02 open Assets:B\n";
2652 assert_eq!(
2653 format_source(ws_blank),
2654 "2024-01-01 open Assets:A\n\n2024-01-02 open Assets:B\n"
2655 );
2656 }
2657
2658 #[test]
2659 fn trailing_newline_always_present() {
2660 let src = "2024-01-01 open Assets:A";
2661 let formatted = format_source(src);
2662 assert!(formatted.ends_with('\n'));
2663 assert!(!formatted.ends_with("\n\n"));
2664 }
2665
2666 #[test]
2667 fn idempotent_on_canonical_input() {
2668 let src = "2024-01-01 open Assets:A\n\n2024-01-02 close Assets:A\n";
2669 let once = format_source(src);
2670 let twice = format_source(&once);
2671 assert_eq!(once, twice);
2672 }
2673
2674 #[test]
2675 fn note_canonical() {
2676 let src = "2024-01-15 note Assets:Cash \"a note\"\n";
2677 assert_eq!(
2678 format_source(src),
2679 "2024-01-15 note Assets:Cash \"a note\"\n"
2680 );
2681 }
2682
2683 #[test]
2684 fn event_canonical() {
2685 let src = "2024-01-15 event \"location\" \"NYC\"\n";
2686 assert_eq!(
2687 format_source(src),
2688 "2024-01-15 event \"location\" \"NYC\"\n"
2689 );
2690 }
2691
2692 #[test]
2693 fn query_canonical() {
2694 let src = "2024-01-15 query \"q1\" \"SELECT account\"\n";
2695 assert_eq!(
2696 format_source(src),
2697 "2024-01-15 query \"q1\" \"SELECT account\"\n"
2698 );
2699 }
2700
2701 #[test]
2702 fn pad_canonical() {
2703 let src = "2024-01-15 pad Assets:A Equity:Opening\n";
2704 assert_eq!(
2705 format_source(src),
2706 "2024-01-15 pad Assets:A Equity:Opening\n"
2707 );
2708 }
2709
2710 #[test]
2711 fn document_with_tags_and_links_canonical() {
2712 let src = "2024-06-01 document Assets:Bank \"stmt.pdf\" #q1 ^scan42 #urgent\n";
2713 assert_eq!(
2714 format_source(src),
2715 "2024-06-01 document Assets:Bank \"stmt.pdf\" #q1 ^scan42 #urgent\n"
2716 );
2717 }
2718
2719 #[test]
2720 fn issue_1321_document_tags_links_idempotent_across_directives() {
2721 // Same class as the transaction case, in `document` directives:
2722 // the 2nd+ document's trailing tags/links were dropped on a
2723 // reformat (found by the #1323 corpus idempotence check). Assert
2724 // the fixed-point property: re-formatting must not change (and
2725 // must not drop the tags/links of the second document).
2726 let src = "\
27272013-05-18 document Assets:Bank \"/a.pdf\" #tag1 ^link1
27282013-05-19 document Assets:Bank \"/b.pdf\" #tag2 ^link2
2729";
2730 let once = format_source(src);
2731 assert_eq!(format_source(&once), once, "format must be idempotent");
2732 assert!(
2733 once.contains("#tag2") && once.contains("^link2"),
2734 "the second document's tags/links must survive formatting; got:\n{once}"
2735 );
2736 }
2737
2738 #[test]
2739 fn issue_1321_header_tags_links_idempotent_across_transactions() {
2740 // Header tags/links must stay on the header line for EVERY
2741 // transaction, not just the first. Regression for #1321 where
2742 // the 2nd+ transaction's header tags/links got migrated to
2743 // continuation lines.
2744 let src = "\
27452024-01-15 * \"x\" #tag1 ^link1 #tag2 ^link2
2746 Assets:Cash -1.00 USD
2747 Expenses:Misc 1.00 USD
2748
27492024-01-16 * \"x\" #tag1 ^link1 #tag2 ^link2
2750 Assets:Cash -1.00 USD
2751 Expenses:Misc 1.00 USD
2752";
2753 assert_eq!(
2754 format_source(src),
2755 src,
2756 "format must be a no-op (idempotent)"
2757 );
2758 }
2759
2760 #[test]
2761 fn issue_1321_comment_before_transaction_keeps_header_tags() {
2762 // A comment line before a transaction is leading trivia attached
2763 // inside the transaction node (Directive-Terminator Rule), exactly
2764 // like a blank line. Skipping only WHITESPACE/NEWLINE let the
2765 // comment flip `seen_content`, break at the comment's NEWLINE, and
2766 // migrate the real header tags/links to continuation lines. The
2767 // header tags/links must stay on the header line. (Found by the
2768 // Copilot review of the #1321 fix.)
2769 let src = "\
27702024-01-15 * \"first\" #h1 ^l1
2771 Assets:Cash -1.00 USD
2772 Expenses:Misc 1.00 USD
2773
2774; a comment before the second transaction
27752024-01-16 * \"second\" #tag1 ^link1
2776 Assets:Cash -2.00 USD
2777 Expenses:Misc 2.00 USD
2778";
2779 assert_eq!(
2780 format_source(src),
2781 src,
2782 "a leading comment must not migrate header tags/links to continuation lines"
2783 );
2784 }
2785
2786 #[test]
2787 fn issue_1321_comment_before_document_keeps_tags() {
2788 // Document-directive variant of the comment-trivia case above.
2789 let src = "\
27902013-05-18 document Assets:Bank \"/a.pdf\" #tag1 ^link1
2791; a comment before the second document
27922013-05-19 document Assets:Bank \"/b.pdf\" #tag2 ^link2
2793";
2794 let once = format_source(src);
2795 assert_eq!(format_source(&once), once, "format must be idempotent");
2796 assert!(
2797 once.contains("\"/b.pdf\" #tag2 ^link2"),
2798 "the second document's tags/links must stay on its header line; got:\n{once}"
2799 );
2800 }
2801
2802 #[test]
2803 fn issue_1332_body_comments_in_metadata_preserved() {
2804 // The formatter must NOT delete comment-only lines inside a
2805 // directive body (#1332). Here two commented-out `; price:` lines
2806 // sit between metadata entries in a `commodity` body; they must
2807 // survive, interleaved in source order, and the result is idempotent.
2808 let src = "\
28092023-06-04 commodity EAM-VEUR ; cSpell: word VEUR
2810 name: \"Vanguard FTSE Developed Europe UCITS ETF EUR Dist\"
2811 ; price: \"EUR:alphavantage/price:VEUR.AS:EUR\"
2812 ; price: \"EUR:yahoo/VEUR.AS\"
2813 price: \"EUR:pricehist.beanprice.yahoo/VEUR.AS\"
2814";
2815 assert_eq!(
2816 format_source(src),
2817 src,
2818 "body comments must be preserved verbatim"
2819 );
2820 assert_eq!(format_source(&format_source(src)), format_source(src));
2821 }
2822
2823 #[test]
2824 fn issue_1332_body_comments_between_postings_preserved() {
2825 // Same class, inside a transaction body: a comment-only line between
2826 // postings must survive (in source order, 2-space indent). Asserted
2827 // via preservation + idempotence rather than an exact match, since
2828 // amount alignment is also canonicalized.
2829 let src = "\
28302024-01-15 * \"Cafe\" \"Latte\"
2831 Expenses:Coffee 4.50 USD
2832 ; was 5.00 before the discount
2833 Assets:Checking
2834";
2835 let out = format_source(src);
2836 assert!(
2837 out.contains("\n ; was 5.00 before the discount\n"),
2838 "the body comment must be preserved on its own indented line; got:\n{out}"
2839 );
2840 // Order: the comment stays between the two postings.
2841 let coffee = out.find("Expenses:Coffee").unwrap();
2842 let comment = out.find("; was 5.00").unwrap();
2843 let checking = out.find("Assets:Checking").unwrap();
2844 assert!(
2845 coffee < comment && comment < checking,
2846 "comment must stay between postings:\n{out}"
2847 );
2848 assert_eq!(format_source(&out), out, "format must be idempotent");
2849 }
2850
2851 #[test]
2852 fn issue_1335_org_headers_and_grouped_comments_preserved() {
2853 // The formatter must not delete unparsable content (#1335).
2854 // Org-mode `*` section headers parse into ERROR_NODEs, and comments
2855 // grouped with them get swallowed into the same node — previously all
2856 // dropped. They must survive, and the result must be idempotent.
2857 let src = "\
2858* Section A
2859;; comment between headers
2860;; second line
2861* Section B
28622013-01-01 open Assets:X
2863";
2864 let out = format_source(src);
2865 // Use the exact `;;` needles: a single-`;` substring would still match
2866 // `;; ...` even if one `;` were dropped, weakening the regression.
2867 for needle in [
2868 "* Section A",
2869 ";; comment between headers",
2870 ";; second line",
2871 "* Section B",
2872 "2013-01-01 open Assets:X",
2873 ] {
2874 assert!(
2875 out.contains(needle),
2876 "lost {needle:?} on format; got:\n{out}"
2877 );
2878 }
2879 assert_eq!(format_source(&out), out, "format must be idempotent");
2880 }
2881
2882 #[test]
2883 fn issue_1335_org_header_then_directive_keeps_header() {
2884 // A lone org header before a directive: the header is an ERROR_NODE
2885 // and must be kept (the comment here attaches to the directive and
2886 // was already preserved).
2887 let src = "* Accounts\n2013-01-01 open Assets:X\n";
2888 let out = format_source(src);
2889 assert!(
2890 out.contains("* Accounts"),
2891 "org header dropped; got:\n{out}"
2892 );
2893 assert_eq!(format_source(&out), out);
2894 }
2895
2896 #[test]
2897 fn issue_1335_blank_lines_around_org_header_preserved() {
2898 // An ERROR_NODE is a top-level content block: the author's blank line
2899 // between an org header and the following directive is preserved (it
2900 // is not flushed), and the result is idempotent.
2901 let src = "* Accounts\n\n2013-01-01 open Assets:X\n";
2902 assert_eq!(
2903 format_source(src),
2904 src,
2905 "blank around org header must be kept"
2906 );
2907 assert_eq!(format_source(&format_source(src)), format_source(src));
2908 }
2909
2910 #[test]
2911 fn issue_1337_posting_internal_comments_preserved() {
2912 // A comment on its own line inside a posting attaches as a COMMENT
2913 // token of the POSTING node; it must be preserved (#1337), not
2914 // dropped, and stay between its posting and the next.
2915 let src = "\
29162024-01-15 * \"x\"
2917 Assets:A 1.00 USD
2918 ; posting-internal note
2919 Assets:B
2920";
2921 let out = format_source(src);
2922 assert!(
2923 out.contains("; posting-internal note"),
2924 "posting-internal comment dropped; got:\n{out}"
2925 );
2926 let a = out.find("Assets:A").unwrap();
2927 let c = out.find("; posting-internal note").unwrap();
2928 let b = out.find("Assets:B").unwrap();
2929 assert!(a < c && c < b, "comment must stay between postings:\n{out}");
2930 assert_eq!(format_source(&out), out, "format must be idempotent");
2931 }
2932
2933 #[test]
2934 fn price_canonical_strips_thousands_separators() {
2935 let src = "2024-01-15 price USD 1,234.56 EUR\n";
2936 assert_eq!(format_source(src), "2024-01-15 price USD 1234.56 EUR\n");
2937 }
2938
2939 #[test]
2940 fn price_arithmetic_canonicalizes_spacing() {
2941 let src = "2024-01-15 price USD 1/2 EUR\n";
2942 assert_eq!(format_source(src), "2024-01-15 price USD 1 / 2 EUR\n");
2943 }
2944
2945 #[test]
2946 fn balance_canonical() {
2947 let src = "2024-01-15 balance Assets:Cash 100.00 USD\n";
2948 assert_eq!(
2949 format_source(src),
2950 "2024-01-15 balance Assets:Cash 100.00 USD\n"
2951 );
2952 }
2953
2954 #[test]
2955 fn balance_with_tolerance_canonical() {
2956 // Beancount's form is `AMOUNT ~ TOLERANCE CURRENCY` — ONE trailing
2957 // currency covering both numbers, per its Precision & Tolerances docs
2958 // (`319.020 ~ 0.002 RGAGX`). This test previously asserted
2959 // `100.00 USD ~ 0.01 USD`, repeating the currency; that is not the
2960 // beancount form, and the emitter produced it by running the amount
2961 // expression past the tilde and then emitting the tolerance again.
2962 // Input that repeats the currency now normalizes to the canonical form.
2963 let src = "2024-01-15 balance Assets:Cash 100.00 USD ~ 0.01 USD\n";
2964 assert_eq!(
2965 format_source(src),
2966 "2024-01-15 balance Assets:Cash 100.00 ~ 0.01 USD\n"
2967 );
2968 }
2969
2970 #[test]
2971 fn balance_arithmetic_canonical() {
2972 let src = "2024-01-15 balance Assets:Cash 0.25 + 0.75 USD\n";
2973 assert_eq!(
2974 format_source(src),
2975 "2024-01-15 balance Assets:Cash 0.25 + 0.75 USD\n"
2976 );
2977 }
2978
2979 #[test]
2980 fn custom_canonical() {
2981 let src = "2024-01-01 custom \"budget\" Expenses:Food 500.00 USD\n";
2982 assert_eq!(
2983 format_source(src),
2984 "2024-01-01 custom \"budget\" Expenses:Food 500.00 USD\n"
2985 );
2986 }
2987
2988 #[test]
2989 fn option_canonical() {
2990 let src = "option \"title\" \"My Ledger\"\n";
2991 assert_eq!(format_source(src), "option \"title\" \"My Ledger\"\n");
2992 }
2993
2994 #[test]
2995 fn include_canonical() {
2996 let src = "include \"other.beancount\"\n";
2997 assert_eq!(format_source(src), "include \"other.beancount\"\n");
2998 }
2999
3000 #[test]
3001 fn plugin_canonical_with_config() {
3002 let src = "plugin \"beancount.plugins.unrealized\" \"Unrealized\"\n";
3003 assert_eq!(
3004 format_source(src),
3005 "plugin \"beancount.plugins.unrealized\" \"Unrealized\"\n"
3006 );
3007 }
3008
3009 #[test]
3010 fn plugin_canonical_without_config() {
3011 let src = "plugin \"my.plugin\"\n";
3012 assert_eq!(format_source(src), "plugin \"my.plugin\"\n");
3013 }
3014
3015 #[test]
3016 fn pushtag_poptag_canonical() {
3017 // No blank line in the source — preserved as grouped (#1325).
3018 let src = "pushtag #active\npoptag #active\n";
3019 assert_eq!(format_source(src), "pushtag #active\npoptag #active\n");
3020 }
3021
3022 #[test]
3023 fn pushmeta_popmeta_canonical() {
3024 // No blank line in the source — preserved as grouped (#1325).
3025 let src = "pushmeta location: \"NYC\"\npopmeta location:\n";
3026 assert_eq!(
3027 format_source(src),
3028 "pushmeta location: \"NYC\"\npopmeta location:\n"
3029 );
3030 }
3031
3032 // ---- Transaction tests ------------------------------------
3033
3034 #[test]
3035 fn transaction_minimal_two_postings_aligns_amounts() {
3036 let src = "\
30372024-01-15 * \"Coffee\"
3038 Assets:Cash -5.00 USD
3039 Expenses:Coffee 5.00 USD
3040";
3041 // max LHS = 15 (Expenses:Coffee); number_col = 17.
3042 // max number width = 6 (`-5.00`); number_width = 6.
3043 // Posting 1: account end at col 13, pad 4 → `-5.00` (width 6,
3044 // no left-pad) → currency at col 24.
3045 // Posting 2: account end at col 17, pad 2 → ` 5.00` (width
3046 // 5 left-padded by 1) → currency at col 24.
3047 let expected = "\
30482024-01-15 * \"Coffee\"
3049 Assets:Cash -5.00 USD
3050 Expenses:Coffee 5.00 USD
3051";
3052 assert_eq!(format_source(src), expected);
3053 }
3054
3055 /// Regression for #1290: an amount-less posting (the common elided
3056 /// balancing leg) must NOT widen the number column, even when its
3057 /// account is longer than every amount-bearing account. `bean-format`
3058 /// computes the column only from number-bearing lines, so counting
3059 /// `Expenses:Food` here would make `rledger format` and `bean-format`
3060 /// disagree and never converge on round-trip.
3061 #[test]
3062 fn transaction_elided_posting_does_not_widen_amount_column() {
3063 let src = "\
30642024-01-15 * \"Coffee\"
3065 Assets:Cash -5.00 USD
3066 Expenses:Food
3067";
3068 // Only Assets:Cash (11) bears an amount; Expenses:Food (13) is
3069 // elided and is ignored for alignment. number_col = 2+11+2 = 15.
3070 let expected = "\
30712024-01-15 * \"Coffee\"
3072 Assets:Cash -5.00 USD
3073 Expenses:Food
3074";
3075 assert_eq!(format_source(src), expected);
3076 // Idempotent: re-formatting the output is a no-op.
3077 assert_eq!(format_source(expected), expected);
3078 }
3079
3080 /// Regression for #1290 using the reporter's exact fixture: a long
3081 /// elided account (`Expenses:Thingamabobs`) alongside a short
3082 /// amount-bearing one (`Assets:Money`). Pre-fix the number was
3083 /// pushed right to clear the long account; `bean-format` keeps it
3084 /// two spaces after `Assets:Money`. Also confirms the thousands
3085 /// separator is stripped.
3086 #[test]
3087 fn transaction_long_elided_account_matches_bean_format() {
3088 let src = "\
30892024-07-20 * \"Commas should stay\"
3090 Assets:Money -1,024 USD
3091 Expenses:Thingamabobs
3092";
3093 let expected = "\
30942024-07-20 * \"Commas should stay\"
3095 Assets:Money -1024 USD
3096 Expenses:Thingamabobs
3097";
3098 assert_eq!(format_source(src), expected);
3099 assert_eq!(format_source(expected), expected);
3100 }
3101
3102 /// Regression for the currency-only gap (#1307, found in review): a
3103 /// currency-only posting (`... USD`, no number) renders no number,
3104 /// so — like an elided posting — it must not widen the alignment
3105 /// column even when its account is the longest. Only `Assets:Bank`
3106 /// bears a number here, so the number stays two spaces after it. The
3107 /// assertion checks the numbered line directly, independent of how
3108 /// the currency-only line itself renders.
3109 #[test]
3110 fn transaction_currency_only_posting_does_not_widen_amount_column() {
3111 let out = format_source(
3112 "2024-01-15 * \"x\"\n Assets:Bank -5.00 USD\n Assets:LongCashReserve USD\n",
3113 );
3114 assert!(
3115 out.contains(" Assets:Bank -5.00 USD"),
3116 "number column must align to the numbered posting, not the longer \
3117 currency-only one; got:\n{out}"
3118 );
3119 }
3120
3121 #[test]
3122 fn transaction_payee_and_narration() {
3123 let src =
3124 "2024-01-15 * \"Starbucks\" \"Coffee\"\n Assets:Cash -5.00 USD\n Expenses:Coffee\n";
3125 let out = format_source(src);
3126 assert!(
3127 out.contains("2024-01-15 * \"Starbucks\" \"Coffee\"\n"),
3128 "got: {out}"
3129 );
3130 }
3131
3132 #[test]
3133 fn transaction_pending_flag() {
3134 let src = "2024-01-15 ! \"Pending\"\n Assets:Cash -5.00 USD\n Expenses:Misc\n";
3135 let out = format_source(src);
3136 assert!(out.starts_with("2024-01-15 ! \"Pending\"\n"), "got: {out}");
3137 }
3138
3139 #[test]
3140 fn transaction_txn_keyword_normalized_to_star() {
3141 // The `txn` keyword form is canonical-form equivalent to `*`.
3142 let src = "2024-01-15 txn \"x\"\n Assets:Cash -1.00 USD\n Expenses:Misc\n";
3143 let out = format_source(src);
3144 assert!(out.starts_with("2024-01-15 * \"x\"\n"), "got: {out}");
3145 }
3146
3147 #[test]
3148 fn transaction_header_tags_and_links() {
3149 let src =
3150 "2024-01-15 * \"x\" #tag1 ^link1 #tag2\n Assets:Cash -1.00 USD\n Expenses:Misc\n";
3151 let out = format_source(src);
3152 assert!(
3153 out.starts_with("2024-01-15 * \"x\" #tag1 ^link1 #tag2\n"),
3154 "got: {out}"
3155 );
3156 }
3157
3158 #[test]
3159 fn transaction_auto_balance_posting_no_amount() {
3160 let src = "2024-01-15 * \"x\"\n Assets:Cash -5.00 USD\n Expenses:Misc\n";
3161 let out = format_source(src);
3162 // The auto-balance posting has no amount; should just be
3163 // the indented account name.
3164 assert!(out.contains("\n Expenses:Misc\n"), "got: {out}");
3165 }
3166
3167 #[test]
3168 fn transaction_posting_with_cost_spec() {
3169 let src = "2024-01-15 * \"buy\"\n Assets:Brokerage 10 HOOL {500.00 USD}\n Assets:Cash -5000.00 USD\n";
3170 let out = format_source(src);
3171 assert!(out.contains("10 HOOL {500.00 USD}"), "got: {out}");
3172 }
3173
3174 #[test]
3175 fn transaction_posting_with_total_cost_spec() {
3176 let src = "2024-01-15 * \"buy\"\n Assets:Brokerage 10 HOOL {{5000.00 USD}}\n Assets:Cash -5000.00 USD\n";
3177 let out = format_source(src);
3178 assert!(out.contains("10 HOOL {{5000.00 USD}}"), "got: {out}");
3179 }
3180
3181 #[test]
3182 fn transaction_posting_with_per_unit_price() {
3183 let src = "2024-01-15 * \"buy\"\n Assets:Brokerage 10 HOOL @ 500.00 USD\n Assets:Cash -5000.00 USD\n";
3184 let out = format_source(src);
3185 assert!(out.contains("10 HOOL @ 500.00 USD"), "got: {out}");
3186 }
3187
3188 #[test]
3189 fn transaction_posting_with_total_price() {
3190 let src = "2024-01-15 * \"buy\"\n Assets:Brokerage 10 HOOL @@ 5000.00 USD\n Assets:Cash -5000.00 USD\n";
3191 let out = format_source(src);
3192 assert!(out.contains("10 HOOL @@ 5000.00 USD"), "got: {out}");
3193 }
3194
3195 #[test]
3196 fn transaction_posting_with_flag() {
3197 let src = "2024-01-15 * \"x\"\n ! Assets:Cash -5.00 USD\n Expenses:Misc 5.00 USD\n";
3198 let out = format_source(src);
3199 assert!(out.contains("\n ! Assets:Cash"), "got: {out}");
3200 }
3201
3202 #[test]
3203 fn transaction_negative_amount() {
3204 let src = "2024-01-15 * \"x\"\n Assets:Cash -5.00 USD\n Expenses:Misc 5.00 USD\n";
3205 let out = format_source(src);
3206 assert!(out.contains("-5.00 USD"), "got: {out}");
3207 assert!(out.contains(" 5.00 USD"), "got: {out}");
3208 }
3209
3210 #[test]
3211 fn transaction_strips_thousands_separators_in_postings() {
3212 let src = "2024-01-15 * \"x\"\n Assets:Cash -1,000.00 USD\n Expenses:Misc 1,000.00 USD\n";
3213 let out = format_source(src);
3214 assert!(out.contains("-1000.00 USD"), "got: {out}");
3215 assert!(!out.contains("1,000"), "got: {out}");
3216 }
3217
3218 #[test]
3219 fn transaction_arithmetic_amount() {
3220 let src =
3221 "2024-01-15 * \"x\"\n Assets:Cash -(1.00 + 2.00) USD\n Expenses:Misc 3.00 USD\n";
3222 let out = format_source(src);
3223 // The arithmetic expression should render with single
3224 // spaces around binary ops and tight parens.
3225 assert!(
3226 out.contains("(1.00 + 2.00) USD") || out.contains("-(1.00 + 2.00) USD"),
3227 "got: {out}"
3228 );
3229 }
3230
3231 #[test]
3232 fn transaction_idempotent() {
3233 let src = "\
32342024-01-15 * \"Coffee\"
3235 Assets:Cash -5.00 USD
3236 Expenses:Coffee 5.00 USD
3237";
3238 let once = format_source(src);
3239 let twice = format_source(&once);
3240 assert_eq!(once, twice);
3241 }
3242
3243 #[test]
3244 fn transaction_file_wide_alignment_across_transactions() {
3245 let src = "\
32462024-01-15 * \"x\"
3247 Assets:Cash -5.00 USD
3248 Expenses:Misc 5.00 USD
3249
32502024-01-16 * \"y\"
3251 Liabilities:CreditCard:Visa -100.00 USD
3252 Expenses:Big 100.00 USD
3253";
3254 let out = format_source(src);
3255 // Cross-posting invariant: the currency column (USD here)
3256 // lands at the same column on every posting line, even when
3257 // individual numbers differ in width or sign. The number
3258 // field is right-justified so the currency column is uniform.
3259 let usd_cols: Vec<usize> = out
3260 .lines()
3261 .filter(|l| l.starts_with(" ") && l.contains(" USD"))
3262 .filter_map(|l| l.find("USD"))
3263 .collect();
3264 assert!(
3265 usd_cols.len() >= 4,
3266 "expected ≥4 posting lines, got {usd_cols:?} in {out}"
3267 );
3268 let first = usd_cols[0];
3269 assert!(
3270 usd_cols.iter().all(|&c| c == first),
3271 "expected USD column uniform at {first}, got {usd_cols:?} in:\n{out}"
3272 );
3273 }
3274
3275 #[test]
3276 fn transaction_posting_metadata_indented_four() {
3277 let src =
3278 "2024-01-15 * \"x\"\n Assets:Cash -5.00 USD\n foo: \"bar\"\n Expenses:Misc\n";
3279 let out = format_source(src);
3280 assert!(out.contains("\n foo: \"bar\"\n"), "got: {out}");
3281 }
3282
3283 // ---- Code-review regression tests -----------------------------
3284 //
3285 // Each test pins a bug surfaced by the high-effort code review of
3286 // PR #1284 and verified at runtime against the unfixed formatter.
3287
3288 #[test]
3289 fn cost_spec_per_unit_plus_total_opener_preserved() {
3290 // Bug: format_cost_spec only branched on is_total() and emitted
3291 // `{` for the `{#` opener too, dropping the `#` marker and
3292 // changing semantics from per-unit-plus-total to plain
3293 // per-unit cost.
3294 let src = "2024-01-01 * \"buy\"\n Assets:Brokerage 10 HOOL {# 500.00 USD}\n Assets:Cash -5000.00 USD\n";
3295 let out = format_source(src);
3296 assert!(
3297 out.contains("{# 500.00 USD}"),
3298 "expected `{{#` opener preserved; got:\n{out}"
3299 );
3300 assert!(!out.contains("{500.00 USD}"), "got:\n{out}");
3301 }
3302
3303 #[test]
3304 fn cost_spec_comma_stays_tight_to_prev_token() {
3305 // Bug: format_cost_spec's catch-all arm inserted a space
3306 // before every non-trivia token including COMMA, producing
3307 // `{500.00 USD , 2024-01-15}` instead of the canonical
3308 // `{500.00 USD, 2024-01-15}`.
3309 let src = "2024-01-01 * \"buy\"\n Assets:Brokerage 10 HOOL {500.00 USD, 2024-01-15}\n Assets:Cash -5000.00 USD\n";
3310 let out = format_source(src);
3311 assert!(
3312 out.contains("{500.00 USD, 2024-01-15}"),
3313 "comma must stay tight to USD; got:\n{out}"
3314 );
3315 assert!(
3316 !out.contains("USD ,"),
3317 "no space allowed before comma; got:\n{out}"
3318 );
3319 }
3320
3321 #[test]
3322 fn custom_directive_preserves_date_value_arguments() {
3323 // Bug: emit_custom's post-seen_type match skipped every DATE
3324 // token, silently dropping legitimate date-typed value
3325 // arguments. The leading directive date is already skipped
3326 // via the seen_type=false phase.
3327 let src = "2024-01-01 custom \"budget\" \"name\" 2024-06-15 100.00 USD\n";
3328 let out = format_source(src);
3329 assert!(
3330 out.contains("2024-06-15"),
3331 "value-position DATE must survive; got: {out}"
3332 );
3333 }
3334
3335 #[test]
3336 fn file_level_adjacent_comments_stay_tight() {
3337 // Bug: format_node's top-level walk inserted a blank `\n`
3338 // separator before every emitted item including comments,
3339 // breaking section-header blocks like `; ====\n; HEADER\n; ====`
3340 // by injecting blanks between every adjacent comment line.
3341 let src = "; ====\n; HEADER\n; ====\n2024-01-01 open Assets:A\n";
3342 let expected = "; ====\n; HEADER\n; ====\n2024-01-01 open Assets:A\n";
3343 assert_eq!(format_source(src), expected);
3344 }
3345
3346 #[test]
3347 fn metadata_internal_whitespace_normalized() {
3348 // Bug: emit_meta_entries_of passed META_ENTRY source text
3349 // through verbatim, so `foo: "bar"` and `foo: "bar"` —
3350 // identical typed ASTs — produced different formatter
3351 // output, violating the gofmt-style invariant the rustdoc
3352 // declares.
3353 let a = "2024-01-01 open Assets:Bank\n starting: \"foo\"\n";
3354 let b = "2024-01-01 open Assets:Bank\n starting: \"foo\"\n";
3355 assert_eq!(format_source(a), format_source(b));
3356 }
3357
3358 #[test]
3359 fn metadata_number_thousands_separator_stripped() {
3360 // Same invariant: numbers inside metadata values share the
3361 // canonical thousands-separator policy with posting numbers
3362 // (otherwise the same file would emit inconsistent numeric
3363 // forms in postings vs. metadata).
3364 let src = "2024-01-01 open Assets:Bank\n starting_balance: 1,000.00 USD\n";
3365 let out = format_source(src);
3366 assert!(
3367 out.contains("1000.00 USD"),
3368 "thousands-sep should strip in metadata too; got: {out}"
3369 );
3370 assert!(!out.contains("1,000"), "got: {out}");
3371 }
3372
3373 #[test]
3374 fn bare_cr_line_endings_normalized_to_lf_before_parse() {
3375 // Bug: the lexer doesn't treat bare CR as a line terminator,
3376 // so a classic-Mac-authored `directive\r…\rdirective\r`
3377 // parsed as one broken directive and the rest were silently
3378 // dropped. format_source normalizes line endings BEFORE
3379 // parsing so bare CR (and CRLF) are treated as LF.
3380 let src = "2024-01-01 open Assets:A\r2024-01-02 open Assets:B\r";
3381 let out = format_source(src);
3382 assert!(
3383 out.contains("2024-01-01 open Assets:A"),
3384 "first directive lost: {out:?}"
3385 );
3386 assert!(
3387 out.contains("2024-01-02 open Assets:B"),
3388 "second directive lost on bare-CR input: {out:?}"
3389 );
3390 }
3391
3392 #[test]
3393 fn crlf_input_canonicalizes_to_lf() {
3394 // CRLF and bare CR both fold to LF on the way through the
3395 // canonical pass (the canonical form is LF-only).
3396 let src = "2024-01-01 open Assets:A\r\n2024-01-02 open Assets:B\r\n";
3397 let out = format_source(src);
3398 assert!(
3399 !out.contains('\r'),
3400 "canonical output must be LF-only: {out:?}"
3401 );
3402 assert!(out.contains("2024-01-01 open Assets:A\n"), "got: {out:?}");
3403 assert!(out.contains("2024-01-02 open Assets:B\n"), "got: {out:?}");
3404 }
3405
3406 #[test]
3407 fn metadata_value_with_unary_minus_stays_tight() {
3408 // Bug: emit_meta_entry's tokenized walk inserted a space
3409 // after a unary `+`/`-`, breaking `key: -5.00 USD` →
3410 // `key: - 5.00 USD`. Routed through write_canonical_token_sequence
3411 // so unary detection matches the balance/price/posting paths.
3412 let src = "2024-01-01 open Assets:Bank\n threshold: -5.00 USD\n";
3413 let out = format_source(src);
3414 assert!(
3415 out.contains("threshold: -5.00 USD"),
3416 "unary minus must stay tight in metadata; got: {out}"
3417 );
3418 assert!(
3419 !out.contains("- 5.00"),
3420 "no space after unary minus; got: {out}"
3421 );
3422 }
3423
3424 #[test]
3425 fn metadata_value_with_unary_plus_stays_tight() {
3426 let src = "2024-01-01 open Assets:Bank\n min: +1.00 USD\n";
3427 let out = format_source(src);
3428 assert!(out.contains("min: +1.00 USD"), "got: {out}");
3429 assert!(!out.contains("+ 1.00"), "got: {out}");
3430 }
3431
3432 #[test]
3433 fn cost_spec_negative_cost_stays_tight() {
3434 // Bug: format_cost_spec catch-all had no unary-operator
3435 // handling. `{-500 USD}` formatted to `{- 500 USD}`. Now
3436 // routes through write_canonical_token_sequence.
3437 let src = "2024-01-01 * \"x\"\n Assets:Brokerage 10 HOOL {-500 USD}\n Assets:Cash -5000.00 USD\n";
3438 let out = format_source(src);
3439 assert!(
3440 out.contains("{-500 USD}"),
3441 "negative cost spec must stay tight; got:\n{out}"
3442 );
3443 assert!(!out.contains("{- "), "got:\n{out}");
3444 }
3445
3446 #[test]
3447 fn cost_spec_arithmetic_with_unary_stays_tight() {
3448 // `{500 * -2 USD}` formerly emitted `{500 * - 2 USD}` because
3449 // the cost-spec catch-all didn't understand unary +/-.
3450 let src = "2024-01-01 * \"x\"\n Assets:Brokerage 10 HOOL {500 * -2 USD}\n Assets:Cash -1000.00 USD\n";
3451 let out = format_source(src);
3452 assert!(
3453 out.contains("{500 * -2 USD}"),
3454 "cost-spec arithmetic unary must stay tight; got:\n{out}"
3455 );
3456 }
3457
3458 // ---- Property tests -------------------------------------------
3459 //
3460 // Two invariants the rustdoc's gofmt-style promise depends on,
3461 // pinned over a hand-curated input matrix:
3462 //
3463 // - **Idempotence:** `format_source(format_source(x)) == format_source(x)`.
3464 // - **Round-trip stability for canonicalize_directives:** the
3465 // synthesize-then-canonicalize shim produces text that, when
3466 // parsed back, yields the same Directive count and zero parse
3467 // errors.
3468 //
3469 // The matrix covers every directive kind plus the high-risk
3470 // edge cases the prior reviews surfaced (unary +/- in metadata,
3471 // cost-spec arithmetic, CRLF, bare CR, multi-line strings,
3472 // comments containing quotes, non-Latin accounts). When the
3473 // upstream compatibility corpus is fetched into
3474 // `tests/compatibility/files/` the per-file sweep at the bottom
3475 // also runs; otherwise the file-based test is skipped.
3476
3477 const IDEMPOTENCE_MATRIX: &[(&str, &str)] = &[
3478 ("empty", ""),
3479 ("only_comment", "; header comment\n"),
3480 ("only_directive", "2024-01-01 open Assets:Cash\n"),
3481 (
3482 "two_open_directives",
3483 "2024-01-01 open Assets:A\n2024-01-02 open Assets:B\n",
3484 ),
3485 (
3486 "transaction_with_cost_and_price",
3487 "2024-01-15 * \"buy\"\n Assets:Brokerage 10 HOOL {500.00 USD} @ 510.00 USD\n Assets:Cash -5000.00 USD\n",
3488 ),
3489 (
3490 "transaction_with_per_unit_plus_total_cost",
3491 "2024-01-15 * \"x\"\n Assets:Brokerage 10 HOOL {# 500.00 USD}\n Assets:Cash -5000.00 USD\n",
3492 ),
3493 (
3494 "transaction_with_arithmetic_amount",
3495 "2024-01-15 * \"x\"\n Assets:Cash -(1.00 + 2.00) USD\n Expenses:Misc 3.00 USD\n",
3496 ),
3497 (
3498 "balance_with_arithmetic_and_tolerance",
3499 "2024-01-15 balance Assets:Cash 0.25 + 0.75 USD ~ 0.01 USD\n",
3500 ),
3501 // Regression for Copilot #2: a previous emit_amount_expression
3502 // skipped tokens until the first NUMBER, which dropped a
3503 // leading unary `-` and silently flipped the sign — a
3504 // balance assertion that asserted a debit would assert a
3505 // credit after a round-trip. These fixtures pin the
3506 // sign / paren preservation explicitly.
3507 (
3508 "balance_leading_unary_minus",
3509 "2024-01-15 balance Assets:A -1.00 USD\n",
3510 ),
3511 (
3512 "balance_leading_parenthesized_expression",
3513 "2024-01-15 balance Assets:A (1 + 2) USD\n",
3514 ),
3515 (
3516 "price_leading_unary_minus",
3517 "2024-01-15 price USD -1.00 EUR\n",
3518 ),
3519 (
3520 "price_with_thousands_separator",
3521 "2024-01-15 price USD 1,234.56 EUR\n",
3522 ),
3523 (
3524 "metadata_unary_minus",
3525 "2024-01-01 open Assets:Bank\n threshold: -5.00 USD\n",
3526 ),
3527 (
3528 "metadata_arithmetic",
3529 "2024-01-01 open Assets:Bank\n total: 1000 + 500 USD\n",
3530 ),
3531 (
3532 "cost_spec_with_comma_and_date",
3533 "2024-01-15 * \"x\"\n Assets:Brokerage 10 HOOL {500.00 USD, 2024-01-15}\n Assets:Cash -5000.00 USD\n",
3534 ),
3535 (
3536 "cost_spec_with_negative",
3537 "2024-01-15 * \"x\"\n Assets:Brokerage 10 HOOL {-500 USD}\n Assets:Cash 5000.00 USD\n",
3538 ),
3539 (
3540 "transaction_with_tags_and_links",
3541 "2024-01-15 * \"x\" #tag1 ^link1 #tag2\n Assets:Cash -1.00 USD\n Expenses:Misc 1.00 USD\n",
3542 ),
3543 (
3544 "custom_with_date_value",
3545 "2024-01-01 custom \"budget\" \"name\" 2024-06-15 100.00 USD\n",
3546 ),
3547 (
3548 "non_latin_account_name",
3549 "2024-01-15 * \"x\"\n Активы:Банк -5.00 USD\n Expenses:Misc 5.00 USD\n",
3550 ),
3551 (
3552 "section_header_comments",
3553 "; ====\n; HEADER\n; ====\n2024-01-01 open Assets:A\n",
3554 ),
3555 (
3556 "multiline_note_string",
3557 "2024-01-15 note Assets:Bank \"line 1\nline 2\"\n",
3558 ),
3559 (
3560 "comment_containing_quote",
3561 "; comment with \"a quote\n2024-01-01 open Assets:A\n",
3562 ),
3563 (
3564 "crlf_input",
3565 "2024-01-01 open Assets:A\r\n2024-01-02 open Assets:B\r\n",
3566 ),
3567 (
3568 "bare_cr_input",
3569 "2024-01-01 open Assets:A\r2024-01-02 open Assets:B\r",
3570 ),
3571 (
3572 "file_with_trailing_newlines",
3573 "2024-01-01 open Assets:A\n\n\n",
3574 ),
3575 ("file_without_trailing_newline", "2024-01-01 open Assets:A"),
3576 // Regression for Copilot #1: collect_trailing_comment
3577 // previously returned None for a directive with no
3578 // header-terminating NEWLINE token, which silently dropped
3579 // a same-line trailing comment at EOF when the file lacked
3580 // a trailing newline. The canonical formatter restores the
3581 // trailing newline, but the dropped comment was already
3582 // gone.
3583 (
3584 "trailing_comment_no_final_newline",
3585 "2024-01-15 open Assets:A ; trailing",
3586 ),
3587 (
3588 "posting_with_trailing_comment",
3589 "2024-01-15 * \"x\"\n Assets:Cash -5.00 USD ; pocket\n Expenses:Misc 5.00 USD\n",
3590 ),
3591 (
3592 "balance_assertion_with_meta",
3593 "2024-01-15 balance Assets:Cash 100.00 USD\n source: \"bank\"\n",
3594 ),
3595 (
3596 "options_and_includes",
3597 "option \"title\" \"My Ledger\"\ninclude \"sub.beancount\"\nplugin \"my.plugin\" \"cfg\"\n",
3598 ),
3599 // ---- per-variant coverage ---------------------------------
3600 ("close_directive", "2024-12-31 close Assets:Cash\n"),
3601 ("commodity_directive", "2024-01-01 commodity HOOL\n"),
3602 ("note_directive", "2024-01-15 note Assets:Cash \"a note\"\n"),
3603 ("event_directive", "2024-01-15 event \"location\" \"NYC\"\n"),
3604 (
3605 "query_directive",
3606 "2024-01-15 query \"q1\" \"SELECT account\"\n",
3607 ),
3608 ("pad_directive", "2024-01-15 pad Assets:A Equity:Opening\n"),
3609 (
3610 "document_directive",
3611 "2024-06-01 document Assets:Bank \"stmt.pdf\" #q1\n",
3612 ),
3613 // Note: `#!` and `#+` anywhere on a line, not just at
3614 // line start, open the lexer's SHEBANG / EMACS_DIRECTIVE
3615 // tokens. The fixture places `#+` mid-line and tails it
3616 // with an unbalanced `"`: an incorrect state machine that
3617 // gated the opener on `at_line_start` would stay in Code
3618 // when it hit the `#+`, then flip to InString on the next
3619 // `"` and trap there for the remainder of the file. The
3620 // lexer-agreement property test catches that divergence,
3621 // and the round-trip body runs too because the parser
3622 // treats the mid-line EMACS_DIRECTIVE as same-line
3623 // trailing trivia under the directive-terminator rule.
3624 (
3625 "emacs_directive_mid_line_with_quote",
3626 "2024-01-15 open Assets:A #+stray \"q\n",
3627 ),
3628 ("pushtag_directive", "pushtag #active\n"),
3629 ("poptag_directive", "poptag #active\n"),
3630 ("pushmeta_directive", "pushmeta location: \"NYC\"\n"),
3631 ("popmeta_directive", "popmeta location:\n"),
3632 ];
3633
3634 /// Number of fixtures in [`IDEMPOTENCE_MATRIX`] that legitimately
3635 /// produce zero typed directives — comment-only / empty /
3636 /// pragma-only inputs. The round-trip property test skips these
3637 /// (they have nothing to emit), but every OTHER fixture MUST
3638 /// exercise the body. Bumping this constant when adding such a
3639 /// fixture is the only manual maintenance the coverage floor
3640 /// needs; otherwise the floor (`IDEMPOTENCE_MATRIX.len() -
3641 /// ROUNDTRIP_KNOWN_ZERO_DIRECTIVE_FIXTURES`) tracks the matrix
3642 /// automatically.
3643 ///
3644 /// Today's zero-directive fixtures (skipped by the round-trip
3645 /// body), verified by an exhaustive probe against the live
3646 /// parser:
3647 ///
3648 /// - `empty`, `only_comment` — no directives at all.
3649 /// - `bare_cr_input` — the parser does not recognize bare CR
3650 /// (without a following LF) as a directive terminator, so
3651 /// the file's two would-be directives never surface as
3652 /// structured tokens. The fixture's purpose is the
3653 /// line-ending state-machine pass, not the round-trip body.
3654 /// - `pushtag_directive`, `poptag_directive`,
3655 /// `pushmeta_directive`, `popmeta_directive` — pragma
3656 /// directives don't surface as `Directive` variants on the
3657 /// typed-AST side (the parser also rejects them today, so
3658 /// they produce parse errors and the skip-on-errors guard
3659 /// triggers).
3660 /// - `options_and_includes` — option / include / plugin lines
3661 /// live on separate `ParseResult` collections, not on
3662 /// `.directives`.
3663 ///
3664 /// Note: `comment_containing_quote` and
3665 /// `emacs_directive_mid_line_with_quote` BOTH exercise the
3666 /// body — each is paired with a parseable directive on the
3667 /// same line or an adjacent line, and the trivia token
3668 /// (comment / `EMACS_DIRECTIVE`) attaches as same-line or
3669 /// inter-directive trivia under the directive-terminator
3670 /// rule. Their purpose is the state-machine / lexer agreement
3671 /// property on a comment with an unbalanced `"`, not the
3672 /// zero-directive case.
3673 const ROUNDTRIP_KNOWN_ZERO_DIRECTIVE_FIXTURES: usize = 8;
3674
3675 #[test]
3676 fn lf_to_crlf_outside_strings_preserves_string_interior() {
3677 // Bug: a flat in_string-only state machine would re-inject
3678 // CRLF inside multi-line strings, mutating the user's bytes.
3679 let s = "2024-01-15 note Assets:Bank \"line 1\nline 2\"\n";
3680 let out = lf_to_crlf_outside_strings(s);
3681 assert!(out.contains("line 1\nline 2"), "got: {out:?}");
3682 assert!(out.ends_with("\r\n"), "got: {out:?}");
3683 }
3684
3685 #[test]
3686 fn lf_to_crlf_outside_strings_handles_comment_with_quote() {
3687 // Bug: an unbalanced `"` inside a `;` comment formerly flipped
3688 // in_string=true for the rest of the file, leaving every
3689 // subsequent newline as LF.
3690 let s = "; comment with \"a quote\n2024-01-01 open Assets:A\n";
3691 let out = lf_to_crlf_outside_strings(s);
3692 assert_eq!(
3693 out,
3694 "; comment with \"a quote\r\n2024-01-01 open Assets:A\r\n",
3695 );
3696 }
3697
3698 #[test]
3699 fn lf_to_crlf_outside_strings_handles_percent_comment_with_quote() {
3700 let s = "% percent \"quote\n2024-01-01 open Assets:A\n";
3701 let out = lf_to_crlf_outside_strings(s);
3702 assert_eq!(out, "% percent \"quote\r\n2024-01-01 open Assets:A\r\n");
3703 }
3704
3705 #[test]
3706 fn crlf_to_lf_preserves_crlf_inside_strings() {
3707 // Bug fix mirror: a Windows-authored multi-line string had
3708 // its CRLF folded to LF by the pre-parse normalizer too,
3709 // which silently mutated the user's bytes.
3710 let s = "2024-01-15 note Assets:Bank \"line1\r\nline2\"\r\n";
3711 let normalized = crlf_to_lf_outside_strings(s);
3712 // Outside the string, the trailing CRLF folds to LF; inside
3713 // the string, CRLF stays CRLF (user's bytes preserved).
3714 assert!(
3715 normalized.contains("\"line1\r\nline2\""),
3716 "got: {:?}",
3717 &*normalized
3718 );
3719 assert!(normalized.ends_with('\n') && !normalized.ends_with("\r\n"));
3720 }
3721
3722 #[test]
3723 fn idempotence_matrix() {
3724 // The gofmt invariant in the file rustdoc: f(f(x)) == f(x)
3725 // on every accepted input. Each fixture below covers one
3726 // axis of the canonical-form spec; together they exercise
3727 // every directive kind and every spacing rule shared via
3728 // write_canonical_token_sequence.
3729 for (name, src) in IDEMPOTENCE_MATRIX {
3730 let once = format_source(src);
3731 let twice = format_source(&once);
3732 assert_eq!(
3733 once, twice,
3734 "idempotence broken on fixture `{name}`\n--- once ---\n{once}\n--- twice ---\n{twice}",
3735 );
3736 }
3737 }
3738
3739 /// The number-display context (#1766) threads through the
3740 /// two-pass canonicalize shim: precision pads. Thousands
3741 /// separators are deliberately absent — canonical ledger text has
3742 /// none (this canonicalizer strips them by definition, and
3743 /// `render_number` agrees so direct emitters match the shim).
3744 #[test]
3745 fn canonicalize_directives_honors_number_display_context() {
3746 use rustledger_core::format::FormatConfig;
3747
3748 let source = "2024-01-15 balance Assets:Bank 1234.5 USD\n";
3749 let parsed = crate::parse(source);
3750 assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
3751
3752 let mut ctx = rustledger_core::DisplayContext::new();
3753 ctx.set_fixed_precision("USD", 2);
3754 ctx.set_render_commas(true);
3755 let config = FormatConfig {
3756 number_display: Some(ctx),
3757 ..FormatConfig::default()
3758 };
3759 let out = canonicalize_directives(parsed.directives.iter().map(|d| &d.value), &config)
3760 .expect("comma-grouped canonical text must survive the reparse");
3761 // REVERSED from "precision pads, no separators". The shim's second
3762 // pass now carries the grouping rule, so a context that asks for
3763 // separators gets them in ledger text — matching beancount, whose
3764 // `render_commas` is documented to affect its PRINT command. The
3765 // machine boundary is the parser, and the grammar admits grouped
3766 // numerals; csv/json (whose consumers have no grammar) are unaffected.
3767 assert!(
3768 out.contains("1,234.50 USD"),
3769 "precision AND grouping both flow through the shim: {out}"
3770 );
3771
3772 // And the default config stays byte-faithful to the value's scale.
3773 let out = canonicalize_directives(
3774 parsed.directives.iter().map(|d| &d.value),
3775 &FormatConfig::default(),
3776 )
3777 .expect("canonicalizes");
3778 assert!(out.contains("1234.5 USD"), "own scale preserved: {out}");
3779 }
3780
3781 /// Padded COST and PRICE-annotation numbers survive the shim's
3782 /// pass 2 (the CST re-canonicalization preserves trailing zeros),
3783 /// and the pass-1 emitter and the shim agree on every rendered
3784 /// number — the cross-surface drift guard the `render_number` doc
3785 /// claims (deep review of #1807).
3786 #[test]
3787 fn canonicalize_pads_costs_and_prices_and_agrees_with_pass_one() {
3788 use rustledger_core::format::FormatConfig;
3789
3790 let source = "2024-01-10 * \"buy\"\n Assets:Broker 2 AAPL {150 USD} @ 155.5 USD\n Assets:Cash -310.00 USD\n";
3791 let parsed = crate::parse(source);
3792 assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
3793
3794 let mut ctx = rustledger_core::DisplayContext::new();
3795 ctx.set_fixed_precision("USD", 2);
3796 let config = FormatConfig {
3797 number_display: Some(ctx),
3798 ..FormatConfig::default()
3799 };
3800
3801 let pass_one = rustledger_core::format::format_directives(
3802 parsed.directives.iter().map(|d| &d.value),
3803 &config,
3804 );
3805 let canonical =
3806 canonicalize_directives(parsed.directives.iter().map(|d| &d.value), &config)
3807 .expect("padded cost/price text must survive the reparse");
3808
3809 for padded in ["{150.00 USD}", "@ 155.50 USD"] {
3810 assert!(
3811 pass_one.contains(padded),
3812 "pass-1 emitter pads {padded}: {pass_one}"
3813 );
3814 assert!(
3815 canonical.contains(padded),
3816 "the shim preserves the padded {padded}: {canonical}"
3817 );
3818 }
3819 }
3820
3821 #[test]
3822 fn canonicalize_directives_roundtrips_every_synthesized_directive() {
3823 // For each canonical-form fixture: parse → take the typed
3824 // directives → run them through canonicalize_directives →
3825 // re-parse the canonical text → assert the parser reports
3826 // zero errors and the directive count is preserved.
3827 //
3828 // This is the proper end-to-end test of the two-pass shim
3829 // the FFI format.entry and rledger add/extract commands all
3830 // depend on. Without it, a future Directive variant added
3831 // to rustledger-core without matching coverage in
3832 // cst::format would silently round-trip to truncated text.
3833 //
3834 // Counter + assertion guards against silent-skip: if the
3835 // guard at the top of the loop ever filters too many
3836 // fixtures (e.g. a parser regression that drops directives
3837 // from previously-clean fixtures), the test fails instead
3838 // of silently passing with zero coverage.
3839 use rustledger_core::format::FormatConfig;
3840 let cfg = FormatConfig::default();
3841 let mut exercised = 0usize;
3842 for (name, src) in IDEMPOTENCE_MATRIX {
3843 let parsed = crate::parse(src);
3844 if parsed.errors.is_empty() && !parsed.directives.is_empty() {
3845 let dirs: Vec<&rustledger_core::Directive> =
3846 parsed.directives.iter().map(|s| &s.value).collect();
3847 let formatted = super::canonicalize_directives(dirs.iter().copied(), &cfg)
3848 .unwrap_or_else(|e| {
3849 panic!("canonicalize_directives error on fixture `{name}`: {e}")
3850 });
3851 let reparsed = crate::parse(&formatted);
3852 assert!(
3853 reparsed.errors.is_empty(),
3854 "round-trip parse errors on fixture `{name}`:\n--- formatted ---\n{formatted}\n--- errors ---\n{:?}",
3855 reparsed.errors,
3856 );
3857 assert_eq!(
3858 parsed.directives.len(),
3859 reparsed.directives.len(),
3860 "directive count drifted on fixture `{name}`\n--- formatted ---\n{formatted}",
3861 );
3862 exercised += 1;
3863 }
3864 }
3865 let expected = IDEMPOTENCE_MATRIX
3866 .len()
3867 .saturating_sub(ROUNDTRIP_KNOWN_ZERO_DIRECTIVE_FIXTURES);
3868 assert!(
3869 exercised >= expected,
3870 "only {exercised} fixtures exercised the round-trip body, \
3871 expected at least {expected} (= IDEMPOTENCE_MATRIX.len() - \
3872 {ROUNDTRIP_KNOWN_ZERO_DIRECTIVE_FIXTURES}). A parser \
3873 regression or a broken fixture is silently dropping coverage."
3874 );
3875 }
3876
3877 /// `SHEBANG` / `EMACS_DIRECTIVE` lines (`#!…` / `#+…` at line
3878 /// start) also count as comments for the LSP-CRLF state
3879 /// machine. A stray quote inside such a line used to flip
3880 /// `in_string=true` for the rest of the file just like the
3881 /// `;` / `%` comment case the round-3 fix covered.
3882 #[test]
3883 fn lf_to_crlf_outside_strings_handles_emacs_directive_with_quote() {
3884 let s = "#+title: \"My Book\n2024-01-01 open Assets:A\n";
3885 let out = lf_to_crlf_outside_strings(s);
3886 assert_eq!(out, "#+title: \"My Book\r\n2024-01-01 open Assets:A\r\n");
3887 }
3888
3889 #[test]
3890 fn lf_to_crlf_outside_strings_handles_shebang_with_quote() {
3891 let s = "#!shebang \"quote\n2024-01-01 open Assets:A\n";
3892 let out = lf_to_crlf_outside_strings(s);
3893 assert_eq!(out, "#!shebang \"quote\r\n2024-01-01 open Assets:A\r\n");
3894 }
3895
3896 /// `#` NOT at line start is a TAG / HASH token; the state
3897 /// machine must NOT treat it as a comment opener.
3898 #[test]
3899 fn lf_to_crlf_outside_strings_hash_mid_line_is_not_comment() {
3900 let s = "2024-01-15 * \"x\" #tag1\n Assets:A 1 USD\n";
3901 let out = lf_to_crlf_outside_strings(s);
3902 // Every LF outside strings becomes CRLF — including the
3903 // one ending the tag-bearing line.
3904 assert!(out.contains("#tag1\r\n"), "got: {out:?}");
3905 assert!(out.ends_with("\r\n"), "got: {out:?}");
3906 }
3907
3908 /// Regression for Copilot #2 inline review on PR #1284: a
3909 /// previous `emit_amount_expression` dropped leading unary
3910 /// signs and parens, flipping the sign on
3911 /// `2024-01-15 balance Assets:A
3912 /// -1.00 USD` to `1.00 USD` — silent data corruption (a debit
3913 /// asserted as a credit). Byte-exact pins on every shape.
3914 #[test]
3915 fn balance_price_preserve_leading_unary_and_parens() {
3916 // Bare leading minus on balance.
3917 let src = "2024-01-15 balance Assets:A -1.00 USD\n";
3918 assert_eq!(
3919 format_source(src),
3920 "2024-01-15 balance Assets:A -1.00 USD\n"
3921 );
3922
3923 // Bare leading minus on price (sign flip would change
3924 // every quote on the user's commodity).
3925 let src = "2024-01-15 price USD -1.00 EUR\n";
3926 assert_eq!(format_source(src), "2024-01-15 price USD -1.00 EUR\n");
3927
3928 // Leading parenthesized expression. The previous code
3929 // dropped the `(`, which made the trailing `)` unbalanced
3930 // AND made the first-CURRENCY scan find the wrong token.
3931 let src = "2024-01-15 balance Assets:A (1 + 2) USD\n";
3932 assert_eq!(
3933 format_source(src),
3934 "2024-01-15 balance Assets:A (1 + 2) USD\n"
3935 );
3936
3937 // Leading minus on a parenthesized arithmetic expression.
3938 let src = "2024-01-15 balance Assets:A -(1 + 2) USD\n";
3939 assert_eq!(
3940 format_source(src),
3941 "2024-01-15 balance Assets:A -(1 + 2) USD\n"
3942 );
3943 }
3944
3945 /// Regression for Copilot #1 inline review on PR #1284:
3946 /// `collect_trailing_comment` used `?` on the header-terminating
3947 /// NEWLINE, silently dropping same-line trailing comments at
3948 /// EOF when the file had no final newline. The canonical
3949 /// formatter restores the trailing newline, but the dropped
3950 /// comment was already gone — a real-world case for editors
3951 /// that don't insert a trailing newline on save.
3952 #[test]
3953 fn trailing_comment_preserved_at_eof_without_newline() {
3954 let src = "2024-01-15 open Assets:A ; trailing";
3955 assert_eq!(format_source(src), "2024-01-15 open Assets:A ; trailing\n");
3956 }
3957
3958 #[test]
3959 fn try_format_source_returns_ok_on_clean_input() {
3960 let src = "2024-01-15 open Assets:Cash\n";
3961 let out = super::try_format_source(src).expect("clean input should format");
3962 assert_eq!(out, super::format_source(src));
3963 }
3964
3965 #[test]
3966 fn try_format_source_returns_err_on_parse_error() {
3967 // Bare `unparsable` text triggers parser errors. The
3968 // helper must surface them instead of silently emitting
3969 // canonical text around a broken file.
3970 let src = "this is not a directive at all\n";
3971 let err = super::try_format_source(src).expect_err("garbage should error");
3972 assert!(!err.is_empty(), "errors must not be empty");
3973 }
3974
3975 #[test]
3976 fn cr_outside_strings_present_distinguishes_in_string_cr() {
3977 // CR inside a multi-line string literal must NOT count —
3978 // the formatter wouldn't fold it.
3979 let in_string_only = "2024-01-15 note Assets:Bank \"line1\r\nline2\"\n";
3980 assert!(!super::cr_outside_strings_present(in_string_only));
3981
3982 // CR outside any string literal (CRLF line terminator)
3983 // counts — that's what crlf_to_lf_outside_strings would
3984 // fold.
3985 let crlf_terminator = "2024-01-01 open Assets:A\r\n";
3986 assert!(super::cr_outside_strings_present(crlf_terminator));
3987
3988 // No `\r` at all — fast path.
3989 let lf_only = "2024-01-01 open Assets:A\n";
3990 assert!(!super::cr_outside_strings_present(lf_only));
3991
3992 // CR inside a `;` comment is outside any string and counts.
3993 // (Beancount lexer's comment regex excludes the newline, so
3994 // the comment region ends at `\r`; either way, the predicate
3995 // says "yes, the formatter would fold this byte".)
3996 let comment_with_cr = "; comment with \"quote\rstuff\n";
3997 assert!(super::cr_outside_strings_present(comment_with_cr));
3998 }
3999
4000 #[test]
4001 fn canonicalize_directives_directive_count_mismatch_is_reported() {
4002 // Drive the new DirectiveCountMismatch error variant.
4003 // Today's Directive variants all round-trip with matching
4004 // counts, so this test pins the Display rendering of the
4005 // variant (the user-facing message). The positive-count-
4006 // match path is exercised by
4007 // `canonicalize_directives_positive_count_check` below.
4008 let err = super::CanonicalizeError::DirectiveCountMismatch {
4009 input: 3,
4010 reparsed: 2,
4011 };
4012 let msg = format!("{err}");
4013 assert!(msg.contains("3 directive(s)"), "got: {msg}");
4014 assert!(msg.contains("2 survived"), "got: {msg}");
4015 assert!(msg.contains("rledger bug"), "got: {msg}");
4016 }
4017
4018 /// Single source of truth for the variant → fixture mapping
4019 /// used by both the compile-time exhaustiveness check
4020 /// ([`_directive_variant_fixture_coverage`]) and the runtime
4021 /// semantic check
4022 /// ([`directive_variant_fixture_names_resolve_in_matrix`]).
4023 ///
4024 /// Each tuple is `(VariantName, fixture_name)`. The
4025 /// `VariantName` half is the string the runtime check uses to
4026 /// confirm the fixture parses to that variant; the
4027 /// `fixture_name` half is what the compile-time match returns
4028 /// for the same variant. A future `Directive::Hedge` variant
4029 /// only ships with canonical-form coverage if BOTH a new
4030 /// arm is added to the compile-time match AND a row here
4031 /// names a fixture that actually produces a `Hedge` on parse.
4032 const DIRECTIVE_VARIANT_FIXTURE_MAP: &[(&str, &str)] = &[
4033 ("Transaction", "transaction_with_cost_and_price"),
4034 ("Balance", "balance_with_arithmetic_and_tolerance"),
4035 ("Open", "only_directive"),
4036 ("Close", "close_directive"),
4037 ("Commodity", "commodity_directive"),
4038 ("Pad", "pad_directive"),
4039 ("Event", "event_directive"),
4040 ("Query", "query_directive"),
4041 ("Note", "note_directive"),
4042 ("Document", "document_directive"),
4043 ("Price", "price_with_thousands_separator"),
4044 ("Custom", "custom_with_date_value"),
4045 ];
4046
4047 /// Lookup helper: variant tag string → fixture name. Used by
4048 /// the compile-time match below. Panics if the variant is not
4049 /// in the map (which would be an internal-consistency bug, not
4050 /// a user-facing case).
4051 const fn fixture_for_variant(tag: &str) -> &'static str {
4052 let mut i = 0;
4053 while i < DIRECTIVE_VARIANT_FIXTURE_MAP.len() {
4054 let (v, f) = DIRECTIVE_VARIANT_FIXTURE_MAP[i];
4055 // const_str equality: compare byte slices.
4056 let v_bytes = v.as_bytes();
4057 let t_bytes = tag.as_bytes();
4058 if v_bytes.len() == t_bytes.len() {
4059 let mut k = 0;
4060 let mut eq = true;
4061 while k < v_bytes.len() {
4062 if v_bytes[k] != t_bytes[k] {
4063 eq = false;
4064 break;
4065 }
4066 k += 1;
4067 }
4068 if eq {
4069 return f;
4070 }
4071 }
4072 i += 1;
4073 }
4074 panic!("DIRECTIVE_VARIANT_FIXTURE_MAP missing entry for variant tag");
4075 }
4076
4077 /// Compile-time check that every `rustledger_core::Directive`
4078 /// variant has at least one source-text fixture in
4079 /// [`IDEMPOTENCE_MATRIX`] exercising its emit path. The
4080 /// function NEVER runs — its body is an exhaustive `match` over
4081 /// the `Directive` enum. Adding a new variant breaks
4082 /// compilation unless the author adds a match arm referencing
4083 /// `fixture_for_variant("NewVariantName")`, AND adds a row to
4084 /// [`DIRECTIVE_VARIANT_FIXTURE_MAP`] naming the fixture. The
4085 /// runtime test then confirms the fixture parses to a directive
4086 /// of that variant.
4087 ///
4088 /// The non-`Directive` pragma-style directives (Pushtag,
4089 /// Poptag, Pushmeta, Popmeta, options, includes, plugins)
4090 /// don't appear in the typed `Directive` enum; they're covered
4091 /// by separate fixtures whose names map directly into
4092 /// `IDEMPOTENCE_MATRIX`.
4093 #[allow(dead_code)]
4094 fn _directive_variant_fixture_coverage(d: &rustledger_core::Directive) -> &'static str {
4095 match d {
4096 rustledger_core::Directive::Transaction(_) => fixture_for_variant("Transaction"),
4097 rustledger_core::Directive::Balance(_) => fixture_for_variant("Balance"),
4098 rustledger_core::Directive::Open(_) => fixture_for_variant("Open"),
4099 rustledger_core::Directive::Close(_) => fixture_for_variant("Close"),
4100 rustledger_core::Directive::Commodity(_) => fixture_for_variant("Commodity"),
4101 rustledger_core::Directive::Pad(_) => fixture_for_variant("Pad"),
4102 rustledger_core::Directive::Event(_) => fixture_for_variant("Event"),
4103 rustledger_core::Directive::Query(_) => fixture_for_variant("Query"),
4104 rustledger_core::Directive::Note(_) => fixture_for_variant("Note"),
4105 rustledger_core::Directive::Document(_) => fixture_for_variant("Document"),
4106 rustledger_core::Directive::Price(_) => fixture_for_variant("Price"),
4107 rustledger_core::Directive::Custom(_) => fixture_for_variant("Custom"),
4108 }
4109 }
4110
4111 #[test]
4112 fn directive_variant_fixture_names_resolve_in_matrix() {
4113 // Runtime mirror of the compile-time match above:
4114 //
4115 // (1) every fixture name appears in IDEMPOTENCE_MATRIX;
4116 // (2) parsing that fixture produces AT LEAST one
4117 // directive of the variant the map row names.
4118 //
4119 // Without check (2) the compile-time match is satisfied by
4120 // any fixture-name string — a future contributor adding
4121 // a row `("Hedge", "only_comment")` would compile, the
4122 // lookup would resolve, and Hedge would ship with zero
4123 // canonical-form coverage. The semantic check rejects that
4124 // by parsing the named fixture and inspecting the
4125 // directive variant.
4126 use rustledger_core::Directive;
4127 fn matches_variant(d: &Directive, expected: &str) -> bool {
4128 matches!(
4129 (d, expected),
4130 (Directive::Transaction(_), "Transaction")
4131 | (Directive::Balance(_), "Balance")
4132 | (Directive::Open(_), "Open")
4133 | (Directive::Close(_), "Close")
4134 | (Directive::Commodity(_), "Commodity")
4135 | (Directive::Pad(_), "Pad")
4136 | (Directive::Event(_), "Event")
4137 | (Directive::Query(_), "Query")
4138 | (Directive::Note(_), "Note")
4139 | (Directive::Document(_), "Document")
4140 | (Directive::Price(_), "Price")
4141 | (Directive::Custom(_), "Custom")
4142 )
4143 }
4144 for (variant, name) in DIRECTIVE_VARIANT_FIXTURE_MAP {
4145 let (_, src) = IDEMPOTENCE_MATRIX
4146 .iter()
4147 .find(|(n, _)| *n == *name)
4148 .unwrap_or_else(|| {
4149 panic!(
4150 "fixture `{name}` is named by \
4151 DIRECTIVE_VARIANT_FIXTURE_MAP but missing from \
4152 IDEMPOTENCE_MATRIX"
4153 )
4154 });
4155 let parsed = crate::parse(src);
4156 let found = parsed
4157 .directives
4158 .iter()
4159 .any(|s| matches_variant(&s.value, variant));
4160 assert!(
4161 found,
4162 "fixture `{name}` is mapped to `Directive::{variant}` by \
4163 DIRECTIVE_VARIANT_FIXTURE_MAP, but parsing it produced \
4164 no directive of that variant (got {:?}). This silently \
4165 leaves the variant without canonical-form coverage.",
4166 parsed
4167 .directives
4168 .iter()
4169 .map(|s| std::mem::discriminant(&s.value))
4170 .collect::<Vec<_>>()
4171 );
4172 }
4173 }
4174
4175 /// Coverage-mirror check: every `matrix_name` half of the
4176 /// `MIRROR_PAIRS` table in the file-pair integration test
4177 /// (`crates/rustledger-parser/tests/format_compat.rs`) must
4178 /// exist as an entry in [`IDEMPOTENCE_MATRIX`]. The
4179 /// integration test asserts the symmetric half (every
4180 /// `file_pair_name` exists as a directory under `cases/`).
4181 /// Together the two checks guarantee that retiring a
4182 /// bug-class fixture from EITHER side forces an edit to
4183 /// `MIRROR_PAIRS` - which surfaces in review and prevents
4184 /// the silent one-sided drop the README's "two audience" split
4185 /// design would otherwise admit.
4186 ///
4187 /// Hand-maintained copy of the matrix half of the table.
4188 /// Editing `MIRROR_PAIRS` in the integration test requires
4189 /// editing this list too; the test below fires otherwise.
4190 #[test]
4191 fn idempotence_matrix_mirrors_format_compat_pairs() {
4192 const MIRROR_PAIRS_MATRIX_HALF: &[&str] = &[
4193 "balance_leading_unary_minus",
4194 "balance_leading_parenthesized_expression",
4195 "price_leading_unary_minus",
4196 "cost_spec_with_negative",
4197 "cost_spec_with_comma_and_date",
4198 "transaction_with_per_unit_plus_total_cost",
4199 "metadata_unary_minus",
4200 "metadata_arithmetic",
4201 "non_latin_account_name",
4202 "posting_with_trailing_comment",
4203 "multiline_note_string",
4204 "comment_containing_quote",
4205 "transaction_with_tags_and_links",
4206 "custom_with_date_value",
4207 "options_and_includes",
4208 "balance_assertion_with_meta",
4209 "crlf_input",
4210 ];
4211 let matrix_names: std::collections::BTreeSet<&str> =
4212 IDEMPOTENCE_MATRIX.iter().map(|(name, _)| *name).collect();
4213 let missing: Vec<&&str> = MIRROR_PAIRS_MATRIX_HALF
4214 .iter()
4215 .filter(|name| !matrix_names.contains(*name))
4216 .collect();
4217 assert!(
4218 missing.is_empty(),
4219 "IDEMPOTENCE_MATRIX is missing the matrix-half of MIRROR_PAIRS: {missing:?}. \
4220 Either re-add the entry to IDEMPOTENCE_MATRIX, or edit MIRROR_PAIRS in \
4221 tests/format_compat.rs to retire the pair from BOTH sides.",
4222 );
4223 }
4224
4225 /// Property test: the `SourceState` classification used by the
4226 /// line-ending helpers must agree with the lexer's
4227 /// classification on every byte of a corpus of fixtures.
4228 ///
4229 /// Concretely: for every byte offset in every fixture, the
4230 /// state machine's `InString` periods MUST line up with the
4231 /// lexer's STRING token spans, and its `InComment` periods MUST
4232 /// line up with the union of COMMENT / SHEBANG /
4233 /// `EMACS_DIRECTIVE` token spans. A divergence — e.g. the lexer
4234 /// gains a new comment lexeme that the state machine treats as
4235 /// code — fails this test instead of silently mutating user
4236 /// bytes inside the new lexeme on a line-ending round-trip.
4237 #[test]
4238 fn source_state_classification_agrees_with_lexer() {
4239 use crate::logos_lexer::{Token, tokenize_lossless};
4240
4241 for (name, src) in IDEMPOTENCE_MATRIX {
4242 // Run the lexer to get authoritative classification of
4243 // each token. Build a per-byte map of expected state.
4244 let tokens = tokenize_lossless(src);
4245 let mut expected = vec![SourceState::Code; src.len()];
4246 for (token, span) in &tokens {
4247 let classify = match token {
4248 Token::String(_) => Some(SourceState::InString),
4249 Token::Comment(_) | Token::Shebang(_) | Token::EmacsDirective(_) => {
4250 Some(SourceState::InComment)
4251 }
4252 _ => None,
4253 };
4254 if let Some(state) = classify {
4255 for byte in &mut expected[span.start..span.end] {
4256 *byte = state;
4257 }
4258 }
4259 }
4260
4261 // Run the state-machine classifier and compare per
4262 // byte. We skip ONLY the exact bytes where a
4263 // transition fires — the lexer includes those bytes
4264 // inside the resulting token while the state machine
4265 // tags them with the PRE-transition state (the
4266 // 'opener' is still Code, the closing LF is still
4267 // InComment). Tracking the transition indices
4268 // explicitly (rather than skipping every `"`/`;`/`%`
4269 // / newline byte) means a state-machine bug at any
4270 // non-transition `"`/`;`/`%` byte — e.g. inside a
4271 // comment or string — surfaces as a real failure
4272 // instead of being silently masked.
4273 let (actual, transitions) = classify_source_bytes_with_transitions(src);
4274
4275 for (i, (&want, &got)) in expected.iter().zip(actual.iter()).enumerate() {
4276 if transitions.contains(&i) {
4277 continue;
4278 }
4279 assert_eq!(
4280 want,
4281 got,
4282 "state-machine / lexer disagreement on fixture `{name}` \
4283 at byte {i} ({:?}): lexer said {want:?}, state machine said {got:?}",
4284 src.as_bytes()[i] as char
4285 );
4286 }
4287 }
4288 }
4289
4290 /// Walk `s` through the same state-machine logic the
4291 /// line-ending helpers use, returning a per-byte classification
4292 /// AND the set of byte indices where a state transition
4293 /// fired. The transition indices are the ONLY bytes where the
4294 /// state machine and the lexer can legitimately disagree (the
4295 /// off-by-one at opener / closer / terminator); callers
4296 /// comparing against the lexer should skip exactly those
4297 /// indices and assert agreement everywhere else.
4298 fn classify_source_bytes_with_transitions(
4299 s: &str,
4300 ) -> (Vec<SourceState>, std::collections::HashSet<usize>) {
4301 let (body, bom_len) = match s.strip_prefix('\u{FEFF}') {
4302 Some(rest) => (rest, '\u{FEFF}'.len_utf8()),
4303 None => (s, 0),
4304 };
4305 let mut out: Vec<SourceState> = vec![SourceState::Code; s.len()];
4306 let mut transitions = std::collections::HashSet::new();
4307 let mut chars = body.char_indices().peekable();
4308 let mut state = SourceState::Code;
4309 let mut prev_was_backslash = false;
4310 while let Some((rel_i, ch)) = chars.next() {
4311 let i = bom_len + rel_i;
4312 let peek = chars.peek().map(|&(_, c)| c);
4313 // Classify THIS byte under the state BEFORE advancing.
4314 for byte in &mut out[i..i + ch.len_utf8()] {
4315 *byte = state;
4316 }
4317 let prev_state = state;
4318 let next_state = advance_source_state(ch, peek, state, &mut prev_was_backslash);
4319 // Record only OPENING transitions and the comment-
4320 // closing newline, where the state machine and lexer
4321 // legitimately disagree on this single byte:
4322 // - Code → InString : opening `"` is Code-side but
4323 // the lexer puts it inside the STRING token.
4324 // - Code → InComment: opening `;` / `%` / `#!` /
4325 // `#+` is Code-side but the lexer puts it inside
4326 // the COMMENT / SHEBANG / EMACS_DIRECTIVE token.
4327 // - InComment → Code: the `\n` ending the comment is
4328 // classified InComment by the state machine but
4329 // sits OUTSIDE the comment token (the lexer's
4330 // `[^\n\r]*` excludes it).
4331 // The InString → Code transition (closing `"`) is NOT
4332 // a disagreement: the state machine still tags that
4333 // byte as InString (pre-transition), and the lexer
4334 // includes the closing `"` inside the STRING token.
4335 // Skipping it would silently mask a real bug.
4336 if next_state != state {
4337 let opening = matches!(prev_state, SourceState::Code)
4338 && matches!(next_state, SourceState::InString | SourceState::InComment);
4339 let comment_close = matches!(prev_state, SourceState::InComment)
4340 && matches!(next_state, SourceState::Code);
4341 if opening || comment_close {
4342 transitions.insert(i);
4343 // For a `#!` or `#+` opener the lexer's token
4344 // span begins at the `#`, so the second byte
4345 // (`!` / `+`) is also a "before the lexer's
4346 // token start" byte the state machine tags as
4347 // Code. Record it too.
4348 if matches!(ch, '#') && matches!(peek, Some('!' | '+')) {
4349 transitions.insert(i + 1);
4350 }
4351 }
4352 }
4353 state = next_state;
4354 }
4355 (out, transitions)
4356 }
4357
4358 #[test]
4359 fn canonicalize_directives_positive_count_check() {
4360 // Pin the success path of the count check: pass a real
4361 // multi-directive input through canonicalize_directives and
4362 // assert that the output round-trips to the SAME directive
4363 // count. Without this test, a regression that always
4364 // returned CountMismatch (e.g. `==` instead of `!=` on the
4365 // count comparison) would be caught only on production
4366 // calls, not in CI. Together with the Display test above,
4367 // this gives coverage of both arms of the count guard.
4368 use rustledger_core::format::FormatConfig;
4369 let cfg = FormatConfig::default();
4370 let src = "2024-01-01 open Assets:Cash\n2024-01-02 open Assets:Bank\n2024-01-03 close Assets:Cash\n";
4371 let parsed = crate::parse(src);
4372 assert_eq!(
4373 parsed.directives.len(),
4374 3,
4375 "fixture must parse to 3 directives"
4376 );
4377 let dirs: Vec<&rustledger_core::Directive> =
4378 parsed.directives.iter().map(|s| &s.value).collect();
4379 let formatted = super::canonicalize_directives(dirs.iter().copied(), &cfg)
4380 .expect("canonicalize_directives should succeed on this input");
4381 let reparsed = crate::parse(&formatted);
4382 assert_eq!(
4383 reparsed.directives.len(),
4384 3,
4385 "count check accepted but round-trip dropped directives: {formatted}"
4386 );
4387 }
4388
4389 // ---- format_node_range -----------------------------------------
4390
4391 /// Parse `source` via the same pipeline `format_source` uses
4392 /// so the resulting `SyntaxNode`'s `TextRange`s are in the
4393 /// same byte frame `format_node_range`'s `range` argument
4394 /// is expected to use (post-BOM-strip, post-CRLF-to-LF).
4395 /// Returns the syntax node + the normalized source text so
4396 /// tests can compute byte offsets by `.find()`.
4397 fn parse_for_range(source: &str) -> (crate::SyntaxNode, String) {
4398 let (stripped, _bom) = crate::bom::strip_leading(source);
4399 let normalized = crlf_to_lf_outside_strings(stripped).to_string();
4400 let sf = SourceFile::parse(&normalized);
4401 (sf.syntax().clone(), normalized)
4402 }
4403
4404 fn ts(n: usize) -> rowan::TextSize {
4405 rowan::TextSize::try_from(n).expect("offset fits TextSize")
4406 }
4407
4408 /// For any selection covering the whole file, the result text
4409 /// equals `format_node(node)`. Pins the round-trip invariant
4410 /// the design rests on: range formatting is the whole-file
4411 /// formatter restricted to a range, not a parallel canonical
4412 /// form.
4413 #[test]
4414 fn format_node_range_full_range_matches_format_node() {
4415 let source = "\
44162024-01-01 open Assets:Bank USD
44172024-01-15 * \"Coffee\"
4418 Assets:Bank -5.00 USD
4419 Expenses:Food
44202024-01-31 close Assets:Bank
4421";
4422 let (node, src) = parse_for_range(source);
4423 let full = rowan::TextRange::new(ts(0), ts(src.len()));
4424 let (snap, formatted) =
4425 format_node_range(&node, full).expect("full range must include all directives");
4426 assert_eq!(
4427 snap,
4428 rowan::TextRange::new(ts(0), ts(src.len())),
4429 "snap range should be the whole file's textual span"
4430 );
4431 assert_eq!(formatted, format_node(&node));
4432 }
4433
4434 /// A selection that hits only inter-directive whitespace
4435 /// (no directive intersected, no top-level comment
4436 /// intersected) returns `None` — the caller surfaces this
4437 /// as an empty `Vec<TextEdit>`.
4438 #[test]
4439 fn format_node_range_trivia_only_returns_none() {
4440 // The phase-2.0 Directive-Terminator Rule puts every
4441 // inter-directive blank line on the next directive's
4442 // leading trivia, so any byte index between two
4443 // directives is INSIDE the next directive's text_range.
4444 // The only way to reach a truly trivia-only selection
4445 // is a source that has no directives at all (file is
4446 // pure whitespace). That is the case worth pinning —
4447 // the LSP handler maps `None` to an empty
4448 // `Vec<TextEdit>`, which is exactly the right "nothing
4449 // to format" response for a whitespace-only buffer.
4450 let (empty, _) = parse_for_range("\n\n\n");
4451 let sel = rowan::TextRange::new(ts(0), ts(3));
4452 assert!(format_node_range(&empty, sel).is_none());
4453 }
4454
4455 /// Selecting only the first directive's content (the
4456 /// transaction) snaps to that directive and the second
4457 /// directive is left out of both the snap and the output.
4458 #[test]
4459 fn format_node_range_single_directive() {
4460 let source = "\
44612024-01-01 open Assets:Bank USD
44622024-01-15 * \"Coffee\"
4463 Assets:Bank -5.00 USD
4464 Expenses:Food
4465";
4466 let (node, src) = parse_for_range(source);
4467 // Position the selection inside the `open` line. Use
4468 // the byte offset of the word `open` so the test is
4469 // robust to whitespace changes in the fixture.
4470 let open_byte = src.find("open").expect("fixture contains 'open'");
4471 let sel = rowan::TextRange::new(ts(open_byte), ts(open_byte + "open".len()));
4472 let (snap, formatted) = format_node_range(&node, sel).expect("intersects 1 directive");
4473
4474 // Snap should start at byte 0 (the open directive's
4475 // text_range starts at the file's start) and end at
4476 // the open directive's terminating newline.
4477 let open_end = src.find('\n').expect("first directive has terminator") + 1;
4478 assert_eq!(snap.start(), ts(0));
4479 assert_eq!(snap.end(), ts(open_end));
4480 // Output is exactly the open directive's canonical form
4481 // + its `\n` terminator. No second-directive content.
4482 assert_eq!(formatted, "2024-01-01 open Assets:Bank USD\n");
4483 }
4484
4485 /// Multi-directive selection: the author's inter-directive
4486 /// blank lines are preserved (a blank stays a blank; grouped
4487 /// stays grouped), matching whole-file formatting (#1325).
4488 #[test]
4489 fn format_node_range_multi_directive_preserves_blank_lines() {
4490 // #1325: range formatting preserves the author's inter-directive
4491 // blank lines, identically to whole-file formatting. A source
4492 // with a blank between the two directives keeps it...
4493 let spaced = "\
44942024-01-01 open Assets:Bank USD
4495
44962024-01-31 close Assets:Bank
4497";
4498 let (node, src) = parse_for_range(spaced);
4499 let sel = rowan::TextRange::new(ts(0), ts(src.len()));
4500 let (snap, formatted) = format_node_range(&node, sel).expect("intersects 2 directives");
4501 assert_eq!(snap, rowan::TextRange::new(ts(0), ts(src.len())));
4502 assert_eq!(formatted, spaced, "the blank separator must be preserved");
4503
4504 // ...and a grouped source (no blank) stays grouped, rather than
4505 // having a separator inserted.
4506 let grouped = "\
45072024-01-01 open Assets:Bank USD
45082024-01-31 close Assets:Bank
4509";
4510 let (node2, src2) = parse_for_range(grouped);
4511 let sel2 = rowan::TextRange::new(ts(0), ts(src2.len()));
4512 let (_, formatted2) = format_node_range(&node2, sel2).expect("intersects 2 directives");
4513 assert_eq!(formatted2, grouped, "grouped directives must stay grouped");
4514 }
4515
4516 #[test]
4517 fn format_node_range_first_directive_in_snap_keeps_leading_blank() {
4518 // Regression (Copilot review of #1325): when the selection
4519 // covers only the SECOND directive, its predecessor sits outside
4520 // the snap, but the blank line between them is the second
4521 // directive's leading trivia and therefore inside the snapped
4522 // range. Range formatting must re-emit it, not silently delete
4523 // the blank line above the selection.
4524 let source = "2024-01-01 open Assets:Bank USD\n\n2024-01-31 close Assets:Bank\n";
4525 let (node, src) = parse_for_range(source);
4526 // Cursor inside the second (close) directive only.
4527 let close_byte = src.find("close").expect("fixture has 'close'");
4528 let cursor = rowan::TextRange::new(ts(close_byte), ts(close_byte));
4529 let (snap, formatted) = format_node_range(&node, cursor).expect("intersects close");
4530 // The leading blank is preserved in the replacement text...
4531 assert_eq!(formatted, "\n2024-01-31 close Assets:Bank\n");
4532 // ...so applying the edit leaves the blank line intact.
4533 let mut result = src;
4534 result.replace_range(
4535 usize::from(snap.start())..usize::from(snap.end()),
4536 &formatted,
4537 );
4538 assert_eq!(
4539 result, source,
4540 "range-formatting the second directive must not delete the blank above it"
4541 );
4542 }
4543
4544 /// Cursor-only (zero-width) selection inside a directive
4545 /// snaps to that directive. The cursor convention: inside
4546 /// or at the directive's start byte counts as inside;
4547 /// boundary at the directive's end belongs to the next
4548 /// child.
4549 #[test]
4550 fn format_node_range_cursor_inside_directive() {
4551 let source = "\
45522024-01-01 open Assets:Bank USD
45532024-01-31 close Assets:Bank
4554";
4555 let (node, src) = parse_for_range(source);
4556 // Cursor on the `c` of `close` (line 2 of the fixture).
4557 let close_byte = src.find("close").expect("fixture has 'close'");
4558 let cursor = rowan::TextRange::new(ts(close_byte), ts(close_byte));
4559 let (snap, formatted) = format_node_range(&node, cursor).expect("intersects close");
4560 // Snap starts at the close directive's text_range start.
4561 // Per Directive-Terminator Rule the second directive
4562 // OWNS the leading inter-directive trivia — so snap
4563 // starts immediately after the first directive's
4564 // terminator newline.
4565 let close_dir_start = src
4566 .find("\n2024-01-31")
4567 .map(|n| n + 1)
4568 .expect("close directive starts on its own line");
4569 assert_eq!(snap.start(), ts(close_dir_start));
4570 assert_eq!(snap.end(), ts(src.len()));
4571 assert_eq!(formatted, "2024-01-31 close Assets:Bank\n");
4572 }
4573
4574 /// Cursor exactly at the start of a directive snaps to
4575 /// that directive (start-boundary inclusion rule).
4576 #[test]
4577 fn format_node_range_cursor_at_directive_start_includes_directive() {
4578 let source = "\
45792024-01-01 open Assets:Bank USD
45802024-01-31 close Assets:Bank
4581";
4582 let (node, _src) = parse_for_range(source);
4583 // Cursor at byte 0 = start of first directive.
4584 let cursor = rowan::TextRange::new(ts(0), ts(0));
4585 let (_snap, formatted) = format_node_range(&node, cursor).expect("intersects open");
4586 // Only the OPEN should be formatted, not the close.
4587 assert!(formatted.starts_with("2024-01-01 open"));
4588 assert!(!formatted.contains("close"));
4589 }
4590
4591 /// Selection containing a top-level standalone comment
4592 /// (file-leading or between-directive comment that the
4593 /// trivia attachment policy puts on `SOURCE_FILE`) includes
4594 /// the comment in both the snap and the output.
4595 #[test]
4596 fn format_node_range_includes_top_level_comments() {
4597 let source = "\
4598; header
45992024-01-01 open Assets:Bank USD
4600";
4601 let (node, src) = parse_for_range(source);
4602 let sel = rowan::TextRange::new(ts(0), ts(src.len()));
4603 let (snap, formatted) = format_node_range(&node, sel).expect("intersects both");
4604 assert_eq!(snap, rowan::TextRange::new(ts(0), ts(src.len())));
4605 // Header comment, then directive on the next line. No
4606 // canonical blank between a file-level comment group
4607 // and a directive (matches format_node's policy).
4608 assert_eq!(formatted, "; header\n2024-01-01 open Assets:Bank USD\n");
4609 }
4610
4611 /// A selection that lands entirely inside an `ERROR_NODE`
4612 /// (no Directive intersected) returns None. Matches
4613 /// `format_node`'s policy of skipping `ERROR_NODE` children
4614 /// at the top level.
4615 #[test]
4616 fn format_node_range_error_node_only_returns_none() {
4617 // `}}}` at top level isn't a directive — the parser
4618 // wraps it in an ERROR_NODE.
4619 let source = "}}}\n";
4620 let (node, src) = parse_for_range(source);
4621 let sel = rowan::TextRange::new(ts(0), ts(src.len()));
4622 assert!(format_node_range(&node, sel).is_none());
4623 }
4624
4625 /// Past-EOF selection still works: the snap clamps to the
4626 /// last child that intersects within the file. (rowan's
4627 /// `TextRange` is bounded by usize but `format_node_range`
4628 /// doesn't validate `range` against file length — bytes past
4629 /// EOF can never intersect any child, so the rule is
4630 /// degenerate but well-defined.)
4631 #[test]
4632 fn format_node_range_past_eof_clamps() {
4633 let source = "2024-01-01 open Assets:Bank USD\n";
4634 let (node, src) = parse_for_range(source);
4635 let past_eof = rowan::TextRange::new(ts(src.len()), ts(src.len() + 1000));
4636 // The cursor / range is past EOF — no child intersects.
4637 assert!(format_node_range(&node, past_eof).is_none());
4638 // But a range that STRADDLES EOF still snaps to the
4639 // last intersecting directive.
4640 let straddle = rowan::TextRange::new(ts(0), ts(src.len() + 1000));
4641 let (snap, formatted) = format_node_range(&node, straddle).expect("intersects open");
4642 assert_eq!(snap, rowan::TextRange::new(ts(0), ts(src.len())));
4643 assert_eq!(formatted, "2024-01-01 open Assets:Bank USD\n");
4644 }
4645
4646 /// A cursor inside a posting (sub-directive position) snaps
4647 /// up to the enclosing transaction — the design pins
4648 /// "round to top-level directive boundaries, no finer."
4649 #[test]
4650 fn format_node_range_cursor_in_posting_snaps_to_transaction() {
4651 let source = "\
46522024-01-15 * \"Coffee\"
4653 Assets:Bank -5.00 USD
4654 Expenses:Food
4655";
4656 let (node, src) = parse_for_range(source);
4657 // Position the cursor on the `B` of `Bank` in the
4658 // first posting.
4659 let bank_byte = src.find("Bank").expect("fixture has Bank");
4660 let cursor = rowan::TextRange::new(ts(bank_byte), ts(bank_byte));
4661 let (snap, _formatted) = format_node_range(&node, cursor).expect("intersects transaction");
4662 // Snap covers the WHOLE transaction (start of file
4663 // through final posting's newline).
4664 assert_eq!(snap.start(), ts(0));
4665 assert_eq!(snap.end(), ts(src.len()));
4666 }
4667
4668 /// Selection straddling an `ERROR_NODE` between two valid
4669 /// directives: snap range would cover the union (including
4670 /// `ERROR_NODE` bytes), so `format_node_range` returns
4671 /// `None` instead of silently deleting the error content.
4672 ///
4673 /// This is the deliberate divergence from `format_node`'s
4674 /// whole-file policy. `format_source(broken_source)` does
4675 /// drop `ERROR_NODE` content — but that path's callers
4676 /// (`rledger format` CLI, FFI `format.entry`) opt into
4677 /// content loss by invoking the canonical-form pipeline. The
4678 /// per-handler LSP `textDocument/rangeFormatting` path has no
4679 /// such opt-in, so it refuses to delete user content the
4680 /// parser couldn't classify. See the function's rustdoc for
4681 /// the per-handler asymmetry rationale.
4682 #[test]
4683 fn format_node_range_bails_when_snap_covers_error_node() {
4684 let source = "\
46852024-01-01 open Assets:Bank USD
4686}}}garbage{{{
46872024-01-31 close Assets:Bank
4688";
4689 let (node, src) = parse_for_range(source);
4690 let sel = rowan::TextRange::new(ts(0), ts(src.len()));
4691 assert!(
4692 format_node_range(&node, sel).is_none(),
4693 "selection covering both directives + ERROR_NODE between them must bail \
4694 to avoid silently deleting the garbage line — got Some output",
4695 );
4696 }
4697
4698 /// Selection that intersects only the FIRST valid directive
4699 /// in a broken file (no `ERROR_NODE` byte in the snap range)
4700 /// still formats. Pins that the `ERROR_NODE` bail is precisely
4701 /// scoped to the snap range, not to "the file has any
4702 /// `ERROR_NODE` at all".
4703 #[test]
4704 fn format_node_range_formats_directive_when_snap_does_not_cover_error_node() {
4705 let source = "\
47062024-01-01 open Assets:Bank USD
4707}}}garbage{{{
47082024-01-31 close Assets:Bank
4709";
4710 let (node, src) = parse_for_range(source);
4711 // Selection covers ONLY the open directive (first line +
4712 // its terminator). The ERROR_NODE on line 1 sits at byte
4713 // offset == open_end (length of first line including \n)
4714 // onward, OUTSIDE the snap range.
4715 let open_end = src.find('\n').expect("first directive has newline") + 1;
4716 let sel = rowan::TextRange::new(ts(0), ts(open_end));
4717 let (snap, formatted) =
4718 format_node_range(&node, sel).expect("selection covers only the open");
4719 assert_eq!(snap.start(), ts(0));
4720 assert_eq!(snap.end(), ts(open_end));
4721 assert_eq!(formatted, "2024-01-01 open Assets:Bank USD\n");
4722 }
4723
4724 /// `format_node_with_alignment(node, compute_alignment(sf))` is
4725 /// byte-identical to `format_node(node)`. Pins the cache
4726 /// contract: passing the correct alignment is a pure
4727 /// optimization, NOT a behavior change.
4728 #[test]
4729 fn format_node_equals_format_node_with_alignment() {
4730 let fixtures: &[(&str, &str)] = &[
4731 ("empty", ""),
4732 ("open only", "2024-01-01 open Assets:Bank USD\n"),
4733 (
4734 "single txn",
4735 "\
47362024-01-15 * \"Coffee\"
4737 Assets:Bank -5.00 USD
4738 Expenses:Food
4739",
4740 ),
4741 (
4742 "multi txn varying widths",
4743 "\
47442024-01-15 * \"A\"
4745 Assets:Bank -5.00 USD
4746 Expenses:Food
47472024-02-15 * \"B\"
4748 Assets:Investment:Long:Path -123456.78 USD
4749 Expenses:Tax 100.00 USD
4750",
4751 ),
4752 ];
4753 for (label, source) in fixtures {
4754 let (node, _src) = parse_for_range(source);
4755 let source_file = SourceFile::cast(node.clone()).unwrap();
4756 let alignment = compute_alignment(&source_file, GroupingStyle::default());
4757 assert_eq!(
4758 format_node(&node),
4759 format_node_with_alignment(&node, alignment),
4760 "format_node_with_alignment must match format_node for {label}",
4761 );
4762 }
4763 }
4764
4765 /// `format_node_range_with_alignment(node, range, compute_alignment(sf))`
4766 /// matches `format_node_range(node, range)` byte-identically.
4767 /// Same shape as the previous test, for the range path.
4768 #[test]
4769 fn format_node_range_matches_format_node_range_with_alignment() {
4770 let source = "\
47712024-01-15 * \"A\"
4772 Assets:Bank -5.00 USD
4773 Expenses:Food
47742024-02-15 * \"B\"
4775 Assets:Investment:Long:Path -123456.78 USD
4776 Expenses:Tax 100.00 USD
4777";
4778 let (node, src) = parse_for_range(source);
4779 let source_file = SourceFile::cast(node.clone()).unwrap();
4780 let alignment = compute_alignment(&source_file, GroupingStyle::default());
4781 // Pin the equivalence on three ranges: whole file,
4782 // cursor inside the first transaction, cursor inside the
4783 // second.
4784 let sels = [
4785 rowan::TextRange::new(ts(0), ts(src.len())),
4786 rowan::TextRange::new(ts(0), ts(10)),
4787 rowan::TextRange::new(ts(src.len() - 10), ts(src.len())),
4788 ];
4789 for sel in sels {
4790 let uncached = format_node_range(&node, sel);
4791 let cached = format_node_range_with_alignment(&node, sel, alignment);
4792 assert_eq!(
4793 uncached, cached,
4794 "format_node_range_with_alignment must match \
4795 format_node_range for range {sel:?}",
4796 );
4797 }
4798 }
4799
4800 /// The cached [`crate::ParseResult::alignment`] value matches what
4801 /// `format_node` would compute on the parsed tree. End-to-end
4802 /// regression: an LSP caller passing `parse_result.alignment()`
4803 /// to `format_node_with_alignment` produces the same output
4804 /// as the bare `format_node` (uncached path).
4805 #[test]
4806 fn parse_result_alignment_drives_identical_format_output() {
4807 let source = "\
48082024-01-15 * \"Coffee\"
4809 Assets:Bank -5.00 USD
4810 Expenses:Food
4811";
4812 let parse_result = crate::parse(source);
4813 let node = parse_result.syntax_node();
4814 assert_eq!(
4815 format_node(&node),
4816 format_node_with_alignment(&node, parse_result.alignment()),
4817 "ParseResult::alignment must drive identical format output to format_node",
4818 );
4819 }
4820
4821 /// `format_source_with_parsed(parse(s), s) == format_source(s)`
4822 /// byte-identical across a representative fixture set including
4823 /// CRLF and BOM-prefixed sources. This is the load-bearing
4824 /// equivalence for the LSP `format_document` / FFI
4825 /// `format.source` / WASM `ParsedLedger::format` migrations:
4826 /// they swap `format_source(source)` for
4827 /// `format_source_with_parsed(parse_result, source)` on the
4828 /// assumption that the two produce the same output. Without
4829 /// this test, a future converter or formatter change that
4830 /// silently diverged the two paths would break canonical-form
4831 /// expectations in production.
4832 #[test]
4833 fn format_source_with_parsed_matches_format_source() {
4834 let fixtures: &[(&str, &str)] = &[
4835 ("empty", ""),
4836 ("comment only", "; hello\n"),
4837 (
4838 "single transaction LF",
4839 "\
48402024-01-15 * \"Coffee\"
4841 Assets:Bank -5.00 USD
4842 Expenses:Food
4843",
4844 ),
4845 (
4846 "multi transaction varying widths LF",
4847 "\
48482024-01-15 * \"A\"
4849 Assets:Bank -5.00 USD
4850 Expenses:Food
48512024-02-15 * \"B\"
4852 Assets:Investment:Long:Path -123456.78 USD
4853 Expenses:Tax 100.00 USD
4854",
4855 ),
4856 (
4857 "arithmetic amounts LF",
4858 "\
48592024-01-15 * \"Split\"
4860 Assets:Bank -10.00 + 5.00 USD
4861 Expenses:Misc
4862",
4863 ),
4864 (
4865 "CRLF source",
4866 "2024-01-15 * \"Coffee\"\r\n Assets:Bank -5.00 USD\r\n Expenses:Food\r\n",
4867 ),
4868 ("BOM-prefixed", "\u{FEFF}2024-01-01 open Assets:Bank USD\n"),
4869 // BOM + CRLF — Windows-authored ledger with a BOM
4870 // prefix. `format_source` BOM-strips + CRLF→LF
4871 // normalizes before parsing. The cache path consumes
4872 // a CST that's BOM-stripped but NOT CRLF-normalized.
4873 // Byte-identity holds because the formatter rebuilds
4874 // canonical output from typed values (no trivia
4875 // passthrough).
4876 (
4877 "BOM + CRLF combination",
4878 "\u{FEFF}2024-01-15 * \"Coffee\"\r\n Assets:Bank -5.00 USD\r\n Expenses:Food\r\n",
4879 ),
4880 // Parse-error file — exercises the fallback. Without
4881 // the `errors.is_empty()` guard, the cache path would
4882 // emit text for ERROR_NODE-wrapped content while
4883 // `format_source` would drop those bytes; identity
4884 // would fail. The fallback delegates to
4885 // `format_source(source)` so identity holds.
4886 (
4887 "parse errors (exercises fallback)",
4888 "2024-01-15 * \"x\"\n Assets:Bank -5.00 USD\n}}}garbage\n",
4889 ),
4890 // Bare-`\r` (classic Mac) line terminators. The
4891 // `format_source` path normalizes bare-CR to LF via
4892 // `crlf_to_lf_outside_strings`, then parses cleanly.
4893 // `parse_via_cst` does NOT normalize bare-CR, so the
4894 // CST sees broken syntax and `parse_result.errors`
4895 // is non-empty — the fallback fires. Byte-identity
4896 // holds via the same `format_source` delegation.
4897 (
4898 "bare CR line terminators (exercises fallback)",
4899 "2024-01-01 open Assets:Bank USD\r2024-01-02 open Assets:Cash USD\r",
4900 ),
4901 ];
4902 for (label, source) in fixtures {
4903 let parse_result = crate::parse(source);
4904 let baseline = format_source(source);
4905 let cached = format_source_with_parsed(&parse_result, source);
4906 assert_eq!(
4907 cached, baseline,
4908 "format_source_with_parsed must match format_source for {label}: \
4909 baseline {baseline:?}, cached {cached:?}",
4910 );
4911 }
4912 }
4913
4914 /// Mismatched-pair safety: in debug builds, passing a
4915 /// length-mismatched `(parse_result, source)` pair panics via
4916 /// the `debug_assert_eq!`. Release builds silently emit text
4917 /// for the wrong buffer — pairing the two arguments is the
4918 /// caller's responsibility, per this function's rustdoc.
4919 #[cfg(debug_assertions)]
4920 #[test]
4921 #[should_panic(expected = "source` whose length doesn't match")]
4922 fn format_source_with_parsed_panics_on_length_mismatch() {
4923 let parse_result = crate::parse("2024-01-01 open Assets:Bank USD\n");
4924 // Different length — debug_assert fires.
4925 let _ = format_source_with_parsed(&parse_result, "different");
4926 }
4927
4928 /// A grouping style imposes separators, and the alignment pre-pass measures
4929 /// the GROUPED width — so the currency column still lines up.
4930 ///
4931 /// They agree because `compute_alignment` and the emitters are handed the
4932 /// SAME `GroupingStyle`. Measuring under one style and emitting under
4933 /// another is the mismatch that would reproduce #1290, which is why the two
4934 /// entry points that could express it are crate-private and every public
4935 /// one either measures its own alignment or is fixed to the default style.
4936 #[test]
4937 fn grouped_formatting_aligns_and_is_idempotent() {
4938 let src = "\
49392020-01-02 * \"mixed magnitudes\"
4940 Assets:Bank 1234567.89 USD
4941 Assets:VeryLongAccountName:Nested 12.00 USD
4942 Income:Sales -1234579.89 USD
4943";
4944 let grouped = format_source_grouped(src, grouped_style(&all_commas()));
4945 assert!(
4946 grouped.contains("1,234,567.89") && grouped.contains("-1,234,579.89"),
4947 "grouping must be imposed regardless of the source form:\n{grouped}"
4948 );
4949 // Currency column uniform => every ` USD` starts at the same column.
4950 let cols: Vec<usize> = grouped
4951 .lines()
4952 .filter(|l| l.contains(" USD"))
4953 .map(|l| l.find("USD").expect("USD"))
4954 .collect();
4955 assert!(
4956 cols.windows(2).all(|w| w[0] == w[1]),
4957 "currency column must stay uniform under grouping: {cols:?}\n{grouped}"
4958 );
4959 assert_eq!(
4960 format_source_grouped(&grouped, grouped_style(&all_commas())),
4961 grouped,
4962 "grouped formatting must be idempotent"
4963 );
4964 }
4965
4966 /// Grouping is a TOTAL rewrite, not a preserve: it converges from either
4967 /// direction, so a file whose numerals are inconsistent still normalizes.
4968 /// That is the property `preserve` would have given up.
4969 #[test]
4970 fn grouping_converges_from_either_direction() {
4971 let mixed = "\
49722020-01-02 * \"inconsistent source\"
4973 Assets:A 1,234,567.89 USD
4974 Assets:B -1234567.89 USD
4975";
4976 let on = format_source_grouped(mixed, grouped_style(&all_commas()));
4977 assert_eq!(on.matches(',').count(), 4, "both numerals grouped:\n{on}");
4978 let off = format_source_grouped(mixed, GroupingStyle::default());
4979 assert!(!off.contains(','), "both numerals bare:\n{off}");
4980 // And each is a fixed point of its own rule.
4981 assert_eq!(format_source_grouped(&on, grouped_style(&all_commas())), on);
4982 assert_eq!(format_source_grouped(&off, GroupingStyle::default()), off);
4983 }
4984
4985 /// The default entry point is untouched: every existing caller, and every
4986 /// ledger that has not opted in, gets byte-identical output.
4987 #[test]
4988 fn default_formatting_still_strips_separators() {
4989 let src = "2020-01-02 balance Assets:A 1,234.50 USD\n";
4990 assert_eq!(
4991 format_source(src),
4992 "2020-01-02 balance Assets:A 1234.50 USD\n"
4993 );
4994 assert_eq!(
4995 format_source(src),
4996 format_source_grouped(src, GroupingStyle::default())
4997 );
4998 }
4999
5000 /// Grouped output must re-parse to the SAME values — the formatter may not
5001 /// emit text its own lexer rejects. Groups are three digits because that is
5002 /// all `(\d{1,3}(,\d{3})*|\d+)` admits.
5003 #[test]
5004 fn grouped_output_reparses_to_the_same_values() {
5005 for n in [
5006 "1",
5007 "12",
5008 "123",
5009 "1234",
5010 "1234567",
5011 "1234567.891",
5012 "0.5",
5013 "1000000",
5014 ] {
5015 let src = format!("2020-01-02 balance Assets:A {n} USD\n");
5016 let grouped = format_source_grouped(&src, grouped_style(&all_commas()));
5017 let reparsed = crate::parse(&grouped);
5018 assert!(
5019 reparsed.errors.is_empty(),
5020 "grouped `{n}` -> `{}` must re-parse: {:?}",
5021 grouped.trim(),
5022 reparsed.errors
5023 );
5024 // And round-trips to the identical value.
5025 let before = crate::parse(&src);
5026 let val = |r: &crate::ParseResult| match &r.directives[0].value {
5027 rustledger_core::Directive::Balance(b) => b.amount.number,
5028 _ => panic!("balance"),
5029 };
5030 assert_eq!(val(&before), val(&reparsed), "value changed for `{n}`");
5031 }
5032 }
5033
5034 /// A ledger-wide context used by the grouping tests.
5035 fn all_commas() -> rustledger_core::DisplayContext {
5036 let mut c = rustledger_core::DisplayContext::new();
5037 c.set_render_commas(true);
5038 c
5039 }
5040
5041 fn grouped_style(ctx: &rustledger_core::DisplayContext) -> GroupingStyle<'_> {
5042 GroupingStyle::from_context(ctx)
5043 }
5044
5045 /// A commodity may opt OUT of the ledger-wide default, so a 4000:1 currency
5046 /// can be grouped without also grouping two-digit USD amounts — the reason
5047 /// a single global boolean was not enough (#1892).
5048 #[test]
5049 fn grouping_is_resolved_per_commodity() {
5050 let mut ctx = rustledger_core::DisplayContext::new();
5051 ctx.set_render_commas(true);
5052 ctx.set_render_commas_for("USD", false);
5053
5054 let src = "\
50552020-01-02 * \"two currencies\"
5056 Assets:Local 1234567.89 IQD
5057 Assets:Dollars 1234567.89 USD
5058";
5059 let out = format_source_grouped(src, GroupingStyle::from_context(&ctx));
5060 assert!(
5061 out.contains("1,234,567.89 IQD"),
5062 "the ledger default applies to IQD:\n{out}"
5063 );
5064 assert!(
5065 out.contains("1234567.89 USD") && !out.contains("1,234,567.89 USD"),
5066 "USD opted out and must stay bare:\n{out}"
5067 );
5068 }
5069
5070 /// The inverse: grouping declared on ONE commodity while the ledger default
5071 /// is off. This is the shape a user with a single hyperinflated currency
5072 /// actually wants.
5073 #[test]
5074 fn a_single_commodity_can_opt_in() {
5075 let mut ctx = rustledger_core::DisplayContext::new();
5076 ctx.set_render_commas_for("IQD", true);
5077
5078 let src = "\
50792020-01-02 * \"two currencies\"
5080 Assets:Local 1234567.89 IQD
5081 Assets:Dollars 1234567.89 USD
5082";
5083 let out = format_source_grouped(src, GroupingStyle::from_context(&ctx));
5084 assert!(out.contains("1,234,567.89 IQD"), "IQD opted in:\n{out}");
5085 assert!(
5086 out.contains("1234567.89 USD") && !out.contains("1,234,567.89 USD"),
5087 "USD keeps the (off) default:\n{out}"
5088 );
5089 }
5090
5091 /// A context that groups nothing must produce the no-lookup style, so the
5092 /// overwhelming majority of ledgers pay nothing per numeral.
5093 #[test]
5094 fn a_context_that_groups_nothing_yields_the_default_style() {
5095 let mut ctx = rustledger_core::DisplayContext::new();
5096 ctx.set_fixed_precision("USD", 2);
5097 ctx.set_render_commas_for("USD", false);
5098 assert!(!ctx.renders_any_commas());
5099 let src = "2020-01-02 balance Assets:A 1234.50 USD\n";
5100 assert_eq!(
5101 format_source_grouped(src, GroupingStyle::from_context(&ctx)),
5102 format_source(src),
5103 "no declared grouping must be byte-identical to the default path"
5104 );
5105 }
5106
5107 /// `format` must not duplicate a balance tolerance.
5108 ///
5109 /// `emit_amount_expression` ran from the first NUMBER to the first
5110 /// CURRENCY, which SWALLOWED the `~ tolerance` clause; `emit_balance` then
5111 /// emitted it again via `balance_tolerance`. So
5112 /// `balance Assets:A 0.00 ~ 1234.5 USD` was rewritten as
5113 /// `... 0.00 ~ 1234.5 USD ~ 1234.5 USD` — stable across reformats, but
5114 /// almost certainly not valid beancount, whose balance grammar takes at
5115 /// most one tolerance. `--check` reported the ORIGINAL as unformatted, so a
5116 /// CI gate pushed users into the corrupted form.
5117 ///
5118 /// Pre-existing on main; unrelated to grouping.
5119 #[test]
5120 fn balance_tolerance_is_emitted_exactly_once() {
5121 for (src, want) in [
5122 (
5123 "2020-01-04 balance Assets:A 0.00 ~ 1234.5 USD\n",
5124 "2020-01-04 balance Assets:A 0.00 ~ 1234.5 USD\n",
5125 ),
5126 // A source that repeats the currency collapses to the one-currency
5127 // beancount form rather than keeping both.
5128 (
5129 "2020-01-04 balance Assets:A 0.00 USD ~ 0.05 USD\n",
5130 "2020-01-04 balance Assets:A 0.00 ~ 0.05 USD\n",
5131 ),
5132 // No tolerance: unchanged.
5133 (
5134 "2020-01-04 balance Assets:A 1234.50 USD\n",
5135 "2020-01-04 balance Assets:A 1234.50 USD\n",
5136 ),
5137 // Arithmetic still terminates at the currency, not the tilde.
5138 (
5139 "2020-01-04 balance Assets:A (1 + 5) / 2 USD\n",
5140 "2020-01-04 balance Assets:A (1 + 5) / 2 USD\n",
5141 ),
5142 ] {
5143 let out = format_source(src);
5144 assert_eq!(out, want, "formatting {src:?}");
5145 assert_eq!(format_source(&out), out, "not idempotent for {src:?}");
5146 // And the output must still parse — a formatter may not emit text
5147 // its own parser rejects.
5148 assert!(
5149 crate::parse(&out).errors.is_empty(),
5150 "output must re-parse: {out}"
5151 );
5152 }
5153 }
5154}