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