Skip to main content

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