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 crate::Directives::new_unchecked(v)
748 }
749}
750
751/// Run booking and interpolation on transactions, returning the
752/// directives partitioned into `(booked, failed)`.
753///
754/// The caller has already sorted `directives` into canonical display
755/// order `(date, priority, file_id, span.start)`. Booking needs the
756/// extra constraint that cost-reduction transactions process AFTER
757/// augmentations on the same `(date, priority)` so lots exist when
758/// matched. Rather than re-sorting the whole vec, we walk it via a
759/// transient `Vec<usize>` of indices sorted by booking order. Stable
760/// sort preserves display-order tiebreaks between transactions with
761/// the same `has_cost_reduction` flag.
762///
763/// Failed transactions are partitioned out into the second return
764/// value so they don't flow into regular plugins or Late validation
765/// (they're in pre-booking shape — postings have unresolved cost
766/// specs and unfilled elided slots, so downstream processing would
767/// cascade misleading errors). The caller is responsible for
768/// re-merging `failed` into the final `Ledger.directives` for output
769/// so the user still sees their original input.
770#[cfg(feature = "booking")]
771fn run_booking(
772 mut directives: Vec<Spanned<Directive>>,
773 booking_method: BookingMethod,
774 errors: &mut Vec<LedgerError>,
775) -> (Vec<Spanned<Directive>>, Vec<Spanned<Directive>>) {
776 use rustledger_booking::BookingEngine;
777
778 let mut engine = BookingEngine::with_method(booking_method);
779 engine.register_account_methods(directives.iter().map(|s| &s.value));
780
781 // Build an index ordered for booking: stable sort by
782 // `has_cost_reduction` only (display order — `(date, priority,
783 // file_id, span.start)` — is already encoded in the existing
784 // positional order, and stable_sort preserves that as the tiebreak).
785 let mut order: Vec<usize> = (0..directives.len()).collect();
786 order.sort_by_key(|&i| rustledger_core::booking_sort_key(&directives[i].value));
787
788 let mut failed_indices: Vec<usize> = Vec::new();
789 for &i in &order {
790 let spanned = &mut directives[i];
791 if let Directive::Transaction(txn) = &mut spanned.value {
792 match engine.book_and_interpolate(txn) {
793 Ok(result) => {
794 engine.apply(&result.transaction);
795 *txn = result.transaction;
796 }
797 Err(e) => {
798 errors.push(LedgerError::error(
799 "BOOK",
800 format!("{} ({}, \"{}\")", e, txn.date, txn.narration),
801 ));
802 failed_indices.push(i);
803 }
804 }
805 }
806 }
807
808 // Partition into (booked, failed). Indices are valid in the current
809 // `directives` vec (no mutation has happened since they were
810 // collected); after this consuming iteration the vec is gone and
811 // partition is fait accompli — no window where a caller could
812 // accidentally mutate between collection and partition.
813 let failed_set: rustc_hash::FxHashSet<usize> = failed_indices.iter().copied().collect();
814 let mut booked = Vec::with_capacity(directives.len() - failed_indices.len());
815 let mut failed = Vec::with_capacity(failed_indices.len());
816 for (i, d) in directives.into_iter().enumerate() {
817 if failed_set.contains(&i) {
818 failed.push(d);
819 } else {
820 booked.push(d);
821 }
822 }
823 (booked, failed)
824}
825
826/// Which subset of plugins to run.
827///
828/// The loader pipeline calls `run_plugins` twice: once with
829/// [`PluginPass::PreBookingSynth`] before the Early validation phase
830/// (so synthesizers can inject Opens / Documents that early checks
831/// depend on), and once with [`PluginPass::PostBooking`] after booking
832/// (so cost-spec-reading plugins like `implicit_prices`,
833/// `capital_gains_classifier`, `check_average_cost`, `sell_gains`,
834/// `unrealized`, and `valuation` see filled-in per-unit values on the
835/// `CostNumber::PerUnitFromTotal` variant).
836///
837/// Standalone callers (LSP / FFI / tests on already-booked input) pass
838/// [`PluginPass::PostBooking`] — synth plugins are a loader-internal
839/// concern and would re-Open already-opened accounts if run a second
840/// time.
841#[cfg(feature = "plugins")]
842#[derive(Debug, Clone, Copy, PartialEq, Eq)]
843pub enum PluginPass {
844 /// Only plugins that synthesize directives the Early validator
845 /// depends on: `auto_accounts` (synthesizes Open directives) and
846 /// the built-in document discovery walker (synthesizes Document
847 /// directives the early phase checks for missing files).
848 PreBookingSynth,
849 /// All file-declared plugins and CLI `extra_plugins`, EXCLUDING
850 /// `auto_accounts` and `document_discovery` (those ran pre-booking).
851 /// Includes the 28 plugins that don't depend on synth state but
852 /// may depend on booked cost specs.
853 PostBooking,
854}
855
856/// Run plugins on directives.
857///
858/// Executes native plugins (and document discovery) on the given directives,
859/// modifying them in-place. Plugin errors are appended to `errors`.
860///
861/// A single plugin invocation in `run_plugins`'s unified dispatch
862/// list. `force_python` ("python:..." prefix) overrides native
863/// resolution; `config` is the plugin-specific string passed to
864/// `PluginInput.config`.
865#[cfg(feature = "plugins")]
866struct PluginInvocation {
867 name: String,
868 config: Option<String>,
869 force_python: bool,
870}
871
872/// `pass` selects which subset of plugins to run — see [`PluginPass`].
873/// The loader pipeline calls this twice (synth pass before Early,
874/// regular pass after booking).
875#[cfg(feature = "plugins")]
876pub fn run_plugins(
877 directives: &mut Vec<Spanned<Directive>>,
878 file_plugins: &[Plugin],
879 file_options: &Options,
880 options: &LoadOptions,
881 source_map: &SourceMap,
882 errors: &mut Vec<LedgerError>,
883 pass: PluginPass,
884) -> Result<(), ProcessError> {
885 use rustledger_plugin::{NativePluginRegistry, PluginOptions};
886
887 // Resolve document directories relative to the main file's directory.
888 // Used to build doc_discovery's per-call config in the synth pass.
889 let base_dir = source_map
890 .files()
891 .first()
892 .and_then(|f| f.path.parent())
893 .unwrap_or_else(|| std::path::Path::new("."));
894
895 // Access the process-wide registry singleton. The registry is
896 // immutable and stateless, so the same instance services every
897 // call.
898 let registry = NativePluginRegistry::global();
899
900 // Build the unified list of plugins to invoke for this pass:
901 // 1. Implicit synth plugins triggered by `LoadOptions` /
902 // `file_options` (auto_accounts via `options.auto_accounts`;
903 // document_discovery via non-empty `file_options.documents`).
904 // 2. File-declared plugins from `plugin "..."` directives.
905 // 3. CLI `--plugin` extras.
906 // Pass classification happens here — once — via `registry.find_synth`.
907 // A plugin enters the list iff its pass matches the requested `pass`.
908 let mut entries: Vec<PluginInvocation> = Vec::new();
909
910 if matches!(pass, PluginPass::PreBookingSynth) {
911 // Implicit synth: API-level auto_accounts flag.
912 if options.auto_accounts {
913 entries.push(PluginInvocation {
914 name: rustledger_plugin::AUTO_ACCOUNTS_NAME.to_string(),
915 config: None,
916 force_python: false,
917 });
918 }
919 // Implicit synth: document_discovery, driven by `option "documents"`.
920 // The plugin sits in the registry as a ZST; we hand it the
921 // resolved directories + base_dir via its config JSON.
922 if options.run_plugins && !file_options.documents.is_empty() {
923 let resolved: Vec<String> = file_options
924 .documents
925 .iter()
926 .map(|d| {
927 let path = std::path::Path::new(d);
928 if path.is_absolute() {
929 d.clone()
930 } else {
931 base_dir.join(path).to_string_lossy().to_string()
932 }
933 })
934 .collect();
935 entries.push(PluginInvocation {
936 name: rustledger_plugin::DOCUMENT_DISCOVERY_NAME.to_string(),
937 config: Some(rustledger_plugin::document_discovery_config(
938 base_dir, &resolved,
939 )),
940 force_python: false,
941 });
942 }
943 }
944
945 // A plugin name belongs in the current pass iff its synth-marker
946 // membership matches `pass`. Non-native plugins (WASM/Python) are
947 // never in the synth registry and therefore always fall into the
948 // PostBooking pass.
949 let want_synth = matches!(pass, PluginPass::PreBookingSynth);
950
951 // File-declared plugins.
952 if options.run_plugins {
953 for plugin in file_plugins {
954 if registry.find_synth(&plugin.name).is_some() == want_synth {
955 entries.push(PluginInvocation {
956 name: plugin.name.clone(),
957 config: plugin.config.clone(),
958 force_python: plugin.force_python,
959 });
960 }
961 }
962 }
963
964 // CLI extra plugins.
965 for extra in &options.extra_plugins {
966 if registry.find_synth(&extra.name).is_some() == want_synth {
967 entries.push(PluginInvocation {
968 name: extra.name.clone(),
969 config: extra.config.clone(),
970 force_python: false,
971 });
972 }
973 }
974
975 if entries.is_empty() {
976 return Ok(());
977 }
978
979 let plugin_options = PluginOptions {
980 operating_currencies: file_options.operating_currency.clone(),
981 title: file_options.title.clone(),
982 };
983
984 // Dispatch each entry: resolve it to a concrete runtime, then run + apply
985 // uniformly. Resolution (classification, path-security, feature-gating, the
986 // #1432 module-name rejection) lives in `resolve_plugin`; execution lives in
987 // `ResolvedPlugin::run`. Building wrappers and applying ops here — once, not
988 // once per runtime — is the point of the resolve/run split.
989 let pass_kind = match pass {
990 PluginPass::PreBookingSynth => rustledger_plugin::PluginPass::Synth,
991 PluginPass::PostBooking => rustledger_plugin::PluginPass::Regular,
992 };
993 for invocation in &entries {
994 // Resolution (classify + path-security + feature-gate + #1432 reject)
995 // lives in `rustledger_plugin::resolve_plugin`; execution in
996 // `ResolvedPlugin::run`. The loader keeps wrapper building, op
997 // application, and its error-code convention.
998 let resolved = match rustledger_plugin::resolve_plugin(
999 &invocation.name,
1000 invocation.force_python,
1001 pass_kind,
1002 registry,
1003 base_dir,
1004 options.path_security,
1005 ) {
1006 Ok(resolved) => resolved,
1007 Err(e) => {
1008 errors.push(resolve_error_to_ledger(&e));
1009 continue;
1010 }
1011 };
1012
1013 // Rebuild wrappers per plugin so each sees the prior plugin's applied
1014 // ops, then convert + apply uniformly regardless of runtime. Every
1015 // runtime's diagnostics now flow through `record_plugin_errors`, so a
1016 // plugin-set source location is preserved (the old WASM/Python runner
1017 // conversions dropped it; native always kept it).
1018 let wrappers = build_wrappers(directives, source_map);
1019 match resolved.run(wrappers, &plugin_options, &invocation.config, base_dir) {
1020 Ok(output) => {
1021 record_plugin_errors(errors, output.errors, source_map);
1022 apply_plugin_ops(directives, output.ops, errors, source_map)?;
1023 }
1024 Err(e) => errors.push(run_error_to_ledger(&e)),
1025 }
1026 }
1027
1028 // No final wrapper→directive conversion needed: `apply_plugin_ops`
1029 // updates `directives` in place after each plugin call, preserving
1030 // original spans on Keep/Modify ops. Plugin-synthesized directives
1031 // (Insert ops) get `SYNTHESIZED_FILE_ID` and a zero span.
1032 Ok(())
1033}
1034
1035/// Build a fresh `Vec<DirectiveWrapper>` from the current directives,
1036/// carrying filename + line number for plugin-side error reporting.
1037/// Spans don't need to round-trip through the wrappers — the loader
1038/// preserves them via `apply_plugin_ops` matching on op index.
1039#[cfg(feature = "plugins")]
1040fn build_wrappers(
1041 directives: &[Spanned<Directive>],
1042 source_map: &SourceMap,
1043) -> Vec<rustledger_plugin::DirectiveWrapper> {
1044 use rustledger_plugin::directive_to_wrapper_with_location;
1045
1046 directives
1047 .iter()
1048 .map(|spanned| {
1049 let (filename, lineno) = if let Some(file) = source_map.get(spanned.file_id as usize) {
1050 let (line, _col) = file.line_col(spanned.span.start);
1051 (Some(file.path.display().to_string()), Some(line as u32))
1052 } else {
1053 (None, None)
1054 };
1055 directive_to_wrapper_with_location(&spanned.value, filename, lineno)
1056 })
1057 .collect()
1058}
1059
1060/// Push plugin errors into the ledger's error stream, tagged with
1061/// `phase: "plugin"` and — when the plugin set `source_file` /
1062/// `line_number` on the error — an attached `ErrorLocation` so
1063/// downstream renderers (CLI, LSP, JSON output) can pinpoint where
1064/// the plugin objected.
1065///
1066/// Source-location resolution: if the wrapper's `source_file` resolves
1067/// to a real file in the source map, use that for `ErrorLocation.file`
1068/// and treat `line_number` as the line index. Plugin-synthesized
1069/// filenames (e.g. `"<auto_accounts>"`) that don't match any real
1070/// file are passed through as `PathBuf::from(name)` so the rendered
1071/// location still attributes the error to the originating plugin —
1072/// better than silently dropping the field.
1073#[cfg(feature = "plugins")]
1074fn record_plugin_errors(
1075 errors: &mut Vec<LedgerError>,
1076 plugin_errors: Vec<rustledger_plugin::PluginError>,
1077 source_map: &SourceMap,
1078) {
1079 for err in plugin_errors {
1080 let mut ledger_err = match err.severity {
1081 rustledger_plugin::PluginErrorSeverity::Error => {
1082 LedgerError::error("PLUGIN", err.message).with_phase("plugin")
1083 }
1084 rustledger_plugin::PluginErrorSeverity::Warning => {
1085 LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
1086 }
1087 };
1088 // Propagate plugin-set source location into `ErrorLocation`.
1089 // Column defaults to 1 — plugin errors don't carry column info
1090 // through the wrapper protocol.
1091 if let (Some(file), Some(line)) = (&err.source_file, err.line_number) {
1092 let resolved_path = source_map
1093 .get_by_path(std::path::Path::new(file))
1094 .map_or_else(|| std::path::PathBuf::from(file), |f| f.path.clone());
1095 ledger_err = ledger_err.with_location(ErrorLocation {
1096 file: resolved_path,
1097 line: line as usize,
1098 column: 1,
1099 });
1100 }
1101 errors.push(ledger_err);
1102 }
1103}
1104
1105/// Apply a plugin's `Vec<PluginOp>` to `directives` in place.
1106///
1107/// Validates that the op set forms a complete partition of the input
1108/// indices (each input index appears in exactly one `Keep` / `Modify` /
1109/// `Delete` op). Protocol violations produce a `PLUGIN` error in
1110/// `errors` and leave `directives` untouched.
1111///
1112/// For `Keep(i)` / `Modify(i, w)`, the resulting `Spanned<Directive>`
1113/// inherits `directives[i]`'s span and `file_id` — this is the core of
1114/// the ops protocol's correctness guarantee (plugin-transformed
1115/// directives keep their original source identity for error reporting).
1116/// `Insert(w)` directives get `(Span::ZERO, SYNTHESIZED_FILE_ID)`.
1117///
1118/// Inner posting spans returned by plugins are sanitized against the
1119/// host's `SourceMap` (see [`sanitize_inner_posting_spans`]) so a
1120/// misbehaving plugin cannot smuggle out-of-bounds spans into the LSP.
1121#[cfg(feature = "plugins")]
1122fn apply_plugin_ops(
1123 directives: &mut Vec<Spanned<Directive>>,
1124 ops: Vec<rustledger_plugin::PluginOp>,
1125 errors: &mut Vec<LedgerError>,
1126 source_map: &SourceMap,
1127) -> Result<(), ProcessError> {
1128 use rustledger_plugin::PluginOp;
1129 use rustledger_plugin::wrapper_to_directive;
1130
1131 // Validate the op set forms a complete cover of the input — the contract is
1132 // single-sourced in `rustledger-plugin` so the loader and FFI surfaces stay
1133 // in lock-step. On violation, surface the error and leave directives as-is.
1134 if let Err(msg) = rustledger_plugin::validate_op_coverage(directives.len(), &ops) {
1135 errors.push(LedgerError::error("PLUGIN", msg).with_phase("plugin"));
1136 return Ok(());
1137 }
1138
1139 // Materialize new directives, preserving spans for Keep/Modify.
1140 let mut new_directives = Vec::with_capacity(ops.len());
1141 for op in ops {
1142 match op {
1143 PluginOp::Keep(i) => {
1144 new_directives.push(directives[i].clone());
1145 }
1146 PluginOp::Modify(i, wrapper) => {
1147 let mut directive = wrapper_to_directive(&wrapper)
1148 .map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
1149 // Plugins are not trusted to return well-formed inner
1150 // posting spans — a misbehaving plugin can synthesize a
1151 // file_id pointing at a nonexistent source or a span
1152 // that runs past EOF. The LSP later builds TextEdits
1153 // from these spans, so an out-of-bounds posting span
1154 // would produce a corrupt edit. Reset any inner posting
1155 // span that doesn't refer to a real loaded file or that
1156 // exceeds the file's length to `Spanned::synthesized`.
1157 sanitize_inner_posting_spans(&mut directive, source_map);
1158 new_directives.push(Spanned {
1159 value: directive,
1160 span: directives[i].span,
1161 file_id: directives[i].file_id,
1162 });
1163 }
1164 PluginOp::Insert(wrapper) => {
1165 // Same trust caveat as Modify: don't let an Insert smuggle
1166 // bogus inner-posting spans through.
1167 // (Wrapper-derived outer span is validated below.)
1168 // Resolve the wrapper's filename + line number, if set,
1169 // into a real (file_id, span) when the filename
1170 // corresponds to a loaded source file. Falls back to
1171 // SYNTHESIZED_FILE_ID + zero span otherwise — including
1172 // for plugin-only attribution like `"<auto_accounts>"`
1173 // (which never matches a loaded file).
1174 let (span, file_id) = match (&wrapper.filename, wrapper.lineno) {
1175 (Some(filename), Some(lineno)) => {
1176 if let Some(file) = source_map.get_by_path(std::path::Path::new(filename)) {
1177 let span_start = file.line_start(lineno as usize).unwrap_or(0);
1178 (
1179 rustledger_parser::Span::new(span_start, span_start),
1180 file.id as u16,
1181 )
1182 } else {
1183 (
1184 rustledger_parser::Span::ZERO,
1185 rustledger_parser::SYNTHESIZED_FILE_ID,
1186 )
1187 }
1188 }
1189 _ => (
1190 rustledger_parser::Span::ZERO,
1191 rustledger_parser::SYNTHESIZED_FILE_ID,
1192 ),
1193 };
1194 let mut directive = wrapper_to_directive(&wrapper)
1195 .map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
1196 sanitize_inner_posting_spans(&mut directive, source_map);
1197 new_directives.push(Spanned::new(directive, span).with_file_id(file_id as usize));
1198 }
1199 PluginOp::Delete(_) => {}
1200 }
1201 }
1202
1203 *directives = new_directives;
1204 Ok(())
1205}
1206
1207/// Reset any inner `Spanned<Posting>` whose location does not refer to a
1208/// real loaded source range to [`Spanned::synthesized`]. Plugins are not
1209/// trusted to return well-formed `file_id` + byte ranges; without this,
1210/// a misbehaving plugin could induce out-of-bounds LSP text edits.
1211///
1212/// A span is considered valid when:
1213/// - `file_id == SYNTHESIZED_FILE_ID` (genuine synthesis), OR
1214/// - the `file_id` resolves in `SourceMap` AND `0 <= start <= end <= len`
1215/// for that file's source.
1216///
1217/// Everything else collapses to `Spanned::synthesized(posting)`. As a
1218/// final pass, synthesized postings that arrived with a non-zero span
1219/// are normalized to `Span::ZERO` so the in-memory state matches the
1220/// `Spanned::synthesized` constructor's contract (`file_id` +
1221/// `Span::ZERO`).
1222#[cfg(feature = "plugins")]
1223fn sanitize_inner_posting_spans(directive: &mut Directive, source_map: &SourceMap) {
1224 use rustledger_core::Span;
1225 use rustledger_parser::SYNTHESIZED_FILE_ID;
1226 if let Directive::Transaction(txn) = directive {
1227 for p in &mut txn.postings {
1228 let ok = if p.file_id == SYNTHESIZED_FILE_ID {
1229 true
1230 } else {
1231 source_map
1232 .get(p.file_id as usize)
1233 .is_some_and(|f| p.span.start <= p.span.end && p.span.end <= f.source.len())
1234 };
1235 if !ok {
1236 let inner = std::mem::replace(
1237 &mut p.value,
1238 rustledger_core::Posting::auto(rustledger_core::InternedStr::from("")),
1239 );
1240 *p = rustledger_core::Spanned::synthesized(inner);
1241 } else if p.file_id == SYNTHESIZED_FILE_ID && p.span != Span::ZERO {
1242 // Synthesized → span is meaningless; normalize so the
1243 // state is consistent with `Spanned::synthesized`.
1244 p.span = Span::ZERO;
1245 }
1246 }
1247 }
1248}
1249
1250/// Build a [`ValidationOptions`] from loader-level file options.
1251///
1252/// Factored out of the old `run_validation` so both the early and
1253/// late phases in `process()` can share the same `ValidationSession`
1254/// configuration. Document-dir resolution is relative to the main
1255/// file's parent directory.
1256#[cfg(feature = "validation")]
1257fn build_validation_options(
1258 file_options: &Options,
1259 source_map: &SourceMap,
1260 default_booking_method: BookingMethod,
1261) -> rustledger_validate::ValidationOptions {
1262 use rustledger_validate::ValidationOptions;
1263
1264 // Resolve document directories relative to the main file's
1265 // directory. Absolute paths pass through; relative paths are
1266 // joined onto the source map's first file's parent. Matches the
1267 // pre-refactor `run_validation` behavior exactly.
1268 let base_dir = source_map
1269 .files()
1270 .first()
1271 .and_then(|f| f.path.parent())
1272 .unwrap_or_else(|| std::path::Path::new("."));
1273
1274 let resolved_document_dirs: Vec<std::path::PathBuf> = file_options
1275 .documents
1276 .iter()
1277 .map(|d| {
1278 let path = std::path::Path::new(d);
1279 if path.is_absolute() {
1280 path.to_path_buf()
1281 } else {
1282 base_dir.join(path)
1283 }
1284 })
1285 .collect();
1286
1287 let account_types: Vec<String> = file_options
1288 .account_types()
1289 .iter()
1290 .map(|s| (*s).to_string())
1291 .collect();
1292
1293 // Per-`file_id` source-file directories, so the validator can resolve a
1294 // relative `document` path against its own directive's file (matching
1295 // Beancount and `include`) instead of the process CWD. `file_id` indexes
1296 // `source_map.files()`, so this vec is parallel to it.
1297 let document_source_dirs: Vec<std::path::PathBuf> = source_map
1298 .files()
1299 .iter()
1300 .map(|f| {
1301 f.path.parent().map_or_else(
1302 || std::path::PathBuf::from("."),
1303 std::path::Path::to_path_buf,
1304 )
1305 })
1306 .collect();
1307
1308 ValidationOptions::default()
1309 .with_account_types(account_types)
1310 .with_document_dirs(resolved_document_dirs)
1311 .with_document_source_dirs(document_source_dirs)
1312 .with_infer_tolerance_from_cost(file_options.infer_tolerance_from_cost)
1313 .with_tolerance_multiplier(file_options.inferred_tolerance_multiplier)
1314 .with_inferred_tolerance_default(file_options.inferred_tolerance_default.clone())
1315 .with_default_booking_method(default_booking_method)
1316}
1317
1318/// Convert a batch of [`rustledger_validate::ValidationError`]s into
1319/// loader-level [`LedgerError`]s (with resolved `file:line:column`
1320/// locations) and append to the existing list.
1321///
1322/// Factored out so both validation phases in `process()` share the
1323/// same conversion path.
1324#[cfg(feature = "validation")]
1325fn ledger_errors_extend(
1326 errors: &mut Vec<LedgerError>,
1327 validation_errors: Vec<rustledger_validate::ValidationError>,
1328 source_map: &SourceMap,
1329) {
1330 for err in validation_errors {
1331 let phase = if err.code.is_parse_phase() {
1332 "parse"
1333 } else {
1334 "validate"
1335 };
1336 let severity_level = if err.code.is_warning() {
1337 ErrorSeverity::Warning
1338 } else {
1339 ErrorSeverity::Error
1340 };
1341 // Fold the advisory note (if any) into the message so it propagates
1342 // through every downstream format (LedgerError, JSON diagnostic, CLI
1343 // report, LSP diagnostic) without each one needing a dedicated field.
1344 let message = match &err.note {
1345 Some(note) => format!("{err}\n note: {note}"),
1346 None => err.to_string(),
1347 };
1348 // Resolve span + file_id into a file/line/column triple so CLI and
1349 // LSP consumers can render `file:line:col` headers without having
1350 // to do the lookup themselves (issue #901).
1351 let location = err.span.and_then(|span| {
1352 let fid = err.file_id? as usize;
1353 let file = source_map.get(fid)?;
1354 let (line, column) = file.line_col(span.start);
1355 Some(ErrorLocation {
1356 file: file.path.clone(),
1357 line,
1358 column,
1359 })
1360 });
1361 errors.push(LedgerError {
1362 severity: severity_level,
1363 code: err.code.code().to_string(),
1364 message,
1365 location,
1366 source_span: err.span.map(|s| (s.start, s.end)),
1367 file_id: err.file_id,
1368 phase: phase.to_string(),
1369 });
1370 }
1371}
1372
1373/// Load and fully process a beancount file.
1374///
1375/// This is the main entry point, equivalent to Python's `loader.load_file()`.
1376/// It performs: parse → sort → synth-plugins → Early → book → regular-plugins → Late → finalize.
1377///
1378/// # Example
1379///
1380/// ```ignore
1381/// use rustledger_loader::{load, LoadOptions};
1382/// use std::path::Path;
1383///
1384/// let ledger = load(Path::new("ledger.beancount"), LoadOptions::default())?;
1385/// for error in &ledger.errors {
1386/// eprintln!("{}: {}", error.code, error.message);
1387/// }
1388/// ```
1389pub fn load(path: &Path, options: &LoadOptions) -> Result<Ledger, ProcessError> {
1390 let mut loader = crate::Loader::new();
1391
1392 if options.path_security {
1393 loader = loader.with_path_security(true);
1394 }
1395
1396 let raw = loader.load(path)?;
1397 process(raw, options)
1398}
1399
1400/// Load a beancount file without processing.
1401///
1402/// This returns raw directives without sorting, booking, or plugins.
1403/// Use this when you need the original parse output.
1404pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
1405 crate::Loader::new().load(path)
1406}
1407
1408/// Actionable error for a Python plugin referenced by module name. `file` is the
1409/// module's resolved source path when system Python could find it. The raw
1410/// "module not found" reads as a venv/PYTHONPATH problem, so name the
1411/// unsupported form and point at the file path instead. (#1432)
1412#[cfg(feature = "plugins")]
1413fn module_ref_message(raw_name: &str, file: Option<&str>) -> String {
1414 match file {
1415 Some(path) => format!(
1416 "Python plugin \"{raw_name}\" is not supported by module name; \
1417 reference the file directly: plugin \"{path}\""
1418 ),
1419 None => format!(
1420 "Python plugin \"{raw_name}\" is not supported by module name; \
1421 reference the file directly, e.g. plugin \"/path/to/plugin.py\". \
1422 The plugin sandbox cannot see the host venv, so the plugin must be \
1423 self-contained (stdlib plus the beancount compat shim)."
1424 ),
1425 }
1426}
1427
1428/// Map a typed [`rustledger_plugin::PluginResolveError`] to a host `LedgerError`,
1429/// preserving the loader's plugin error codes (`E8001`/`E8004`/`E8005`/`PLUGIN`)
1430/// and messages. The `rustledger-plugin` dispatcher is runtime-knowledge-pure
1431/// and does not own these codes; this is where the host convention is applied.
1432#[cfg(feature = "plugins")]
1433fn resolve_error_to_ledger(e: &rustledger_plugin::PluginResolveError) -> LedgerError {
1434 use rustledger_plugin::PluginResolveError as Re;
1435 match e {
1436 Re::PathOutsideBase { name } => LedgerError::error(
1437 "PLUGIN",
1438 format!("plugin path '{name}' is outside the ledger directory"),
1439 )
1440 .with_phase("plugin"),
1441 Re::WasmFeatureDisabled { name } => LedgerError::error(
1442 "PLUGIN",
1443 format!("WASM plugin '{name}' requires the wasm-plugins feature"),
1444 )
1445 .with_phase("plugin"),
1446 Re::PythonFeatureDisabled { name } => LedgerError::error(
1447 "E8005",
1448 format!("Python plugin \"{name}\" requires the python-plugins feature"),
1449 )
1450 .with_phase("plugin"),
1451 Re::PythonModuleName {
1452 name,
1453 suggested_file,
1454 } => LedgerError::error("E8004", module_ref_message(name, suggested_file.as_deref()))
1455 .with_phase("plugin"),
1456 Re::NotFound {
1457 name,
1458 suggested_file,
1459 } => match suggested_file {
1460 Some(path) => LedgerError::error("E8004", module_ref_message(name, Some(path)))
1461 .with_phase("plugin"),
1462 None => LedgerError::error("E8001", format!("Plugin not found: \"{name}\""))
1463 .with_phase("plugin"),
1464 },
1465 }
1466}
1467
1468/// Map a typed [`rustledger_plugin::PluginRunError`] to a host `LedgerError`.
1469#[cfg(feature = "plugins")]
1470fn run_error_to_ledger(e: &rustledger_plugin::PluginRunError) -> LedgerError {
1471 use rustledger_plugin::PluginRunError as Rn;
1472 match e {
1473 Rn::WasmFailed { path, message } => LedgerError::error(
1474 "PLUGIN",
1475 format!("WASM plugin {} failed: {message}", path.display()),
1476 )
1477 .with_phase("plugin"),
1478 Rn::PythonFailed { message } => {
1479 LedgerError::error("E8002", message.clone()).with_phase("plugin")
1480 }
1481 }
1482}
1483
1484#[cfg(all(test, feature = "plugins"))]
1485mod sanitize_tests {
1486 use super::sanitize_inner_posting_spans;
1487 use crate::source_map::SourceMap;
1488 use rust_decimal_macros::dec;
1489 use rustledger_core::{
1490 Amount, Directive, IncompleteAmount, Posting, SYNTHESIZED_FILE_ID, Span, Spanned,
1491 Transaction,
1492 };
1493 use std::path::PathBuf;
1494 use std::sync::Arc;
1495
1496 fn txn_with_postings(postings: Vec<Spanned<Posting>>) -> Directive {
1497 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1498 let mut txn = Transaction::new(date, "x");
1499 txn.postings = postings;
1500 Directive::Transaction(txn)
1501 }
1502
1503 fn posting_at(file_id: u16, span: Span) -> Spanned<Posting> {
1504 let p = Posting::with_incomplete(
1505 "Assets:Cash",
1506 IncompleteAmount::Complete(Amount::new(dec!(1), "USD")),
1507 );
1508 Spanned::new(p, span).with_file_id(file_id as usize)
1509 }
1510
1511 fn source_map_with_one_file(source: &str) -> (SourceMap, u16) {
1512 let mut sm = SourceMap::new();
1513 let id = sm.add_file(PathBuf::from("test.bean"), Arc::from(source));
1514 (sm, id as u16)
1515 }
1516
1517 #[test]
1518 fn span_within_real_file_is_preserved() {
1519 let (sm, fid) = source_map_with_one_file("0123456789");
1520 let mut d = txn_with_postings(vec![posting_at(fid, Span::new(2, 6))]);
1521 sanitize_inner_posting_spans(&mut d, &sm);
1522 let Directive::Transaction(t) = &d else {
1523 unreachable!()
1524 };
1525 assert_eq!(t.postings[0].file_id, fid);
1526 assert_eq!(t.postings[0].span, Span::new(2, 6));
1527 }
1528
1529 #[test]
1530 fn span_past_eof_is_reset_to_synthesized() {
1531 // Bug case: a misbehaving plugin claims the posting extends past
1532 // the file's actual length. The sanitizer must reject it so the
1533 // LSP can't be tricked into producing an out-of-bounds TextEdit.
1534 let (sm, fid) = source_map_with_one_file("0123456789"); // 10 bytes
1535 let mut d = txn_with_postings(vec![posting_at(fid, Span::new(0, 9999))]);
1536 sanitize_inner_posting_spans(&mut d, &sm);
1537 let Directive::Transaction(t) = &d else {
1538 unreachable!()
1539 };
1540 assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1541 assert_eq!(t.postings[0].span, Span::ZERO);
1542 }
1543
1544 #[test]
1545 fn unknown_file_id_is_reset_to_synthesized() {
1546 // Plugin claims a file_id that the host's SourceMap doesn't know.
1547 let (sm, _real) = source_map_with_one_file("hello");
1548 let mut d = txn_with_postings(vec![posting_at(123, Span::new(0, 5))]);
1549 sanitize_inner_posting_spans(&mut d, &sm);
1550 let Directive::Transaction(t) = &d else {
1551 unreachable!()
1552 };
1553 assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1554 assert_eq!(t.postings[0].span, Span::ZERO);
1555 }
1556
1557 #[test]
1558 fn start_after_end_is_reset_to_synthesized() {
1559 let (sm, fid) = source_map_with_one_file("abcdef");
1560 let mut d = txn_with_postings(vec![posting_at(fid, Span::new(5, 2))]);
1561 sanitize_inner_posting_spans(&mut d, &sm);
1562 let Directive::Transaction(t) = &d else {
1563 unreachable!()
1564 };
1565 assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1566 assert_eq!(t.postings[0].span, Span::ZERO);
1567 }
1568
1569 #[test]
1570 fn synthesized_file_id_is_left_alone_but_span_normalized() {
1571 // file_id == SYNTHESIZED_FILE_ID with a non-zero span: the
1572 // sanitizer leaves it synthesized (span is meaningless for
1573 // synth postings) but normalizes to Span::ZERO for tidy state.
1574 let (sm, _fid) = source_map_with_one_file("x");
1575 let mut d = txn_with_postings(vec![posting_at(SYNTHESIZED_FILE_ID, Span::new(100, 200))]);
1576 sanitize_inner_posting_spans(&mut d, &sm);
1577 let Directive::Transaction(t) = &d else {
1578 unreachable!()
1579 };
1580 assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1581 assert_eq!(t.postings[0].span, Span::ZERO, "synth span normalized");
1582 }
1583
1584 #[test]
1585 fn boundary_span_eq_source_len_is_valid() {
1586 // end == source.len() is the canonical "to-end-of-file" span;
1587 // must not be rejected.
1588 let (sm, fid) = source_map_with_one_file("abcd");
1589 let mut d = txn_with_postings(vec![posting_at(fid, Span::new(0, 4))]);
1590 sanitize_inner_posting_spans(&mut d, &sm);
1591 let Directive::Transaction(t) = &d else {
1592 unreachable!()
1593 };
1594 assert_eq!(t.postings[0].file_id, fid);
1595 assert_eq!(t.postings[0].span, Span::new(0, 4));
1596 }
1597
1598 #[test]
1599 fn non_transaction_directive_is_left_alone() {
1600 // Sanitizer only walks transactions; other directive types have
1601 // no inner posting spans.
1602 let (sm, _fid) = source_map_with_one_file("x");
1603 let mut d = Directive::Open(rustledger_core::Open {
1604 date: rustledger_core::naive_date(2024, 1, 1).unwrap(),
1605 account: "Assets:Bank".into(),
1606 currencies: vec![],
1607 booking: None,
1608 meta: Default::default(),
1609 });
1610 sanitize_inner_posting_spans(&mut d, &sm); // no panic, no change
1611 assert!(matches!(d, Directive::Open(_)));
1612 }
1613}
1614
1615// The `is_python_module_name` classifier moved with dispatch into
1616// `rustledger-plugin`; its tests live there now. `module_ref_message` (the host's
1617// E8004 wording) stays here, so its tests do too.
1618#[cfg(all(test, feature = "plugins"))]
1619mod module_ref_message_tests {
1620 use super::module_ref_message;
1621
1622 #[test]
1623 fn message_uses_resolved_path_when_known() {
1624 let msg = module_ref_message("pkg.mod", Some("/abs/pkg/mod.py"));
1625 assert!(msg.contains("is not supported by module name"));
1626 assert!(msg.contains("plugin \"/abs/pkg/mod.py\""));
1627 }
1628
1629 #[test]
1630 fn message_falls_back_to_guidance_when_unresolved() {
1631 let msg = module_ref_message("pkg.mod", None);
1632 assert!(msg.contains("reference the file directly"));
1633 assert!(msg.contains("self-contained"));
1634 }
1635}