Skip to main content

rustledger_plugin/native/
mod.rs

1//! Native (non-WASM) plugin support.
2//!
3//! These plugins run as native Rust code for maximum performance.
4//! They implement the same interface as WASM plugins.
5//!
6//! ## Pass discrimination (issue #1166)
7//!
8//! Plugins run in one of two passes — see the loader's `PluginPass`
9//! enum:
10//!
11//! - **Pre-booking synth pass**: synthesizers like `auto_accounts`
12//!   and `document_discovery` that inject directives the Early
13//!   validator depends on (e.g. `Open` directives so account-
14//!   presence checks see them).
15//! - **Post-booking regular pass**: transformations on already-
16//!   booked directives — most plugins, including the cost-spec-
17//!   reading ones (`implicit_prices`, `unrealized`, etc.) that need
18//!   to see filled-in per-unit values from the booker.
19//!
20//! Each native plugin declares which pass it runs in by implementing
21//! either [`SynthPlugin`] or [`RegularPlugin`] (both extend the base
22//! [`NativePlugin`] trait). The registry holds two separately-typed
23//! Vecs (`Vec<Box<dyn SynthPlugin>>` and `Vec<Box<dyn RegularPlugin>>`),
24//! and the loader's runner consults the typed registry for the
25//! appropriate pass via [`NativePluginRegistry::find_synth`] /
26//! [`NativePluginRegistry::find_regular`]. The returned trait
27//! reference's type matches the pass: `find_synth` can never return
28//! a `RegularPlugin` and vice versa, so the dispatch site can't
29//! accidentally invoke a wrong-pass plugin even on a name collision.
30//!
31//! ## Where the discipline is enforced
32//!
33//! The marker traits are intentionally **not mutually exclusive** at
34//! the type level — `SynthPlugin` and `RegularPlugin` are empty
35//! sub-traits of `NativePlugin`, and nothing in the type system
36//! prevents a single type from implementing both. Exclusivity is
37//! enforced by:
38//!
39//! 1. **Registry construction convention**: each plugin is pushed
40//!    into exactly one of the two Vecs in `build_global_registry`.
41//! 2. **A pinned test** (`test_registry_synth_and_regular_are_disjoint`):
42//!    iterates `registry.iter()` and asserts every plugin lives in
43//!    exactly one Vec — CI catches a wrong-pass registration or a
44//!    type that implements both markers and ends up in both Vecs.
45//!
46//! The marker pair is therefore lighter than full type-level
47//! exclusivity (which would need negative trait bounds or a sealed
48//! pass-marker pattern that breaks object safety in our registry) —
49//! the cost is one assertion in CI instead of a compile error.
50//!
51//! ## Why a marker-trait pair rather than a single trait with a const
52//!
53//! `const PASS: PluginPass` on the base trait would be cleaner if
54//! consts were object-safe — but they aren't, and the registry uses
55//! trait objects (`Box<dyn SynthPlugin>` / `Box<dyn RegularPlugin>`)
56//! for heterogeneous storage. The marker-pair approach gives the
57//! dispatch-site type guarantee (described above) at the cost of one
58//! extra empty `impl` line per plugin.
59//!
60//! ## WASM and Python plugins
61//!
62//! Non-native plugins don't implement `NativePlugin` and therefore
63//! aren't held in this registry. They're dispatched by the loader
64//! through path-based name resolution, run only in the post-booking
65//! regular pass, and never carry a synth/regular marker at the type
66//! level. Synth-pass semantics are a native-only concern.
67
68mod plugins;
69
70pub use plugins::*;
71
72use std::sync::LazyLock;
73
74use crate::types::PluginInput;
75use crate::types::PluginOutput;
76
77/// Base capability for native plugins. Both [`SynthPlugin`] and
78/// [`RegularPlugin`] extend this — every native plugin has these
79/// three methods regardless of pass.
80///
81/// The bounds (`Send + Sync`) are the minimum to satisfy the
82/// global singleton registry; the registry's `Box<dyn ...>` storage
83/// implicitly requires `'static`, but the trait itself doesn't add
84/// that bound so external implementors can write borrowing impls
85/// for non-registry use (testing helpers, ad-hoc adapters).
86pub trait NativePlugin: Send + Sync {
87    /// Plugin name (short form — `"implicit_prices"`, not the
88    /// fully-qualified module path).
89    fn name(&self) -> &'static str;
90
91    /// Plugin description for `--help` and similar UI surfaces.
92    fn description(&self) -> &'static str;
93
94    /// Process directives and return modified directives + errors.
95    fn process(&self, input: PluginInput) -> PluginOutput;
96}
97
98/// Marker trait: a plugin that runs in the **pre-booking synth pass**.
99///
100/// Synth plugins inject directives the Early validator depends on —
101/// e.g. `auto_accounts` injects `Open` directives so account-
102/// presence checks (E1001) see accounts that user code references
103/// without explicitly opening. They run BEFORE booking and BEFORE
104/// validation.
105///
106/// Implement this trait (in addition to [`NativePlugin`]) for any
107/// plugin that synthesizes directives. The registry's
108/// [`NativePluginRegistry::find_synth`] lookup only returns plugins
109/// implementing this marker; a regular plugin can't accidentally
110/// be invoked in the synth pass.
111pub trait SynthPlugin: NativePlugin {}
112
113/// Marker trait: a plugin that runs in the **post-booking regular pass**.
114///
115/// Regular plugins transform already-booked directives. The
116/// cost-spec-reading ones (`implicit_prices`,
117/// `capital_gains_classifier`, `check_average_cost`, `sell_gains`,
118/// `unrealized`, `valuation`) specifically need to see filled-in
119/// per-unit values on `CostNumber::PerUnitFromTotal` — which is
120/// what booking produces. Running them pre-booking would see the
121/// raw `Total` shape and produce wrong results.
122///
123/// Implement this trait (in addition to [`NativePlugin`]) for any
124/// plugin that transforms post-booking directives. Most plugins go
125/// here.
126pub trait RegularPlugin: NativePlugin {}
127
128/// Registry of built-in native plugins, split by pass.
129///
130/// Holding synth and regular plugins in separately-typed `Vec`s lets
131/// the loader's pass-dispatch site ask for the right kind directly:
132/// the returned trait reference's type matches the pass, so a
133/// regular-pass plugin can't be returned from `find_synth` even on a
134/// name collision.
135///
136/// The loader still gates two **implicit** synth-pass invocations on
137/// `LoadOptions` / `Options` flags (`options.auto_accounts` and the
138/// `option "documents"` directive that drives `document_discovery`),
139/// but those flow through the same unified dispatch loop as
140/// file-declared and CLI plugins — there's no per-plugin special
141/// case at the dispatch site.
142pub struct NativePluginRegistry {
143    synth: Vec<Box<dyn SynthPlugin>>,
144    regular: Vec<Box<dyn RegularPlugin>>,
145}
146
147/// Extract the short plugin name from a potentially qualified module path.
148///
149/// Examples:
150/// - `"zerosum"` → `"zerosum"`
151/// - `"beancount.plugins.implicit_prices"` → `"implicit_prices"`
152/// - `"beancount_reds_plugins.zerosum.zerosum"` → `"zerosum"`
153#[inline]
154fn plugin_short_name(name: &str) -> &str {
155    name.rsplit('.').next().unwrap_or(name)
156}
157
158/// Build the singleton registry. Called once per process via the
159/// `LazyLock` below; broken out as a named function so the call stack
160/// reflects what's happening at first access.
161fn build_global_registry() -> NativePluginRegistry {
162    let synth: Vec<Box<dyn SynthPlugin>> = vec![
163        Box::new(AutoAccountsPlugin),
164        Box::new(DocumentDiscoveryPlugin),
165    ];
166    let regular: Vec<Box<dyn RegularPlugin>> = vec![
167        Box::new(ImplicitPricesPlugin),
168        Box::new(CheckCommodityPlugin),
169        Box::new(AutoTagPlugin::new()),
170        Box::new(LeafOnlyPlugin),
171        Box::new(NoDuplicatesPlugin),
172        Box::new(OneCommodityPlugin),
173        Box::new(UniquePricesPlugin),
174        Box::new(CheckClosingPlugin),
175        Box::new(CloseTreePlugin),
176        Box::new(CoherentCostPlugin),
177        Box::new(ForecastPlugin),
178        Box::new(SellGainsPlugin),
179        Box::new(PedanticPlugin),
180        Box::new(RxTxnPlugin),
181        Box::new(SplitExpensesPlugin),
182        Box::new(UnrealizedPlugin::new()),
183        Box::new(NoUnusedPlugin),
184        Box::new(CheckDrainedPlugin),
185        Box::new(CommodityAttrPlugin::new()),
186        Box::new(CheckAverageCostPlugin::new()),
187        Box::new(CurrencyAccountsPlugin::new()),
188        Box::new(ZerosumPlugin),
189        Box::new(EffectiveDatePlugin),
190        Box::new(GenerateBaseCcyPricesPlugin),
191        Box::new(RenameAccountsPlugin),
192        Box::new(ValuationPlugin),
193        Box::new(CapitalGainsLongShortPlugin),
194        Box::new(CapitalGainsGainLossPlugin),
195        Box::new(BoxAccrualPlugin),
196    ];
197    NativePluginRegistry { synth, regular }
198}
199
200/// Process-wide singleton registry — the registry holds no per-load
201/// state, so allocating one per call is pure waste. Use
202/// [`NativePluginRegistry::global`] to access it.
203static GLOBAL_REGISTRY: LazyLock<NativePluginRegistry> = LazyLock::new(build_global_registry);
204
205impl NativePluginRegistry {
206    /// Access the process-wide registry singleton.
207    ///
208    /// The registry is immutable and stateless; reuse this reference
209    /// instead of constructing a fresh registry per call. The
210    /// underlying `LazyLock` initializes on first access.
211    #[must_use]
212    pub fn global() -> &'static Self {
213        &GLOBAL_REGISTRY
214    }
215
216    /// Find a **synth-pass** plugin by name.
217    ///
218    /// Returns `None` if the plugin doesn't exist OR if it exists
219    /// but is a regular-pass plugin — the type system guarantees the
220    /// returned reference is `dyn SynthPlugin`.
221    ///
222    /// Accepts both short names (`"auto_accounts"`) and fully
223    /// qualified module paths (`"beancount.plugins.auto_accounts"`).
224    pub fn find_synth(&self, name: &str) -> Option<&dyn SynthPlugin> {
225        let short_name = plugin_short_name(name);
226        self.synth
227            .iter()
228            .find(|p| p.name() == short_name)
229            .map(std::convert::AsRef::as_ref)
230    }
231
232    /// Find a **regular-pass** plugin by name.
233    ///
234    /// Returns `None` if the plugin doesn't exist OR if it exists
235    /// but is a synth-pass plugin — the type system guarantees the
236    /// returned reference is `dyn RegularPlugin`.
237    ///
238    /// Accepts both short names (`"implicit_prices"`) and fully
239    /// qualified module paths (`"beancount.plugins.implicit_prices"`).
240    pub fn find_regular(&self, name: &str) -> Option<&dyn RegularPlugin> {
241        let short_name = plugin_short_name(name);
242        self.regular
243            .iter()
244            .find(|p| p.name() == short_name)
245            .map(std::convert::AsRef::as_ref)
246    }
247
248    /// Iterate every plugin in the registry, synth then regular.
249    /// Returns trait references upcast to the base [`NativePlugin`] —
250    /// callers that need pass information should use
251    /// [`Self::find_synth`] / [`Self::find_regular`] instead.
252    pub fn iter(&self) -> impl Iterator<Item = &dyn NativePlugin> {
253        self.synth
254            .iter()
255            .map(|p| p.as_ref() as &dyn NativePlugin)
256            .chain(self.regular.iter().map(|p| p.as_ref() as &dyn NativePlugin))
257    }
258
259    /// Check if a name refers to any plugin in this registry, in
260    /// either pass. Use this for existence queries; for invocation
261    /// use [`Self::find_synth`] / [`Self::find_regular`] so the
262    /// returned reference's type carries the pass.
263    #[must_use]
264    pub fn has(&self, name: &str) -> bool {
265        let short_name = plugin_short_name(name);
266        self.synth.iter().any(|p| p.name() == short_name)
267            || self.regular.iter().any(|p| p.name() == short_name)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_plugin_short_name_bare() {
277        assert_eq!(plugin_short_name("zerosum"), "zerosum");
278        assert_eq!(plugin_short_name("implicit_prices"), "implicit_prices");
279    }
280
281    #[test]
282    fn test_plugin_short_name_beancount_plugins() {
283        assert_eq!(
284            plugin_short_name("beancount.plugins.implicit_prices"),
285            "implicit_prices"
286        );
287        assert_eq!(
288            plugin_short_name("beancount.plugins.check_commodity"),
289            "check_commodity"
290        );
291    }
292
293    #[test]
294    fn test_plugin_short_name_beanahead() {
295        assert_eq!(
296            plugin_short_name("beanahead.plugins.rx_txn_plugin"),
297            "rx_txn_plugin"
298        );
299    }
300
301    #[test]
302    fn test_plugin_short_name_reds_plugins() {
303        assert_eq!(
304            plugin_short_name("beancount_reds_plugins.zerosum.zerosum"),
305            "zerosum"
306        );
307        assert_eq!(
308            plugin_short_name("beancount_reds_plugins.capital_gains_classifier.gain_loss"),
309            "gain_loss"
310        );
311        assert_eq!(
312            plugin_short_name("beancount_reds_plugins.effective_date.effective_date"),
313            "effective_date"
314        );
315    }
316
317    #[test]
318    fn test_plugin_short_name_tarioch() {
319        assert_eq!(
320            plugin_short_name("tariochbctools.plugins.generate_base_ccy_prices"),
321            "generate_base_ccy_prices"
322        );
323    }
324
325    #[test]
326    fn test_plugin_short_name_empty() {
327        assert_eq!(plugin_short_name(""), "");
328    }
329
330    #[test]
331    fn test_registry_find_regular_short_name() {
332        let registry = NativePluginRegistry::global();
333        assert!(registry.find_regular("implicit_prices").is_some());
334        assert!(registry.find_regular("zerosum").is_some());
335        assert!(registry.find_regular("nonexistent").is_none());
336    }
337
338    #[test]
339    fn test_registry_find_regular_qualified_name() {
340        let registry = NativePluginRegistry::global();
341        assert!(
342            registry
343                .find_regular("beancount.plugins.implicit_prices")
344                .is_some()
345        );
346        assert!(
347            registry
348                .find_regular("beanahead.plugins.rx_txn_plugin")
349                .is_some()
350        );
351        assert!(
352            registry
353                .find_regular("beancount_reds_plugins.zerosum.zerosum")
354                .is_some()
355        );
356        assert!(
357            registry
358                .find_regular("beancount_reds_plugins.capital_gains_classifier.gain_loss")
359                .is_some()
360        );
361    }
362
363    /// Pin the trait-split contract (issue #1166): EVERY plugin in the
364    /// registry lives in exactly one pass-typed Vec. This is what
365    /// catches "regular plugin invoked in synth pass" at the type
366    /// level — the lookup in the wrong Vec wouldn't find it.
367    ///
368    /// Exhaustive over `list()` so adding a new plugin without
369    /// declaring a pass marker can't slip past CI as a Vec-membership
370    /// mistake. Also covers `has` and prefix-stripping coverage that
371    /// the old separate `is_builtin_*` tests used to duplicate.
372    #[test]
373    fn test_registry_synth_and_regular_are_disjoint() {
374        let registry = NativePluginRegistry::global();
375
376        for plugin in registry.iter() {
377            let name = plugin.name();
378            let in_synth = registry.find_synth(name).is_some();
379            let in_regular = registry.find_regular(name).is_some();
380            assert!(
381                in_synth ^ in_regular,
382                "plugin {name:?} must live in exactly one pass Vec (synth={in_synth}, regular={in_regular})",
383            );
384            assert!(
385                registry.has(name),
386                "list() yielded {name:?} but has() disagrees"
387            );
388        }
389
390        // Non-existent names return false from every lookup.
391        assert!(!registry.has("nonexistent"));
392        assert!(registry.find_synth("nonexistent").is_none());
393        assert!(registry.find_regular("nonexistent").is_none());
394
395        // Prefix-stripping works for fully-qualified module paths.
396        assert!(registry.has("beancount.plugins.implicit_prices"));
397        assert!(registry.has("beanahead.plugins.rx_txn_plugin"));
398        assert!(registry.has("beancount_reds_plugins.zerosum.zerosum"));
399        assert!(!registry.has("some.random.nonexistent"));
400    }
401}