lattice_plugin_host/teardown.rs
1//! Plugin teardown — reversing every contribution a plugin made (PH7.12b.3).
2//!
3//! The seam a plugin **unload** (and, composed with a fresh `spawn_*`, a
4//! **reload**) drives. Each contribution surface got a provenance/id-driven
5//! `unregister_*` in PH7.12b.1; this module aggregates the *tokens* those
6//! registrations produced ([`PluginTeardown`]) and applies each reversal
7//! against the host-owned registries ([`TeardownRegistries`]).
8//!
9//! **Why an explicit driver, not `Drop`.** The registries this touches have
10//! mixed mutability — `CommandRegistry` / `PickerRegistry` / `ModeRegistry` are
11//! `&mut`-owned by the editor, while `ConfigRegistry` / `KeymapHandle` /
12//! `EventBus` are `Arc`-shared with interior mutability. A `Drop` impl would
13//! have to capture all six, forcing every registry behind `Arc<Mutex<_>>` just
14//! to fit RAII — a strictly weaker foundation. So teardown is an explicit call
15//! the caller makes when it holds the registry set (a `&mut Editor` context in
16//! Phase 8; the test harness in Phase 7).
17//!
18//! **Why no `reload` method.** Re-instantiation is just re-invoking the same
19//! `spawn_*` that produced the plugin, minting a fresh `Store` with a fresh,
20//! untripped [`Quarantine`](crate::Quarantine). So reload = `unload` + `spawn_*`,
21//! composed by the caller (the Phase-8 plugin manager), not a bespoke method.
22//!
23//! **Completion** is absent by design: the host never registers it with plugin
24//! provenance (it goes through the generic builtin-stamped `register_generator`),
25//! so its teardown is pure channel-drop — dropping the client ends the actor
26//! loop. **Decoration** (PL8.E) *does* have a registry — the loader RCU-registers
27//! its producer into the [`GutterDecorationSourceRegistry`] — so its teardown
28//! unregisters by producer id, like the picker surface.
29//!
30//! **Error parsers** (CM.6b) have a registry too, but no token: the compilation
31//! parser-factory registry keys entries by the host-issued plugin id, so
32//! reversal is by provenance like the command surface, and there is no
33//! per-contribution `Vec` on [`PluginTeardown`] to populate or to forget.
34
35use lattice_config::ConfigRegistry;
36use lattice_grammar::CommandRegistry;
37use lattice_keymap::{KeymapCapability, KeymapHandle, KeymapLayer, ModeId};
38use lattice_mode::{ContextSourceRegistry, GutterDecorationSourceRegistry, ModeRegistry};
39use lattice_picker::source::PickerRegistry;
40use lattice_protocol::event_registry::unregister_runtime_event;
41use lattice_runtime::{EventBus, SubscriptionId};
42
43use crate::PluginId;
44
45/// The union of teardown tokens a plugin's contributions produce, aggregated at
46/// spawn time and consumed by [`unload`](Self::unload). A given plugin populates
47/// only the surfaces it exercised; the rest stay empty and their reversal is a
48/// no-op. Every field is `pub` so the spawning caller fills it from the tokens
49/// the `spawn_*` fns already return (`Vec<SubscriptionId>`, `Vec<ModeId>`,
50/// `Vec<String>` option names, …).
51#[derive(Debug, Clone)]
52pub struct PluginTeardown {
53 /// The host-issued identity — drives `CommandRegistry::unregister_plugin`,
54 /// which unconditionally removes every `SourceLayer::Plugin(plugin_id)`
55 /// command (grammar contributions + the modes seam's `:<mode>` toggles) by
56 /// provenance. No per-command token or "did I register grammar?" flag: the
57 /// provenance IS the token.
58 pub plugin_id: PluginId,
59 /// **Every** host id this plugin's seams were issued, `plugin_id`
60 /// included.
61 ///
62 /// Each `spawn_*` issues its own id — deliberately, because a provenance
63 /// id must never be derived from guest-controlled input, so it cannot be
64 /// keyed on the manifest's string id. A plugin providing N seams therefore
65 /// has N provenances, and reversing only one of them leaves the rest
66 /// registered: bundled `auto-pair` (grammar, modes, config, help) leaked
67 /// its `:help` pages on unload exactly that way.
68 ///
69 /// Token-based reversals below are unaffected — the drains capture their
70 /// tokens on the record. This is only for the provenance-keyed ones.
71 pub seam_ids: Vec<PluginId>,
72 /// Picker source ids the plugin registered (`PickerRegistry::unregister`).
73 pub picker_sources: Vec<String>,
74 /// MV.1: multibuffer view ids the plugin registered openers under
75 /// (`ProviderViewRegistry::unregister`). Without reversing these, a
76 /// reloaded plugin's `register` returns `false` against its OWN stale
77 /// opener and its views come back dead.
78 pub provider_views: Vec<String>,
79 /// Modes the plugin registered — each reversed via `ModeRegistry::unregister`
80 /// *and* `KeymapHandle::remove_layer(MinorMode(id))` (both halves of the mode
81 /// surface, PH7.11).
82 pub modes: Vec<ModeId>,
83 /// Config option names the plugin registered (`ConfigRegistry::unregister`);
84 /// mirrors `PluginState::config_contributions`.
85 pub config_options: Vec<String>,
86 /// Plugin-defined event names (`unregister_runtime_event`, process-wide).
87 pub events_defined: Vec<String>,
88 /// Event-bus subscription ids the plugin's `subscribe` calls produced
89 /// (`EventBus::unsubscribe`); the `Vec` `spawn_event_plugin` returns.
90 pub subscriptions: Vec<SubscriptionId>,
91 /// User keybindings the plugin bound via the `keymap` seam (PL8.D) — each
92 /// reversed by `KeymapHandle::try_unbind_chord_string` from `KeymapLayer::User`.
93 /// The `Vec` `spawn_keymap_plugin` returns.
94 pub keymap_bindings: Vec<crate::keymap_host::KeymapBindingToken>,
95 /// PL8.E: decoration producer ids the plugin registered into the
96 /// [`GutterDecorationSourceRegistry`] — each reversed via
97 /// `GutterDecorationSourceRegistry::unregister`. Mirrors `picker_sources`.
98 pub decoration_sources: Vec<u64>,
99 /// IM.6b: media producers to unregister, by plugin id. Mirrors
100 /// `decoration_sources` — without this a `:plugin-reload` would leave the
101 /// old producer registered and every image would be requested twice.
102 pub media_sources: Vec<u64>,
103 /// OM.A1: agenda producers to unregister, by plugin id. Mirrors
104 /// `media_sources` — without this a `:plugin-reload` would leave the old
105 /// producer registered and every agenda row would appear twice.
106 pub agenda_sources: Vec<u64>,
107 /// TC.2: context producer ids the plugin registered into the
108 /// [`ContextSourceRegistry`] — each reversed via
109 /// `ContextSourceRegistry::unregister`. Mirrors `decoration_sources`.
110 pub context_sources: Vec<u64>,
111 /// TR.2b: transient-menu names the plugin registered. Reversed by the
112 /// LOADER, not by [`unload`](Self::unload) — the registry is
113 /// `Arc`-shared rather than one of the `&mut` snapshots
114 /// [`TeardownRegistries`] carries, and it is the same placement
115 /// `help_topics` / `dashboard_sections` use for the same reason.
116 ///
117 /// Leaving a name registered after unload is not cosmetic: the entry holds
118 /// a `TransientClient` whose actor has ended, so the chord would report a
119 /// host error instead of "unknown source".
120 pub transient_sources: Vec<String>,
121 /// TC.4: namespaced theme-element names the plugin registered. Reversed by
122 /// `ThemeRegistry::unregister_element` so an unloaded plugin's elements stop
123 /// appearing in `:customize` and stop resolving.
124 pub theme_elements: Vec<String>,
125 /// SG.3a: namespaced sign names the plugin declared. Reversed by
126 /// `SignRegistry::undefine` so an unloaded plugin's signs stop painting.
127 ///
128 /// A NAME list rather than the namespace prefix, even though
129 /// `undefine_prefix` exists for the latter — because the seam's store is
130 /// dropped when `register-signs` returns, so a plugin cannot declare a
131 /// sign later in its life the way a modeline segment can. The list is
132 /// therefore complete by construction, and it gives the report an exact
133 /// count instead of a boolean. `undefine_prefix` is what to switch to if
134 /// that ever stops being true.
135 pub signs: Vec<String>,
136 /// OC.3 / ML.6: the plugin's element **namespace** — its manifest id — not
137 /// a list of the ids it registered.
138 ///
139 /// Reversal is by prefix on purpose. A plugin may register a segment at any
140 /// point in its life (org's clock could arm one the first time you clock
141 /// in), so a token list collected at load would miss a later one and leave
142 /// its descriptor behind — and that matters because the renderer iterates
143 /// DESCRIPTORS, so an orphan renders the plugin's last segment forever with
144 /// nobody left to update or clear it. A prefix has nothing to forget. Same
145 /// reasoning as the compilation parser factories, reversed by provenance.
146 pub modeline_namespace: Option<String>,
147}
148
149impl PluginTeardown {
150 /// A bundle for `plugin_id` with no contributions recorded yet.
151 pub fn new(plugin_id: PluginId) -> Self {
152 Self {
153 plugin_id,
154 seam_ids: Vec::new(),
155 picker_sources: Vec::new(),
156 provider_views: Vec::new(),
157 modes: Vec::new(),
158 config_options: Vec::new(),
159 events_defined: Vec::new(),
160 subscriptions: Vec::new(),
161 keymap_bindings: Vec::new(),
162 decoration_sources: Vec::new(),
163 media_sources: Vec::new(),
164 agenda_sources: Vec::new(),
165 context_sources: Vec::new(),
166 transient_sources: Vec::new(),
167 theme_elements: Vec::new(),
168 signs: Vec::new(),
169 modeline_namespace: None,
170 }
171 }
172
173 /// Reverse every recorded contribution against the host registries, returning
174 /// a [`TeardownReport`] of what was removed. Idempotent — each underlying
175 /// `unregister_*` is a no-op on an already-removed entry, so a double-unload
176 /// (or an unload after a partial crash) is safe and simply reports zeros the
177 /// second time. Order is irrelevant: the surfaces are independent (grammar
178 /// commands, picker sources, modes+their keymap layer, options, events, and
179 /// subscriptions never share an entry).
180 /// Every provenance this plugin stamped contributions with.
181 ///
182 /// Falls back to `plugin_id` alone when `seam_ids` was never populated, so
183 /// a hand-built `PluginTeardown` (tests, and any caller predating
184 /// `seam_ids`) still reverses its one id rather than silently reversing
185 /// nothing.
186 pub fn provenances(&self) -> Vec<PluginId> {
187 if self.seam_ids.is_empty() {
188 vec![self.plugin_id]
189 } else {
190 self.seam_ids.clone()
191 }
192 }
193
194 pub fn unload(&self, reg: &mut TeardownRegistries<'_>) -> TeardownReport {
195 let mut report = TeardownReport::default();
196
197 // Reversed by provenance: remove every `SourceLayer::Plugin(plugin_id)`
198 // command — grammar contributions AND the `:<mode>` toggle ex-commands
199 // the modes seam registers (`drain_mode`). Unconditional + idempotent
200 // (returns 0 if the plugin contributed none), so no per-seam "did I
201 // register commands?" flag exists to forget; the `run_teardown`
202 // clone/store around this happens regardless of the count.
203 // Over EVERY seam id, not just `plugin_id`: see `seam_ids`.
204 for id in self.provenances() {
205 report.commands += reg.commands.unregister_plugin(id.0);
206 }
207 for id in &self.picker_sources {
208 if reg.pickers.unregister(id) {
209 report.pickers += 1;
210 }
211 }
212 // MV.1: reversed only when the registry is wired. A boot without it
213 // registered nothing, so there is nothing to leak.
214 if let Some(providers) = reg.provider_views.as_ref() {
215 for id in &self.provider_views {
216 providers.unregister(id);
217 }
218 }
219 for mode in &self.modes {
220 // OM.2: which layer the chords went into follows the mode's KIND,
221 // so read it BEFORE unregistering — afterwards the registry no
222 // longer knows, and a plugin major would leak its keymap layer.
223 let kind = reg.modes.get(*mode).map(|m| m.kind());
224 if reg.modes.unregister(*mode) {
225 report.modes += 1;
226 }
227 // Both halves of the mode surface: the registry entry AND the gated
228 // keymap layer its chords were bound into (PH7.11b / PH7.12b.1c).
229 match kind {
230 Some(lattice_mode::ModeKind::Major) => {
231 reg.keymap.remove_layer(KeymapLayer::MajorMode(*mode));
232 }
233 // `None` (already gone) takes the minor branch: the id was
234 // never a major we bound, and `remove_layer` on an absent
235 // layer is a no-op.
236 _ => {
237 reg.keymap.remove_layer(KeymapLayer::MinorMode(*mode));
238 }
239 }
240 }
241 for name in &self.config_options {
242 if reg.config.unregister(name) {
243 report.config_options += 1;
244 }
245 }
246 for name in &self.events_defined {
247 unregister_runtime_event(name);
248 report.events_defined += 1;
249 }
250 for id in &self.subscriptions {
251 if reg.bus.unsubscribe(*id) {
252 report.subscriptions += 1;
253 }
254 }
255 for binding in &self.keymap_bindings {
256 // Reverse the `KeymapLayer::User` binding by the same chord string the
257 // plugin bound with (`KeymapCapability::User`). `Ok(Some(_))` = a
258 // binding was dropped; an already-removed / unparseable entry is a
259 // graceful no-op (idempotent re-unload).
260 if matches!(
261 reg.keymap.try_unbind_chord_string(
262 KeymapCapability::User,
263 KeymapLayer::User,
264 binding.mode,
265 &binding.chord,
266 ),
267 Ok(Some(_))
268 ) {
269 report.keymap_bindings += 1;
270 }
271 }
272 for source_id in &self.decoration_sources {
273 report.decoration_sources += reg.decorations.unregister(*source_id);
274 }
275 for source_id in &self.media_sources {
276 report.media_sources += reg.media.unregister(*source_id);
277 }
278 for source_id in &self.agenda_sources {
279 report.agenda_sources += reg.agenda.unregister(*source_id);
280 }
281 for source_id in &self.context_sources {
282 report.context_sources += reg.contexts.unregister(*source_id);
283 }
284 for name in &self.theme_elements {
285 if reg
286 .theme
287 .unregister_element(&lattice_theme::ElementName::from(name.clone()))
288 {
289 report.theme_elements += 1;
290 }
291 }
292 // SG.3a: signs. Copy-on-write against the `ArcSwap` once for the whole
293 // list rather than per name — the render path reads this handle, and
294 // storing N times would make N intermediate snapshots visible, each
295 // with a different subset of the plugin's signs still painting.
296 if !self.signs.is_empty()
297 && let Some(registry) = reg.signs
298 {
299 let mut next: lattice_mode::SignRegistry = (**registry.load()).clone();
300 for name in &self.signs {
301 if next.id_of(name).is_some() {
302 next.undefine(name);
303 report.signs += 1;
304 }
305 }
306 registry.store(std::sync::Arc::new(next));
307 }
308 // OC.3: modeline elements. BOTH halves, and the second is easy to miss:
309 // `remove` drops the descriptor (which is what stops it rendering), but
310 // the pushed content stays in the store keyed by an id nothing names —
311 // invisible, and leaked across every `:plugin-reload`.
312 // OC.3: modeline elements, by namespace. BOTH halves per id, and the
313 // second is easy to miss: `remove` drops the descriptor (which is what
314 // stops it rendering), but the pushed content stays in the store keyed
315 // by an id nothing names — invisible, and leaked across every
316 // `:plugin-reload`. Guarded on `Some` so the overwhelmingly common
317 // unload (no plugin segment anywhere) takes no snapshot at all.
318 if let (Some(ns), Some(modeline)) = (&self.modeline_namespace, reg.modeline) {
319 let prefix = format!("{ns}.");
320 let doomed: Vec<_> = modeline
321 .snapshot()
322 .registry
323 .ids()
324 .filter(|id| id.as_str().starts_with(&prefix))
325 .cloned()
326 .collect();
327 for element in doomed {
328 modeline.clear(lattice_mode::modeline::ModelineKey::Global, &element);
329 modeline.remove(&element);
330 report.modeline_elements += 1;
331 }
332 }
333 // CM.6b: compilation parser factories, reversed by PROVENANCE like
334 // the command surface above — there is no per-factory token to
335 // record or forget, because the registry already keys them by the
336 // host-issued plugin id. Guarded on non-empty so the overwhelmingly
337 // common unload (no error-parser plugin anywhere) does not churn the
338 // `ArcSwap` a compilation run reads.
339 let snapshot = reg.parsers.load();
340 if !snapshot.is_empty() {
341 let mut next = (**snapshot).clone();
342 for id in self.provenances() {
343 report.parser_factories += next.unregister_plugin(id.0 as u64);
344 }
345 if report.parser_factories > 0 {
346 reg.parsers.store(std::sync::Arc::new(next));
347 }
348 }
349
350 report
351 }
352}
353
354/// The host-owned registries a plugin's contributions live in — the set
355/// [`PluginTeardown::unload`] reverses against. Grouped as a borrow struct so
356/// `unload` takes one argument instead of six, and so the caller passes exactly
357/// the registries a `&mut Editor` context already holds. All are required: a
358/// plugin can contribute to any surface, and passing the whole set is cheaper
359/// than threading option-ness through the driver (an unexercised surface's
360/// reversal is already a no-op).
361pub struct TeardownRegistries<'a> {
362 pub commands: &'a mut CommandRegistry,
363 pub pickers: &'a mut PickerRegistry,
364 /// MV.1: where a plugin's declared multibuffer views registered their
365 /// openers. `Option` because a headless boot may not publish the seam,
366 /// and a teardown must not require a service the load never used.
367 pub provider_views: Option<&'a lattice_mode::ProviderViewRegistry>,
368 pub modes: &'a mut ModeRegistry,
369 pub keymap: &'a KeymapHandle,
370 pub config: &'a ConfigRegistry,
371 pub bus: &'a EventBus,
372 /// PL8.E: the decoration-producer registry (`unregister` by producer id).
373 pub decorations: &'a mut GutterDecorationSourceRegistry,
374 /// IM.6b: the media-producer registry, for the same reversal.
375 pub media: &'a mut lattice_mode::MediaSourceRegistry,
376 /// OM.A1: the agenda-producer registry, for the same reversal.
377 pub agenda: &'a mut lattice_mode::ScannedExcerptSourceRegistry,
378 /// TC.2: the context-producer registry (`unregister` by producer id).
379 pub contexts: &'a mut ContextSourceRegistry,
380 /// TC.4: the theme registry (`unregister_element` by namespaced name).
381 pub theme: &'a dyn lattice_theme::ThemeRegistry,
382 /// SG.3a: the sign registry (`undefine` by namespaced name).
383 ///
384 /// `Option`, like `modeline` and for the same reason: the loader gates the
385 /// whole reversal on one all-or-nothing tuple of handles, so making this
386 /// required would turn every unload in a harness that has not wired a sign
387 /// registry into a silent no-op — options, commands and all. A sign
388 /// registry is not a precondition for reversing a config option.
389 pub signs: Option<&'a lattice_mode::SignRegistryHandle>,
390 /// OC.3 / ML.6: the modeline element registry (`clear` + `remove` by
391 /// namespaced id). A handle, not a `&mut` — `ModelineService` is
392 /// `ArcSwap`-backed interior-mutable, like `theme` above.
393 ///
394 /// `Option`, unlike its neighbours, and the reason is a bug this field
395 /// caused before it was one: the loader gates the whole reversal on a
396 /// single all-or-nothing tuple of registry handles, so making the modeline
397 /// a required member turned every unload in a harness that had not wired
398 /// one into a silent no-op — options, commands and all. A modeline is not a
399 /// precondition for reversing a config option. Wired-ness is still checked
400 /// where it belongs, in `WiredSeams::all()`, which the boot pin asserts.
401 pub modeline: Option<&'a lattice_mode::ModelineServiceHandle>,
402 /// CM.6b: the compilation parser-factory registry (`unregister_plugin`
403 /// by host-issued id, RCU'd like the other `ArcSwap`-held registries).
404 pub parsers: &'a lattice_compilation::CompilationParserFactoriesHandle,
405}
406
407/// Count of what an [`unload`](PluginTeardown::unload) actually removed, per
408/// surface — for structured logs and test assertions. A field being lower than
409/// the bundle's recorded token count means those entries were already gone (a
410/// prior unload, or a crash that never completed registration): expected under
411/// idempotent re-unload, not an error.
412#[derive(Debug, Default, Clone, PartialEq, Eq)]
413pub struct TeardownReport {
414 pub commands: usize,
415 pub pickers: usize,
416 pub modes: usize,
417 pub config_options: usize,
418 pub events_defined: usize,
419 pub subscriptions: usize,
420 pub keymap_bindings: usize,
421 /// PL8.E: decoration producers unregistered.
422 pub decoration_sources: usize,
423 /// IM.6b: media producers unregistered.
424 pub media_sources: usize,
425 /// OM.A1: agenda producers unregistered.
426 pub agenda_sources: usize,
427 /// TC.2: context producers unregistered.
428 pub context_sources: usize,
429 /// TC.4: theme elements unregistered.
430 pub theme_elements: usize,
431 /// SG.3a: signs undefined.
432 pub signs: usize,
433 /// OC.3: modeline elements unregistered.
434 pub modeline_elements: usize,
435 /// TR.2b: transient menus unregistered.
436 ///
437 /// Filled by the LOADER, like `help_topics` — see the field's doc on
438 /// [`PluginTeardown`].
439 pub transient_sources: usize,
440 /// CM.6b: compilation parser factories unregistered.
441 pub parser_factories: usize,
442 /// CR.4: dashboard sections unregistered. Filled by the loader, for the
443 /// same crate-boundary reason as `help_topics` below.
444 pub dashboard_sections: usize,
445 /// CR.3: `:help` topics unregistered.
446 ///
447 /// Filled by the LOADER after `unload` returns, not by `unload` itself —
448 /// the help registry lives in `lattice-help`, and reversing it here would
449 /// pull that crate into the host purely to name a field. The seam crosses
450 /// plain data in both directions, so nothing else about help belongs on
451 /// this side of the line.
452 pub help_topics: usize,
453 /// LG.3c: plugin-contributed languages unregistered.
454 ///
455 /// Filled by the LOADER, like `help_topics`, and for the same reason: the
456 /// language registry lives in `lattice-syntax`. Unlike every other field
457 /// here this one can never be skipped for want of a handle — the registry
458 /// is process-global, so there is no `Option` to be `None`.
459 pub languages: usize,
460}
461
462#[cfg(test)]
463mod tests {
464 #![allow(clippy::unwrap_used)]
465 use std::sync::Arc;
466
467 use super::*;
468
469 use lattice_config::option::Option as ConfigOption;
470 use lattice_grammar::CommandInvocation;
471 use lattice_grammar::registry::{MotionResult, MotionSpec};
472 use lattice_grammar::source::SourceLocation;
473 use lattice_keymap::{BindingMode, ChordPattern, KeymapCapability};
474 use lattice_picker::source::PickerSourceSpec;
475 use lattice_protocol::EventKind;
476 use lattice_protocol::chord::KeyChord;
477 use lattice_protocol::ids::CommandId;
478 use lattice_runtime::{EventFilter, SubscriptionTarget};
479
480 fn dummy_motion() -> MotionSpec {
481 MotionSpec {
482 curswant: lattice_grammar::CurswantEffect::default(),
483 jump: false,
484 exclusive: false,
485 apply: Arc::new(|ctx| {
486 Ok(MotionResult {
487 curswant: None,
488 target: ctx.from,
489 linewise: false,
490 exclusive: None,
491 notice: None,
492 })
493 }),
494 args_schema: vec![],
495 }
496 }
497
498 /// The driver reverses every surface a plugin touched, in one `unload`, and
499 /// leaves a co-resident *built-in / native* contribution on each registry
500 /// untouched. Host-layer proof of the reload teardown; the wasm reload cycle
501 /// (spawn → quarantine → unload → respawn) is `tests/plugin_teardown.rs`. The
502 /// `ModeRegistry` half is proven in `lattice-mode`'s own `unregister` test —
503 /// here we prove the driver runs the mode's *keymap-layer* teardown.
504 #[test]
505 fn unload_reverses_every_surface_and_spares_the_rest() {
506 let plugin = PluginId(7);
507
508 // --- Grammar: one plugin motion + one built-in motion. ---
509 let mut commands = CommandRegistry::new();
510 commands.register_motion("builtin:w", "builtin", dummy_motion());
511 commands.register_plugin_motion(plugin.0, "p7:down", "", dummy_motion());
512
513 // --- Picker: the plugin source + a native source. ---
514 let mut pickers = PickerRegistry::new();
515 pickers.register(PickerSourceSpec::no_args("files", "native"));
516 pickers.register(PickerSourceSpec::no_args("p7:things", "plugin"));
517
518 // --- Mode keymap half: a chord bound into the plugin mode's layer. ---
519 let mode_id = ModeId::new("p7-mode");
520 let mut modes = ModeRegistry::new();
521 let keymap = KeymapHandle::new();
522 keymap
523 .try_bind(
524 KeymapCapability::OwnedLayer { mode_id },
525 KeymapLayer::MinorMode(mode_id),
526 BindingMode::Normal,
527 &[ChordPattern::Literal(KeyChord::char('j'))],
528 CommandInvocation::of(CommandId::new(1)),
529 SourceLocation::plugin(plugin.0),
530 )
531 .unwrap();
532
533 // --- Config: the plugin option + a native option. ---
534 let config = ConfigRegistry::new();
535 config.register(ConfigOption::<i64>::new("native.opt", 1, "native"));
536 config.register(ConfigOption::<i64>::new("p7.opt", 8, "plugin"));
537
538 // --- Events: a native subscriber + the plugin's subscription. ---
539 let bus = EventBus::new();
540 let (native_tx, _native_rx) = tokio::sync::mpsc::unbounded_channel();
541 let native_sub = bus.subscribe(
542 EventFilter::kind(EventKind::DocumentSaved),
543 SubscriptionTarget::Channel(native_tx),
544 );
545 let (plugin_tx, _plugin_rx) = tokio::sync::mpsc::unbounded_channel();
546 let plugin_sub = bus.subscribe(
547 EventFilter::kind(EventKind::DocumentSaved),
548 SubscriptionTarget::Channel(plugin_tx),
549 );
550
551 // Build the bundle the way a spawning caller would from returned tokens.
552 let mut teardown = PluginTeardown::new(plugin);
553 teardown.picker_sources = vec!["p7:things".to_string()];
554 teardown.modes = vec![mode_id];
555 teardown.config_options = vec!["p7.opt".to_string()];
556 teardown.subscriptions = vec![plugin_sub];
557
558 let mut decorations = GutterDecorationSourceRegistry::new();
559 let mut contexts = ContextSourceRegistry::new();
560 let theme_reg = lattice_theme::InMemoryThemeRegistry::new(lattice_theme::default_palette());
561 let modeline: lattice_mode::ModelineServiceHandle =
562 std::sync::Arc::new(lattice_mode::ModelineService::new());
563 let parsers = lattice_compilation::CompilationParserFactories::new_handle();
564 let report = {
565 let mut reg = TeardownRegistries {
566 provider_views: None,
567 media: &mut Default::default(),
568 agenda: &mut Default::default(),
569 commands: &mut commands,
570 pickers: &mut pickers,
571 modes: &mut modes,
572 keymap: &keymap,
573 config: &config,
574 bus: &bus,
575 decorations: &mut decorations,
576 contexts: &mut contexts,
577 theme: &theme_reg,
578 signs: None,
579 modeline: Some(&modeline),
580 parsers: &parsers,
581 };
582 teardown.unload(&mut reg)
583 };
584
585 // Report: the plugin's registry contributions were removed. `modes` is 0
586 // because no mode was registered in this host-layer test (the keymap-layer
587 // half is asserted below via binding_count).
588 assert_eq!(
589 report,
590 TeardownReport {
591 commands: 1,
592 pickers: 1,
593 modes: 0,
594 config_options: 1,
595 events_defined: 0,
596 subscriptions: 1,
597 keymap_bindings: 0,
598 decoration_sources: 0,
599 media_sources: 0,
600 agenda_sources: 0,
601 context_sources: 0,
602 theme_elements: 0,
603 signs: 0,
604 modeline_elements: 0,
605 parser_factories: 0,
606 transient_sources: 0,
607 // CR.3 / CR.4 / LG.3c: always 0 from `unload` — the loader
608 // fills these after reversing the help, dashboard and
609 // language registries, which live on its side of the crate
610 // boundary.
611 help_topics: 0,
612 dashboard_sections: 0,
613 languages: 0,
614 }
615 );
616
617 // Grammar: plugin motion gone, built-in survives.
618 assert!(commands.lookup_by_name("p7:down").is_none());
619 assert!(commands.lookup_by_name("builtin:w").is_some());
620 // Picker: plugin source gone, native survives.
621 assert!(pickers.get("p7:things").is_none());
622 assert!(pickers.get("files").is_some());
623 // Mode keymap layer: the plugin's bound chord is gone.
624 assert_eq!(keymap.binding_count(), 0);
625 // Config: plugin option gone, native survives.
626 assert!(config.lookup("p7.opt").is_none());
627 assert!(config.lookup("native.opt").is_some());
628 // Events: the plugin's subscription is gone, the native one still fires.
629 assert!(!bus.unsubscribe(plugin_sub), "plugin sub already removed");
630 assert!(bus.unsubscribe(native_sub), "native sub was untouched");
631
632 // Idempotent: a second unload removes nothing (all zeros).
633 let mut reg = TeardownRegistries {
634 provider_views: None,
635 media: &mut Default::default(),
636 agenda: &mut Default::default(),
637 commands: &mut commands,
638 pickers: &mut pickers,
639 modes: &mut modes,
640 keymap: &keymap,
641 config: &config,
642 bus: &bus,
643 decorations: &mut decorations,
644 contexts: &mut contexts,
645 theme: &theme_reg,
646 signs: None,
647 modeline: Some(&modeline),
648 parsers: &parsers,
649 };
650 assert_eq!(teardown.unload(&mut reg), TeardownReport::default());
651 }
652}