Skip to main content

rustledger_loader/
process.rs

1//! Processing pipeline: sort → synth-plugins → Early → book → regular-plugins → Late → finalize.
2//!
3//! This module orchestrates the full processing pipeline for a beancount ledger,
4//! equivalent to Python's `loader.load_file()` function.
5
6// ratchet: fxhash-only — hot path; use FxHashMap/FxHashSet, not std SipHash collections (#1237).
7use crate::{LoadError, LoadResult, Options, Plugin, SourceMap};
8use rustledger_core::{BookingMethod, Directive, DisplayContext};
9use rustledger_parser::Spanned;
10use std::path::Path;
11use thiserror::Error;
12
13/// A CLI-supplied (or programmatic) extra plugin invocation.
14///
15/// Bundles the plugin name with its optional config string so the two
16/// can't drift apart — the previous parallel-Vec representation could
17/// silently misalign a config with the wrong plugin.
18#[derive(Debug, Clone)]
19pub struct ExtraPlugin {
20    /// Plugin name (short or fully-qualified module path).
21    pub name: String,
22    /// Plugin-specific config string, if any.
23    pub config: Option<String>,
24}
25
26/// Options for loading and processing a ledger.
27#[derive(Debug, Clone)]
28pub struct LoadOptions {
29    /// Booking method for lot matching (default: Strict).
30    pub booking_method: BookingMethod,
31    /// Run plugins declared in the file (default: true).
32    pub run_plugins: bool,
33    /// Run `auto_accounts` plugin (default: false).
34    pub auto_accounts: bool,
35    /// Additional plugins to run (CLI `--plugin` or programmatic API),
36    /// each with an optional config string.
37    pub extra_plugins: Vec<ExtraPlugin>,
38    /// Run validation after processing (default: true).
39    pub validate: bool,
40    /// Enable path security (prevent include traversal).
41    pub path_security: bool,
42}
43
44impl Default for LoadOptions {
45    fn default() -> Self {
46        Self {
47            booking_method: BookingMethod::Strict,
48            run_plugins: true,
49            auto_accounts: false,
50            extra_plugins: Vec::new(),
51            validate: true,
52            path_security: false,
53        }
54    }
55}
56
57impl LoadOptions {
58    /// Create options for raw loading (no booking, no plugins, no validation).
59    #[must_use]
60    pub const fn raw() -> Self {
61        Self {
62            booking_method: BookingMethod::Strict,
63            run_plugins: false,
64            auto_accounts: false,
65            extra_plugins: Vec::new(),
66            validate: false,
67            path_security: false,
68        }
69    }
70}
71
72/// Errors that can occur during ledger processing.
73#[derive(Debug, Error)]
74pub enum ProcessError {
75    /// Loading failed.
76    #[error("loading failed: {0}")]
77    Load(#[from] LoadError),
78
79    /// Booking/interpolation error.
80    #[cfg(feature = "booking")]
81    #[error("booking error: {message}")]
82    Booking {
83        /// Error message.
84        message: String,
85        /// Date of the transaction.
86        date: rustledger_core::NaiveDate,
87        /// Narration of the transaction.
88        narration: String,
89    },
90
91    /// Plugin execution error.
92    #[cfg(feature = "plugins")]
93    #[error("plugin error: {0}")]
94    Plugin(String),
95
96    /// Validation error.
97    #[cfg(feature = "validation")]
98    #[error("validation error: {0}")]
99    Validation(String),
100
101    /// Plugin output conversion error.
102    #[cfg(feature = "plugins")]
103    #[error("failed to convert plugin output: {0}")]
104    PluginConversion(String),
105}
106
107/// A fully processed ledger.
108///
109/// This is the result of loading and processing a beancount file,
110/// equivalent to the tuple returned by Python's `loader.load_file()`.
111#[derive(Debug)]
112pub struct Ledger {
113    /// Processed directives in source-faithful form: sorted by date,
114    /// booked (cost specs resolved, interpolations applied), and
115    /// plugin-rewritten. **`Pad` directives remain as `Pad`**; they
116    /// are not pre-expanded into synthesized transactions.
117    ///
118    /// Consumers split into two groups:
119    ///
120    /// - **Source-faithful consumers** (stats, journal, formatter,
121    ///   LSP, BQL `FROM #entries WHERE type = 'pad'` audits,
122    ///   source-mapped diagnostics) iterate this field directly.
123    ///   Pads count as Pads.
124    /// - **Balance-computing consumers** (holdings, balances,
125    ///   balsheet, networth, income, FFI `query.execute`/`batch`,
126    ///   WASM `expandPads`/`query`) call [`Ledger::balance_view`]
127    ///   to get the directive stream MERGED with synthesized P-flag
128    ///   transactions for each pad-balance pair. This is the only
129    ///   way to get pad effects into per-account inventory math.
130    ///
131    /// The two views are derived from the same source; there is no
132    /// drift possible because [`Ledger::balance_view`] is a pure
133    /// function of `self.directives`.
134    pub directives: Vec<Spanned<Directive>>,
135    /// Options parsed from the file.
136    pub options: Options,
137    /// Plugins declared in the file.
138    pub plugins: Vec<Plugin>,
139    /// Source map for error reporting.
140    pub source_map: SourceMap,
141    /// Errors encountered during processing.
142    pub errors: Vec<LedgerError>,
143    /// Display context for formatting numbers.
144    pub display_context: DisplayContext,
145}
146
147impl Ledger {
148    /// Return the directive stream merged with synthesized
149    /// pad-equivalent transactions, suitable for inventory /
150    /// balance math.
151    ///
152    /// For each `Pad` directive followed (in date order) by a
153    /// `Balance` assertion on the same account, a `Transaction`
154    /// with `flag = 'P'` is added to the view carrying the
155    /// postings needed to make the balance match. A multi-currency
156    /// pad produces one synth transaction per currency.
157    ///
158    /// **Original `Pad` directives are preserved in the view.**
159    /// Synth transactions are added alongside, not in place of.
160    /// This matters for two reasons:
161    ///
162    /// 1. BQL queries against the `#entries` table
163    ///    (`SELECT * FROM #entries WHERE type = 'pad'`) can still
164    ///    enumerate the pad directives the user authored. A
165    ///    REPLACE-style expansion would silently zero those out.
166    ///    (BQL's default SELECT path operates on postings; pads
167    ///    have no postings, so a default SELECT never matches them
168    ///    regardless of this view shape.)
169    /// 2. Multi-pad cases (issue #1300) produce exactly one synth
170    ///    per pad-balance pair:
171    ///    `rustledger_booking::process_pads` (which
172    ///    `merge_with_padding` delegates to) only retains the most
173    ///    recent same-account pad in its pending-pads map, so
174    ///    earlier same-account pads are silently shadowed and
175    ///    their `source_account` does NOT contribute to the synth.
176    ///    The validator emits `E2003` for shadowed pads
177    ///    independently; this view reflects only the effective pad.
178    ///
179    /// Inventory-walking consumers iterate `Directive::Transaction`
180    /// and ignore `Pad` directives, so the preserved Pads are
181    /// invisible to them.
182    ///
183    /// **When to use this vs. [`Ledger.directives`](Self::directives):**
184    /// any consumer that maintains running per-account inventory
185    /// state and asks "what is the balance" needs this view. Any
186    /// consumer that asks "what did the user write" wants the raw
187    /// `directives` field.
188    ///
189    /// # Performance
190    ///
191    /// Each call clones every source directive once (`O(n)`).
192    /// Inlines the merge logic from
193    /// [`rustledger_booking::merge_with_padding`] so the already-
194    /// owned `booked` vector can be moved into the merged output
195    /// instead of cloned a second time. For short-lived CLI
196    /// invocations the single clone is negligible. Long-lived
197    /// processes (FFI servers, LSPs) that query the same ledger
198    /// repeatedly should hoist the result above their loop.
199    /// `TODO(perf):` memoize internally once a benchmark shows it
200    /// matters.
201    #[cfg(feature = "booking")]
202    #[must_use]
203    pub fn balance_view(&self) -> Vec<Directive> {
204        let mut booked: Vec<Directive> = self.directives.iter().map(|s| s.value.clone()).collect();
205
206        // Inlined from `rustledger_booking::merge_with_padding` so
207        // `booked` is moved (not re-cloned via `to_vec()`).
208        // Algorithmically identical: prepend synth transactions, then
209        // stable-sort by date. Same-date pad+balance pairs land as
210        // `[synth, pad, balance]` because synths sit at the front of
211        // their date-group pre-sort.
212        debug_assert!(
213            !booked.iter().any(|d| matches!(d, Directive::Transaction(t) if rustledger_booking::is_synthesized_pad(t))),
214            "balance_view called on a Ledger whose directives already contain synth pad transactions",
215        );
216        let pad_result = rustledger_booking::process_pads(&booked);
217        let mut merged: Vec<Directive> =
218            Vec::with_capacity(booked.len() + pad_result.padding_transactions.len());
219        for txn in pad_result.padding_transactions {
220            merged.push(Directive::Transaction(txn));
221        }
222        merged.append(&mut booked);
223        merged.sort_by_key(rustledger_core::Directive::date);
224        merged
225    }
226}
227
228/// Unified error type for ledger processing.
229///
230/// This encompasses all error types that can occur during loading,
231/// booking, plugin execution, and validation.
232#[derive(Debug)]
233#[non_exhaustive]
234pub struct LedgerError {
235    /// Error severity.
236    pub severity: ErrorSeverity,
237    /// Error code (e.g., "E0001", "W8002").
238    pub code: String,
239    /// Human-readable error message.
240    pub message: String,
241    /// Source location, if available.
242    pub location: Option<ErrorLocation>,
243    /// Byte span (inclusive start, exclusive end) in the source file,
244    /// used by rich renderers (e.g. miette) to draw a snippet around
245    /// the offending directive. Consumers that only need `file:line:col`
246    /// should use `location`; those that want to show the surrounding
247    /// source text want this.
248    pub source_span: Option<(usize, usize)>,
249    /// Source file ID — index into the ledger's [`SourceMap`]. Used
250    /// alongside `source_span` for snippet rendering.
251    pub file_id: Option<u16>,
252    /// Processing phase that produced this error: "parse", "validate", or "plugin".
253    pub phase: String,
254}
255
256/// Error severity level.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum ErrorSeverity {
259    /// Error - indicates a problem that should be fixed.
260    Error,
261    /// Warning - indicates a potential issue.
262    Warning,
263}
264
265/// Source location for an error.
266#[derive(Debug, Clone)]
267pub struct ErrorLocation {
268    /// File path.
269    pub file: std::path::PathBuf,
270    /// Line number (1-indexed).
271    pub line: usize,
272    /// Column number (1-indexed).
273    pub column: usize,
274}
275
276impl LedgerError {
277    /// Create a new error with the given phase.
278    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
279        Self {
280            severity: ErrorSeverity::Error,
281            code: code.into(),
282            message: message.into(),
283            location: None,
284            source_span: None,
285            file_id: None,
286            phase: "validate".to_string(),
287        }
288    }
289
290    /// Create a new warning.
291    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
292        Self {
293            severity: ErrorSeverity::Warning,
294            code: code.into(),
295            message: message.into(),
296            location: None,
297            source_span: None,
298            file_id: None,
299            phase: "validate".to_string(),
300        }
301    }
302
303    /// Attach a source span and file ID so rich renderers can draw a snippet.
304    #[must_use]
305    pub const fn with_source_span(mut self, span: (usize, usize), file_id: u16) -> Self {
306        self.source_span = Some(span);
307        self.file_id = Some(file_id);
308        self
309    }
310
311    /// Set the processing phase for this error.
312    #[must_use]
313    pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
314        self.phase = phase.into();
315        self
316    }
317
318    /// Add a location to this error.
319    #[must_use]
320    pub fn with_location(mut self, location: ErrorLocation) -> Self {
321        self.location = Some(location);
322        self
323    }
324}
325
326/// Process a raw load result into a fully processed ledger.
327///
328/// Pipeline (see numbered comments below for the rationale of each step):
329///
330/// ```text
331///   1. sort                         (canonical display order)
332///   2. synth plugins                (auto_accounts, document_discovery)
333///   3. Early validation             (account presence, structural, lifecycle)
334///   4. booking                      (cost spec resolution, interpolation)
335///   5. partition                    (set aside failed-booking txns)
336///   6. regular plugins              (file plugins + extras, on booked only)
337///   7. Late validation              (balance, currency, inventory, on booked only)
338///   8. finalize                     (unused-pad warnings)
339///   9. re-merge                     (booked + failed → final Ledger.directives)
340/// ```
341pub fn process(raw: LoadResult, options: &LoadOptions) -> Result<Ledger, ProcessError> {
342    let mut errors: Vec<LedgerError> = Vec::new();
343
344    // Convert load errors to ledger errors (parse phase). Iterate by
345    // reference so `raw` stays borrowable for the rest of the pipeline
346    // (the phase transitions and validator setup below borrow it).
347    for load_err in &raw.errors {
348        errors.push(LedgerError::error("LOAD", load_err.to_string()).with_phase("parse"));
349    }
350
351    // Phase-typed pipeline (issue #1166). The phantom-typed
352    // `Directives<P>` wrapper makes the sequence
353    //
354    //     Raw → Sorted → Synthed → EarlyValidated → Booked
355    //         → RegularPluginsApplied → LateValidated → Finalized
356    //
357    // a compile-time property of the type system. Each transition
358    // method consumes one phase and produces the next; the compiler
359    // rejects any call-site that drops a phase, swaps two, or invokes
360    // a later phase on raw input. See `crates/rustledger-loader/src/phase.rs`.
361    //
362    // The transitions themselves wrap the existing subsystem entry
363    // points (`run_booking`, `run_plugins`, validators) without
364    // changing their semantics — this PR is the structural refactor
365    // only; behavior is bit-identical to the pre-#1166 pipeline.
366
367    // Resolve the effective booking method once, before the pipeline
368    // starts, so both the validator (early/late phases — needs it to
369    // seed each opened account's per-account booking method, see
370    // issue #1182) and the booking engine see the same value. File-
371    // level `option "booking_method"` wins when explicitly set;
372    // otherwise the API-level `LoadOptions.booking_method` is used.
373    #[cfg(any(feature = "validation", feature = "booking"))]
374    let effective_booking_method = resolve_effective_booking_method(&raw, options);
375
376    #[cfg(feature = "validation")]
377    let validation_session = if options.validate {
378        Some(rustledger_validate::ValidationSession::new(
379            build_validation_options(&raw.options, &raw.source_map, effective_booking_method),
380        ))
381    } else {
382        None
383    };
384
385    // Compute `today` once for both phases — avoids a midnight-crossing
386    // race where Early and Late could disagree on what day it is, and
387    // gives `FutureDate` warnings a single coherent reference point.
388    #[cfg(feature = "validation")]
389    let today = jiff::Zoned::now().date();
390
391    let synthed = crate::Directives::<crate::Raw>::from_parser(raw.directives)
392        .sort()
393        .apply_synth_plugins(
394            &raw.plugins,
395            &raw.options,
396            options,
397            &raw.source_map,
398            &mut errors,
399        )?;
400
401    // The validation feature changes `early_validate`'s shape: with
402    // it on we thread the `Option<ValidationSession<Pending>>` in and
403    // catch the returned `Option<ValidationSession<EarlyDone>>` for
404    // `late_validate` (typestate-moved per #1236); without it we just
405    // get the next-phase `Directives` back. Branching here keeps each
406    // cfg's signature small and prevents the call site from having to
407    // know the typestate phase parameters in the disabled case.
408    #[cfg(feature = "validation")]
409    let (directives, validation_session) =
410        synthed.early_validate(validation_session, today, &raw.source_map, &mut errors);
411    #[cfg(not(feature = "validation"))]
412    let directives = synthed.early_validate(&raw.source_map, &mut errors);
413
414    let (booked, failed) = directives.book(
415        #[cfg(feature = "booking")]
416        effective_booking_method,
417        #[cfg(feature = "booking")]
418        &mut errors,
419    );
420
421    let regular_applied = booked.apply_regular_plugins(
422        &raw.plugins,
423        &raw.options,
424        options,
425        &raw.source_map,
426        &mut errors,
427    )?;
428
429    #[cfg(feature = "validation")]
430    let late_validated =
431        regular_applied.late_validate(validation_session, today, &raw.source_map, &mut errors);
432    #[cfg(not(feature = "validation"))]
433    let late_validated = regular_applied.late_validate(&raw.source_map, &mut errors);
434
435    let finalized = late_validated.finalize(failed);
436
437    Ok(Ledger {
438        directives: finalized.into_inner(),
439        options: raw.options,
440        plugins: raw.plugins,
441        source_map: raw.source_map,
442        errors,
443        display_context: raw.display_context,
444    })
445}
446
447/// Resolve the booking method from `LoadOptions` + file-level option.
448///
449/// Factored out of `process()` so both the validator session (which
450/// needs it to seed per-account booking) and the booking engine see
451/// the same value. File-level `option "booking_method"` wins when
452/// explicitly set; otherwise the API-level default is used.
453#[cfg(any(feature = "validation", feature = "booking"))]
454fn resolve_effective_booking_method(
455    raw: &LoadResult,
456    options: &LoadOptions,
457) -> rustledger_core::BookingMethod {
458    let file_set = raw.options.set_options.contains("booking_method");
459    if file_set {
460        raw.options
461            .booking_method
462            .parse()
463            .unwrap_or(options.booking_method)
464    } else {
465        options.booking_method
466    }
467}
468
469// ============================================================================
470// Phase transitions
471// ============================================================================
472//
473// Each transition consumes a `Directives<P>` of one phase and
474// produces a `Directives<NextP>` of the next phase. Bodies wrap the
475// existing subsystem calls (`run_booking`, `run_plugins`, validators)
476// without changing their semantics — only the type-level sequencing
477// is new. See `phase.rs` for the phase markers and overall rationale.
478
479/// Canonical display-order sort key: `(date, priority, file_id, span.start)`.
480/// What BQL / JSON / format output expects and what Python beancount
481/// produces. Used by `sort` (initial ordering) and `finalize` (re-sort
482/// after merging failed bookings back in).
483type CanonicalSortKey = (
484    rustledger_core::NaiveDate,
485    rustledger_core::DirectivePriority,
486    u16,
487    usize,
488);
489
490#[inline]
491const fn canonical_sort_key(d: &Spanned<Directive>) -> CanonicalSortKey {
492    (d.value.date(), d.value.priority(), d.file_id, d.span.start)
493}
494
495impl crate::Directives<crate::Raw> {
496    /// Sort directives into canonical display order — see
497    /// [`canonical_sort_key`].
498    ///
499    /// Booking needs a different iteration order (augmentations
500    /// BEFORE reductions on the same `(date, priority)`) but doesn't
501    /// need the underlying vec reordered — `run_booking` walks via
502    /// a transient `Vec<usize>` index. This sort goes once, here,
503    /// and the display order survives the rest of the pipeline.
504    #[must_use]
505    pub(crate) fn sort(mut self) -> crate::Directives<crate::Sorted> {
506        self.as_vec_mut().sort_by_key(canonical_sort_key);
507        crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
508    }
509}
510
511impl crate::Directives<crate::Sorted> {
512    /// Run synth-only plugins (`auto_accounts`, `document_discovery`)
513    /// BEFORE early validation so the synthesizers inject Opens /
514    /// Documents that Early checks depend on (E1001 account
515    /// presence, E5001 missing-document file).
516    ///
517    /// Only this narrow synth subset runs here; everything else
518    /// waits until after booking (post-booking plugin pass) so
519    /// cost-spec-reading plugins see filled-in per-unit values on
520    /// `CostNumber::PerUnitFromTotal`. See `PluginPass` rustdoc for
521    /// the detailed split rationale.
522    pub(crate) fn apply_synth_plugins(
523        mut self,
524        plugins: &[crate::Plugin],
525        file_options: &crate::Options,
526        options: &LoadOptions,
527        source_map: &SourceMap,
528        errors: &mut Vec<LedgerError>,
529    ) -> Result<crate::Directives<crate::Synthed>, ProcessError> {
530        // `run_plugins` early-returns when no plugin entry matches the
531        // pass; no outer gate needed (and any outer gate risked
532        // missing one of the implicit-synth triggers — auto_accounts,
533        // document_discovery via `option "documents"`, file-declared
534        // synth plugins).
535        #[cfg(feature = "plugins")]
536        run_plugins(
537            self.as_vec_mut(),
538            plugins,
539            file_options,
540            options,
541            source_map,
542            errors,
543            PluginPass::PreBookingSynth,
544        )?;
545        // Suppress unused-arg warnings when `plugins` feature is off.
546        #[cfg(not(feature = "plugins"))]
547        {
548            let _ = (plugins, file_options, options, source_map, errors);
549        }
550        Ok(crate::Directives::new_unchecked(std::mem::take(
551            self.as_vec_mut(),
552        )))
553    }
554}
555
556impl crate::Directives<crate::Synthed> {
557    /// Run the early-phase validators. Account-presence /
558    /// lifecycle / structural errors are collected into `errors`
559    /// (via the `LedgerError` stream); the directive list itself is
560    /// unchanged by validation.
561    ///
562    /// Runs on pre-booking directives, AFTER synth plugins so
563    /// account-presence checks (E1001) see any Opens that plugins
564    /// like `auto_accounts` injected. This is what lets booking
565    /// match Python's "prune zero-interp postings" behavior without
566    /// losing E1001 on the elided-zero-to-unopened-account case
567    /// (rustledger#877).
568    #[cfg(feature = "validation")]
569    pub(crate) fn early_validate(
570        mut self,
571        validation_session: Option<
572            rustledger_validate::ValidationSession<rustledger_validate::Pending>,
573        >,
574        today: rustledger_core::NaiveDate,
575        source_map: &SourceMap,
576        errors: &mut Vec<LedgerError>,
577    ) -> (
578        crate::Directives<crate::EarlyValidated>,
579        Option<rustledger_validate::ValidationSession<rustledger_validate::EarlyDone>>,
580    ) {
581        // Typestate move: consume `Pending`, return `EarlyDone`. The
582        // session must be threaded by value rather than `&mut`-borrowed
583        // because the phase parameter on `ValidationSession<P>` changes
584        // as a result of the call (#1236). The caller in `process()`
585        // captures the returned session and passes it to
586        // `late_validate`.
587        let session_out = validation_session.map(|session| {
588            let (session, phase_errors) = session.run_early_spanned(self.as_slice(), today);
589            ledger_errors_extend(errors, phase_errors, source_map);
590            session
591        });
592        (
593            crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut())),
594            session_out,
595        )
596    }
597
598    #[cfg(not(feature = "validation"))]
599    pub(crate) fn early_validate(
600        mut self,
601        source_map: &SourceMap,
602        errors: &mut Vec<LedgerError>,
603    ) -> crate::Directives<crate::EarlyValidated> {
604        let _ = (source_map, errors);
605        crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
606    }
607}
608
609impl crate::Directives<crate::EarlyValidated> {
610    /// Run booking/interpolation. Returns the successfully-booked
611    /// directives plus a typed wrapper holding failed transactions.
612    ///
613    /// Failed transactions are in pre-booking shape (unresolved cost
614    /// specs, unfilled elided slots, possibly unbalanced); they
615    /// don't flow into regular plugins or Late validation — booking
616    /// already reported the root cause and the downstream checks
617    /// would cascade misleading errors. They get re-merged at
618    /// [`crate::Directives::<crate::LateValidated>::finalize`].
619    ///
620    /// When the `booking` feature is disabled this is an identity
621    /// transition: directives pass through unchanged and the failed
622    /// set is always empty. The same method exists in both feature
623    /// configurations so the caller in `process()` doesn't need a
624    /// `#[cfg]` match — the booking-specific arguments appear or
625    /// disappear via per-parameter `#[cfg]` attributes, mirroring
626    /// `early_validate` / `late_validate`.
627    pub(crate) fn book(
628        mut self,
629        #[cfg(feature = "booking")] effective_method: rustledger_core::BookingMethod,
630        #[cfg(feature = "booking")] errors: &mut Vec<LedgerError>,
631    ) -> (
632        crate::Directives<crate::Booked>,
633        crate::phase::FailedBookings,
634    ) {
635        #[cfg(feature = "booking")]
636        let (booked, failed) =
637            run_booking(std::mem::take(self.as_vec_mut()), effective_method, errors);
638        #[cfg(not(feature = "booking"))]
639        let (booked, failed): (Vec<Spanned<Directive>>, Vec<Spanned<Directive>>) =
640            (std::mem::take(self.as_vec_mut()), Vec::new());
641        (
642            crate::Directives::new_unchecked(booked),
643            crate::phase::FailedBookings::new(failed),
644        )
645    }
646}
647
648impl crate::Directives<crate::Booked> {
649    /// Run post-booking plugins — file-declared + CLI extras.
650    /// Cost-spec-reading plugins (`implicit_prices`,
651    /// `capital_gains_classifier`, `check_average_cost`,
652    /// `sell_gains`, `unrealized`, `valuation`) see filled-in
653    /// per-unit values on `CostNumber::PerUnitFromTotal` because
654    /// booking has run.
655    ///
656    /// Matches Python beancount's plugins-after-booking ordering
657    /// and closes rustledger#1117. Failed transactions were
658    /// partitioned out by `book`; plugins only see
659    /// successfully-booked input.
660    pub(crate) fn apply_regular_plugins(
661        mut self,
662        plugins: &[crate::Plugin],
663        file_options: &crate::Options,
664        options: &LoadOptions,
665        source_map: &SourceMap,
666        errors: &mut Vec<LedgerError>,
667    ) -> Result<crate::Directives<crate::RegularPluginsApplied>, ProcessError> {
668        // `run_plugins` early-returns when no plugin entry matches
669        // the pass; no outer gate needed.
670        #[cfg(feature = "plugins")]
671        run_plugins(
672            self.as_vec_mut(),
673            plugins,
674            file_options,
675            options,
676            source_map,
677            errors,
678            PluginPass::PostBooking,
679        )?;
680        #[cfg(not(feature = "plugins"))]
681        {
682            let _ = (plugins, file_options, options, source_map, errors);
683        }
684        Ok(crate::Directives::new_unchecked(std::mem::take(
685            self.as_vec_mut(),
686        )))
687    }
688}
689
690impl crate::Directives<crate::RegularPluginsApplied> {
691    /// Run the late-phase validators on booked + plugin-processed
692    /// directives. Reuses the `ValidationSession` from
693    /// `early_validate` so account / commodity / pad bookkeeping
694    /// carries forward.
695    #[cfg(feature = "validation")]
696    pub(crate) fn late_validate(
697        mut self,
698        validation_session: Option<
699            rustledger_validate::ValidationSession<rustledger_validate::EarlyDone>,
700        >,
701        today: rustledger_core::NaiveDate,
702        source_map: &SourceMap,
703        errors: &mut Vec<LedgerError>,
704    ) -> crate::Directives<crate::LateValidated> {
705        // Typestate move: consume `EarlyDone`, drive through `LateDone`
706        // to `finalize()`. The compile-time enforcement here is that
707        // we cannot call `late_validate` with a fresh `Pending` session
708        // (no `From<Pending>` to `EarlyDone`), so the loader caller
709        // must have routed the session through `early_validate` first
710        // (#1236).
711        if let Some(session) = validation_session {
712            let (session, phase_errors) = session.run_late_spanned(self.as_slice(), today);
713            ledger_errors_extend(errors, phase_errors, source_map);
714            let finalize_errors = session.finalize();
715            ledger_errors_extend(errors, finalize_errors, source_map);
716        }
717        crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
718    }
719
720    #[cfg(not(feature = "validation"))]
721    pub(crate) fn late_validate(
722        mut self,
723        source_map: &SourceMap,
724        errors: &mut Vec<LedgerError>,
725    ) -> crate::Directives<crate::LateValidated> {
726        let _ = (source_map, errors);
727        crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
728    }
729}
730
731impl crate::Directives<crate::LateValidated> {
732    /// Re-merge failed (un-booked) transactions back into the
733    /// directive list for output. The user wrote them and expects
734    /// to see them in `Ledger.directives`; we kept them isolated
735    /// from post-booking processing.
736    ///
737    /// Re-sorts to restore canonical display order — `booked`
738    /// retained order during plugin transformation; the sort
739    /// restores the failed entries' positions.
740    pub(crate) fn finalize(
741        mut self,
742        failed: crate::phase::FailedBookings,
743    ) -> crate::Directives<crate::Finalized> {
744        let mut v = std::mem::take(self.as_vec_mut());
745        v.extend(failed.into_inner());
746        v.sort_by_key(canonical_sort_key);
747
748        // Normalize `@@` total prices to per-unit (`@`) as the final pipeline
749        // step. This runs AFTER Late validation, so exact totals still survived
750        // for the precise balance-residual check (#1240) — which is the entire
751        // reason normalization is deferred rather than done during booking.
752        //
753        // Doing it HERE, in the one transition that produces `Finalized` (the
754        // only publicly-exposed phase), makes "prices are normalized" an
755        // invariant of every loaded `Ledger`: `rledger check`, the FFI/MCP
756        // component, and BQL all get it by construction, and none can drift by
757        // forgetting to normalize. It was previously bolted onto the CLI `check`
758        // path only, so the FFI surface silently regressed to exposing raw `@@`
759        // totals when it moved onto this shared pipeline (#1462).
760        #[cfg(feature = "booking")]
761        for spanned in &mut v {
762            if let Directive::Transaction(txn) = &mut spanned.value {
763                rustledger_booking::normalize_prices(txn);
764            }
765        }
766
767        crate::Directives::new_unchecked(v)
768    }
769}
770
771/// Run booking and interpolation on transactions, returning the
772/// directives partitioned into `(booked, failed)`.
773///
774/// The caller has already sorted `directives` into canonical display
775/// order `(date, priority, file_id, span.start)`. Booking needs the
776/// extra constraint that cost-reduction transactions process AFTER
777/// augmentations on the same `(date, priority)` so lots exist when
778/// matched. Rather than re-sorting the whole vec, we walk it via a
779/// transient `Vec<usize>` of indices sorted by booking order. Stable
780/// sort preserves display-order tiebreaks between transactions with
781/// the same `has_cost_reduction` flag.
782///
783/// Failed transactions are partitioned out into the second return
784/// value so they don't flow into regular plugins or Late validation
785/// (they're in pre-booking shape — postings have unresolved cost
786/// specs and unfilled elided slots, so downstream processing would
787/// cascade misleading errors). The caller is responsible for
788/// re-merging `failed` into the final `Ledger.directives` for output
789/// so the user still sees their original input.
790#[cfg(feature = "booking")]
791fn run_booking(
792    mut directives: Vec<Spanned<Directive>>,
793    booking_method: BookingMethod,
794    errors: &mut Vec<LedgerError>,
795) -> (Vec<Spanned<Directive>>, Vec<Spanned<Directive>>) {
796    use rustledger_booking::BookingEngine;
797
798    let mut engine = BookingEngine::with_method(booking_method);
799    engine.register_account_methods(directives.iter().map(|s| &s.value));
800
801    // Build an index ordered for booking: stable sort by
802    // `has_cost_reduction` only (display order — `(date, priority,
803    // file_id, span.start)` — is already encoded in the existing
804    // positional order, and stable_sort preserves that as the tiebreak).
805    let mut order: Vec<usize> = (0..directives.len()).collect();
806    order.sort_by_key(|&i| rustledger_core::booking_sort_key(&directives[i].value));
807
808    let mut failed_indices: Vec<usize> = Vec::new();
809    for &i in &order {
810        let spanned = &mut directives[i];
811        if let Directive::Transaction(txn) = &mut spanned.value {
812            match engine.book_and_interpolate(txn) {
813                Ok(result) => {
814                    engine.apply(&result.transaction);
815                    *txn = result.transaction;
816                }
817                Err(e) => {
818                    errors.push(LedgerError::error(
819                        "BOOK",
820                        format!("{} ({}, \"{}\")", e, txn.date, txn.narration),
821                    ));
822                    failed_indices.push(i);
823                }
824            }
825        }
826    }
827
828    // Partition into (booked, failed). Indices are valid in the current
829    // `directives` vec (no mutation has happened since they were
830    // collected); after this consuming iteration the vec is gone and
831    // partition is fait accompli — no window where a caller could
832    // accidentally mutate between collection and partition.
833    let failed_set: rustc_hash::FxHashSet<usize> = failed_indices.iter().copied().collect();
834    let mut booked = Vec::with_capacity(directives.len() - failed_indices.len());
835    let mut failed = Vec::with_capacity(failed_indices.len());
836    for (i, d) in directives.into_iter().enumerate() {
837        if failed_set.contains(&i) {
838            failed.push(d);
839        } else {
840            booked.push(d);
841        }
842    }
843    (booked, failed)
844}
845
846/// Which subset of plugins to run.
847///
848/// The loader pipeline calls `run_plugins` twice: once with
849/// [`PluginPass::PreBookingSynth`] before the Early validation phase
850/// (so synthesizers can inject Opens / Documents that early checks
851/// depend on), and once with [`PluginPass::PostBooking`] after booking
852/// (so cost-spec-reading plugins like `implicit_prices`,
853/// `capital_gains_classifier`, `check_average_cost`, `sell_gains`,
854/// `unrealized`, and `valuation` see filled-in per-unit values on the
855/// `CostNumber::PerUnitFromTotal` variant).
856///
857/// Standalone callers (LSP / FFI / tests on already-booked input) pass
858/// [`PluginPass::PostBooking`] — synth plugins are a loader-internal
859/// concern and would re-Open already-opened accounts if run a second
860/// time.
861#[cfg(feature = "plugins")]
862#[derive(Debug, Clone, Copy, PartialEq, Eq)]
863pub enum PluginPass {
864    /// Only plugins that synthesize directives the Early validator
865    /// depends on: `auto_accounts` (synthesizes Open directives) and
866    /// the built-in document discovery walker (synthesizes Document
867    /// directives the early phase checks for missing files).
868    PreBookingSynth,
869    /// All file-declared plugins and CLI `extra_plugins`, EXCLUDING
870    /// `auto_accounts` and `document_discovery` (those ran pre-booking).
871    /// Includes the 28 plugins that don't depend on synth state but
872    /// may depend on booked cost specs.
873    PostBooking,
874}
875
876/// Run plugins on directives.
877///
878/// Executes native plugins (and document discovery) on the given directives,
879/// modifying them in-place. Plugin errors are appended to `errors`.
880///
881/// A single plugin invocation in `run_plugins`'s unified dispatch
882/// list. `force_python` ("python:..." prefix) overrides native
883/// resolution; `config` is the plugin-specific string passed to
884/// `PluginInput.config`.
885#[cfg(feature = "plugins")]
886struct PluginInvocation {
887    name: String,
888    config: Option<String>,
889    force_python: bool,
890}
891
892/// `pass` selects which subset of plugins to run — see [`PluginPass`].
893/// The loader pipeline calls this twice (synth pass before Early,
894/// regular pass after booking).
895#[cfg(feature = "plugins")]
896pub fn run_plugins(
897    directives: &mut Vec<Spanned<Directive>>,
898    file_plugins: &[Plugin],
899    file_options: &Options,
900    options: &LoadOptions,
901    source_map: &SourceMap,
902    errors: &mut Vec<LedgerError>,
903    pass: PluginPass,
904) -> Result<(), ProcessError> {
905    use rustledger_plugin::{NativePluginRegistry, PluginOptions};
906
907    // Resolve document directories relative to the main file's directory.
908    // Used to build doc_discovery's per-call config in the synth pass.
909    let base_dir = source_map
910        .files()
911        .first()
912        .and_then(|f| f.path.parent())
913        .unwrap_or_else(|| std::path::Path::new("."));
914
915    // Access the process-wide registry singleton. The registry is
916    // immutable and stateless, so the same instance services every
917    // call.
918    let registry = NativePluginRegistry::global();
919
920    // Build the unified list of plugins to invoke for this pass:
921    //   1. Implicit synth plugins triggered by `LoadOptions` /
922    //      `file_options` (auto_accounts via `options.auto_accounts`;
923    //      document_discovery via non-empty `file_options.documents`).
924    //   2. File-declared plugins from `plugin "..."` directives.
925    //   3. CLI `--plugin` extras.
926    // Pass classification happens here — once — via `registry.find_synth`.
927    // A plugin enters the list iff its pass matches the requested `pass`.
928    let mut entries: Vec<PluginInvocation> = Vec::new();
929
930    if matches!(pass, PluginPass::PreBookingSynth) {
931        // Implicit synth: API-level auto_accounts flag.
932        if options.auto_accounts {
933            entries.push(PluginInvocation {
934                name: rustledger_plugin::AUTO_ACCOUNTS_NAME.to_string(),
935                config: None,
936                force_python: false,
937            });
938        }
939        // Implicit synth: document_discovery, driven by `option "documents"`.
940        // The plugin sits in the registry as a ZST; we hand it the
941        // resolved directories + base_dir via its config JSON.
942        if options.run_plugins && !file_options.documents.is_empty() {
943            let resolved: Vec<String> = file_options
944                .documents
945                .iter()
946                .map(|d| {
947                    let path = std::path::Path::new(d);
948                    if path.is_absolute() {
949                        d.clone()
950                    } else {
951                        base_dir.join(path).to_string_lossy().to_string()
952                    }
953                })
954                .collect();
955            entries.push(PluginInvocation {
956                name: rustledger_plugin::DOCUMENT_DISCOVERY_NAME.to_string(),
957                config: Some(rustledger_plugin::document_discovery_config(
958                    base_dir, &resolved,
959                )),
960                force_python: false,
961            });
962        }
963    }
964
965    // A plugin name belongs in the current pass iff its synth-marker
966    // membership matches `pass`. Non-native plugins (WASM/Python) are
967    // never in the synth registry and therefore always fall into the
968    // PostBooking pass.
969    let want_synth = matches!(pass, PluginPass::PreBookingSynth);
970
971    // File-declared plugins.
972    if options.run_plugins {
973        for plugin in file_plugins {
974            if registry.find_synth(&plugin.name).is_some() == want_synth {
975                entries.push(PluginInvocation {
976                    name: plugin.name.clone(),
977                    config: plugin.config.clone(),
978                    force_python: plugin.force_python,
979                });
980            }
981        }
982    }
983
984    // CLI extra plugins.
985    for extra in &options.extra_plugins {
986        if registry.find_synth(&extra.name).is_some() == want_synth {
987            entries.push(PluginInvocation {
988                name: extra.name.clone(),
989                config: extra.config.clone(),
990                force_python: false,
991            });
992        }
993    }
994
995    if entries.is_empty() {
996        return Ok(());
997    }
998
999    let plugin_options = PluginOptions {
1000        operating_currencies: file_options.operating_currency.clone(),
1001        title: file_options.title.clone(),
1002    };
1003
1004    // Dispatch each entry: resolve it to a concrete runtime, then run + apply
1005    // uniformly. Resolution (classification, path-security, feature-gating, the
1006    // #1432 module-name rejection) lives in `resolve_plugin`; execution lives in
1007    // `ResolvedPlugin::run`. Building wrappers and applying ops here — once, not
1008    // once per runtime — is the point of the resolve/run split.
1009    let pass_kind = match pass {
1010        PluginPass::PreBookingSynth => rustledger_plugin::PluginPass::Synth,
1011        PluginPass::PostBooking => rustledger_plugin::PluginPass::Regular,
1012    };
1013    for invocation in &entries {
1014        // Resolution (classify + path-security + feature-gate + #1432 reject)
1015        // lives in `rustledger_plugin::resolve_plugin`; execution in
1016        // `ResolvedPlugin::run`. The loader keeps wrapper building, op
1017        // application, and its error-code convention.
1018        let resolved = match rustledger_plugin::resolve_plugin(
1019            &invocation.name,
1020            invocation.force_python,
1021            pass_kind,
1022            registry,
1023            base_dir,
1024            options.path_security,
1025        ) {
1026            Ok(resolved) => resolved,
1027            Err(e) => {
1028                errors.push(resolve_error_to_ledger(&e));
1029                continue;
1030            }
1031        };
1032
1033        // Rebuild wrappers per plugin so each sees the prior plugin's applied
1034        // ops, then convert + apply uniformly regardless of runtime. Every
1035        // runtime's diagnostics now flow through `record_plugin_errors`, so a
1036        // plugin-set source location is preserved (the old WASM/Python runner
1037        // conversions dropped it; native always kept it).
1038        let wrappers = build_wrappers(directives, source_map);
1039        match resolved.run(wrappers, &plugin_options, &invocation.config, base_dir) {
1040            Ok(output) => {
1041                record_plugin_errors(errors, output.errors, source_map);
1042                apply_plugin_ops(directives, output.ops, errors, source_map)?;
1043            }
1044            Err(e) => errors.push(run_error_to_ledger(&e)),
1045        }
1046    }
1047
1048    // No final wrapper→directive conversion needed: `apply_plugin_ops`
1049    // updates `directives` in place after each plugin call, preserving
1050    // original spans on Keep/Modify ops. Plugin-synthesized directives
1051    // (Insert ops) get `SYNTHESIZED_FILE_ID` and a zero span.
1052    Ok(())
1053}
1054
1055/// Build a fresh `Vec<DirectiveWrapper>` from the current directives,
1056/// carrying filename + line number for plugin-side error reporting.
1057/// Spans don't need to round-trip through the wrappers — the loader
1058/// preserves them via `apply_plugin_ops` matching on op index.
1059#[cfg(feature = "plugins")]
1060fn build_wrappers(
1061    directives: &[Spanned<Directive>],
1062    source_map: &SourceMap,
1063) -> Vec<rustledger_plugin::DirectiveWrapper> {
1064    use rustledger_plugin::directive_to_wrapper_with_location;
1065
1066    directives
1067        .iter()
1068        .map(|spanned| {
1069            let (filename, lineno) = if let Some(file) = source_map.get(spanned.file_id as usize) {
1070                let (line, _col) = file.line_col(spanned.span.start);
1071                (Some(file.path.display().to_string()), Some(line as u32))
1072            } else {
1073                (None, None)
1074            };
1075            directive_to_wrapper_with_location(&spanned.value, filename, lineno)
1076        })
1077        .collect()
1078}
1079
1080/// Push plugin errors into the ledger's error stream, tagged with
1081/// `phase: "plugin"` and — when the plugin set `source_file` /
1082/// `line_number` on the error — an attached `ErrorLocation` so
1083/// downstream renderers (CLI, LSP, JSON output) can pinpoint where
1084/// the plugin objected.
1085///
1086/// Source-location resolution: if the wrapper's `source_file` resolves
1087/// to a real file in the source map, use that for `ErrorLocation.file`
1088/// and treat `line_number` as the line index. Plugin-synthesized
1089/// filenames (e.g. `"<auto_accounts>"`) that don't match any real
1090/// file are passed through as `PathBuf::from(name)` so the rendered
1091/// location still attributes the error to the originating plugin —
1092/// better than silently dropping the field.
1093#[cfg(feature = "plugins")]
1094fn record_plugin_errors(
1095    errors: &mut Vec<LedgerError>,
1096    plugin_errors: Vec<rustledger_plugin::PluginError>,
1097    source_map: &SourceMap,
1098) {
1099    for err in plugin_errors {
1100        let mut ledger_err = match err.severity {
1101            rustledger_plugin::PluginErrorSeverity::Error => {
1102                LedgerError::error("PLUGIN", err.message).with_phase("plugin")
1103            }
1104            rustledger_plugin::PluginErrorSeverity::Warning => {
1105                LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
1106            }
1107        };
1108        // Propagate plugin-set source location into `ErrorLocation`.
1109        // Column defaults to 1 — plugin errors don't carry column info
1110        // through the wrapper protocol.
1111        if let (Some(file), Some(line)) = (&err.source_file, err.line_number) {
1112            let resolved_path = source_map
1113                .get_by_path(std::path::Path::new(file))
1114                .map_or_else(|| std::path::PathBuf::from(file), |f| f.path.clone());
1115            ledger_err = ledger_err.with_location(ErrorLocation {
1116                file: resolved_path,
1117                line: line as usize,
1118                column: 1,
1119            });
1120        }
1121        errors.push(ledger_err);
1122    }
1123}
1124
1125/// Apply a plugin's `Vec<PluginOp>` to `directives` in place.
1126///
1127/// Validates that the op set forms a complete partition of the input
1128/// indices (each input index appears in exactly one `Keep` / `Modify` /
1129/// `Delete` op). Protocol violations produce a `PLUGIN` error in
1130/// `errors` and leave `directives` untouched.
1131///
1132/// For `Keep(i)` / `Modify(i, w)`, the resulting `Spanned<Directive>`
1133/// inherits `directives[i]`'s span and `file_id` — this is the core of
1134/// the ops protocol's correctness guarantee (plugin-transformed
1135/// directives keep their original source identity for error reporting).
1136/// `Insert(w)` directives get `(Span::ZERO, SYNTHESIZED_FILE_ID)`.
1137///
1138/// Inner posting spans returned by plugins are sanitized against the
1139/// host's `SourceMap` (see [`sanitize_inner_posting_spans`]) so a
1140/// misbehaving plugin cannot smuggle out-of-bounds spans into the LSP.
1141#[cfg(feature = "plugins")]
1142fn apply_plugin_ops(
1143    directives: &mut Vec<Spanned<Directive>>,
1144    ops: Vec<rustledger_plugin::PluginOp>,
1145    errors: &mut Vec<LedgerError>,
1146    source_map: &SourceMap,
1147) -> Result<(), ProcessError> {
1148    use rustledger_plugin::PluginOp;
1149    use rustledger_plugin::wrapper_to_directive;
1150
1151    // Validate the op set forms a complete cover of the input — the contract is
1152    // single-sourced in `rustledger-plugin` so the loader and FFI surfaces stay
1153    // in lock-step. On violation, surface the error and leave directives as-is.
1154    if let Err(msg) = rustledger_plugin::validate_op_coverage(directives.len(), &ops) {
1155        errors.push(LedgerError::error("PLUGIN", msg).with_phase("plugin"));
1156        return Ok(());
1157    }
1158
1159    // Materialize new directives, preserving spans for Keep/Modify.
1160    let mut new_directives = Vec::with_capacity(ops.len());
1161    for op in ops {
1162        match op {
1163            PluginOp::Keep(i) => {
1164                new_directives.push(directives[i].clone());
1165            }
1166            PluginOp::Modify(i, wrapper) => {
1167                let mut directive = wrapper_to_directive(&wrapper)
1168                    .map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
1169                // Plugins are not trusted to return well-formed inner
1170                // posting spans — a misbehaving plugin can synthesize a
1171                // file_id pointing at a nonexistent source or a span
1172                // that runs past EOF. The LSP later builds TextEdits
1173                // from these spans, so an out-of-bounds posting span
1174                // would produce a corrupt edit. Reset any inner posting
1175                // span that doesn't refer to a real loaded file or that
1176                // exceeds the file's length to `Spanned::synthesized`.
1177                sanitize_inner_posting_spans(&mut directive, source_map);
1178                new_directives.push(Spanned {
1179                    value: directive,
1180                    span: directives[i].span,
1181                    file_id: directives[i].file_id,
1182                });
1183            }
1184            PluginOp::Insert(wrapper) => {
1185                // Same trust caveat as Modify: don't let an Insert smuggle
1186                // bogus inner-posting spans through.
1187                // (Wrapper-derived outer span is validated below.)
1188                // Resolve the wrapper's filename + line number, if set,
1189                // into a real (file_id, span) when the filename
1190                // corresponds to a loaded source file. Falls back to
1191                // SYNTHESIZED_FILE_ID + zero span otherwise — including
1192                // for plugin-only attribution like `"<auto_accounts>"`
1193                // (which never matches a loaded file).
1194                let (span, file_id) = match (&wrapper.filename, wrapper.lineno) {
1195                    (Some(filename), Some(lineno)) => {
1196                        if let Some(file) = source_map.get_by_path(std::path::Path::new(filename)) {
1197                            let span_start = file.line_start(lineno as usize).unwrap_or(0);
1198                            (
1199                                rustledger_parser::Span::new(span_start, span_start),
1200                                file.id as u16,
1201                            )
1202                        } else {
1203                            (
1204                                rustledger_parser::Span::ZERO,
1205                                rustledger_parser::SYNTHESIZED_FILE_ID,
1206                            )
1207                        }
1208                    }
1209                    _ => (
1210                        rustledger_parser::Span::ZERO,
1211                        rustledger_parser::SYNTHESIZED_FILE_ID,
1212                    ),
1213                };
1214                let mut directive = wrapper_to_directive(&wrapper)
1215                    .map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
1216                sanitize_inner_posting_spans(&mut directive, source_map);
1217                new_directives.push(Spanned::new(directive, span).with_file_id(file_id as usize));
1218            }
1219            PluginOp::Delete(_) => {}
1220        }
1221    }
1222
1223    *directives = new_directives;
1224    Ok(())
1225}
1226
1227/// Reset any inner `Spanned<Posting>` whose location does not refer to a
1228/// real loaded source range to [`Spanned::synthesized`]. Plugins are not
1229/// trusted to return well-formed `file_id` + byte ranges; without this,
1230/// a misbehaving plugin could induce out-of-bounds LSP text edits.
1231///
1232/// A span is considered valid when:
1233/// - `file_id == SYNTHESIZED_FILE_ID` (genuine synthesis), OR
1234/// - the `file_id` resolves in `SourceMap` AND `0 <= start <= end <= len`
1235///   for that file's source.
1236///
1237/// Everything else collapses to `Spanned::synthesized(posting)`. As a
1238/// final pass, synthesized postings that arrived with a non-zero span
1239/// are normalized to `Span::ZERO` so the in-memory state matches the
1240/// `Spanned::synthesized` constructor's contract (`file_id` +
1241/// `Span::ZERO`).
1242#[cfg(feature = "plugins")]
1243fn sanitize_inner_posting_spans(directive: &mut Directive, source_map: &SourceMap) {
1244    use rustledger_core::Span;
1245    use rustledger_parser::SYNTHESIZED_FILE_ID;
1246    if let Directive::Transaction(txn) = directive {
1247        for p in &mut txn.postings {
1248            let ok = if p.file_id == SYNTHESIZED_FILE_ID {
1249                true
1250            } else {
1251                source_map
1252                    .get(p.file_id as usize)
1253                    .is_some_and(|f| p.span.start <= p.span.end && p.span.end <= f.source.len())
1254            };
1255            if !ok {
1256                let inner = std::mem::replace(
1257                    &mut p.value,
1258                    rustledger_core::Posting::auto(rustledger_core::InternedStr::from("")),
1259                );
1260                *p = rustledger_core::Spanned::synthesized(inner);
1261            } else if p.file_id == SYNTHESIZED_FILE_ID && p.span != Span::ZERO {
1262                // Synthesized → span is meaningless; normalize so the
1263                // state is consistent with `Spanned::synthesized`.
1264                p.span = Span::ZERO;
1265            }
1266        }
1267    }
1268}
1269
1270/// Map loader [`Options`] to [`rustledger_validate::ValidationOptions`].
1271///
1272/// The single source of truth for the *option-derived* validation settings:
1273/// custom account-type names (`name_*`) and the tolerance options
1274/// (`inferred_tolerance_default`, `inferred_tolerance_multiplier`,
1275/// `infer_tolerance_from_cost`). Path-relative settings (document directories)
1276/// and the effective booking method are layered on by callers that hold the
1277/// necessary context — see `build_validation_options`.
1278///
1279/// Both `rledger check` (via `build_validation_options`) and the LSP/MCP
1280/// diagnostics path call this, so the two cannot drift. Issue #1648 was exactly
1281/// that drift: the LSP built its own `ValidationOptions` that dropped the
1282/// tolerance options, so it reported residual errors `check` did not.
1283#[cfg(feature = "validation")]
1284#[must_use]
1285pub fn validation_options_from_options(
1286    options: &Options,
1287) -> rustledger_validate::ValidationOptions {
1288    rustledger_validate::ValidationOptions::default()
1289        .with_account_types(
1290            options
1291                .account_types()
1292                .iter()
1293                .map(|s| (*s).to_string())
1294                .collect(),
1295        )
1296        .with_infer_tolerance_from_cost(options.infer_tolerance_from_cost)
1297        .with_tolerance_multiplier(options.inferred_tolerance_multiplier)
1298        .with_inferred_tolerance_default(options.inferred_tolerance_default.clone())
1299        // File-level `option "booking_method"`. `build_validation_options`
1300        // overrides this with the *effective* method (which also honors the
1301        // API-level `LoadOptions` default); callers without that override — the
1302        // LSP — get the file option, keeping editor diagnostics aligned with
1303        // `check` for booking-sensitive balance checks.
1304        .with_default_booking_method(
1305            options
1306                .booking_method
1307                .parse()
1308                .unwrap_or(BookingMethod::Strict),
1309        )
1310}
1311
1312/// Resolve `documents` option directories to filesystem paths.
1313///
1314/// Absolute entries pass through; relative entries join onto `base_dir` when it
1315/// is `Some`, otherwise are kept as-is (single-file buffers without an on-disk
1316/// path). Shared so `check` and the LSP resolve document directories identically.
1317#[cfg(feature = "validation")]
1318#[must_use]
1319pub fn resolve_document_dirs(
1320    documents: &[String],
1321    base_dir: Option<&std::path::Path>,
1322) -> Vec<std::path::PathBuf> {
1323    documents
1324        .iter()
1325        .map(|d| {
1326            let path = std::path::Path::new(d);
1327            if path.is_absolute() {
1328                path.to_path_buf()
1329            } else if let Some(base) = base_dir {
1330                base.join(path)
1331            } else {
1332                path.to_path_buf()
1333            }
1334        })
1335        .collect()
1336}
1337
1338/// Per-`file_id` source-file directories, parallel to `source_map.files()`.
1339///
1340/// Lets the validator resolve a relative `document` path against its own
1341/// directive's file (matching Beancount and `include`) instead of the CWD.
1342#[cfg(feature = "validation")]
1343#[must_use]
1344pub fn document_source_dirs(source_map: &SourceMap) -> Vec<std::path::PathBuf> {
1345    source_map
1346        .files()
1347        .iter()
1348        .map(|f| {
1349            f.path.parent().map_or_else(
1350                || std::path::PathBuf::from("."),
1351                std::path::Path::to_path_buf,
1352            )
1353        })
1354        .collect()
1355}
1356
1357/// Build a [`ValidationOptions`] from loader-level file options.
1358///
1359/// Layers the path-relative document directories and the *effective* booking
1360/// method onto [`validation_options_from_options`] (the shared option-derived
1361/// core). Factored out of the old `run_validation` so both the early and late
1362/// phases in `process()` share the same `ValidationSession` configuration.
1363#[cfg(feature = "validation")]
1364fn build_validation_options(
1365    file_options: &Options,
1366    source_map: &SourceMap,
1367    default_booking_method: BookingMethod,
1368) -> rustledger_validate::ValidationOptions {
1369    // Document dirs resolve against the main file's parent directory (CWD as a
1370    // fallback when the source map is empty — matches the pre-refactor behavior).
1371    let base_dir = source_map
1372        .files()
1373        .first()
1374        .and_then(|f| f.path.parent())
1375        .unwrap_or_else(|| std::path::Path::new("."));
1376
1377    validation_options_from_options(file_options)
1378        .with_document_dirs(resolve_document_dirs(
1379            &file_options.documents,
1380            Some(base_dir),
1381        ))
1382        .with_document_source_dirs(document_source_dirs(source_map))
1383        .with_default_booking_method(default_booking_method)
1384}
1385
1386/// Convert a batch of [`rustledger_validate::ValidationError`]s into
1387/// loader-level [`LedgerError`]s (with resolved `file:line:column`
1388/// locations) and append to the existing list.
1389///
1390/// Factored out so both validation phases in `process()` share the
1391/// same conversion path.
1392#[cfg(feature = "validation")]
1393fn ledger_errors_extend(
1394    errors: &mut Vec<LedgerError>,
1395    validation_errors: Vec<rustledger_validate::ValidationError>,
1396    source_map: &SourceMap,
1397) {
1398    for err in validation_errors {
1399        let phase = if err.code.is_parse_phase() {
1400            "parse"
1401        } else {
1402            "validate"
1403        };
1404        let severity_level = if err.code.is_warning() {
1405            ErrorSeverity::Warning
1406        } else {
1407            ErrorSeverity::Error
1408        };
1409        // Fold the advisory note (if any) into the message so it propagates
1410        // through every downstream format (LedgerError, JSON diagnostic, CLI
1411        // report, LSP diagnostic) without each one needing a dedicated field.
1412        let message = match &err.note {
1413            Some(note) => format!("{err}\n  note: {note}"),
1414            None => err.to_string(),
1415        };
1416        // Resolve span + file_id into a file/line/column triple so CLI and
1417        // LSP consumers can render `file:line:col` headers without having
1418        // to do the lookup themselves (issue #901).
1419        let location = err.span.and_then(|span| {
1420            let fid = err.file_id? as usize;
1421            let file = source_map.get(fid)?;
1422            let (line, column) = file.line_col(span.start);
1423            Some(ErrorLocation {
1424                file: file.path.clone(),
1425                line,
1426                column,
1427            })
1428        });
1429        errors.push(LedgerError {
1430            severity: severity_level,
1431            code: err.code.code().to_string(),
1432            message,
1433            location,
1434            source_span: err.span.map(|s| (s.start, s.end)),
1435            file_id: err.file_id,
1436            phase: phase.to_string(),
1437        });
1438    }
1439}
1440
1441/// Load and fully process a beancount file.
1442///
1443/// This is the main entry point, equivalent to Python's `loader.load_file()`.
1444/// It performs: parse → sort → synth-plugins → Early → book → regular-plugins → Late → finalize.
1445///
1446/// # Example
1447///
1448/// ```ignore
1449/// use rustledger_loader::{load, LoadOptions};
1450/// use std::path::Path;
1451///
1452/// let ledger = load(Path::new("ledger.beancount"), LoadOptions::default())?;
1453/// for error in &ledger.errors {
1454///     eprintln!("{}: {}", error.code, error.message);
1455/// }
1456/// ```
1457pub fn load(path: &Path, options: &LoadOptions) -> Result<Ledger, ProcessError> {
1458    let mut loader = crate::Loader::new();
1459
1460    if options.path_security {
1461        loader = loader.with_path_security(true);
1462    }
1463
1464    let raw = loader.load(path)?;
1465    process(raw, options)
1466}
1467
1468/// Like [`load`], but with a caller-provided [`FileSystem`](crate::FileSystem).
1469///
1470/// Lets the WASI component inject a filesystem whose
1471/// [`decrypt`](crate::FileSystem::decrypt) delegates to a host capability, so
1472/// GPG-encrypted ledgers load in the sandbox (a WASI guest can neither spawn
1473/// `gpg` nor reach the keyring) — #1667.
1474///
1475/// # Errors
1476///
1477/// Returns a [`ProcessError`] if loading or processing fails.
1478pub fn load_with_fs(
1479    path: &Path,
1480    options: &LoadOptions,
1481    fs: Box<dyn crate::FileSystem>,
1482) -> Result<Ledger, ProcessError> {
1483    let mut loader = crate::Loader::new().with_filesystem(fs);
1484
1485    if options.path_security {
1486        loader = loader.with_path_security(true);
1487    }
1488
1489    let raw = loader.load(path)?;
1490    process(raw, options)
1491}
1492
1493/// Load a beancount file without processing.
1494///
1495/// This returns raw directives without sorting, booking, or plugins.
1496/// Use this when you need the original parse output.
1497pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
1498    crate::Loader::new().load(path)
1499}
1500
1501/// Actionable error for a Python plugin referenced by module name. `file` is the
1502/// module's resolved source path when system Python could find it. The raw
1503/// "module not found" reads as a venv/PYTHONPATH problem, so name the
1504/// unsupported form and point at the file path instead. (#1432)
1505#[cfg(feature = "plugins")]
1506fn module_ref_message(raw_name: &str, file: Option<&str>) -> String {
1507    match file {
1508        Some(path) => format!(
1509            "Python plugin \"{raw_name}\" is not supported by module name; \
1510             reference the file directly: plugin \"{path}\""
1511        ),
1512        None => format!(
1513            "Python plugin \"{raw_name}\" is not supported by module name; \
1514             reference the file directly, e.g. plugin \"/path/to/plugin.py\". \
1515             The plugin sandbox cannot see the host venv, so the plugin must be \
1516             self-contained (stdlib plus the beancount compat shim)."
1517        ),
1518    }
1519}
1520
1521/// Map a typed [`rustledger_plugin::PluginResolveError`] to a host `LedgerError`,
1522/// preserving the loader's plugin error codes (`E8001`/`E8004`/`E8005`/`PLUGIN`)
1523/// and messages. The `rustledger-plugin` dispatcher is runtime-knowledge-pure
1524/// and does not own these codes; this is where the host convention is applied.
1525#[cfg(feature = "plugins")]
1526fn resolve_error_to_ledger(e: &rustledger_plugin::PluginResolveError) -> LedgerError {
1527    use rustledger_plugin::PluginResolveError as Re;
1528    match e {
1529        Re::PathOutsideBase { name } => LedgerError::error(
1530            "PLUGIN",
1531            format!("plugin path '{name}' is outside the ledger directory"),
1532        )
1533        .with_phase("plugin"),
1534        Re::WasmFeatureDisabled { name } => LedgerError::error(
1535            "PLUGIN",
1536            format!("WASM plugin '{name}' requires the wasm-plugins feature"),
1537        )
1538        .with_phase("plugin"),
1539        Re::PythonFeatureDisabled { name } => LedgerError::error(
1540            "E8005",
1541            format!("Python plugin \"{name}\" requires the python-plugins feature"),
1542        )
1543        .with_phase("plugin"),
1544        Re::PythonModuleName {
1545            name,
1546            suggested_file,
1547        } => LedgerError::error("E8004", module_ref_message(name, suggested_file.as_deref()))
1548            .with_phase("plugin"),
1549        Re::NotFound {
1550            name,
1551            suggested_file,
1552        } => match suggested_file {
1553            Some(path) => LedgerError::error("E8004", module_ref_message(name, Some(path)))
1554                .with_phase("plugin"),
1555            None => LedgerError::error("E8001", format!("Plugin not found: \"{name}\""))
1556                .with_phase("plugin"),
1557        },
1558    }
1559}
1560
1561/// Map a typed [`rustledger_plugin::PluginRunError`] to a host `LedgerError`.
1562#[cfg(feature = "plugins")]
1563fn run_error_to_ledger(e: &rustledger_plugin::PluginRunError) -> LedgerError {
1564    use rustledger_plugin::PluginRunError as Rn;
1565    match e {
1566        Rn::WasmFailed { path, message } => LedgerError::error(
1567            "PLUGIN",
1568            format!("WASM plugin {} failed: {message}", path.display()),
1569        )
1570        .with_phase("plugin"),
1571        Rn::PythonFailed { message } => {
1572            LedgerError::error("E8002", message.clone()).with_phase("plugin")
1573        }
1574    }
1575}
1576
1577#[cfg(all(test, feature = "booking", feature = "validation"))]
1578mod finalize_price_tests {
1579    use rustledger_core::{Directive, PriceKind};
1580
1581    /// `finalize` normalizes `@@` (total) prices to per-unit (`@`), so every
1582    /// loaded `Ledger` carries normalized prices by construction — the FFI
1583    /// component and `rledger check` cannot disagree. Regression guard for #1462,
1584    /// where the FFI surface lost the normalization that lived only in the CLI
1585    /// `check` path and so exposed the raw `@@` total.
1586    #[test]
1587    fn finalize_normalizes_total_at_at_price_to_per_unit() {
1588        let dir = tempfile::tempdir().unwrap();
1589        let path = dir.path().join("main.bean");
1590        std::fs::write(
1591            &path,
1592            "2024-01-01 open Assets:Cash USD\n\
1593             2024-01-01 open Assets:Other EUR\n\
1594             2024-01-02 * \"total price\"\n  \
1595               Assets:Cash   7 USD @@ 10 EUR\n  \
1596               Assets:Other  -10 EUR\n",
1597        )
1598        .unwrap();
1599
1600        let ledger =
1601            super::load(&path, &super::LoadOptions::default()).expect("ledger should load");
1602        let price = ledger
1603            .directives
1604            .iter()
1605            .find_map(|s| match &s.value {
1606                Directive::Transaction(t) => t.postings.iter().find_map(|p| p.price.as_ref()),
1607                _ => None,
1608            })
1609            .expect("the `@@` posting should carry a price");
1610
1611        assert_eq!(
1612            price.kind,
1613            PriceKind::Unit,
1614            "`@@` must be normalized to a per-unit price, not left as a total"
1615        );
1616        let amount = price
1617            .amount
1618            .as_ref()
1619            .and_then(|a| a.as_amount())
1620            .expect("normalized per-unit amount present");
1621        // 10 EUR / 7 USD = 1.4285714… per unit — NOT the raw total `10`.
1622        assert!(
1623            amount.number.to_string().starts_with("1.42857"),
1624            "per-unit price should be 10/7, got {}",
1625            amount.number
1626        );
1627    }
1628}
1629
1630#[cfg(all(test, feature = "validation"))]
1631mod validation_options_tests {
1632    use super::validation_options_from_options;
1633    use crate::Options;
1634    use rust_decimal_macros::dec;
1635
1636    /// The shared converter must carry the per-currency tolerance override
1637    /// (`inferred_tolerance_default`) and `name_*` account types. This is the
1638    /// single source of truth both `check` and the LSP/MCP go through, so they
1639    /// cannot drift — issue #1648, where the LSP dropped the tolerance options
1640    /// and reported residual errors `check` did not.
1641    #[test]
1642    fn maps_inferred_tolerance_default_and_account_types() {
1643        let mut opts = Options::new();
1644        opts.set("inferred_tolerance_default", "CLP:0.5");
1645        opts.set("name_assets", "Activos");
1646
1647        let vo = validation_options_from_options(&opts);
1648
1649        assert_eq!(vo.inferred_tolerance_default.get("CLP"), Some(&dec!(0.5)));
1650        assert_eq!(vo.account_types[0], "Activos");
1651    }
1652}
1653
1654#[cfg(all(test, feature = "plugins"))]
1655mod sanitize_tests {
1656    use super::sanitize_inner_posting_spans;
1657    use crate::source_map::SourceMap;
1658    use rust_decimal_macros::dec;
1659    use rustledger_core::{
1660        Amount, Directive, IncompleteAmount, Posting, SYNTHESIZED_FILE_ID, Span, Spanned,
1661        Transaction,
1662    };
1663    use std::path::PathBuf;
1664    use std::sync::Arc;
1665
1666    fn txn_with_postings(postings: Vec<Spanned<Posting>>) -> Directive {
1667        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1668        let mut txn = Transaction::new(date, "x");
1669        txn.postings = postings;
1670        Directive::Transaction(txn)
1671    }
1672
1673    fn posting_at(file_id: u16, span: Span) -> Spanned<Posting> {
1674        let p = Posting::with_incomplete(
1675            "Assets:Cash",
1676            IncompleteAmount::Complete(Amount::new(dec!(1), "USD")),
1677        );
1678        Spanned::new(p, span).with_file_id(file_id as usize)
1679    }
1680
1681    fn source_map_with_one_file(source: &str) -> (SourceMap, u16) {
1682        let mut sm = SourceMap::new();
1683        let id = sm.add_file(PathBuf::from("test.bean"), Arc::from(source));
1684        (sm, id as u16)
1685    }
1686
1687    #[test]
1688    fn span_within_real_file_is_preserved() {
1689        let (sm, fid) = source_map_with_one_file("0123456789");
1690        let mut d = txn_with_postings(vec![posting_at(fid, Span::new(2, 6))]);
1691        sanitize_inner_posting_spans(&mut d, &sm);
1692        let Directive::Transaction(t) = &d else {
1693            unreachable!()
1694        };
1695        assert_eq!(t.postings[0].file_id, fid);
1696        assert_eq!(t.postings[0].span, Span::new(2, 6));
1697    }
1698
1699    #[test]
1700    fn span_past_eof_is_reset_to_synthesized() {
1701        // Bug case: a misbehaving plugin claims the posting extends past
1702        // the file's actual length. The sanitizer must reject it so the
1703        // LSP can't be tricked into producing an out-of-bounds TextEdit.
1704        let (sm, fid) = source_map_with_one_file("0123456789"); // 10 bytes
1705        let mut d = txn_with_postings(vec![posting_at(fid, Span::new(0, 9999))]);
1706        sanitize_inner_posting_spans(&mut d, &sm);
1707        let Directive::Transaction(t) = &d else {
1708            unreachable!()
1709        };
1710        assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1711        assert_eq!(t.postings[0].span, Span::ZERO);
1712    }
1713
1714    #[test]
1715    fn unknown_file_id_is_reset_to_synthesized() {
1716        // Plugin claims a file_id that the host's SourceMap doesn't know.
1717        let (sm, _real) = source_map_with_one_file("hello");
1718        let mut d = txn_with_postings(vec![posting_at(123, Span::new(0, 5))]);
1719        sanitize_inner_posting_spans(&mut d, &sm);
1720        let Directive::Transaction(t) = &d else {
1721            unreachable!()
1722        };
1723        assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1724        assert_eq!(t.postings[0].span, Span::ZERO);
1725    }
1726
1727    #[test]
1728    fn start_after_end_is_reset_to_synthesized() {
1729        let (sm, fid) = source_map_with_one_file("abcdef");
1730        let mut d = txn_with_postings(vec![posting_at(fid, Span::new(5, 2))]);
1731        sanitize_inner_posting_spans(&mut d, &sm);
1732        let Directive::Transaction(t) = &d else {
1733            unreachable!()
1734        };
1735        assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1736        assert_eq!(t.postings[0].span, Span::ZERO);
1737    }
1738
1739    #[test]
1740    fn synthesized_file_id_is_left_alone_but_span_normalized() {
1741        // file_id == SYNTHESIZED_FILE_ID with a non-zero span: the
1742        // sanitizer leaves it synthesized (span is meaningless for
1743        // synth postings) but normalizes to Span::ZERO for tidy state.
1744        let (sm, _fid) = source_map_with_one_file("x");
1745        let mut d = txn_with_postings(vec![posting_at(SYNTHESIZED_FILE_ID, Span::new(100, 200))]);
1746        sanitize_inner_posting_spans(&mut d, &sm);
1747        let Directive::Transaction(t) = &d else {
1748            unreachable!()
1749        };
1750        assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1751        assert_eq!(t.postings[0].span, Span::ZERO, "synth span normalized");
1752    }
1753
1754    #[test]
1755    fn boundary_span_eq_source_len_is_valid() {
1756        // end == source.len() is the canonical "to-end-of-file" span;
1757        // must not be rejected.
1758        let (sm, fid) = source_map_with_one_file("abcd");
1759        let mut d = txn_with_postings(vec![posting_at(fid, Span::new(0, 4))]);
1760        sanitize_inner_posting_spans(&mut d, &sm);
1761        let Directive::Transaction(t) = &d else {
1762            unreachable!()
1763        };
1764        assert_eq!(t.postings[0].file_id, fid);
1765        assert_eq!(t.postings[0].span, Span::new(0, 4));
1766    }
1767
1768    #[test]
1769    fn non_transaction_directive_is_left_alone() {
1770        // Sanitizer only walks transactions; other directive types have
1771        // no inner posting spans.
1772        let (sm, _fid) = source_map_with_one_file("x");
1773        let mut d = Directive::Open(rustledger_core::Open {
1774            date: rustledger_core::naive_date(2024, 1, 1).unwrap(),
1775            account: "Assets:Bank".into(),
1776            currencies: vec![],
1777            booking: None,
1778            meta: Default::default(),
1779        });
1780        sanitize_inner_posting_spans(&mut d, &sm); // no panic, no change
1781        assert!(matches!(d, Directive::Open(_)));
1782    }
1783}
1784
1785// The `is_python_module_name` classifier moved with dispatch into
1786// `rustledger-plugin`; its tests live there now. `module_ref_message` (the host's
1787// E8004 wording) stays here, so its tests do too.
1788#[cfg(all(test, feature = "plugins"))]
1789mod module_ref_message_tests {
1790    use super::module_ref_message;
1791
1792    #[test]
1793    fn message_uses_resolved_path_when_known() {
1794        let msg = module_ref_message("pkg.mod", Some("/abs/pkg/mod.py"));
1795        assert!(msg.contains("is not supported by module name"));
1796        assert!(msg.contains("plugin \"/abs/pkg/mod.py\""));
1797    }
1798
1799    #[test]
1800    fn message_falls_back_to_guidance_when_unresolved() {
1801        let msg = module_ref_message("pkg.mod", None);
1802        assert!(msg.contains("reference the file directly"));
1803        assert!(msg.contains("self-contained"));
1804    }
1805}