Skip to main content

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}