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}