rustledger_parser/lib.rs
1//! Beancount parser built on a Logos lexer + structured CST.
2//!
3//! [`parse`] tokenizes via [`logos_lexer`], constructs a lossless
4//! CST through [`parse_structured`], and walks it via the
5//! converter in `cst::convert` to produce a [`ParseResult`] with
6//! the typed AST plus errors, options, includes, plugins,
7//! comments, and currency occurrences.
8//!
9//! # Features
10//!
11//! - Full Beancount syntax support (all 12 directive types)
12//! - Error recovery (continues parsing after errors)
13//! - Precise source locations for error reporting
14//! - Support for includes, options, plugins
15//!
16//! # Example
17//!
18//! ```ignore
19//! use rustledger_parser::parse;
20//!
21//! let source = r#"
22//! 2024-01-15 * "Coffee Shop" "Morning coffee"
23//! Expenses:Food:Coffee 5.00 USD
24//! Assets:Cash
25//! "#;
26//!
27//! let result = parse(source);
28//! assert!(result.errors.is_empty());
29//! assert_eq!(result.directives.len(), 1);
30//! ```
31
32#![forbid(unsafe_code)]
33#![warn(missing_docs)]
34// Never-panic surface: the parser must handle malformed input gracefully (no
35// panics — see CLAUDE.md), so production code must not `unwrap`/`expect`.
36// `not(test)` scopes the deny to non-test builds, exempting this crate's own
37// `#[cfg(test)]` code (it compiles with `cfg(test)`); integration tests under
38// `tests/` are separate crates. Proven-safe production sites (parser invariants
39// like "the root is always SOURCE_FILE") carry an audited `#[allow]` with reason.
40#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
41
42pub mod bom;
43pub mod cst;
44mod diagnostics;
45mod error;
46pub mod logos_lexer;
47
48/// Opinionated CST-backed formatter entries.
49///
50/// **Sole** import path for the formatter surface - `format_source`,
51/// `format_source_with_parsed`, `try_format_source`, `format_node`,
52/// `format_node_range`, `format_node_with_alignment`,
53/// `format_node_range_with_alignment`, `PostingAlignment`,
54/// `compute_alignment`, `canonicalize_directives`,
55/// `CanonicalizeError`, `lf_to_crlf_outside_strings`,
56/// `crlf_to_lf_outside_strings`, `cr_outside_strings_present`. The
57/// flat crate-root re-exports were removed in round-5 and the
58/// duplicate `crate::cst::format` path was sealed in round-6 of
59/// the PR #1284 reviews, so a future deprecation can be done at
60/// exactly one site.
61pub mod format {
62 pub use crate::cst::format::{
63 CanonicalizeError, GroupingStyle, PostingAlignment, canonicalize_directives,
64 compute_alignment, cr_outside_strings_present, crlf_to_lf_outside_strings, format_node,
65 format_node_grouped, format_node_range, format_node_range_grouped,
66 format_node_range_with_alignment, format_node_with_alignment, format_source,
67 format_source_grouped, format_source_with_parsed, lf_to_crlf_outside_strings,
68 try_format_source, try_format_source_grouped,
69 };
70}
71
72pub use cst::{
73 BeancountLanguage, SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken, lossless_kind_tokens,
74 parse_flat, parse_structured, parse_via_cst, parse_via_cst_opts,
75};
76
77// Rowan types CST consumers need. Flat re-exports at the crate
78// root match the surrounding `SyntaxNode` / `SyntaxToken` shape -
79// downstream `use rustledger_parser::{SyntaxNode, TextRange};`
80// resolves both halves uniformly without a sub-module hop.
81//
82// The set covers what LSP handlers need for tree walking:
83// - `TextRange` / `TextSize`: byte-offset ranges on every node
84// - `TokenAtOffset`: cursor-position lookup
85// - `WalkEvent`: preorder / postorder traversal for folding-range
86// and semantic-tokens implementations
87// - `NodeOrToken`: pattern-matching `SyntaxElement` children
88// - `Direction`: sibling iteration
89//
90// `GreenNode` is deliberately NOT re-exported - it's the
91// thread-safe storage backing for `SyntaxNode` but downstream
92// consumers should walk via the cursor API, not the green tree.
93//
94// **Stability.** These types are versioned in lockstep with this
95// crate, NOT with `rowan` directly. A rowan minor bump that
96// changes any of these will require a coordinated bump of this
97// crate so the re-export contract holds at THIS crate's semver.
98pub use error::{ParseError, ParseErrorKind};
99pub use logos_lexer::is_valid_account_name;
100pub use rowan::{Direction, NodeOrToken, TextRange, TextSize, TokenAtOffset, WalkEvent};
101pub use rustledger_core::{InternedStr, SYNTHESIZED_FILE_ID, Span, Spanned};
102
103use rustledger_core::Directive;
104
105/// Result of parsing a beancount file.
106///
107/// Marked `#[non_exhaustive]` so external consumers must go through
108/// [`parse`] rather than constructing the struct by literal. Future
109/// field additions (e.g., diagnostic metadata, source-map back-
110/// references) then land as non-breaking changes.
111#[derive(Debug)]
112#[non_exhaustive]
113pub struct ParseResult {
114 /// Successfully parsed directives.
115 pub directives: Vec<Spanned<Directive>>,
116 /// Options found in the file.
117 pub options: Vec<(String, String, Span)>,
118 /// Include directives found.
119 pub includes: Vec<(String, Span)>,
120 /// Plugin directives found.
121 pub plugins: Vec<(String, Option<String>, Span)>,
122 /// Standalone comments found in the file.
123 pub comments: Vec<Spanned<String>>,
124 /// Parse errors encountered.
125 pub errors: Vec<ParseError>,
126 /// Deprecation warnings.
127 pub warnings: Vec<ParseWarning>,
128 /// Every `Currency` token the parser consumed, paired with its
129 /// interned value and source-byte range.
130 ///
131 /// Source-position-aware tooling (LSP rename / references /
132 /// document-highlight) walks this list to produce edits, locations,
133 /// and highlights without resorting to string search of the source,
134 /// which produces false positives in comments, payee strings,
135 /// account-name segments, etc. The order matches source order
136 /// because the parser fills it as tokens are consumed (and the
137 /// parser is strictly forward-advancing, including on error
138 /// recovery).
139 ///
140 /// **Error-recovery contract.** Tokens consumed during a
141 /// directive that ultimately fails to parse remain in this list.
142 /// Rationale: the lexer's classification of a token as a
143 /// `Currency` is independent of whether the surrounding syntax is
144 /// valid, and tooling that wants to rename or highlight a
145 /// currency the user typed should follow that classification.
146 /// Do not "clean up" partially-consumed entries after a parse
147 /// failure - that would hide real currency identifiers from
148 /// downstream tooling while the user is mid-edit.
149 ///
150 /// **`file_id` is always 0 in parser output.** The parser
151 /// processes one file at a time and doesn't know its own file
152 /// id. The loader sets the correct id on each entry via
153 /// `.with_file_id(n)` when assembling a multi-file `SourceMap`,
154 /// the same way it does for `directives`. Per-file consumers
155 /// (today: every LSP handler) can ignore `file_id`; future
156 /// multi-file consumers must remember to thread it through.
157 pub currency_occurrences: Vec<Spanned<rustledger_core::Currency>>,
158 /// Every `Account` token the parser consumed, paired with its
159 /// interned value and source-byte range.
160 ///
161 /// Mirrors [`Self::currency_occurrences`] for the account
162 /// shape. The CST conversion (`walk_descendants_once`) tracks
163 /// every `ACCOUNT` token whose ancestors do NOT include an
164 /// `ERROR_NODE`. The LSP rename handler (phase 5.4) walks
165 /// this list to emit exact-span edits without resorting to
166 /// per-directive substring search, which used to produce
167 /// false positives wherever an account-name fragment appeared
168 /// inside a payee string, a STRING-typed metadata value, or a
169 /// comment. ACCOUNT-typed metadata values (e.g.
170 /// `counterparty: Assets:Bank`) DO produce an `ACCOUNT` token
171 /// at the lexer level and ARE included in this list - so a
172 /// rename of `Assets:Bank` correctly rewrites that metadata
173 /// value too.
174 ///
175 /// **Migration status (#1262 phase 5.4).** Only the LSP
176 /// rename handler currently consumes this index. The sibling
177 /// handlers `references`, `document_highlight`, and
178 /// `linked_editing` still walk the typed AST with substring
179 /// search for accounts (see those modules' rustdoc); migrating
180 /// them to consume `account_occurrences` is tracked as a
181 /// phase 5.5+ follow-up.
182 ///
183 /// **Error-recovery contract.** Two notions of "failing
184 /// directive" need to be distinguished:
185 ///
186 /// - A directive that PARSES SYNTACTICALLY but whose
187 /// typed-AST conversion errors (e.g.,
188 /// [`crate::ParseErrorKind::InvalidBookingMethod`] on an
189 /// `open Assets:Bank "GARBAGE"`). The ACCOUNT node is
190 /// intact in the CST and NOT inside an `ERROR_NODE`. The
191 /// token IS tracked - tooling can still rename it during
192 /// the mid-edit state.
193 /// - A directive so garbled that the CST wraps the region
194 /// in an `ERROR_NODE`. The ACCOUNT token is inside an
195 /// `ERROR_NODE` and is NOT tracked. This is deliberate -
196 /// the recovery boundary is fuzzy and including such
197 /// tokens would surface as confusing rename hits inside
198 /// garbage source.
199 ///
200 /// # Limitations
201 ///
202 /// The list is undifferentiated: declarations (from
203 /// open/close/balance/pad/note/document) and references
204 /// (from posting accounts and ACCOUNT-typed metadata) are
205 /// mixed together. There is no equivalent of the
206 /// `commodity_declaration_spans` helper used for currencies
207 /// (the account case has six declaration directive shapes vs.
208 /// the single `Commodity` shape, so no symmetric helper
209 /// exists yet). A future go-to-definition migration will need
210 /// either a re-walk over `directives` or an additional
211 /// `account_declarations: Vec<Span>` field.
212 ///
213 /// **`file_id` is always 0 in parser output** - same loader
214 /// contract as `currency_occurrences`.
215 pub account_occurrences: Vec<Spanned<rustledger_core::Account>>,
216 /// `true` iff the parsed source began with a UTF-8 BOM (strict
217 /// byte 0).
218 ///
219 /// This is the **single source of truth** for downstream consumers
220 /// that need to know whether to preserve a leading BOM on output
221 /// (notably `format_source`). Do NOT inspect the source bytes
222 /// directly; the parser already handled the strip/detect logic in
223 /// one place ([`crate::bom::strip_leading`]) and stored the result
224 /// here. Reproducing the check elsewhere is exactly the contract-
225 /// drift class of bug this field was introduced to eliminate.
226 ///
227 /// Span coordinates in this `ParseResult` are in the **original
228 /// source frame** - i.e., if `has_leading_bom` is true, spans
229 /// already include the 3-byte BOM offset and index directly into
230 /// the caller's source.
231 pub has_leading_bom: bool,
232 /// The lossless CST root the converter walked to produce
233 /// everything above. Stored as a [`rowan::GreenNode`], which
234 /// is `Send + Sync` and reference-counted internally, so an
235 /// `Arc<ParseResult>` (the shape the LSP caches per document)
236 /// shares this handle across handler invocations without
237 /// re-parsing.
238 ///
239 /// **Prefer [`Self::syntax_node`]** over reading this field
240 /// directly. The method is the supported entry point: it
241 /// returns a [`SyntaxNode`] (the cursor-API view), keeps the
242 /// `rowan::GreenNode` type name out of consumer code, and
243 /// shields callers from minor rowan upgrades that touch the
244 /// `GreenNode` shape. The field is public for two reasons —
245 /// the exhaustive destructure in
246 /// [`__baseline_canonical_payload`] needs to bind it, and
247 /// `Arc::clone`-style sharing patterns benefit from direct
248 /// access — but downstream code should reach for the method.
249 ///
250 /// **Byte-offset frame: post-BOM.** The CST is built from
251 /// the BOM-stripped source — the parser strips a strict-
252 /// byte-0 UTF-8 BOM (see [`crate::bom::strip_leading`]) and
253 /// feeds the stripped slice to `parse_structured`. So every
254 /// `TextRange` / `TextSize` reachable through this tree is
255 /// in the **post-BOM** byte frame: an offset of `0` here
256 /// corresponds to byte `BOM_LEN == 3` of the original source
257 /// when [`Self::has_leading_bom`] is `true`. This differs
258 /// from the typed-AST fields above ([`Self::directives`],
259 /// [`Self::currency_occurrences`], [`Self::account_occurrences`],
260 /// [`Self::errors`], …), whose spans the converter
261 /// pre-shifts back into the *original*-source frame so
262 /// downstream consumers can index directly into the caller's
263 /// source bytes. CST-walking consumers must apply the
264 /// equivalent shift themselves: subtract `BOM_LEN` when
265 /// translating an original-source offset down to a CST
266 /// offset (e.g., `cst.token_at_offset(orig - BOM_LEN)`), and
267 /// add `BOM_LEN` back when emitting an original-source
268 /// position from a `TextRange`. The LSP `selection_range`
269 /// handler does this — see its rustdoc and the
270 /// `bom_prefixed_source_does_not_shift_ranges` regression
271 /// test.
272 ///
273 /// **Canonical-payload exclusion.** This field is deliberately
274 /// NOT fed into [`__baseline_canonical_payload`]. The green
275 /// node is a redundant cache of the source bytes; the
276 /// existing `directives` / `currency_occurrences` /
277 /// `account_occurrences` / `errors` fields already capture
278 /// everything downstream consumers track for drift detection.
279 /// Adding the green node's `Debug` output would multiply
280 /// the fingerprint size without surfacing any new drift
281 /// signal. The corresponding `assert_field_in_hash` arm is
282 /// also intentionally absent in `tests/corpus_baseline.rs`.
283 /// A negative-form test (`__canonical_payload_excludes_syntax_root`
284 /// in this file) pins the exclusion: it confirms that mutating
285 /// `syntax_root` while every other field is equal does NOT
286 /// change the canonical payload bytes.
287 pub syntax_root: rowan::GreenNode,
288 /// File-wide alignment columns the formatter would use for
289 /// this source — pre-computed at parse time so hot formatting
290 /// paths skip the `O(N_postings)` per-call walk.
291 ///
292 /// `PostingAlignment` is `Copy`; pass it directly into the
293 /// `_with_alignment` variants of the formatter
294 /// ([`crate::format::format_node_with_alignment`],
295 /// [`crate::format::format_node_range_with_alignment`],
296 /// [`crate::format::format_source_with_parsed`]) to reuse this
297 /// cached value. The LSP `format_document` /
298 /// `range_formatting` fallback handlers, the FFI `format.source`
299 /// endpoint, and the WASM `ParsedLedger::format` bridge all
300 /// consume the cache to skip both the redundant parse and the
301 /// redundant alignment walk.
302 ///
303 /// **Computed on first ask, then cached.** Nothing populates this at
304 /// parse time any more; the first call to [`ParseResult::alignment`]
305 /// computes it from `syntax_root` and every later call is free. Because
306 /// the value is derived from the tree WHEN ASKED rather than snapshotted
307 /// at parse time, the staleness hazard the old eager field carried — a
308 /// consumer mutating `directives` or `syntax_root` through their `pub`
309 /// handles and leaving a stale alignment behind — only exists if the
310 /// mutation happens after something already forced the cache.
311 ///
312 /// **Equivalence pinned.**
313 /// `parse_result_alignment_cache::*` (7 fixtures) assert that
314 /// `parse(s).alignment()` equals
315 /// `compute_alignment(&SourceFile::cast(parse(s).syntax_node()).unwrap())`
316 /// across representative fixtures, so a `compute_alignment` change that
317 /// breaks the contract fails CI. `parse_leaves_the_alignment_uncomputed_until_asked`
318 /// separately pins that parsing does not force it — the value is the same
319 /// either way, so nothing else would notice the cost coming back.
320 ///
321 /// **Canonical-payload exclusion.** Excluded from
322 /// [`__baseline_canonical_payload`] for the same reason as
323 /// `syntax_root`: it's a redundant derivation of `directives`
324 /// content. Mutating it without changing `directives` would
325 /// silently flip the corpus hash; including it in the
326 /// payload would change the hash for every source with a
327 /// non-default alignment (i.e. essentially every real
328 /// Beancount file). The exclusion is pinned by
329 /// `canonical_payload_excludes_alignment`.
330 /// Lazily computed; see [`ParseResult::alignment`].
331 alignment: std::sync::OnceLock<crate::format::PostingAlignment>,
332}
333
334impl ParseResult {
335 /// Cursor-API view of the lossless CST that produced this
336 /// `ParseResult`. Equivalent to
337 /// `SyntaxNode::new_root(self.syntax_root.clone())`.
338 ///
339 /// Construction is an `Arc` bump (the green node's internal
340 /// refcount); cheap enough to call per request. This is the
341 /// supported entry point for CST consumers — prefer it over
342 /// reading [`Self::syntax_root`] directly, so the `rowan`
343 /// dependency stays an implementation detail.
344 #[must_use]
345 pub fn syntax_node(&self) -> SyntaxNode {
346 SyntaxNode::new_root(self.syntax_root.clone())
347 }
348
349 /// File-wide alignment columns the formatter would use, computed on
350 /// first call and cached.
351 ///
352 /// This used to be computed eagerly by every parse and stored as a
353 /// public field. `compute_alignment` walks the tree through the RED ast
354 /// accessors (`Posting::flag`, `Amount::is_arithmetic`, ...), and rowan
355 /// allocates a `Box<NodeData>` per red node with no recycling — so every
356 /// caller paid a full formatter pass whether or not it ever formatted
357 /// anything. Measured by ablation, it was 5.7%-12.3% of ALL instructions
358 /// depending on workload, and ~34% of the process's allocations.
359 ///
360 /// Only the formatter reads it (`format_source_with_parsed`, and through
361 /// it the LSP, `rledger format` and `doctor roundtrip`), so it is now
362 /// paid for by those callers alone. The caching contract they relied on
363 /// is unchanged: the first call computes, every later call is free, and
364 /// the value equals a fresh `compute_alignment` on the same tree — which
365 /// is what `parse_result_alignment_cache::*` pins.
366 ///
367 /// `OnceLock`, deliberately, not `OnceCell`: `ParseResult` is shared as
368 /// `Arc<ParseResult>` across the LSP's threads and there is a
369 /// compile-time assertion below that it stays `Send + Sync`.
370 ///
371 /// # Panics
372 ///
373 /// Panics if `syntax_root` is not a `SOURCE_FILE`, matching
374 /// [`crate::format::format_node_grouped`]. It always is — `parse_via_cst`
375 /// builds the root — and the alternative considered here, falling back to
376 /// `PostingAlignment::default()`, is worse than a panic: it would format
377 /// the whole file to the wrong columns while looking like it worked.
378 #[must_use]
379 pub fn alignment(&self) -> crate::format::PostingAlignment {
380 *self.alignment.get_or_init(|| {
381 use crate::cst::ast::AstNode as _;
382 // Precondition as in `format_node_grouped`, which panics on the
383 // same breach rather than guessing an alignment.
384 #[allow(clippy::expect_used)]
385 let source_file = crate::cst::ast::SourceFile::cast(self.syntax_node())
386 .expect("ParseResult::syntax_root is always a SOURCE_FILE");
387 crate::cst::format::compute_alignment(
388 &source_file,
389 crate::cst::format::GroupingStyle::default(),
390 )
391 })
392 }
393}
394
395// Compile-time assertion: `ParseResult` is shared as
396// `Arc<ParseResult>` across the LSP's main thread and its
397// background worker (see `rustledger-lsp/src/main_loop.rs`).
398// A future field whose type is not `Send + Sync` (e.g. an `Rc`,
399// a `Cell`, or a non-thread-safe handle) would silently break
400// the LSP build at the call site, far from the parser change
401// that caused it. This assertion fences the invariant at the
402// definition site so the parser crate's own build fails first.
403const _: fn() = || {
404 const fn assert_send_sync<T: Send + Sync>() {}
405 assert_send_sync::<ParseResult>();
406};
407
408/// A warning from the parser (non-fatal).
409#[derive(Debug, Clone)]
410pub struct ParseWarning {
411 /// The warning message.
412 pub message: String,
413 /// Location in source.
414 pub span: Span,
415}
416
417impl ParseWarning {
418 /// Create a new warning.
419 pub fn new(message: impl Into<String>, span: Span) -> Self {
420 Self {
421 message: message.into(),
422 span,
423 }
424 }
425}
426
427/// Parse beancount source code.
428///
429/// Routes through the CST-backed implementation
430/// ([`parse_via_cst`]): a lossless Logos lexer feeds a structured
431/// CST builder, and the converter in `crate::cst::convert` walks
432/// the resulting tree to produce the [`ParseResult`].
433///
434/// # Arguments
435///
436/// * `source` - The beancount source code to parse
437///
438/// # Returns
439///
440/// A `ParseResult` containing directives, options, includes, plugins, and errors.
441#[must_use]
442pub fn parse(source: &str) -> ParseResult {
443 parse_via_cst(source)
444}
445
446/// Parse beancount source for the processing pipeline, **without** collecting
447/// the `currency_occurrences` / `account_occurrences` indices.
448///
449/// Those two indices are consumed only by the LSP. The loader / CLI processing
450/// path (`rustledger_loader::load` and friends) never reads them, so skipping
451/// their collection avoids the largest per-token allocation+CPU cost in the
452/// parser (one `Account::new` / `Currency::new` plus an in-`ERROR_NODE` ancestor
453/// walk per `ACCOUNT` / `CURRENCY` token). The returned [`ParseResult`] is
454/// identical to [`parse`]'s except both occurrence vectors are empty.
455///
456/// Editor / LSP callers that need rename / references / highlight data must use
457/// [`parse`] instead.
458#[must_use]
459pub fn parse_without_occurrences(source: &str) -> ParseResult {
460 parse_via_cst_opts(source, /* collect_occurrences = */ false)
461}
462
463/// Parse beancount source code, returning only directives and errors.
464///
465/// This is a simpler interface when you don't need options/includes/plugins.
466#[must_use]
467pub fn parse_directives(source: &str) -> (Vec<Spanned<Directive>>, Vec<ParseError>) {
468 let result = parse(source);
469 (result.directives, result.errors)
470}
471
472/// Canonical hash-payload serialization for the corpus baseline
473/// (#1262 phase 0). **Internal**: this exists only so the baseline
474/// integration test can hash a `ParseResult` without listing fields
475/// outside the defining crate.
476///
477/// Returns a byte string that uniquely identifies the `ParseResult`'s
478/// observable content. Directives route through `serde_json::to_value`
479/// to normalize the `FxHashMap` iteration order in metadata; all
480/// other fields use `Debug` formatting, which is deterministic for
481/// `Vec`-based types.
482///
483/// **Why this lives in `rustledger-parser` instead of the test:**
484/// `ParseResult` is `#[non_exhaustive]`, which blocks exhaustive
485/// destructuring from external crates (including the integration
486/// test). Performing the destructure here forces the compiler to
487/// flag any field added to `ParseResult` that the canonical
488/// serialization does not feed into its output. Without this, a new
489/// `ParseResult` field could silently exit the baseline fingerprint -
490/// the BOM-flag-omission class of bug the round-3 review caught.
491///
492/// **Add a new field?** Add a binding (NOT `_`) AND a hasher feed
493/// line to the destructure below. The compiler enforces the binding;
494/// reviewers must enforce the feed.
495///
496/// **Determinism precondition:** this routes directives through
497/// `serde_json::to_value`, which is only sort-stable when
498/// `serde_json`'s `preserve_order` feature is **off**. Cargo feature
499/// unification can flip this on workspace-wide; the unit test
500/// `serde_json_object_is_sorted` in this crate's tests catches that
501/// flip before the canonical hash silently desyncs.
502#[doc(hidden)]
503#[must_use]
504pub fn __baseline_canonical_payload(result: &ParseResult) -> Vec<u8> {
505 let ParseResult {
506 directives,
507 options,
508 includes,
509 plugins,
510 comments,
511 errors,
512 warnings,
513 currency_occurrences,
514 account_occurrences,
515 has_leading_bom,
516 syntax_root,
517 alignment,
518 } = result;
519 // Both `syntax_root` and `alignment` are redundant
520 // derivations of fields already in the canonical payload
521 // (`syntax_root` of the source bytes captured by
522 // `directives`/`occurrences`/`errors`; `alignment` of the
523 // posting widths inside `directives`). Bind them so the
524 // compiler still flags future field additions on this
525 // exhaustive destructure, but discard them from the canonical
526 // payload. Pinned by `canonical_payload_excludes_syntax_root`
527 // and `canonical_payload_excludes_alignment`.
528 let _ = syntax_root;
529 let _ = alignment;
530 let mut out: Vec<u8> = Vec::new();
531 let directives_json = serde_json::to_value(directives)
532 .map_or_else(|e| format!("serialize-error:{e}"), |v| v.to_string());
533 out.extend_from_slice(b"directives:");
534 out.extend_from_slice(directives_json.as_bytes());
535 out.extend_from_slice(b"\noptions:");
536 out.extend_from_slice(format!("{options:?}").as_bytes());
537 out.extend_from_slice(b"\nincludes:");
538 out.extend_from_slice(format!("{includes:?}").as_bytes());
539 out.extend_from_slice(b"\nplugins:");
540 out.extend_from_slice(format!("{plugins:?}").as_bytes());
541 out.extend_from_slice(b"\ncomments:");
542 out.extend_from_slice(format!("{comments:?}").as_bytes());
543 out.extend_from_slice(b"\nerrors:");
544 out.extend_from_slice(format!("{errors:?}").as_bytes());
545 out.extend_from_slice(b"\nwarnings:");
546 out.extend_from_slice(format!("{warnings:?}").as_bytes());
547 out.extend_from_slice(b"\ncurrency_occurrences:");
548 out.extend_from_slice(format!("{currency_occurrences:?}").as_bytes());
549 out.extend_from_slice(b"\naccount_occurrences:");
550 out.extend_from_slice(format!("{account_occurrences:?}").as_bytes());
551 out.extend_from_slice(b"\nhas_leading_bom:");
552 out.extend_from_slice(format!("{has_leading_bom:?}").as_bytes());
553 out
554}
555
556#[cfg(test)]
557mod canonical_payload_determinism {
558 //! Guard against cargo feature unification silently enabling
559 //! `serde_json/preserve_order` workspace-wide. When `preserve_order`
560 //! is OFF, `serde_json::Value::Object` is BTreeMap-backed and sorts
561 //! its keys; when ON, it's IndexMap-backed and preserves insertion
562 //! order. `__baseline_canonical_payload` relies on the sort-stable
563 //! behavior to neutralize `FxHashMap` iteration order in directive
564 //! metadata. A workspace crate flipping the feature on would make
565 //! canonical hashes vary with hashbrown state across machines -
566 //! the very class of bug the canonicalization was added to
567 //! prevent. This test fails fast and points at the cargo-feature
568 //! cause instead of letting the corpus baseline mysteriously drift.
569 use serde_json::json;
570
571 #[test]
572 fn serde_json_object_is_sorted() {
573 // Insertion order `b, a` would survive under `preserve_order`.
574 // Default features sort to `a, b`.
575 let v = json!({ "b": 1, "a": 2 });
576 let s = v.to_string();
577 assert!(
578 s.starts_with("{\"a\""),
579 "serde_json::Value::Object is not sorting keys (got {s}). \
580 This means cargo feature unification turned on \
581 serde_json/preserve_order somewhere in the workspace. \
582 The corpus baseline's canonical hash assumes sorted \
583 Object keys to neutralize FxHashMap iteration order in \
584 directive metadata. Find the crate that enabled \
585 `serde_json = {{ ..., features = [\"preserve_order\"] }}` \
586 and remove it, or thread an alternative canonicalization \
587 through __baseline_canonical_payload.",
588 );
589 }
590}
591
592#[cfg(test)]
593mod cached_syntax_root_matches_fresh_parse {
594 //! The `selection_range` handler (and any future CST-walking
595 //! handler) consumes [`ParseResult::syntax_root`] instead of
596 //! re-invoking [`crate::parse_structured`]. The safety
597 //! argument is "the cached green root is the same tree the
598 //! converter walked, which is the same tree a fresh
599 //! `parse_structured` would return."
600 //!
601 //! Today that argument is trivially true because the cache is
602 //! populated directly from the converter's `source_file`.
603 //! But if a future change introduces post-conversion CST
604 //! mutation (span rewrites, error-recovery splicing, trivia
605 //! reattachment) the cached root would diverge from a fresh
606 //! re-parse — silently, since nothing else compares the two
607 //! trees. This test pins the invariant across a small fixture
608 //! set covering empty source, every directive kind, error
609 //! recovery, mid-file BOM, and metadata-bearing transactions.
610 use super::{cst::parse_structured, parse};
611
612 fn assert_round_trip(label: &str, source: &str) {
613 let parsed = parse(source);
614 let (stripped, _bom) = crate::bom::strip_leading(source);
615 let fresh = parse_structured(stripped).green().to_owned();
616 assert_eq!(
617 parsed.syntax_root, fresh,
618 "cached syntax_root diverged from fresh parse_structured for {label}: \n\
619 this means something is mutating the green tree between converter \
620 capture and consumer access. The two are supposed to be identical."
621 );
622 }
623
624 #[test]
625 fn empty_source() {
626 assert_round_trip("empty", "");
627 }
628
629 #[test]
630 fn simple_directive() {
631 assert_round_trip("open", "2024-01-01 open Assets:Bank USD\n");
632 }
633
634 #[test]
635 fn every_directive_shape() {
636 assert_round_trip(
637 "directive zoo",
638 r#"option "title" "Test"
639plugin "myplugin"
640include "other.beancount"
6412024-01-01 open Assets:Bank USD
6422024-01-01 commodity USD
6432024-06-15 * "Coffee"
644 Assets:Bank -5.00 USD
645 Expenses:Food
6462024-12-31 close Assets:Bank
6472024-01-31 balance Assets:Bank 100 USD
6482024-01-15 pad Assets:Bank Equity:Opening
6492024-01-15 note Assets:Bank "deposit pending"
6502024-01-15 event "location" "SF"
6512024-01-15 price USD 1.00 EUR
652"#,
653 );
654 }
655
656 #[test]
657 fn with_parse_errors() {
658 // Trigger error recovery (unterminated string, garbled
659 // directive) to ensure the post-pass `fixup_directive_spans`
660 // and error-node wrapping don't drift between cache and
661 // fresh re-parse.
662 assert_round_trip(
663 "broken",
664 "2024-01-01 open Assets:Bank \"unterminated\n2024-01-02 garbage line here\n",
665 );
666 }
667
668 #[test]
669 fn with_metadata_and_comments() {
670 assert_round_trip(
671 "metadata",
672 r#"; standalone comment
6732024-01-01 open Assets:Bank USD
674 payee_account: Assets:Other
6752024-06-15 * "Coffee" ; eol comment
676 memo: "morning"
677 Assets:Bank -5.00 USD
678"#,
679 );
680 }
681}
682
683#[cfg(test)]
684mod canonical_payload_excludes_syntax_root {
685 //! Pins the deliberate exclusion of `ParseResult::syntax_root`
686 //! from [`__baseline_canonical_payload`]. The exclusion is
687 //! documented in three places (the field's rustdoc, the
688 //! destructure comment in `__baseline_canonical_payload`, and
689 //! the CHANGELOG entry under `[Unreleased] / Features`) but
690 //! none of those are executable. A future contributor
691 //! mechanically pattern-matching on "all fields get an arm"
692 //! could add a `syntax_root` feed to the canonical payload —
693 //! the corpus manifest would silently drift on every source
694 //! that touched the green tree.
695 //!
696 //! This test mutates `syntax_root` while leaving every other
697 //! field equal, and asserts the canonical payload bytes are
698 //! unchanged.
699 use super::{__baseline_canonical_payload, parse};
700
701 #[test]
702 fn mutating_syntax_root_does_not_change_canonical_payload() {
703 let src_a = "2024-01-01 open Assets:Bank USD\n";
704 // A different source produces a different green tree but
705 // we want every OTHER field equal; pick a source that
706 // produces an identical typed ParseResult on every field
707 // EXCEPT `syntax_root`. Empty source is the simplest
708 // counterexample for "syntax_root differs"; we go further
709 // and synthesize the mutation explicitly to keep the test
710 // independent of the converter's behavior.
711 let parsed_a = parse(src_a);
712 let mut mutated = parse(src_a);
713 // Replace the green tree with a freshly-parsed but
714 // structurally-different one. `parse("")` gives an empty
715 // SOURCE_FILE green root; the original has an OPEN_DIRECTIVE
716 // child. Other fields will differ for `parse("")`, so we
717 // construct the mutation by swapping ONLY the field.
718 mutated.syntax_root = parse("").syntax_root;
719
720 let payload_original = __baseline_canonical_payload(&parsed_a);
721 let payload_mutated = __baseline_canonical_payload(&mutated);
722 assert_eq!(
723 payload_original, payload_mutated,
724 "canonical payload changed after mutating only `syntax_root`. \
725 Either the destructure in `__baseline_canonical_payload` \
726 grew a `syntax_root` feed line (revert that — the field \
727 is deliberately excluded; see its rustdoc), or another \
728 field now reads from `syntax_root` indirectly. Either \
729 way the corpus manifest is about to drift."
730 );
731 }
732}
733
734#[cfg(test)]
735mod canonical_payload_excludes_alignment {
736 //! Pins the deliberate exclusion of `ParseResult::alignment`
737 //! from [`__baseline_canonical_payload`]. Same shape as
738 //! `canonical_payload_excludes_syntax_root`: mutate the field,
739 //! re-hash, assert unchanged.
740 //!
741 //! Including `alignment` in the canonical payload would change
742 //! the corpus hash for every source whose postings determine
743 //! non-default column widths — i.e. essentially every real
744 //! Beancount file. The field is a derivation of `directives`
745 //! content (already in the payload via the typed-AST hash);
746 //! it carries no independent drift signal.
747 use super::{__baseline_canonical_payload, parse};
748 use crate::cst::format::PostingAlignment;
749
750 /// `parse` must NOT compute the alignment.
751 ///
752 /// This is the whole point of the change and nothing else asserts it: the
753 /// value is identical either way, so an eager computation would be
754 /// invisible to every other test while costing 7.6%-12.4% of the
755 /// program's instructions depending on workload.
756 ///
757 /// Checks the cache is empty after `parse`, that asking fills it, and
758 /// that the answer still equals a fresh `compute_alignment` on the same
759 /// tree.
760 #[test]
761 fn parse_leaves_the_alignment_uncomputed_until_asked() {
762 use crate::cst::ast::AstNode as _;
763
764 let src = "\
7652024-01-15 * \"Coffee\"
766 Assets:Bank -5.00 USD
767 Expenses:Food
768";
769 let parsed = parse(src);
770 assert!(
771 parsed.alignment.get().is_none(),
772 "parse computed the alignment eagerly — that is the cost this \
773 change removed, and it is invisible in output because the value \
774 is the same either way",
775 );
776
777 let asked = parsed.alignment();
778 assert!(
779 parsed.alignment.get().is_some(),
780 "asking must populate the cache",
781 );
782
783 let fresh = crate::cst::format::compute_alignment(
784 &crate::cst::ast::SourceFile::cast(parsed.syntax_node()).expect("root casts"),
785 crate::cst::format::GroupingStyle::default(),
786 );
787 assert_eq!(
788 asked, fresh,
789 "the lazily-computed value must equal a fresh computation",
790 );
791 }
792
793 #[test]
794 fn mutating_alignment_does_not_change_canonical_payload() {
795 let src = "\
7962024-01-15 * \"Coffee\"
797 Assets:Bank -5.00 USD
798 Expenses:Food
799";
800 let parsed = parse(src);
801 let mutated = parse(src);
802 // Synthesize a different PostingAlignment value: bump number_col
803 // by 100. Real-world alignment would never be this wide
804 // for the fixture, so we get a guaranteed-different cache.
805 //
806 // `set` rather than assignment now that the cache is a `OnceLock`.
807 // It succeeds precisely because `parse` no longer fills it — which is
808 // the point of this change, so a failure here would mean the eager
809 // computation came back.
810 mutated
811 .alignment
812 .set(PostingAlignment {
813 number_col: parsed.alignment().number_col + 100,
814 number_width: parsed.alignment().number_width + 7,
815 })
816 .expect("parse must leave the alignment cache empty");
817
818 let payload_original = __baseline_canonical_payload(&parsed);
819 let payload_mutated = __baseline_canonical_payload(&mutated);
820 assert_eq!(
821 payload_original, payload_mutated,
822 "canonical payload changed after mutating only `alignment`. \
823 Either the destructure in `__baseline_canonical_payload` \
824 grew an `alignment` feed line (revert that — the field \
825 is deliberately excluded), or another field now reads \
826 from `alignment` indirectly. Either way the corpus \
827 manifest is about to drift across every source with \
828 postings.",
829 );
830 }
831}
832
833#[cfg(test)]
834mod parse_result_alignment_cache {
835 //! Pins the equivalence between `ParseResult::alignment` (the
836 //! pre-computed cache populated by `parse_via_cst`) and a
837 //! fresh `compute_alignment` call on the same syntax tree.
838 //! A converter change that forgets to refresh the cache, or a
839 //! `compute_alignment` change that breaks the cache's
840 //! semantics, fails this test before reaching the LSP.
841 use super::parse;
842 use crate::cst::ast::{AstNode, SourceFile};
843 use crate::cst::format::compute_alignment;
844
845 fn assert_equivalent(label: &str, source: &str) {
846 let result = parse(source);
847 let source_file = SourceFile::cast(result.syntax_node())
848 .expect("ParseResult::syntax_node() must be a SOURCE_FILE");
849 let fresh = compute_alignment(&source_file, crate::cst::format::GroupingStyle::default());
850 assert_eq!(
851 result.alignment(),
852 fresh,
853 "ParseResult::alignment() diverged from a fresh \
854 compute_alignment call for {label}: cached = {:?}, fresh = {:?}. \
855 The accessor and this test must agree on how the alignment is \
856 derived from the tree — most likely the accessor's \
857 `GroupingStyle` no longer matches the one used here, or \
858 compute_alignment's semantics changed.",
859 result.alignment(),
860 fresh,
861 );
862 }
863
864 #[test]
865 fn empty_source() {
866 assert_equivalent("empty", "");
867 }
868
869 #[test]
870 fn open_only_no_postings() {
871 assert_equivalent("open only", "2024-01-01 open Assets:Bank USD\n");
872 }
873
874 #[test]
875 fn single_transaction() {
876 assert_equivalent(
877 "single txn",
878 "\
8792024-01-15 * \"Coffee\"
880 Assets:Bank -5.00 USD
881 Expenses:Food
882",
883 );
884 }
885
886 #[test]
887 fn multi_transaction_varying_widths() {
888 assert_equivalent(
889 "varying widths",
890 "\
8912024-01-15 * \"A\"
892 Assets:Bank -5.00 USD
893 Expenses:Food
8942024-02-15 * \"B\"
895 Assets:Investment:Long:Path -123456.78 USD
896 Expenses:Tax 100.00 USD
897",
898 );
899 }
900
901 #[test]
902 fn arithmetic_amounts() {
903 assert_equivalent(
904 "arithmetic amounts",
905 "\
9062024-01-15 * \"Split\"
907 Assets:Bank -10.00 + 5.00 USD
908 Expenses:Misc
909",
910 );
911 }
912
913 #[test]
914 fn parse_errors() {
915 // Even on parse-error files the cache must match a fresh
916 // call. The LSP fallback path consumes the cache through
917 // a broken file, so equivalence under error recovery is
918 // load-bearing.
919 assert_equivalent(
920 "broken",
921 "\
9222024-01-15 * \"x\"
923 Assets:Bank -5.00 USD
924}}}garbage
9252024-02-15 * \"y\"
926 Assets:Other 100.00 USD
927",
928 );
929 }
930
931 /// Mid-transaction recovery: when the WIDEST transaction's body
932 /// breaks (becomes `ERROR_NODE` because a posting is
933 /// syntactically incomplete), its postings are EXCLUDED from
934 /// `compute_alignment` because the wrapping Transaction node
935 /// fails the `ast::Directive::Transaction::cast` check inside
936 /// the alignment walk. The cache reflects only the
937 /// successfully-parsed transactions' alignment; this is the
938 /// behavior the LSP fallback observes when format-on-type fires
939 /// during a mid-edit broken state. The test pins the
940 /// equivalence (cache matches fresh call) so the producer-side
941 /// invariant holds even in this awkward transitional state.
942 ///
943 /// Note for users: as the user keeps typing and the parser
944 /// recovers/breaks the wrapping Transaction across edits, the
945 /// alignment columns may visibly shift. This is unavoidable
946 /// without speculatively recovering wide-account information
947 /// from the broken transaction's source bytes — out of scope
948 /// for the cache.
949 #[test]
950 fn mid_transaction_error_node() {
951 // First transaction has wide accounts (Assets:Investment:Long:Path)
952 // but is broken — the posting line ends with garbage that
953 // the recovery should wrap into an ERROR_NODE around the
954 // whole transaction. Second transaction (narrow accounts)
955 // parses cleanly. The cache's alignment reflects only the
956 // narrow transaction's widths.
957 assert_equivalent(
958 "mid-transaction breakage",
959 "\
9602024-01-15 * \"wide broken\"
961 Assets:Investment:Long:Path -123456.78 USD }}}
962 Expenses:Tax
9632024-02-15 * \"narrow clean\"
964 Assets:Bank -5.00 USD
965 Expenses:Food
966",
967 );
968 }
969}