lattice_plugin_loader/lib.rs
1//! `lattice-plugin-loader` — the editor-side plugin loader (Phase 8).
2//!
3//! Phase 7 shipped the plugin *runtime* ([`lattice_plugin_host`]): the wasmtime
4//! engine, the WIT API package, the capability/fuel/crash model, and every
5//! extension seam, each exercised end-to-end by guest fixtures. That crate is
6//! deliberately **substrate-neutral** — it owns "engine + seams" and knows
7//! nothing about the editor: no XDG discovery, no ex-commands, no native
8//! registries. A headless test harness or a future non-editor host can drive it
9//! unchanged.
10//!
11//! This crate is the **subsystem that composes the runtime with the editor's
12//! native registries**. It discovers plugins on disk ([`discovery`]), loads
13//! them (`compile → spawn each declared seam → drain the contribution into its
14//! native registry`), owns the loaded-plugin state past boot as a service, and
15//! (PL8.C) will expose the user-facing load/unload/reload surface.
16//!
17//! # Where this sits (the loader-home decision)
18//!
19//! Three homes were weighed (slice plan, "where the loader lives"): inline in
20//! `lattice-host`, folded into `lattice-plugin-host`, or a dedicated crate. This
21//! is the dedicated crate — the genuinely-better long-term fit (heuristic #1):
22//! the runtime crate stays "engine + seams"; inlining in the host would grow
23//! `Editor::` methods + a host dispatch arm (the half-migration the
24//! mode-ownership acid test forbids). The loader reaches the native registries
25//! through the same [`SubsystemBoot`](lattice_mode::SubsystemBoot) seam every
26//! other subsystem installs through, so wiring it into the editor is one line
27//! ([`install`]) and zero host internals.
28//!
29//! # Status (PL8.B — picker / config / events / grammar / modes / completion)
30//!
31//! On-disk discovery + six seam→registry drains are live: a plugin dropped in
32//! `<data>/lattice/plugins/` loads at boot and its contribution is reachable.
33//! - **picker** RCU-registers its source into the
34//! [`PickerRegistryHandle`](lattice_picker::PickerRegistryHandle);
35//! - **config** registers its typed options into the live
36//! [`ConfigRegistry`](lattice_config::ConfigRegistry);
37//! - **events** subscribes its handlers on the [`EventBus`];
38//! - **grammar** registers its motions / operators / text-objects / ex-commands
39//! into the runtime-mutable
40//! [`CommandRegistryHandle`](lattice_grammar::CommandRegistryHandle) (B3a/B3b)
41//! — the sync-trampoline seam, so the dispatcher fires it on keystroke off a
42//! wait-free `.load()` snapshot with no actor task;
43//! - **modes** registers its minor modes into the runtime-mutable
44//! [`ModeRegistryHandle`](lattice_mode::ModeRegistryHandle) (B2), each mode's
45//! keymap binding landing in its own gated `MinorMode` layer on the
46//! [`KeymapHandle`] — declarative data, so the guest `Store` drops after
47//! registration (no task, nothing to keep alive);
48//! - **completion** wraps its `WasmCompletionSource` as a native async
49//! `CompletionSourceContribution` carried by a loader-owned universal
50//! [`PluginCompletionMode`], so the aggregator reads it through
51//! `Mode::completion_sources()` like any LSP / snippet source (option A —
52//! completion is mode-attached; the async `generate` runs on a spawned actor,
53//! off the keystroke path).
54//!
55//! Each records provenance for `:list-plugins` via the [`PluginMetaSink`] seam.
56//! That closes the PL8.B seam drains. **PL8.C** adds the user-facing lifecycle:
57//! the loader self-registers `:plugin-load` / `:plugin-unload` / `:plugin-reload`
58//! into the runtime-mutable command registry ([`register_ex_commands`](PluginLoader::register_ex_commands),
59//! option A — zero host code), and [`unload`](PluginLoader::unload) reverses every
60//! registry contribution via [`PluginTeardown`]. Decoration caching is the
61//! separate hot-path slice PL8.E; `init.rs`-as-WASM is PL8.D.
62//!
63//! Design: `docs/dev/architecture/plugin-host.md`,
64//! `docs/dev/architecture/boot-composition.md`. Slice plan:
65//! `docs/dev/operations/slice-plans/plugin-loader.md`.
66
67pub mod build;
68pub mod discovery;
69pub mod events;
70mod ex_commands;
71pub mod install;
72pub mod pipeline;
73pub mod resolve;
74pub mod source_record;
75pub mod watch;
76
77pub use build::{
78 BuildOutcome, CargoComponentBuilder, ComponentBuilder, Stamp, artifact_path, build_plugin,
79 source_stamp,
80};
81pub use discovery::{
82 DiscoveredPlugin, default_core_plugins_dir, default_init_dir, default_plugins_dir,
83 default_source_cache_dir, discover, discover_one,
84};
85pub use events::LanguagesRegistered;
86pub use install::{
87 autoload_enabled, disable_autoload, enable_autoload, flush_plugin_stores, install,
88};
89pub use pipeline::{Install, RequiredSpec, install_all, install_required, to_required_spec};
90pub use resolve::{
91 Fetcher, GitRunner, HttpFetcher, PluginSource, Resolved, SystemGit, git_cache_dir, resolve,
92};
93pub use source_record::SourceRecord;
94
95use std::sync::{Arc, Mutex};
96
97use lattice_completion::{CompletionSourceContribution, CompletionSourceKind, SourceId};
98use lattice_config::ConfigRegistry;
99use lattice_grammar::CommandRegistryHandle;
100use lattice_keymap::KeymapHandle;
101use lattice_mode::{
102 ActivationPolicy, AsyncContextSource, AsyncGutterDecorationSource, CapabilitySet,
103 ContextSourceRegistryHandle, GutterDecorationSourceRegistryHandle, LifecycleFuture, Mode,
104 ModeContext, ModeId, ModeKind, ModeRegistryHandle, PluginMetaSinkHandle,
105};
106use lattice_picker::{PickerRegistryHandle, PickerSourceGenerator};
107use lattice_plugin_host::{
108 Capability, LoadedPlugin, ManifestError, PluginBudget, PluginHost, PluginHostError, PluginId,
109 PluginManifest, PluginSeam, PluginTeardown, TeardownRegistries, TeardownReport, TrustTier,
110 WasmCompletionSource, WasmContextSource, WasmDecorationSource, WasmPickerSource,
111};
112use lattice_protocol::{Event, EventKind};
113use lattice_runtime::{EventBus, EventFilter, SubscriptionTarget};
114use tokio::runtime::Handle;
115use tokio::task::JoinHandle;
116
117mod status;
118pub use status::{BuildState, FailedLoad, PluginHealth, PluginStatus};
119
120/// An error and every cause behind it, as `outer: inner: innermost`.
121///
122/// **`err.to_string()` is the wrong thing to report and this exists to replace
123/// it.** Every error type here is a `thiserror` enum whose variants carry a
124/// `#[source]`, and `Display` prints only the outermost line. So a plugin that
125/// failed to instantiate reported exactly `failed to instantiate the plugin
126/// component` — the *category*, never the reason — while the wasmtime error
127/// saying which import was missing or which type did not match sat one link
128/// down, discarded.
129///
130/// That is a bad outcome anywhere and a specially bad one here, because the
131/// message is the whole product: `record_failure` exists (its comment says so)
132/// because a missing diagnostic once cost a debugging session, and it was
133/// itself throwing away the half that identifies the fault. The generic line
134/// then sends every reader to the same wrong fix, since the view's footer
135/// suggests `--wit-sync` whether or not the ABI has anything to do with it.
136///
137/// Bounded at eight links: a chain longer than that is a wrapper bug, and an
138/// unbounded walk over a cyclic `source()` would hang the loader rather than
139/// report anything.
140pub fn error_chain(err: &dyn std::error::Error) -> String {
141 let mut out = err.to_string();
142 let mut source = err.source();
143 let mut depth = 0;
144 while let Some(cause) = source {
145 if depth == 8 {
146 out.push_str(": …");
147 break;
148 }
149 out.push_str(": ");
150 out.push_str(&cause.to_string());
151 source = cause.source();
152 depth += 1;
153 }
154 out
155}
156
157/// The service handle other layers reach the loader through — the ex-command
158/// surface (PL8.C), the plugin-manager view (PL8.H). Per the `ServiceRegistry`
159/// Arc/TypeId rule, register **and** look up with this exact alias
160/// (`Arc<PluginLoader>`), never a bare `PluginLoader`.
161pub type PluginLoaderHandle = Arc<PluginLoader>;
162
163/// A live loaded plugin: its host-issued [`PluginId`], its manifest id (the
164/// user-facing name, and the key for `:plugin-unload <name>`), the source dir to
165/// re-load from on `:plugin-reload`, the actor-lifecycle handles, and the
166/// [`PluginTeardown`] bundle that reverses its registry contributions on unload.
167struct LoadedRecord {
168 id: PluginId,
169 name: String,
170 /// The directory this plugin was loaded from — re-loaded on
171 /// `:plugin-reload`. `None` for plugins loaded from bytes in tests (reload
172 /// is then a no-op with a logged reason).
173 source_dir: Option<std::path::PathBuf>,
174 /// Lifecycle-only plugins (base `plugin` world — `init.rs`, no-op) keep
175 /// their instance alive here; dropping it drops the `Store`. Seam plugins
176 /// are driven by their actor task instead, so this is `None` for them.
177 lifecycle: Option<LoadedPlugin>,
178 /// The detached actor tasks driving this plugin's seams (picker / events /
179 /// completion). Aborted on unload — the actor-lifecycle half. Kept so the
180 /// tasks are not cancelled by a dropped `JoinHandle` (tokio detaches on
181 /// drop). [`PluginTeardown`] reverses the *registry* half; these are the
182 /// running actors it does not cover.
183 tasks: Vec<JoinHandle<()>>,
184 /// The registry-contribution reversal bundle — each drain fills its surface's
185 /// tokens (grammar / picker / modes / config options / event subscriptions);
186 /// `PluginTeardown::unload` consumes it against the live registries on
187 /// `:plugin-unload` / reload.
188 teardown: PluginTeardown,
189 /// PL8.H.1: the trust tier this plugin loaded under — reported in the
190 /// manager view's status, and the gate that decides `denied` below.
191 tier: TrustTier,
192 /// PL8.H.1: capabilities the plugin requested AND received under `tier`
193 /// (`requested` minus `denied`). Computed once at load from the manifest.
194 granted: Vec<Capability>,
195 /// PL8.H.1: requested-but-withheld capabilities (tier-gated). Never fatal —
196 /// the plugin loaded degraded; the manager view surfaces this.
197 denied: Vec<Capability>,
198 /// PL8.H.1: live health — `Healthy` at load, flipped to `Quarantined` by the
199 /// `Event::PluginCrashed` subscription ([`PluginLoader::subscribe_health`]).
200 health: PluginHealth,
201 /// PM.3: the mode this plugin enables by default (from its manifest), gated by
202 /// `<id>.enabled`. Kept so the `OptionChanged` subscription
203 /// ([`PluginLoader::subscribe_mode_gates`]) can map a changed `<id>.enabled`
204 /// back to the modes to (de)activate. Empty ⇒ no default mode.
205 default_modes: Vec<String>,
206 /// PM.8a: where the plugin came from (its `.source` marker at load time).
207 source: crate::source_record::SourceRecord,
208}
209
210/// PM.8b: what a build for one plugin is doing right now.
211#[derive(Debug, Clone, PartialEq, Eq)]
212enum BuildActivity {
213 Running,
214 Failed,
215}
216
217/// PM.8a: is this plugin's artifact current with its source?
218///
219/// Recomputed from disk per snapshot rather than cached at load, because the
220/// interesting transition happens *while the editor runs* — a user edits a
221/// local plugin's source and wants the view to say `stale` without a restart.
222/// It is two small file reads per row, on the `:plugins` refresh path, not a
223/// per-frame cost.
224/// The removable directories under `root`, given the names to keep.
225///
226/// Split from [`PluginLoader::removable_plugin_dirs`] so the rules can be
227/// tested against a real directory tree without a loaded editor behind them —
228/// `default_plugins_dir()` is the user's actual config root, which a test must
229/// never read and certainly never delete from.
230///
231/// Name-ordered, like every other list the manager view reads.
232fn removable_under(
233 root: &std::path::Path,
234 keep: &std::collections::HashSet<String>,
235) -> Vec<(String, std::path::PathBuf)> {
236 let Ok(entries) = std::fs::read_dir(root) else {
237 return Vec::new();
238 };
239 let mut out: Vec<(String, std::path::PathBuf)> = entries
240 .flatten()
241 .filter(|e| e.path().is_dir())
242 .filter_map(|e| {
243 let name = e.file_name().to_string_lossy().to_string();
244 if keep.contains(&name) {
245 return None;
246 }
247 // Clause 4: no provenance, no removal. Re-installing needs a
248 // source to re-install FROM; without one the bytes are the only
249 // copy there is.
250 if !e.path().join(".source").is_file() {
251 tracing::debug!(
252 plugin = %name,
253 "clean: skipping a directory with no `.source` marker"
254 );
255 return None;
256 }
257 Some((name, e.path()))
258 })
259 .collect();
260 out.sort_by(|a, b| a.0.cmp(&b.0));
261 out
262}
263
264/// Remove the `names` that still appear in `removable`.
265///
266/// The re-check is the point, not a formality: a confirmation the user left
267/// sitting while a plugin loaded must not delete the plugin that just
268/// arrived. A name that has stopped being removable is `Skipped`, never
269/// deleted and never reported as a failure.
270///
271/// Split from [`PluginLoader::clean`] so that rule is testable without the
272/// real config root behind it.
273fn clean_listed(removable: &[(String, std::path::PathBuf)], names: &[String]) -> BulkReport {
274 let by_name: std::collections::HashMap<&str, &std::path::PathBuf> =
275 removable.iter().map(|(n, p)| (n.as_str(), p)).collect();
276 let mut report = BulkReport::default();
277 for name in names {
278 let leg = match by_name.get(name.as_str()) {
279 None => BulkLeg::Skipped("no longer removable".to_string()),
280 Some(path) => match std::fs::remove_dir_all(path) {
281 Ok(()) => {
282 tracing::info!(plugin = %name, path = %path.display(), "plugin directory removed");
283 BulkLeg::Done
284 }
285 Err(e) => BulkLeg::Failed(format!("remove {}: {e}", path.display())),
286 },
287 };
288 report.legs.push((name.clone(), leg));
289 }
290 report
291}
292
293/// Which bulk verb [`PluginLoader::spawn_bulk`] should run.
294///
295/// A tag rather than three `spawn_*` methods: the scaffolding around each —
296/// find the runtime, spawn, log the summary and every failure — is identical,
297/// and the only difference is which `async fn` gets awaited in the middle.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum BulkOp {
300 /// [`PluginLoader::rebuild_all`].
301 Rebuild,
302 /// [`PluginLoader::reload_all`].
303 Reload,
304 /// [`PluginLoader::update_all`].
305 Update,
306}
307
308impl BulkOp {
309 /// The past-tense word its summary counts with.
310 fn past(self) -> &'static str {
311 match self {
312 BulkOp::Rebuild => "rebuilt",
313 BulkOp::Reload => "reloaded",
314 BulkOp::Update => "updated",
315 }
316 }
317}
318
319/// What a bulk run did to one plugin.
320///
321/// `Skipped` is not `Failed`, and keeping them apart is the whole reason this
322/// is an enum rather than a `Result`. "Pinned, so there was nothing to update"
323/// and "the build broke" both leave the plugin exactly as it was, but only one
324/// of them is something the user needs to go and look at. A run that reports
325/// `4 updated, 2 pinned` reads as success; the same run reporting `4 updated,
326/// 2 failed` sends someone hunting for a problem that does not exist.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum BulkLeg {
329 /// The operation ran and succeeded.
330 Done,
331 /// The operation did not apply to this plugin, for the reason given.
332 Skipped(String),
333 /// The operation applied, ran, and failed.
334 Failed(String),
335}
336
337/// The outcome of a bulk operation, one leg per plugin.
338///
339/// Ordered as the plugins were visited, which is name order — the same order
340/// `:plugins` lists them in, so a report can be read against the view.
341#[derive(Debug, Clone, Default, PartialEq, Eq)]
342pub struct BulkReport {
343 pub legs: Vec<(String, BulkLeg)>,
344}
345
346impl BulkReport {
347 fn count(&self, f: impl Fn(&BulkLeg) -> bool) -> usize {
348 self.legs.iter().filter(|(_, leg)| f(leg)).count()
349 }
350
351 /// How many legs succeeded.
352 pub fn done(&self) -> usize {
353 self.count(|l| matches!(l, BulkLeg::Done))
354 }
355
356 /// How many legs did not apply.
357 pub fn skipped(&self) -> usize {
358 self.count(|l| matches!(l, BulkLeg::Skipped(_)))
359 }
360
361 /// How many legs ran and failed.
362 pub fn failed(&self) -> usize {
363 self.count(|l| matches!(l, BulkLeg::Failed(_)))
364 }
365
366 /// Every plugin that failed, with its reason — what the caller logs.
367 pub fn failures(&self) -> Vec<(&str, &str)> {
368 self.legs
369 .iter()
370 .filter_map(|(name, leg)| match leg {
371 BulkLeg::Failed(why) => Some((name.as_str(), why.as_str())),
372 _ => None,
373 })
374 .collect()
375 }
376
377 /// A one-line summary for the echo.
378 ///
379 /// Names only the non-zero parts, so the common all-succeeded run says
380 /// `3 updated` rather than `3 updated, 0 skipped, 0 failed` — a count of
381 /// zero is noise that makes the counts that matter harder to find.
382 pub fn summary(&self, verb_past: &str) -> String {
383 if self.legs.is_empty() {
384 return "no plugins loaded".to_string();
385 }
386 let mut parts = vec![format!("{} {verb_past}", self.done())];
387 if self.skipped() > 0 {
388 parts.push(format!("{} skipped", self.skipped()));
389 }
390 if self.failed() > 0 {
391 parts.push(format!("{} failed", self.failed()));
392 }
393 parts.join(", ")
394 }
395}
396
397/// Why `update` can do nothing for `source` — `None` when it can.
398///
399/// Only the pinned-git arm refuses. `Local` is always current (the directory
400/// IS the source), `Prebuilt` re-downloads on every resolve, and unpinned git
401/// is the case the verb exists for. A pin, though, is already the answer to
402/// "which commit": updating past it would discard the choice the user wrote
403/// in `init.rs`, and updating to it is what boot already did.
404///
405/// Split out from [`PluginLoader::update`] so the table is testable without a
406/// loaded plugin behind it — the refusal is a property of the source alone.
407fn update_refusal(name: &str, source: Option<&resolve::PluginSource>) -> Option<String> {
408 match source {
409 Some(resolve::PluginSource::Git { rev: Some(rev), .. }) => {
410 Some(format!("`{name}` is pinned to {rev}; nothing to update"))
411 }
412 _ => None,
413 }
414}
415
416fn build_state_of(record: &LoadedRecord, activity: Option<&BuildActivity>) -> BuildState {
417 // PM.8b: an in-flight or just-failed build is the more current answer —
418 // the artifact on disk describes the *previous* build, and reporting
419 // `cached` while a rebuild is running would tell the user their `b` did
420 // nothing.
421 match activity {
422 Some(BuildActivity::Running) => return BuildState::Building,
423 Some(BuildActivity::Failed) => return BuildState::Failed,
424 None => {}
425 }
426 let Some(source) = record.source.as_plugin_source() else {
427 return BuildState::NotBuilt;
428 };
429 if matches!(source, crate::resolve::PluginSource::Prebuilt { .. }) {
430 // A prebuilt is downloaded, never built, so it has no staleness.
431 return BuildState::NotBuilt;
432 }
433 let Some(dir) = record.source_dir.as_ref() else {
434 return BuildState::NotBuilt;
435 };
436 // The stamp records what the artifact was built from and (WT.3) against;
437 // the source and this editor's ABI are what they stand at now. PM.5 owns
438 // both halves of that comparison.
439 let stamp_path = dir.join(".build-stamp");
440 let Ok(text) = std::fs::read_to_string(&stamp_path) else {
441 // No stamp: the artifact was placed by hand or by a lattice predating
442 // PM.5. Nothing to compare against, so nothing to claim.
443 return BuildState::NotBuilt;
444 };
445 let Some(stamped) = crate::build::Stamp::parse(&text) else {
446 // WT.3: a stamp from a lattice predating the ABI field. It cannot say
447 // what the artifact was built against, so it cannot support a `cached`
448 // claim — and `cached` is exactly the false reassurance that made the
449 // original failure invisible.
450 return BuildState::NotBuilt;
451 };
452 let build_dir = match &source {
453 crate::resolve::PluginSource::Local(p) => p.clone(),
454 // A git plugin builds out of its source-cache checkout.
455 _ => crate::default_source_cache_dir().join(&record.name),
456 };
457 if !build_dir.is_dir() {
458 return BuildState::NotBuilt;
459 }
460 if stamped == crate::build::Stamp::current(&build_dir) {
461 BuildState::Cached
462 } else {
463 // Either the source moved or the ABI did. Both are answered the same
464 // way — rebuild from source — so `:plugins` does not need to
465 // distinguish them, and the build log says which it was.
466 BuildState::Stale
467 }
468}
469
470/// The editor-side runtime environment the loader drives seams against —
471/// captured once from the boot context in [`install`], or built directly by a
472/// headless harness / test. Every handle is `Option` because a given consumer
473/// wires only the seams it exercises; a seam drain with an absent handle is a
474/// logged skip, never a panic. Grows one field per seam without churning the
475/// [`PluginLoader::with_services`] signature.
476#[derive(Default, Clone)]
477pub struct LoaderServices {
478 /// The shared multi-thread runtime handle (seam actors + discovery run here,
479 /// never the current-thread editor actor).
480 pub runtime: Option<Handle>,
481 /// The typed event bus — seam-actor crash quarantine binds to it, and event
482 /// plugins subscribe through it.
483 pub bus: Option<Arc<EventBus>>,
484 /// The runtime-mutable picker registry (RCU-register loaded picker sources).
485 pub picker_registry: Option<PickerRegistryHandle>,
486 /// The config registry (already interior-mutable) plugin options register into.
487 pub config_registry: Option<Arc<ConfigRegistry>>,
488 /// The runtime-mutable command registry (B3a/B3b) a grammar plugin's
489 /// motions / operators / text-objects / ex-commands register into. The
490 /// dispatch path (`DocumentActor`, host-side ex-command / completion reads)
491 /// snapshots it wait-free with `.load()`, so a plugin registered at runtime
492 /// is live for a buffer on its next keystroke.
493 pub command_registry: Option<CommandRegistryHandle>,
494 /// The runtime-mutable mode registry (B2) a mode plugin's minor modes
495 /// register into. RCU'd like the command registry — an owned snapshot is
496 /// cloned, `spawn_mode_plugin` drains into it, and it is published, so
497 /// keymap-resolution / mode-activation reads stay wait-free.
498 pub mode_registry: Option<ModeRegistryHandle>,
499 /// The interior-mutable keymap handle a mode plugin's per-mode `MinorMode`
500 /// keymap bindings land in (a shared clone — its writes are internally
501 /// mutex+ArcSwap-routed, so `spawn_mode_plugin` mutates it through a `&`).
502 pub keymap: Option<KeymapHandle>,
503 /// The provenance sink — records `PluginId → name/doc` for `:list-plugins`.
504 pub meta_sink: Option<PluginMetaSinkHandle>,
505 /// PL8.E: the runtime-mutable decoration-producer registry (RCU-register a
506 /// loaded decoration plugin's `WasmDecorationSource`). The host's per-tick
507 /// `maybe_refresh_wasm_decorations` reads the same handle wait-free.
508 pub decoration_registry: Option<GutterDecorationSourceRegistryHandle>,
509 /// IM.6b: where a loaded `media` plugin's producer is registered.
510 pub media_registry: Option<lattice_mode::MediaSourceRegistryHandle>,
511 /// OM.A1: where a plugin's agenda-row producer lands. Absent leaves the
512 /// seam `NotWired` — the load fails loudly rather than reporting success
513 /// and contributing nothing to every `:agenda` forever.
514 pub agenda_registry: Option<lattice_mode::ScannedExcerptSourceRegistryHandle>,
515 /// CM.2: binds a plugin operator's declared chord into the universal
516 /// operator-pending layer. The host owns it because the composition needs
517 /// host-resolved builtins; the plugin owns the spec and `apply`. Absent
518 /// leaves the seam `NotWired` for any plugin that declares a chord — an
519 /// operator that registered correctly and has no keys is indistinguishable
520 /// from one that never loaded.
521 pub operator_chords: Option<lattice_mode::OperatorChordWirerHandle>,
522 /// MV.1: the provider-view registry a plugin's declared multibuffer views
523 /// register openers into — the SAME one the agenda and magit's project-diff
524 /// use, so a plugin view opens through `open-provider-view` and refreshes
525 /// through `gr` exactly as a native one does. Absent leaves the seam
526 /// `NotWired`, failing the load loudly rather than reporting success and
527 /// contributing a view that can never be opened.
528 pub provider_view_registry: Option<lattice_mode::ProviderViewRegistryHandle>,
529 /// The multibuffer registry the view's excerpts land in once `build`
530 /// answers. Separate from the provider registry because they are different
531 /// services: one resolves a NAME to an opener, the other a view's BufferId
532 /// to its excerpt handle.
533 pub multibuffer_registry: Option<lattice_multibuffer::registry::MultibufferRegistryHandle>,
534 /// TC.2: the runtime-mutable context-producer registry (RCU-register a
535 /// loaded context plugin's `WasmContextSource`). The host's reparse-driven
536 /// refresh reads the same handle wait-free.
537 pub context_registry: Option<ContextSourceRegistryHandle>,
538 /// TC.4: the theme registry a `theme` plugin's elements register into — the
539 /// SAME one builtins use, so a plugin element is themeable and
540 /// `:customize`-able like any other.
541 pub theme_registry: Option<lattice_theme::ThemeRegistryHandle>,
542 /// SG.3a: the sign registry a `signs` plugin's definitions land in — the
543 /// SAME one native producers use, so a plugin's sign is styled through the
544 /// ordinary theme registry and contends for the mark cell by the same
545 /// priority rule, with no host kind-branch.
546 pub sign_registry: Option<lattice_mode::SignRegistryHandle>,
547 /// OC.3 / ML.6: the modeline element registry a plugin's `ui.register-segment`
548 /// declares into — the SAME one built-ins and native modes use, so a plugin
549 /// segment lays out, orders and hides exactly like `lsp` or `claude-code`
550 /// with no renderer branch anywhere.
551 pub modeline: Option<lattice_mode::ModelineServiceHandle>,
552 /// CM.6b: the compilation parser-factory registry an `error-parser`
553 /// plugin's factory RCU-registers into. The compilation service reads
554 /// the same handle once per run to mint each pipe reader's parser.
555 pub parser_factories: Option<lattice_compilation::CompilationParserFactoriesHandle>,
556 /// CR.3: the help-topic registry a `help` plugin's pages RCU-register
557 /// into — the SAME one the builtin docs live in, so a plugin page opens,
558 /// completes and cross-links like any other.
559 pub help_topics: Option<lattice_help::topics::HelpTopicRegistryHandle>,
560 /// CR.4: the dashboard section registry a `dashboard` plugin's sections
561 /// RCU-register into. Shadowing rather than overwriting (CR.2), so a
562 /// plugin replacing a builtin section is reversed by unload.
563 pub dashboard_sections: Option<lattice_dashboard::DashboardRegistryHandle>,
564 /// TR.2b: the transient-menu registry a `transient-source` plugin's menu
565 /// registers into — the SAME one magit's menus live in, so a plugin menu
566 /// opens through `Effect::OpenTransient` like any other. Owned by
567 /// `editor_boot` since TR.1, which is what stops a plugin menu depending
568 /// on whether magit happened to load.
569 pub transient_registry: Option<lattice_picker::TransientSourceRegistryHandle>,
570 /// PO.2: the boundary tracer the loader attaches to each async seam actor
571 /// (`actor.with_tracer(...)` before spawning `run()`), so the actor emits a
572 /// `PluginTraceRecord` per guest call. `None` degrades to no tracing.
573 pub tracer: Option<lattice_plugin_host::PluginTracerHandle>,
574}
575
576/// Which drain-required services the loader captured at [`install`] time —
577/// reported by [`PluginLoader::wired_seams`]. Every flag must be `true` after a
578/// real editor boot; a `false` is a boot-ordering regression (the loader was
579/// installed before that service registered) that silently degrades the
580/// dependent seam's drain to a `NotWired` skip.
581#[derive(Debug, Clone, Copy, PartialEq, Eq)]
582pub struct WiredSeams {
583 pub runtime: bool,
584 pub bus: bool,
585 pub picker_registry: bool,
586 pub config_registry: bool,
587 pub command_registry: bool,
588 pub mode_registry: bool,
589 pub keymap: bool,
590 pub meta_sink: bool,
591 pub decoration_registry: bool,
592 pub context_registry: bool,
593 pub theme_registry: bool,
594 /// SG.3a: the sign registry.
595 pub sign_registry: bool,
596 /// OC.3 / ML.6: the modeline element registry.
597 pub modeline: bool,
598 /// CM.6b: the compilation parser-factory registry.
599 pub parser_factories: bool,
600 /// CR.3: the help-topic registry.
601 pub help_topics: bool,
602 /// CR.4: the dashboard section registry.
603 pub dashboard_sections: bool,
604 /// IM.6b: the inline-media producer registry.
605 ///
606 /// Added at OM.A1 alongside `agenda_registry`. It was missing — media
607 /// drained through a service this struct never reported on, so a boot
608 /// ordering regression there would have degraded `drain_media` to a
609 /// `NotWired` skip with nothing asserting otherwise. Adding the sibling
610 /// and leaving this one silent would be aligned-by-silence.
611 pub media_registry: bool,
612 /// OM.A1: the agenda-row producer registry.
613 pub agenda_registry: bool,
614 /// TR.2b: the transient-menu registry.
615 pub transient_registry: bool,
616 /// MV.1: the multibuffer registry — where a plugin's declared views land.
617 pub multibuffer_registry: bool,
618 /// OA.23 / HB.2b: whether the HOST carries an excerpt-source resolver.
619 ///
620 /// Not a service capture like its neighbours: `install` builds the resolver
621 /// over the multibuffer registry and hands it to the host, so the thing to
622 /// assert is what the host holds. Reported because the failure is invisible
623 /// otherwise — an unwired seam answers `none`, which is also its answer for
624 /// "this line is not composed", so a guest standing on an agenda row cannot
625 /// tell a boot-ordering regression from an ordinary miss.
626 pub excerpt_source: bool,
627 /// OA.27: whether the HOST carries a view-args resolver.
628 ///
629 /// Reported for `excerpt_source`'s reason, and the invisibility is worse
630 /// here: an unwired seam answers an EMPTY LIST, which a guest parses as a
631 /// view showing nothing in particular — so every chord that walks a view
632 /// silently restarts from the default span, day and filter set, with no
633 /// error on any path. That is the bug the seam replaces; a boot-ordering
634 /// regression would reinstate it unnoticed.
635 pub view_args: bool,
636 /// OA.30: whether the HOST carries the decoration-refresh counter.
637 ///
638 /// Reported for `view_args`' reason and it fails the same silent way: a
639 /// guest producer whose state changed can say so, nobody is listening, and
640 /// the gutter simply never updates.
641 pub view_decoration_epoch: bool,
642 /// CD.6b: whether the HOST carries the buffer store `clamp-position` reads.
643 ///
644 /// Unwired, every buffer reads as closed, so a capture's write-back into
645 /// its caller is skipped with a message blaming a buffer that is open.
646 pub buffer_store: bool,
647}
648
649impl WiredSeams {
650 /// True when every drain-required service was captured — the boot pin's
651 /// assertion. `runtime` + `bus` are always present (`install` clones them
652 /// from the boot context directly, not via `service::<T>()`).
653 pub fn all(&self) -> bool {
654 self.runtime
655 && self.bus
656 && self.picker_registry
657 && self.config_registry
658 && self.command_registry
659 && self.mode_registry
660 && self.keymap
661 && self.meta_sink
662 && self.decoration_registry
663 && self.context_registry
664 && self.theme_registry
665 && self.sign_registry
666 && self.modeline
667 && self.parser_factories
668 && self.help_topics
669 && self.dashboard_sections
670 && self.transient_registry
671 && self.multibuffer_registry
672 && self.excerpt_source
673 && self.view_args
674 && self.view_decoration_epoch
675 && self.buffer_store
676 }
677}
678
679/// The plugin loader subsystem: owns the runtime, the loaded-plugin set, and the
680/// discovery + load orchestration. Stood up at boot by [`install`], which
681/// captures the editor environment and registers the loader as a
682/// [`PluginLoaderHandle`] service so the user surface reaches it generically.
683pub struct PluginLoader {
684 host: Arc<PluginHost>,
685 env: LoaderServices,
686 /// `std::sync::Mutex` (not `tokio`): taken only to push / read the loaded
687 /// set *after* the async load work completes, never across an `.await`.
688 loaded: Mutex<Vec<LoadedRecord>>,
689 /// PM.8b: builds running (or failed) **this session**, keyed by plugin
690 /// name.
691 ///
692 /// The only piece of build state not derived from disk. It is
693 /// deliberately not persisted: a build interrupted by a crash is not
694 /// still running after a restart, and a failure the user has since fixed
695 /// should not greet them on the next boot. On a fresh start the artifact
696 /// either exists — with a stamp saying whether it is stale — or it does
697 /// not, and that is the whole truth.
698 building: Mutex<std::collections::HashMap<String, BuildActivity>>,
699 /// PM.8b: how many builds are running, as a lock-free counter.
700 ///
701 /// Duplicated from `building` on purpose. The headerline's `version()` is
702 /// polled by the cells worker on **every tick** and the trait's contract
703 /// says it must not block; taking a mutex there — even an uncontended one
704 /// — puts a lock on a per-tick path for a number that is almost always
705 /// zero. The map stays the source of truth for *which* plugin is doing
706 /// what; this is the cheap "is anything happening" the tick asks.
707 building_count: std::sync::atomic::AtomicUsize,
708 /// PM.7b: specs declared via `plugin-manager.require`, accumulated as
709 /// config guests load and drained once by the boot task.
710 ///
711 /// It lives here rather than being returned from `load_discovered`
712 /// because the seam is one of several a guest may provide — an init.rs
713 /// that also contributes a keymap goes down the same path — and threading
714 /// a second return value through every arm to serve one of them would put
715 /// the cost on all of them.
716 required: Mutex<Vec<pipeline::RequiredSpec>>,
717 /// WT.4: plugins that tried to load and could not, for `:plugins`.
718 ///
719 /// In memory, not on disk. A load failure is a fact about *this* boot
720 /// against *this* editor — persisting it would mean showing a user an error
721 /// about a plugin they have since rebuilt, which is the same reasoning
722 /// [`BuildState::Failed`] is in-memory for.
723 failed: Mutex<Vec<FailedLoad>>,
724}
725
726/// Why a plugin failed to load. Every variant is graceful-degradation input for
727/// the caller (discovery logs + skips; the editor never aborts boot on one bad
728/// plugin) — the load path returns a value, never panics.
729#[derive(Debug, thiserror::Error)]
730pub enum PluginLoaderError {
731 /// The manifest was malformed or declared an unrecognised capability/seam.
732 #[error("plugin manifest invalid: {0}")]
733 Manifest(#[from] ManifestError),
734 /// The component failed to compile, instantiate, activate, or spawn a seam —
735 /// a wasm trap, fuel/epoch exhaustion, or a capability failure.
736 #[error("plugin runtime error: {0}")]
737 Host(#[from] PluginHostError),
738 /// A seam was declared but the loader was constructed without the editor
739 /// environment needed to drive it (the minimal test constructor). Never
740 /// happens on the real boot path.
741 #[error("plugin loader not wired for seam `{0}` (no editor environment)")]
742 NotWired(&'static str),
743 /// The plugin declared no seam the loader can drain yet, so nothing loaded.
744 #[error("plugin declares no loadable seam")]
745 NothingLoaded,
746 /// `:plugin-load <path>` pointed at a directory that is not a plugin (no
747 /// `plugin.toml`, bad TOML, or missing/ambiguous component).
748 #[error("cannot load plugin from path: {0}")]
749 Discovery(String),
750 /// `:plugin-unload` / `:plugin-reload <target>` named no currently-loaded
751 /// plugin (by manifest id or numeric plugin id).
752 #[error("no loaded plugin named `{0}`")]
753 NotLoaded(String),
754 /// `:plugin-reload` on a plugin the loader can't re-read from disk (loaded
755 /// from bytes in a test, or its source dir is gone).
756 #[error("plugin `{0}` has no on-disk source to reload from")]
757 NotReloadable(String),
758}
759
760/// The canonical id of the user-config plugin — the `:reload-config` /
761/// auto-reload target, and the `id = "init"` a `<config>/lattice/init/plugin.toml`
762/// declares.
763pub(crate) const INIT_PLUGIN_ID: &str = "init";
764
765/// How the source → artifact build went during [`reload_config`] — a
766/// `:reload-config` or a plugins-view rebuild of the `init` row. Carried in
767/// [`ReloadConfigReport`] so the surface (the `*messages*` echo, the plugins
768/// view row) can say precisely what happened, and on a failure show the
769/// compiler diagnostics rather than a bare "it failed".
770///
771/// [`reload_config`]: PluginLoader::reload_config
772#[derive(Debug, Clone, PartialEq, Eq)]
773pub enum ConfigBuildStatus {
774 /// The cargo project was rebuilt from source; the fresh artifact was loaded.
775 Rebuilt,
776 /// The source was already current (stamp match); the cached artifact was
777 /// reloaded — no toolchain ran.
778 AlreadyCurrent,
779 /// The init dir holds no cargo project — a hand-built `init.wasm` was
780 /// reloaded as-is (nothing to compile).
781 HandBuilt,
782 /// The build failed. A previous artifact (if any) is still loaded, so the
783 /// editor keeps running the LAST good config — but the edit did NOT take.
784 /// Carries the compiler diagnostics (the tail of cargo's stderr).
785 BuildFailed(String),
786}
787
788/// The detailed outcome of a [`reload_config`](PluginLoader::reload_config) —
789/// enough for the user to see what happened and, on a build failure, the
790/// compiler error.
791#[derive(Debug, Clone)]
792pub struct ReloadConfigReport {
793 /// The host id of the (re)loaded `init` plugin.
794 pub id: PluginId,
795 /// How the build went.
796 pub build: ConfigBuildStatus,
797}
798
799impl ReloadConfigReport {
800 /// Did the edited `init.rs` actually take effect? `false` when the build
801 /// failed and the previous artifact is what is running.
802 pub fn applied_new_config(&self) -> bool {
803 !matches!(self.build, ConfigBuildStatus::BuildFailed(_))
804 }
805
806 /// A user-facing message for `*messages*` / an echo — a one-line verdict,
807 /// plus the compiler diagnostics on a build failure.
808 pub fn summary(&self) -> String {
809 match &self.build {
810 ConfigBuildStatus::Rebuilt => {
811 "config reloaded: rebuilt init.rs and applied it".to_string()
812 }
813 ConfigBuildStatus::AlreadyCurrent => {
814 "config reloaded: init.rs already up to date, re-applied".to_string()
815 }
816 ConfigBuildStatus::HandBuilt => {
817 "config reloaded: re-applied the prebuilt init.wasm (no cargo project)".to_string()
818 }
819 ConfigBuildStatus::BuildFailed(error) => format!(
820 "config reload FAILED to rebuild init.rs — still running the previous \
821 config. Fix the error and reload again:\n{error}"
822 ),
823 }
824 }
825}
826
827/// Match a loaded record against a `:plugin-unload` / `:plugin-reload` target —
828/// its manifest id (the common case) or its numeric host-issued plugin id.
829fn record_matches(record: &LoadedRecord, target: &str) -> bool {
830 record.name == target || target.parse::<u32>().ok() == Some(record.id.0)
831}
832
833/// Default priority bucket for a plugin completion source — below LSP (200) and
834/// snippets (150), above bare buffer-word sources. A per-plugin priority
835/// override (a manifest field / `completion.source.<id>.priority` option) is
836/// future work; for now every plugin source shares this documented default.
837const PLUGIN_COMPLETION_DEFAULT_PRIORITY: u32 = 100;
838
839/// OA.14d: how long a load waits for its `pre-plugin-loaded` handlers.
840///
841/// Generous rather than tight, because what runs behind it is an `init.rs`
842/// handler doing a handful of `set-option` calls — a whole second is already
843/// three orders of magnitude of headroom, and the number exists only so a
844/// handler that hangs cannot hang the boot with it. It is not a latency budget;
845/// nothing user-visible is waiting on this (plugin loading is already off the
846/// boot thread by design).
847const PRE_PLUGIN_LOADED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
848
849/// The loader-owned minor mode that carries a plugin's completion source into
850/// the native aggregator (option A — completion is mode-attached everywhere:
851/// LSP rides the LSP mode, snippets ride the snippet mode). Registered
852/// [`ActivationPolicy::Universal`] so the source contributes on every
853/// completion-capable buffer; `recompute_active_completion_sources_for` walks
854/// the mode registry and picks up [`completion_sources`](Mode::completion_sources)
855/// like any native mode's. A manifest-declared scope (attach to a named
856/// language / major mode instead of universal) is the natural extension.
857#[derive(Debug)]
858struct PluginCompletionMode {
859 id: ModeId,
860 source: CompletionSourceContribution,
861}
862
863impl Mode for PluginCompletionMode {
864 type Guard = ();
865
866 fn id(&self) -> ModeId {
867 self.id
868 }
869
870 fn kind(&self) -> ModeKind {
871 ModeKind::Minor
872 }
873
874 fn activation_policy(&self) -> ActivationPolicy {
875 ActivationPolicy::Universal
876 }
877
878 fn required_capabilities(&self) -> CapabilitySet {
879 CapabilitySet::empty()
880 }
881
882 fn completion_sources(&self) -> Vec<CompletionSourceContribution> {
883 vec![self.source.clone()]
884 }
885
886 fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
887 Box::pin(async { Ok(()) })
888 }
889}
890
891impl PluginLoader {
892 /// Construct a loader over `host` with **no** editor environment — the
893 /// minimal constructor for tests exercising only the lifecycle spine.
894 /// [`install`] uses [`with_env`](Self::with_env) to wire the real seams.
895 pub fn new(host: Arc<PluginHost>) -> Self {
896 Self {
897 host,
898 env: LoaderServices::default(),
899 loaded: Mutex::new(Vec::new()),
900 building: Mutex::new(std::collections::HashMap::new()),
901 building_count: std::sync::atomic::AtomicUsize::new(0),
902 required: Mutex::new(Vec::new()),
903 failed: Mutex::new(Vec::new()),
904 }
905 }
906
907 /// Construct a loader wired with the editor environment ([`LoaderServices`])
908 /// — the boot path ([`install`]) and headless harnesses / tests. The seams a
909 /// plugin declares are driven against the wired handles; an absent handle
910 /// makes that seam a logged skip.
911 pub fn with_services(host: Arc<PluginHost>, services: LoaderServices) -> Self {
912 // OA.14d: give the HOST the option registry, so every store it mints
913 // can answer `get-option` — not only the seams whose spawn signature
914 // happens to take one. Done here rather than in `install` because a
915 // test harness builds its loader through this constructor too, and the
916 // gap this closes (`theme` and `language` reading org's keyword set)
917 // is exactly the kind that hides when production and tests wire
918 // different things.
919 if let Some(registry) = &services.config_registry {
920 host.set_config_registry(Arc::clone(registry));
921 }
922 Self {
923 host,
924 env: services,
925 loaded: Mutex::new(Vec::new()),
926 building: Mutex::new(std::collections::HashMap::new()),
927 building_count: std::sync::atomic::AtomicUsize::new(0),
928 required: Mutex::new(Vec::new()),
929 failed: Mutex::new(Vec::new()),
930 }
931 }
932
933 /// The number of currently-loaded plugins. The spine proof + the PL8.H
934 /// manager view read it.
935 pub fn loaded_count(&self) -> usize {
936 self.loaded
937 .lock()
938 .expect("plugin-loader loaded-set mutex poisoned")
939 .len()
940 }
941
942 /// Which drain-required services the loader captured from the boot context
943 /// ([`install`]). A boot-ordering regression (installing the loader before a
944 /// service it depends on registers) silently leaves a field `false`, turning
945 /// that seam's drain into a `NotWired` skip — so the boot pin asserts every
946 /// flag is set after `Editor::boot`. Test/introspection affordance.
947 pub fn wired_seams(&self) -> WiredSeams {
948 WiredSeams {
949 runtime: self.env.runtime.is_some(),
950 bus: self.env.bus.is_some(),
951 picker_registry: self.env.picker_registry.is_some(),
952 config_registry: self.env.config_registry.is_some(),
953 command_registry: self.env.command_registry.is_some(),
954 mode_registry: self.env.mode_registry.is_some(),
955 keymap: self.env.keymap.is_some(),
956 meta_sink: self.env.meta_sink.is_some(),
957 decoration_registry: self.env.decoration_registry.is_some(),
958 context_registry: self.env.context_registry.is_some(),
959 theme_registry: self.env.theme_registry.is_some(),
960 sign_registry: self.env.sign_registry.is_some(),
961 modeline: self.env.modeline.is_some(),
962 parser_factories: self.env.parser_factories.is_some(),
963 help_topics: self.env.help_topics.is_some(),
964 dashboard_sections: self.env.dashboard_sections.is_some(),
965 media_registry: self.env.media_registry.is_some(),
966 agenda_registry: self.env.agenda_registry.is_some(),
967 transient_registry: self.env.transient_registry.is_some(),
968 multibuffer_registry: self.env.multibuffer_registry.is_some(),
969 excerpt_source: self.host.excerpt_source_wired(),
970 view_args: self.host.view_args_wired(),
971 view_decoration_epoch: self.host.decoration_epoch_wired(),
972 buffer_store: self.host.buffer_store_wired(),
973 }
974 }
975
976 /// Whether a plugin with manifest id `name` is currently loaded (the
977 /// `:plugin-unload <name>` / `:plugin-reload <name>` resolution, PL8.C).
978 pub fn is_loaded(&self, name: &str) -> bool {
979 self.loaded
980 .lock()
981 .expect("plugin-loader loaded-set mutex poisoned")
982 .iter()
983 .any(|r| r.name == name)
984 }
985
986 /// Discover every plugin under `dir` and load each, logging + skipping any
987 /// that fails (never aborting the others). Returns the count loaded. Runs on
988 /// the caller (the multi-thread runtime), off the editor actor.
989 pub async fn discover_and_load(&self, dir: &std::path::Path, tier: TrustTier) -> usize {
990 let discovered = discovery::discover(dir);
991 let mut loaded = 0;
992 for plugin in discovered {
993 // Already loaded by something else — skip rather than load it a
994 // SECOND time. Boot has two paths into the same directory: a
995 // `require`d plugin is staged into the user root and loaded
996 // eagerly (so `enable_mode` can fire against a real load), and the
997 // on-disk scan then walks that same root. Without this, every
998 // `require`d plugin registered its modes, commands and keymaps
999 // twice and appeared twice in `:plugins`.
1000 //
1001 // The guard belongs here, on the SCANNING path, rather than in
1002 // `load_discovered`: "load everything in this directory" can
1003 // always skip what is already in, whereas an explicit
1004 // `:plugin-load` is a request the user made and `reload` unloads
1005 // before loading again.
1006 if self.is_loaded(&plugin.manifest.id) {
1007 tracing::debug!(
1008 plugin = %plugin.manifest.id,
1009 dir = %plugin.dir.display(),
1010 "already loaded; not loading it a second time"
1011 );
1012 continue;
1013 }
1014 match self.load_discovered(&plugin, tier).await {
1015 Ok(_) => {
1016 loaded += 1;
1017 self.clear_failure(&plugin.manifest.id);
1018 }
1019 Err(err) => {
1020 // WT.4: `warn!` reaches `*messages*`, and the record reaches
1021 // `:plugins`. Both, because they answer different questions:
1022 // the log says a thing went wrong just now, the record
1023 // answers "why is org not here?" asked ten minutes later.
1024 // `error_chain`, not `%err`: Display on these enums is
1025 // the category alone, and the cause is what a reader needs.
1026 let detail = error_chain(&err);
1027 tracing::warn!(
1028 plugin = %plugin.manifest.id,
1029 dir = %plugin.dir.display(),
1030 error = %detail,
1031 "plugin failed to load; skipped"
1032 );
1033 self.record_failure(&plugin.manifest.id, &plugin.dir, &detail);
1034 }
1035 }
1036 }
1037 loaded
1038 }
1039
1040 /// Load one already-discovered plugin: compile, then either drive the
1041 /// lifecycle spine (empty `provides`) or drain each declared seam, and
1042 /// record its provenance. Returns the host-issued [`PluginId`].
1043 pub async fn load_discovered(
1044 &self,
1045 plugin: &DiscoveredPlugin,
1046 tier: TrustTier,
1047 ) -> Result<PluginId, PluginLoaderError> {
1048 let manifest = &plugin.manifest;
1049 let component = self.host.compile(&plugin.component_bytes)?;
1050
1051 // PL8.H.1: resolve the capability grant once for the manager-view status.
1052 // `grant` is pure (manifest + tier), so this mirrors exactly what each
1053 // seam spawn computes internally — `denied` is the tier-withheld set,
1054 // `granted` the requested capabilities that survived it.
1055 let outcome = lattice_plugin_host::grant(manifest, tier);
1056 let denied = outcome.denied.clone();
1057 let granted: Vec<Capability> = manifest
1058 .requested
1059 .iter()
1060 .filter(|cap| !denied.contains(cap))
1061 .cloned()
1062 .collect();
1063
1064 let mut record = LoadedRecord {
1065 id: PluginId(0),
1066 name: manifest.id.clone(),
1067 source_dir: Some(plugin.dir.clone()),
1068 lifecycle: None,
1069 tasks: Vec::new(),
1070 teardown: {
1071 let mut t = PluginTeardown::new(PluginId(0));
1072 // OC.3 / ML.6: recorded up front rather than per drain, because
1073 // reversal is by NAMESPACE — any seam of this plugin may
1074 // register a modeline segment, at any point in its life, and a
1075 // per-drain token list would miss the late ones and leave an
1076 // orphan descriptor rendering forever.
1077 t.modeline_namespace = Some(manifest.id.clone());
1078 t
1079 },
1080 tier,
1081 granted,
1082 denied,
1083 health: PluginHealth::Healthy,
1084 default_modes: manifest.default_modes.clone(),
1085 source: plugin.source.clone(),
1086 };
1087 // EVERY host id this load issued, in DRAIN order (see the sort below —
1088 // no longer the order `provides` lists them in).
1089 //
1090 // Each `spawn_*` issues its own — deliberately, since a provenance id
1091 // must never be derived from guest-controlled input and so cannot be
1092 // keyed on the manifest's string id. That means a plugin providing N
1093 // seams stamps its contributions with N provenances, and teardown has
1094 // to reverse all of them. Keeping only the first is why bundled
1095 // `auto-pair` (grammar, modes, config, help) leaked its `:help` pages
1096 // on unload.
1097 let mut seam_ids: Vec<PluginId> = Vec::new();
1098
1099 if manifest.provides.is_empty() {
1100 // OA.14d: fires here too, so the event's contract is "once per
1101 // load, before this plugin runs anything" rather than "once per
1102 // load that happens to declare seams". A lifecycle-only plugin
1103 // declares no options of its own, but a handler may still want to
1104 // set a CORE option before its `activate` reads one.
1105 self.announce_pre_load(&manifest.id).await;
1106 // Lifecycle-only (base `plugin` world): instantiate + activate.
1107 let mut instance = self
1108 .host
1109 .instantiate_plugin(&component, manifest, tier, PluginBudget::default())
1110 .await?;
1111 instance.activate().await?;
1112 seam_ids.push(instance.id());
1113 record.lifecycle = Some(instance);
1114 } else {
1115 // OM.0: drain in DEPENDENCY order, not manifest order. A
1116 // `mode-keymap-binding` resolves its command name against the
1117 // `CommandRegistry` at registration, so a mode binding a chord to
1118 // the plugin's own grammar action needs `grammar` drained first —
1119 // and `provides` is guest-controlled input, so trusting its order
1120 // made a load-bearing invariant depend on a comment in someone
1121 // else's TOML. `drain_rank` decides; the sort is stable, so ties
1122 // keep the author's ordering.
1123 let mut seams = manifest.provides.clone();
1124 seams.sort_by_key(|s| s.drain_rank());
1125 // OA.14d: `pre-plugin-loaded` fires on the rank-0 boundary — after
1126 // every seam that DECLARES an option has drained, before the first
1127 // seam that READS one. Both halves matter and neither is arbitrary:
1128 // fire earlier and a handler's `set-option` names an option that
1129 // does not exist yet (the host rejects it as unknown and logs);
1130 // fire later and org's `register-theme-elements` has already
1131 // derived its per-keyword elements from the compiled default,
1132 // which IS the reported bug.
1133 let mut announced = false;
1134 for seam in &seams {
1135 if !announced && seam.drain_rank() > 0 {
1136 self.announce_pre_load(&manifest.id).await;
1137 announced = true;
1138 }
1139 match seam {
1140 PluginSeam::PickerSource => {
1141 let id = self
1142 .drain_picker(&component, manifest, tier, &mut record)
1143 .await?;
1144 seam_ids.push(id);
1145 }
1146 PluginSeam::Config => {
1147 let id = self
1148 .drain_config(&component, manifest, tier, &mut record)
1149 .await?;
1150 seam_ids.push(id);
1151 }
1152 PluginSeam::Events => {
1153 let id = self
1154 .drain_events(&component, manifest, tier, &mut record)
1155 .await?;
1156 seam_ids.push(id);
1157 }
1158 PluginSeam::Grammar => {
1159 let id = self.drain_grammar(&component, manifest, tier)?;
1160 seam_ids.push(id);
1161 }
1162 PluginSeam::Modes => {
1163 let id = self
1164 .drain_mode(&component, manifest, tier, &mut record)
1165 .await?;
1166 seam_ids.push(id);
1167 }
1168 PluginSeam::CompletionSource => {
1169 let id = self
1170 .drain_completion(&component, manifest, tier, &mut record)
1171 .await?;
1172 seam_ids.push(id);
1173 }
1174 PluginSeam::Keymap => {
1175 let id = self
1176 .drain_keymap(&component, manifest, tier, &mut record)
1177 .await?;
1178 seam_ids.push(id);
1179 }
1180 PluginSeam::Media => {
1181 let id = self
1182 .drain_media(&component, manifest, tier, &mut record)
1183 .await?;
1184 seam_ids.push(id);
1185 }
1186 PluginSeam::Decorations => {
1187 let id = self
1188 .drain_decorations(&component, manifest, tier, &mut record)
1189 .await?;
1190 seam_ids.push(id);
1191 }
1192 PluginSeam::Context => {
1193 let id = self
1194 .drain_context(&component, manifest, tier, &mut record)
1195 .await?;
1196 seam_ids.push(id);
1197 }
1198 PluginSeam::Theme => {
1199 let id = self
1200 .drain_theme(&component, manifest, tier, &mut record)
1201 .await?;
1202 seam_ids.push(id);
1203 }
1204 PluginSeam::Signs => {
1205 let id = self
1206 .drain_signs(&component, manifest, tier, &mut record)
1207 .await?;
1208 seam_ids.push(id);
1209 }
1210 // PO.5: `logging` is a host import the guest CONSUMES (Layer 2),
1211 // not a contribution it provides — it never appears in a
1212 // well-formed `provides`, and the import is wired into the
1213 // linker for every async world regardless. A malformed manifest
1214 // that lists it drains nothing (no-op), never an error.
1215 // PM.7b: the `require` seam. Drained during the ordinary
1216 // load, so the component compiled at the top of this
1217 // function is reused — the alternative (spawning the
1218 // guest a second time to read its specs) would compile
1219 // init.rs twice on every boot to fetch a list.
1220 PluginSeam::PluginManager => {
1221 let id = self
1222 .drain_require(&component, manifest, tier, &mut record)
1223 .await?;
1224 seam_ids.push(id);
1225 }
1226 // CM.6b: live. The registry holds a FACTORY rather than a
1227 // parser because the compilation `ParserRegistry` is built
1228 // per pipe reader — stdout and stderr each get one — and a
1229 // `WasmErrorParser` owns a `Store`, so it cannot be shared
1230 // between them. Each reader mints its own, which is also
1231 // semantically right: the two streams carry independent
1232 // pending state.
1233 PluginSeam::ErrorParser => {
1234 let id = self.drain_error_parser(&component, manifest, tier)?;
1235 seam_ids.push(id);
1236 }
1237 // OM.A1: an agenda-row producer. Live, like `media` — the
1238 // guest stays instantiated and is called once per file of
1239 // every scan, so its per-scan state (`begin`) has
1240 // somewhere to live.
1241 PluginSeam::ScannedExcerptSource => {
1242 let id = self
1243 .drain_agenda(&component, manifest, tier, &mut record)
1244 .await?;
1245 seam_ids.push(id);
1246 }
1247 // TR.2b: a keyed menu. Live, like `dashboard` — a menu's
1248 // rows depend on where it was opened from, so the guest
1249 // stays instantiated and `build` is called per open.
1250 PluginSeam::TransientSource => {
1251 let id = self
1252 .drain_transient(&component, manifest, tier, &mut record)
1253 .await?;
1254 seam_ids.push(id);
1255 }
1256 // CR.3: the plugin's `:help` pages. Data, not a live
1257 // guest — the bodies cross once here and the store is
1258 // dropped, so reading `:help` never touches wasm.
1259 PluginSeam::Help => {
1260 let id = self.drain_help(&component, manifest, tier).await?;
1261 seam_ids.push(id);
1262 }
1263 // LG.3c: the plugin's languages. Data like `help` — the
1264 // grammar bytes and query sources cross once here, the
1265 // host compiles the grammar, and the guest is dropped.
1266 // Parsing never touches wasm-the-plugin again.
1267 PluginSeam::Language => {
1268 let id = self.drain_language(&component, manifest, tier).await?;
1269 seam_ids.push(id);
1270 }
1271 // CR.4: the plugin's launch-page sections. Unlike `help`,
1272 // each keeps a live guest — a section is a function of a
1273 // `DashboardCtx`, so it is called per compose.
1274 PluginSeam::Dashboard => {
1275 let id = self.drain_dashboard(&component, manifest, tier)?;
1276 seam_ids.push(id);
1277 }
1278 // MV.1: a plugin-owned multibuffer view.
1279 PluginSeam::MultibufferViewSource => {
1280 let id = self
1281 .drain_multibuffer_views(&component, manifest, tier, &mut record)
1282 .await?;
1283 seam_ids.push(id);
1284 }
1285 PluginSeam::Logging => {} // Exhaustive: every contribution `PluginSeam` variant is drained
1286 // (PL8.E closed the last, decorations). A new seam variant
1287 // must add its drain here — the compiler enforces it rather
1288 // than a silent skip.
1289 }
1290 }
1291 // A plugin whose every seam is rank 0 never crossed the boundary.
1292 // Fire anyway, so "exactly once per load" is a property a handler
1293 // and a test can rely on rather than one that holds for most
1294 // manifests.
1295 if !announced {
1296 self.announce_pre_load(&manifest.id).await;
1297 }
1298 }
1299
1300 // The FIRST id is the plugin's user-facing identity (`:list-plugins`,
1301 // `SourceLayer::Plugin` rendering); all of them are what teardown
1302 // reverses.
1303 let Some(&id) = seam_ids.first() else {
1304 return Err(PluginLoaderError::NothingLoaded);
1305 };
1306 record.id = id;
1307 record.teardown.plugin_id = id;
1308 record.teardown.seam_ids = seam_ids;
1309
1310 // Provenance: `SourceLayer::Plugin(id)` renders as the name, and
1311 // `:list-plugins` shows it. Doc falls back to the manifest field.
1312 if let Some(sink) = &self.env.meta_sink {
1313 sink.register_plugin(
1314 id.0,
1315 manifest.id.clone(),
1316 manifest.doc.clone().unwrap_or_default(),
1317 );
1318 // Every seam's contributions are stamped with THAT seam's id, so
1319 // the name has to resolve from all of them — or a keymap-seam
1320 // binding reads `<plugin:29>` beside the same plugin's named
1321 // grammar.
1322 let seam_ids: Vec<u32> = record.teardown.seam_ids.iter().map(|s| s.0).collect();
1323 sink.register_seam_ids(id.0, &seam_ids);
1324 }
1325
1326 self.loaded
1327 .lock()
1328 .expect("plugin-loader loaded-set mutex poisoned")
1329 .push(record);
1330 // CI.1: announce the load AFTER the full drain (every seam registered) so
1331 // a subscriber's handler observes a fully-loaded plugin — an `init.rs`
1332 // runs its deferred `on-plugin-loaded` config here. Fires for `init.rs`
1333 // itself too (harmless: handlers match other plugins by name).
1334 if let Some(bus) = &self.env.bus {
1335 bus.publish(lattice_protocol::Event::PluginLoaded {
1336 name: manifest.id.clone(),
1337 id: id.0,
1338 });
1339 }
1340 // PM.3: a plugin declaring a `default_mode` gets a `<id>.enabled` bool
1341 // gate (default true). Register it, read its current value, and enable /
1342 // disable the declared mode accordingly — the batteries-included path
1343 // (auto-pair on out of the box), user-overridable via `:set
1344 // <id>.enabled=false`. Subsequent changes are handled by
1345 // `subscribe_mode_gates`.
1346 self.apply_default_mode_gate(&manifest.id, &manifest.default_modes);
1347 // LA.1: the mode/language CATALOG changed. Separate from `PluginLoaded`
1348 // above because the subscriber is different and much more expensive —
1349 // it re-resolves the major mode of every open buffer
1350 // (`mode-architecture.md` §7.4). Published last, after the default-mode
1351 // gate, so a subscriber resolving against the catalog sees it settled:
1352 // registered by the drain AND enabled/disabled by the gate.
1353 if manifest.provides.iter().any(|s| {
1354 matches!(
1355 s,
1356 lattice_plugin_host::PluginSeam::Language | lattice_plugin_host::PluginSeam::Modes
1357 )
1358 }) && let Some(bus) = &self.env.bus
1359 {
1360 bus.publish_typed(crate::events::LanguagesRegistered { plugin: id });
1361 }
1362 // One-shot, user-actionable event (the "LSP server attached" class).
1363 tracing::info!(plugin = %manifest.id, id = id.0, "plugin loaded");
1364 Ok(id)
1365 }
1366
1367 /// OA.14d: publish `PrePluginLoaded` for `name` and **wait** for every
1368 /// guest handler to return before the load continues.
1369 ///
1370 /// The wait is the whole feature. An ordinary publish hands the event to
1371 /// each plugin's actor channel and returns; the actor runs on the same
1372 /// multi-thread runtime this load is running on, so without the barrier the
1373 /// handler's `set-option` and the export that reads that option race — and
1374 /// the race is invisible when it is lost, because the export simply sees
1375 /// the compiled default and produces a plausible, wrong result.
1376 ///
1377 /// Bounded, because a barrier that a guest can hold forever is a boot that
1378 /// a guest can hang. On expiry the load proceeds and says so: the user gets
1379 /// an editor with one plugin misconfigured rather than no editor at all.
1380 async fn announce_pre_load(&self, name: &str) {
1381 let Some(bus) = &self.env.bus else { return };
1382 let waits = bus.publish_awaited(Event::PrePluginLoaded {
1383 name: name.to_string(),
1384 });
1385 if waits.is_empty() {
1386 return;
1387 }
1388 let handlers = waits.len();
1389 // `Err` on a receiver means the handler will never run (actor gone,
1390 // plugin quarantined) — which is a completed wait, not a failure. So
1391 // every arm of `join_all` is simply ignored; what is awaited is that
1392 // each one is *over*.
1393 let all = futures::future::join_all(waits);
1394 match tokio::time::timeout(PRE_PLUGIN_LOADED_TIMEOUT, all).await {
1395 Ok(_) => tracing::debug!(
1396 plugin = %name,
1397 handlers,
1398 "pre-plugin-loaded handlers completed"
1399 ),
1400 Err(_) => tracing::warn!(
1401 plugin = %name,
1402 handlers,
1403 timeout_secs = PRE_PLUGIN_LOADED_TIMEOUT.as_secs(),
1404 "pre-plugin-loaded handlers did not finish in time; loading anyway \
1405 — options this plugin reads at load may fall back to their defaults"
1406 ),
1407 }
1408 }
1409
1410 /// The name of a plugin's enable-gate option — `<id>.enabled` (PM.3).
1411 fn enabled_option_name(plugin_id: &str) -> String {
1412 format!("{plugin_id}.enabled")
1413 }
1414
1415 /// PM.3: register (if new) the `<id>.enabled` gate for a plugin declaring a
1416 /// `default_mode`, then request the mode's enablement to match the option's
1417 /// current value. A no-op when the plugin declares no default mode, or when no
1418 /// config registry / bus is wired.
1419 fn apply_default_mode_gate(&self, plugin_id: &str, default_modes: &[String]) {
1420 if default_modes.is_empty() {
1421 return;
1422 }
1423 let (Some(registry), Some(bus)) =
1424 (self.env.config_registry.as_ref(), self.env.bus.as_ref())
1425 else {
1426 return;
1427 };
1428 let option = Self::enabled_option_name(plugin_id);
1429 // Idempotent: a re-load (or a plugin that declared the option itself)
1430 // leaves the existing value untouched; only the first load registers it.
1431 lattice_plugin_host::config_host::register_plugin_option(
1432 registry,
1433 &option,
1434 lattice_plugin_host::config_host::PluginOptionKind::Boolean,
1435 "true",
1436 "Enable this plugin's default mode.",
1437 );
1438 let enabled = registry
1439 .lookup(&option)
1440 .map(|opt| opt.get_formatted() == "true")
1441 .unwrap_or(true);
1442 // One gate, N modes (OC.1a). `<id>.enabled` is the PLUGIN's switch, so
1443 // a plugin with two on-by-default modes gets one option rather than
1444 // one per mode — the user is turning org on or off, not curating its
1445 // internals.
1446 for mode in default_modes {
1447 bus.publish(lattice_protocol::Event::ModeEnablementRequested {
1448 mode: mode.clone(),
1449 enabled,
1450 });
1451 }
1452 }
1453
1454 /// PM.7/PM.8 follow-up: honour a `require`'s `enable-mode` sugar.
1455 ///
1456 /// Publishes the same `ModeEnablementRequested` the manifest
1457 /// `default_mode` gate publishes — one mechanism, two ways of asking for
1458 /// it (a plugin declaring its own default, or a user's `init.rs` asking
1459 /// for it at the call site).
1460 ///
1461 /// The host never learns the mode-id statically: it arrives in the spec
1462 /// and is forwarded as an opaque string, so the mode stays the plugin's
1463 /// own surface (`feedback_mode_owns_its_surface`).
1464 ///
1465 /// A missing bus is a silent skip — the same degradation every other
1466 /// event publisher here uses when the editor is not fully wired (tests,
1467 /// headless harnesses).
1468 pub fn request_mode_enablement(&self, mode: &str) {
1469 let Some(bus) = self.env.bus.as_ref() else {
1470 return;
1471 };
1472 bus.publish(lattice_protocol::Event::ModeEnablementRequested {
1473 mode: mode.to_string(),
1474 enabled: true,
1475 });
1476 }
1477
1478 /// PL8.H.1: a read-only snapshot of every loaded plugin — identity, trust
1479 /// tier, capabilities granted/denied, and health — for the `:plugins`
1480 /// manager view (PL8.H.2/.3). Cloned out under the loaded-set lock, so the
1481 /// view renders a stable frame while loads/unloads proceed.
1482 pub fn plugin_status(&self) -> Vec<PluginStatus> {
1483 // Snapshot the in-flight set once, outside the loaded-set lock: two
1484 // locks held at once is how a deadlock gets written, and the build
1485 // task takes `building` while the view takes `loaded`.
1486 let activity = self.building.lock().map(|m| m.clone()).unwrap_or_default();
1487 let mut rows: Vec<PluginStatus> = self
1488 .loaded
1489 .lock()
1490 .expect("plugin-loader loaded-set mutex poisoned")
1491 .iter()
1492 .map(|r| PluginStatus {
1493 id: r.id.0,
1494 name: r.name.clone(),
1495 tier: r.tier,
1496 granted: r.granted.clone(),
1497 denied: r.denied.clone(),
1498 health: r.health.clone(),
1499 source: r.source.clone(),
1500 build: build_state_of(r, activity.get(&r.name)),
1501 })
1502 .collect();
1503 // Stable, name-sorted order (not raw load order). The `:plugins` view keys
1504 // its in-view chords on `cursor.line → this Vec's index`, and a `:plugin`
1505 // reload internally unloads + re-appends (moving the record to the end of
1506 // `loaded`). Sorting by name keeps a reloaded plugin's row in place, so the
1507 // cursor still targets it, and makes the list order predictable for the
1508 // user rather than discovery-order. Names are unique per loaded set.
1509 rows.sort_by(|a, b| a.name.cmp(&b.name));
1510 rows
1511 }
1512
1513 /// WT.4: the plugins that tried to load this session and could not.
1514 ///
1515 /// Name-sorted like [`plugin_status`](Self::plugin_status), for the same
1516 /// reason: the view is read down a column, and discovery order is not
1517 /// something a user can predict or reproduce.
1518 pub fn failed_loads(&self) -> Vec<FailedLoad> {
1519 let mut rows = self.failed.lock().map(|f| f.clone()).unwrap_or_default();
1520 rows.sort_by(|a, b| a.name.cmp(&b.name));
1521 rows
1522 }
1523
1524 /// Record (or replace) `name`'s load failure.
1525 ///
1526 /// Replaces rather than appends: a `:plugin-reload` that fails again should
1527 /// leave one row saying what is wrong now, not a growing pile of attempts.
1528 /// The view is a description of the current state, not a history.
1529 ///
1530 /// Pass [`error_chain`]'s output, not `err.to_string()` — see its doc.
1531 fn record_failure(&self, name: &str, dir: &std::path::Path, error: &str) {
1532 let Ok(mut failed) = self.failed.lock() else {
1533 // A poisoned mutex here would mean losing a diagnostic, and losing a
1534 // diagnostic is not worth taking the editor down over — this whole
1535 // mechanism exists because a missing message cost a debugging
1536 // session, so it must not itself become a crash.
1537 tracing::debug!(plugin = name, "failed-load set poisoned; not recording");
1538 return;
1539 };
1540 failed.retain(|f| f.name != name);
1541 failed.push(FailedLoad {
1542 name: name.to_string(),
1543 dir: dir.to_path_buf(),
1544 error: error.to_string(),
1545 });
1546 }
1547
1548 /// Drop `name`'s failure record — it loaded.
1549 ///
1550 /// Called on every successful load rather than only on a reload, because the
1551 /// paths into a load are several (boot scan, `require`, `:plugin-load`,
1552 /// `:plugin-reload`) and a stale "failed" row surviving a load that worked
1553 /// is a worse lie than no row at all.
1554 fn clear_failure(&self, name: &str) {
1555 if let Ok(mut failed) = self.failed.lock() {
1556 failed.retain(|f| f.name != name);
1557 }
1558 }
1559
1560 /// PL8.H.1: mark the plugin `plugin` quarantined (its instance trapped) — the
1561 /// body of the `Event::PluginCrashed` subscription ([`subscribe_health`]),
1562 /// exposed directly so a test can drive the health flip without a live bus.
1563 /// A crash id matching no loaded plugin is ignored (it may have been unloaded
1564 /// between the trap and the drain) — never a panic.
1565 ///
1566 /// [`subscribe_health`]: Self::subscribe_health
1567 pub fn mark_quarantined(&self, plugin: u32, func: String, kind: String) {
1568 let mut loaded = self
1569 .loaded
1570 .lock()
1571 .expect("plugin-loader loaded-set mutex poisoned");
1572 if let Some(record) = loaded.iter_mut().find(|r| r.id.0 == plugin) {
1573 record.health = PluginHealth::Quarantined { func, kind };
1574 }
1575 }
1576
1577 /// PL8.H.1: subscribe to `Event::PluginCrashed` so a trapped plugin's health
1578 /// flips to `Quarantined` in the manager view. Filtered by kind (indexed
1579 /// dispatch); events drain on the shared runtime via a `Channel` sink, OFF
1580 /// the keystroke path (the bus calls the sink lock-dropped). Holds a
1581 /// `Weak<Self>` so the drain task never keeps the loader alive — the loop
1582 /// ends when the loader drops. Called once by [`install`]; a no-op if no
1583 /// bus/runtime was wired (the minimal test constructor).
1584 pub fn subscribe_health(self: &Arc<Self>) {
1585 let (Some(bus), Some(runtime)) = (self.env.bus.as_ref(), self.env.runtime.as_ref()) else {
1586 return;
1587 };
1588 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
1589 bus.subscribe(
1590 EventFilter::kind(EventKind::PluginCrashed),
1591 SubscriptionTarget::Channel(tx),
1592 );
1593 let weak = Arc::downgrade(self);
1594 runtime.spawn(async move {
1595 while let Some(event) = rx.recv().await {
1596 if let Event::PluginCrashed { plugin, func, kind } = event {
1597 let Some(loader) = weak.upgrade() else { break };
1598 loader.mark_quarantined(plugin, func, kind);
1599 }
1600 }
1601 });
1602 }
1603
1604 /// PM.3: react to `<id>.enabled` changes — the config gate for a plugin's
1605 /// default mode. On a `:set <id>.enabled=<bool>` (an `OptionChanged`), map the
1606 /// option back to the loaded plugin's `default_mode` and request the mode's
1607 /// enablement to match, so the toggle activates / deactivates it live. Mirrors
1608 /// [`Self::subscribe_health`]; a no-op when no bus/runtime was wired.
1609 pub fn subscribe_mode_gates(self: &Arc<Self>) {
1610 let (Some(bus), Some(runtime)) = (self.env.bus.as_ref(), self.env.runtime.as_ref()) else {
1611 return;
1612 };
1613 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
1614 bus.subscribe(
1615 EventFilter::kind(EventKind::OptionChanged),
1616 SubscriptionTarget::Channel(tx),
1617 );
1618 let bus = bus.clone();
1619 let weak = Arc::downgrade(self);
1620 runtime.spawn(async move {
1621 while let Some(event) = rx.recv().await {
1622 let Event::OptionChanged { name, new, .. } = event else {
1623 continue;
1624 };
1625 let Some(plugin_id) = name.strip_suffix(".enabled") else {
1626 continue;
1627 };
1628 let Some(loader) = weak.upgrade() else { break };
1629 // Map `<id>.enabled` → the loaded plugin's default modes.
1630 let modes = {
1631 let loaded = loader
1632 .loaded
1633 .lock()
1634 .expect("plugin-loader loaded-set mutex poisoned");
1635 loaded
1636 .iter()
1637 .find(|r| r.name == plugin_id)
1638 .map(|r| r.default_modes.clone())
1639 .unwrap_or_default()
1640 };
1641 for mode in modes {
1642 bus.publish(lattice_protocol::Event::ModeEnablementRequested {
1643 mode,
1644 enabled: new == "true",
1645 });
1646 }
1647 }
1648 });
1649 }
1650
1651 /// Self-register the `:plugin-load` / `:plugin-unload` / `:plugin-reload`
1652 /// ex-commands into the runtime-mutable command registry (option A — the
1653 /// loader owns its full command surface; zero host code). Plain command
1654 /// names resolve directly via `id_by_name` (no `expand_alias` host entry),
1655 /// exactly like plugin-contributed ex-commands. Called once by [`install`]
1656 /// after the loader is constructed; a no-op (logged) if no command registry
1657 /// was wired.
1658 ///
1659 /// The apply closures capture a `Weak<Self>`. The command registry holds
1660 /// them and the loader holds the registry, so a strong capture was a cycle
1661 /// that kept the loader — and its plugin host's engine and threads — alive
1662 /// after the editor that booted it had been dropped.
1663 pub fn register_ex_commands(self: &Arc<Self>) {
1664 let Some(registry) = self.env.command_registry.clone() else {
1665 tracing::warn!(
1666 "no command registry wired; :plugin-load / :plugin-unload / :plugin-reload unavailable"
1667 );
1668 return;
1669 };
1670 // load → clone → register → store (single-threaded at boot; no retry).
1671 let mut next = (**registry.load()).clone();
1672 ex_commands::register_all(&mut next, self);
1673 registry.store(Arc::new(next));
1674 }
1675
1676 /// Spawn an async [`load_path`](Self::load_path) on the loader's own runtime
1677 /// — the `:plugin-load` apply path (a sync ex-command closure kicking off
1678 /// async work). Completion / failure surfaces via `tracing` (→ `*messages*`).
1679 pub(crate) fn spawn_load_path(self: &Arc<Self>, dir: std::path::PathBuf) {
1680 let Some(runtime) = self.env.runtime.clone() else {
1681 tracing::warn!("no runtime wired; :plugin-load cannot run");
1682 return;
1683 };
1684 let this = Arc::clone(self);
1685 runtime.spawn(async move {
1686 match this.load_path(&dir, TrustTier::UserInstalled).await {
1687 Ok(id) => {
1688 tracing::info!(id = id.0, dir = %dir.display(), "plugin loaded (:plugin-load)")
1689 }
1690 Err(err) => {
1691 tracing::warn!(dir = %dir.display(), error = %err, ":plugin-load failed")
1692 }
1693 }
1694 });
1695 }
1696
1697 /// Spawn an async [`reload`](Self::reload) on the loader's own runtime — the
1698 /// `:plugin-reload` apply path. Reports via `tracing` (→ `*messages*`).
1699 pub(crate) fn spawn_reload(self: &Arc<Self>, target: String) {
1700 let Some(runtime) = self.env.runtime.clone() else {
1701 tracing::warn!("no runtime wired; :plugin-reload cannot run");
1702 return;
1703 };
1704 let this = Arc::clone(self);
1705 runtime.spawn(async move {
1706 match this.reload(&target, TrustTier::UserInstalled).await {
1707 Ok(id) => {
1708 tracing::info!(id = id.0, plugin = %target, "plugin reloaded (:plugin-reload)")
1709 }
1710 Err(err) => {
1711 tracing::warn!(plugin = %target, error = %err, ":plugin-reload failed")
1712 }
1713 }
1714 });
1715 }
1716
1717 /// The loaded plugins, in the order `:plugins` lists them.
1718 ///
1719 /// Snapshotted before a bulk run starts rather than iterated live: every
1720 /// leg of a rebuild or update unloads and re-appends its plugin, so
1721 /// walking the live set while mutating it would visit some plugins twice
1722 /// and miss others.
1723 fn bulk_targets(&self) -> Vec<(String, SourceRecord)> {
1724 self.plugin_status()
1725 .into_iter()
1726 .map(|row| (row.name, row.source))
1727 .collect()
1728 }
1729
1730 /// Run `op` over every loaded plugin, reporting each leg as it starts.
1731 ///
1732 /// **Sequential, deliberately.** The obvious reading is that N plugins
1733 /// should run concurrently, and it is wrong three times over: `cargo`
1734 /// already saturates the machine, so N of them contend rather than
1735 /// parallelise (and can exhaust the disk — a full build tree is tens of
1736 /// gigabytes); every leg finishes by reloading, which mutates the shared
1737 /// registries by copy-on-write RCU, so overlapping legs race to publish;
1738 /// and a user watching the view wants to read which plugin is building
1739 /// now, not six rows all claiming to be.
1740 ///
1741 /// A leg's failure never stops the next one — the same rule `install_all`
1742 /// follows at boot, for the same reason: one broken plugin should cost you
1743 /// that plugin, not the rest.
1744 ///
1745 /// `on_leg(done, total, name)` fires BEFORE each leg runs, which is what
1746 /// lets the `:plugins` view say which plugin it is on rather than only
1747 /// what it finished. A caller with nothing to show passes a no-op.
1748 pub async fn run_bulk(
1749 &self,
1750 op: BulkOp,
1751 on_leg: &(dyn Fn(usize, usize, &str) + Send + Sync),
1752 ) -> BulkReport {
1753 let targets = self.bulk_targets();
1754 let total = targets.len();
1755 let mut report = BulkReport::default();
1756 for (done, (name, source)) in targets.into_iter().enumerate() {
1757 on_leg(done, total, &name);
1758 let leg = self.run_leg(op, &name, &source).await;
1759 report.legs.push((name, leg));
1760 }
1761 report
1762 }
1763
1764 /// One plugin's leg of a bulk run.
1765 ///
1766 /// The skip arms are what keeps `op` honest about scope: a bundled plugin
1767 /// has nothing to build from and a pinned one has nothing to update to, and
1768 /// neither is a failure the user should go looking into.
1769 async fn run_leg(&self, op: BulkOp, name: &str, source: &SourceRecord) -> BulkLeg {
1770 match op {
1771 BulkOp::Reload => match self.reload(name, TrustTier::UserInstalled).await {
1772 Ok(_) => BulkLeg::Done,
1773 // `error_chain`, not `to_string`: a reload failure is almost
1774 // always reported by an inner cause (a missing artifact, a
1775 // trap at instantiation), and the outer layer alone says
1776 // nothing actionable.
1777 Err(why) => BulkLeg::Failed(error_chain(&why)),
1778 },
1779 BulkOp::Rebuild => {
1780 // `init` is buildable in place even though its `SourceRecord` is
1781 // `Unknown` (see `rebuild`), so it is the one exception to the
1782 // buildable-source skip — `rebuild` routes it correctly.
1783 if name != INIT_PLUGIN_ID && !source.is_buildable() {
1784 // Bundled ships prebuilt; Unknown has nowhere to build from.
1785 return BulkLeg::Skipped(format!("no buildable source ({})", source.label()));
1786 }
1787 match self.rebuild(name).await {
1788 Ok(()) => BulkLeg::Done,
1789 Err(why) => BulkLeg::Failed(why),
1790 }
1791 }
1792 BulkOp::Update => {
1793 if let Some(why) = update_refusal(name, source.as_plugin_source().as_ref()) {
1794 return BulkLeg::Skipped(why);
1795 }
1796 if !source.is_buildable() && !matches!(source, SourceRecord::Prebuilt { .. }) {
1797 return BulkLeg::Skipped(format!("no updatable source ({})", source.label()));
1798 }
1799 match self.update(name).await {
1800 Ok(()) => BulkLeg::Done,
1801 Err(why) => BulkLeg::Failed(why),
1802 }
1803 }
1804 }
1805 }
1806
1807 /// Rebuild every loaded plugin from the source it already has, then reload
1808 /// each. See [`Self::run_bulk`].
1809 pub async fn rebuild_all(&self) -> BulkReport {
1810 self.run_bulk(BulkOp::Rebuild, &|_, _, _| {}).await
1811 }
1812
1813 /// Update every loaded plugin: bring each source up to date, rebuild,
1814 /// reload. Pinned plugins are skipped. See [`Self::run_bulk`].
1815 pub async fn update_all(&self) -> BulkReport {
1816 self.run_bulk(BulkOp::Update, &|_, _, _| {}).await
1817 }
1818
1819 /// Re-instantiate every loaded plugin from the artifact already on disk —
1820 /// no build, no network. See [`Self::run_bulk`].
1821 pub async fn reload_all(&self) -> BulkReport {
1822 self.run_bulk(BulkOp::Reload, &|_, _, _| {}).await
1823 }
1824
1825 /// Staged plugin directories that nothing this session claims — what
1826 /// `clean` would remove.
1827 ///
1828 /// A directory is removable only when **all** of these hold, and each
1829 /// clause is here because dropping it deletes something a user wanted:
1830 ///
1831 /// 1. **Not loaded.** The obvious one.
1832 /// 2. **Not a load FAILURE this session.** A plugin that tried and broke
1833 /// is still a plugin the user asked for; cleaning it would turn "my
1834 /// plugin is failing" into "my plugin is gone" and hide the error the
1835 /// view was showing.
1836 /// 3. **Not `init`.** That is the user's own configuration, not a plugin,
1837 /// and it is never in the loaded set under that name.
1838 /// 4. **Carries a `.source` marker.** Provenance is what makes removal
1839 /// recoverable — with it the directory can be re-resolved and rebuilt,
1840 /// without it the bytes are the only copy. A hand-staged directory has
1841 /// no marker, and is exactly the case where deleting is unrecoverable.
1842 ///
1843 /// Returns `(name, path)` pairs in name order. Reading only — the caller
1844 /// decides whether to act, which is what lets `:plugin-clean` show the
1845 /// list and `:plugin-clean!` act on it.
1846 pub fn removable_plugin_dirs(&self) -> Vec<(String, std::path::PathBuf)> {
1847 let Some(root) = default_plugins_dir() else {
1848 return Vec::new();
1849 };
1850 removable_under(&root, &self.clean_keep_set())
1851 }
1852
1853 /// Every name `clean` must leave alone — clauses 1-3 of
1854 /// [`Self::removable_plugin_dirs`].
1855 fn clean_keep_set(&self) -> std::collections::HashSet<String> {
1856 self.plugin_status()
1857 .into_iter()
1858 .map(|r| r.name)
1859 .chain(self.failed_loads().into_iter().map(|f| f.name))
1860 .chain(std::iter::once("init".to_string()))
1861 .collect()
1862 }
1863
1864 /// Remove the named staged plugin directories.
1865 ///
1866 /// Takes names rather than re-deriving the list, so the thing the user
1867 /// confirmed is the thing that gets deleted — `Effect::Confirm` carries
1868 /// the payload for this reason (effect.rs, IX.1). Re-deriving after the
1869 /// prompt would let a reload land in between and change the answer.
1870 ///
1871 /// Each name is re-checked against [`Self::removable_plugin_dirs`] before
1872 /// its directory goes: a confirmation the user left sitting while a plugin
1873 /// loaded must not delete the plugin that just arrived.
1874 pub fn clean(&self, names: &[String]) -> BulkReport {
1875 clean_listed(&self.removable_plugin_dirs(), names)
1876 }
1877
1878 /// Run a bulk verb on the loader's runtime, reporting through `*messages*`.
1879 ///
1880 /// The ex-command `apply` that calls this must return immediately — a bulk
1881 /// rebuild is minutes of `cargo`, and the dispatch path is the keystroke
1882 /// path. So the whole run is spawned and its outcome surfaces the way
1883 /// every other async plugin outcome does: one `info!` with the counts, one
1884 /// `warn!` per failure naming the plugin.
1885 pub(crate) fn spawn_bulk(self: &Arc<Self>, op: BulkOp) {
1886 let Some(runtime) = self.env.runtime.clone() else {
1887 tracing::warn!(?op, "no runtime wired; bulk plugin operation cannot run");
1888 return;
1889 };
1890 let this = Arc::clone(self);
1891 runtime.spawn(async move {
1892 let report = this.run_bulk(op, &|_, _, _| {}).await;
1893 for (name, why) in report.failures() {
1894 tracing::warn!(plugin = %name, error = %why, ?op, "bulk plugin operation failed");
1895 }
1896 tracing::info!(summary = %report.summary(op.past()), ?op, "bulk plugin operation done");
1897 });
1898 }
1899
1900 /// `:plugin-update <name>` — [`Self::update`] on the loader's runtime.
1901 ///
1902 /// Mirrors [`Self::spawn_reload`]: the ex-command's `apply` must not block
1903 /// the dispatch path, so the work is spawned and its outcome reported
1904 /// through `*messages*` — the one-shot user-actionable class.
1905 pub(crate) fn spawn_update(self: &Arc<Self>, target: String) {
1906 let Some(runtime) = self.env.runtime.clone() else {
1907 tracing::warn!("no runtime wired; :plugin-update cannot run");
1908 return;
1909 };
1910 let this = Arc::clone(self);
1911 runtime.spawn(async move {
1912 match this.update(&target).await {
1913 Ok(()) => tracing::info!(plugin = %target, "plugin updated (:plugin-update)"),
1914 Err(err) => tracing::warn!(plugin = %target, error = %err, ":plugin-update failed"),
1915 }
1916 });
1917 }
1918
1919 /// Load a single plugin from an explicit directory — the `:plugin-load <path>`
1920 /// entry point (PL8.C). Unlike [`discover_and_load`](Self::discover_and_load)
1921 /// (a tree scan that silently skips non-plugin dirs), a direct request
1922 /// surfaces a bad path as a [`PluginLoaderError::Discovery`] the user sees.
1923 pub async fn load_path(
1924 &self,
1925 dir: &std::path::Path,
1926 tier: TrustTier,
1927 ) -> Result<PluginId, PluginLoaderError> {
1928 let plugin = discovery::discover_one(dir).map_err(PluginLoaderError::Discovery)?;
1929 // WT.4: a `discover_one` failure is deliberately NOT recorded above —
1930 // that is "this directory is not a plugin", which the caller asked about
1931 // and gets as an error. Past this point the directory *is* a plugin, so
1932 // a failure is a plugin that should be here and is not, and it belongs
1933 // in `:plugins` however the load was triggered.
1934 let outcome = self.load_discovered(&plugin, tier).await;
1935 match &outcome {
1936 Ok(_) => self.clear_failure(&plugin.manifest.id),
1937 Err(err) => self.record_failure(&plugin.manifest.id, dir, &error_chain(err)),
1938 }
1939 outcome
1940 }
1941
1942 /// PM.8b: how many builds are running right now.
1943 ///
1944 /// The `:plugins` headerline reads this, per the
1945 /// async-buffer-status-in-headerline rule — a build takes seconds to
1946 /// minutes and the user needs to see it is happening somewhere other than
1947 /// a status line that the next echo will overwrite.
1948 pub fn builds_in_flight(&self) -> usize {
1949 self.building_count
1950 .load(std::sync::atomic::Ordering::Relaxed)
1951 }
1952
1953 fn set_build_activity(&self, name: &str, activity: Option<BuildActivity>) {
1954 if let Ok(mut map) = self.building.lock() {
1955 match activity {
1956 Some(a) => {
1957 map.insert(name.to_string(), a);
1958 }
1959 None => {
1960 map.remove(name);
1961 }
1962 }
1963 // Recount under the same lock the map was mutated under, so the
1964 // counter can never disagree with it.
1965 let running = map
1966 .values()
1967 .filter(|a| matches!(a, BuildActivity::Running))
1968 .count();
1969 self.building_count
1970 .store(running, std::sync::atomic::Ordering::Relaxed);
1971 }
1972 }
1973
1974 /// PM.8b: force a fresh build of `name` from its recorded source, then
1975 /// reload it.
1976 ///
1977 /// "Force" is the difference from an ordinary load: the build service
1978 /// short-circuits on a matching stamp, which is exactly what you do NOT
1979 /// want when a user pressed rebuild. The stamp is removed first so the
1980 /// build is unconditional — the user asked, not the staleness check.
1981 ///
1982 /// Returns the error when the rebuild could not happen or did not
1983 /// succeed, having left the plugin as it was. A failed rebuild never
1984 /// unloads a working plugin: PM.5's `StaleKept` keeps the old artifact,
1985 /// and this reloads from it.
1986 ///
1987 /// Blocking work runs on `spawn_blocking`; only the reload is awaited.
1988 pub async fn rebuild(&self, name: &str) -> Result<(), String> {
1989 // `init` compiles in place in the config dir and carries no buildable
1990 // `SourceRecord`, so the generic `rebuild_with` pipeline (stage-into-
1991 // cache, refuses a non-buildable source) cannot rebuild it — the
1992 // plugins view's `b` on the `init` row would fail with "no buildable
1993 // source". Route it to the in-place build the boot path and
1994 // `:reload-config` share, so `b` on `init` does the thing the user
1995 // pressed it for and surfaces the compiler error on failure.
1996 if name == INIT_PLUGIN_ID {
1997 return self.rebuild_init().await;
1998 }
1999 self.rebuild_with(name, resolve::RefreshPolicy::UseCache, "rebuild")
2000 .await
2001 }
2002
2003 /// The plugins-view `b` on the `init` row (and `B` when it reaches `init`):
2004 /// build `init.rs` in place, reload, and reflect the build in the view's
2005 /// activity flag so the row reads `cached` on success or `build-failed` on
2006 /// failure. A build failure returns `Err` with the compiler diagnostics —
2007 /// even though the previous config keeps running — so the handler logs the
2008 /// detail to `*messages*` and the row flips to `build-failed`.
2009 async fn rebuild_init(&self) -> Result<(), String> {
2010 self.set_build_activity(INIT_PLUGIN_ID, Some(BuildActivity::Running));
2011 match self.reload_config().await {
2012 Ok(report) => match report.build {
2013 ConfigBuildStatus::BuildFailed(error) => {
2014 self.set_build_activity(INIT_PLUGIN_ID, Some(BuildActivity::Failed));
2015 Err(error)
2016 }
2017 _ => {
2018 self.set_build_activity(INIT_PLUGIN_ID, None);
2019 Ok(())
2020 }
2021 },
2022 Err(err) => {
2023 self.set_build_activity(INIT_PLUGIN_ID, Some(BuildActivity::Failed));
2024 Err(error_chain(&err))
2025 }
2026 }
2027 }
2028
2029 /// Bring `name` up to date with its upstream, then rebuild and reload it.
2030 ///
2031 /// The difference from [`Self::rebuild`] is one argument — the
2032 /// [`RefreshPolicy`](resolve::RefreshPolicy) the resolver runs under — but
2033 /// it is the whole verb: rebuild compiles the source you already have,
2034 /// update goes and gets a newer one first.
2035 ///
2036 /// What "newer" means is the source's to answer, and three of the four
2037 /// kinds answer it without any work here:
2038 ///
2039 /// | source | update |
2040 /// |---|---|
2041 /// | `Git { rev: None }` | fetch, move to the tracked head, rebuild |
2042 /// | `Git { rev: Some(_) }` | **declines** — a pin is the answer already |
2043 /// | `Local(_)` | rebuild; the directory IS the source, so it is always current |
2044 /// | `Prebuilt { url }` | re-download (the resolver fetches unconditionally) |
2045 ///
2046 /// The pinned arm declines rather than silently rebuilding, because those
2047 /// are different outcomes and a user who pinned a plugin and then pressed
2048 /// update is owed the reason nothing moved.
2049 pub async fn update(&self, name: &str) -> Result<(), String> {
2050 let source = {
2051 let loaded = self
2052 .loaded
2053 .lock()
2054 .map_err(|_| "plugin registry unavailable".to_string())?;
2055 loaded
2056 .iter()
2057 .find(|r| r.name == name)
2058 .ok_or_else(|| format!("`{name}` is not loaded"))?
2059 .source
2060 .clone()
2061 };
2062 if let Some(reason) = update_refusal(name, source.as_plugin_source().as_ref()) {
2063 return Err(reason);
2064 }
2065 self.rebuild_with(name, resolve::RefreshPolicy::Update, "update")
2066 .await
2067 }
2068
2069 /// The body [`Self::rebuild`] and [`Self::update`] share.
2070 ///
2071 /// `verb` appears only in the error text for a failed task join, so the
2072 /// message names the thing the user actually pressed.
2073 async fn rebuild_with(
2074 &self,
2075 name: &str,
2076 policy: resolve::RefreshPolicy,
2077 verb: &str,
2078 ) -> Result<(), String> {
2079 let (source, dir) = {
2080 let loaded = self
2081 .loaded
2082 .lock()
2083 .map_err(|_| "plugin registry unavailable".to_string())?;
2084 let record = loaded
2085 .iter()
2086 .find(|r| r.name == name)
2087 .ok_or_else(|| format!("`{name}` is not loaded"))?;
2088 (record.source.clone(), record.source_dir.clone())
2089 };
2090 if !source.is_buildable() {
2091 // Bundled ships prebuilt and Unknown has nowhere to build from.
2092 // Saying so beats running a build that cannot work.
2093 return Err(format!(
2094 "`{name}` has no buildable source ({})",
2095 source.label()
2096 ));
2097 }
2098 let Some(plugin_source) = source.as_plugin_source() else {
2099 return Err(format!("`{name}` has no recorded source"));
2100 };
2101 let Some(user_root) = default_plugins_dir() else {
2102 return Err("no config directory for the plugin cache".to_string());
2103 };
2104
2105 self.set_build_activity(name, Some(BuildActivity::Running));
2106 // Drop the stamp so the build is unconditional — see above.
2107 if let Some(dir) = &dir {
2108 let _ = std::fs::remove_file(dir.join(".build-stamp"));
2109 }
2110
2111 let spec = pipeline::RequiredSpec {
2112 name: name.to_string(),
2113 source: plugin_source,
2114 enable_mode: None,
2115 pinned: false,
2116 };
2117 let cache_root = default_source_cache_dir();
2118 let install = tokio::task::spawn_blocking(move || {
2119 pipeline::install_required(
2120 &resolve::SystemGit,
2121 &resolve::HttpFetcher,
2122 &build::CargoComponentBuilder,
2123 &spec,
2124 &cache_root,
2125 &user_root,
2126 policy,
2127 )
2128 })
2129 .await
2130 .map_err(|e| format!("{verb} task failed: {e}"))?;
2131
2132 match install {
2133 pipeline::Install::Ready {
2134 stale: Some(err), ..
2135 } => {
2136 self.set_build_activity(name, Some(BuildActivity::Failed));
2137 Err(err)
2138 }
2139 pipeline::Install::Skipped { error, .. } => {
2140 self.set_build_activity(name, Some(BuildActivity::Failed));
2141 Err(error)
2142 }
2143 pipeline::Install::Ready { .. } => {
2144 // Clear before the reload, not after: the reload republishes
2145 // status, and a row still reading `building…` after its build
2146 // finished is the kind of stuck indicator users stop trusting.
2147 self.set_build_activity(name, None);
2148 let tier = TrustTier::UserInstalled;
2149 self.reload(name, tier)
2150 .await
2151 .map(|_| ())
2152 .map_err(|e| format!("rebuilt, but reload failed: {e}"))
2153 }
2154 }
2155 }
2156
2157 /// PM.7b: take the plugins declared via `require` so far, leaving the
2158 /// queue empty.
2159 ///
2160 /// Drained exactly once per boot by the install task. Draining rather than
2161 /// reading is what stops a second call from resolving, building and
2162 /// loading the same set twice.
2163 pub fn take_required(&self) -> Vec<pipeline::RequiredSpec> {
2164 self.required
2165 .lock()
2166 .map(|mut q| std::mem::take(&mut *q))
2167 .unwrap_or_default()
2168 }
2169
2170 /// Unload the plugin named `target` (its manifest id, or its numeric plugin
2171 /// id): abort its actor tasks and reverse every registry contribution via
2172 /// [`PluginTeardown`]. Returns the [`TeardownReport`] (what each surface
2173 /// removed), or `None` if no loaded plugin matched. **Synchronous** —
2174 /// teardown and `JoinHandle::abort` don't await — so an ex-command `apply`
2175 /// closure can call it directly. Idempotent per the teardown contract.
2176 pub fn unload(&self, target: &str) -> Option<TeardownReport> {
2177 let record = {
2178 let mut loaded = self
2179 .loaded
2180 .lock()
2181 .expect("plugin-loader loaded-set mutex poisoned");
2182 let pos = loaded.iter().position(|r| record_matches(r, target))?;
2183 loaded.remove(pos)
2184 };
2185
2186 // The running-actor half: abort the detached seam tasks (picker / events
2187 // / completion). The registry half is the `PluginTeardown` below.
2188 for task in &record.tasks {
2189 task.abort();
2190 }
2191 let report = self.run_teardown(&record.teardown);
2192 if let Some(sink) = &self.env.meta_sink {
2193 sink.unregister_plugin(record.id.0);
2194 }
2195 // PO.1/PO.5: reclaim this plugin's boundary-trace state (its per-plugin
2196 // ring, gate override, and hot-path gate). ids are monotonic, so without
2197 // this every unload/reload would leak a ring — the global ring keeps the
2198 // historical records. The tracer lock is poison-tolerant, so this never
2199 // fails the unload.
2200 if let Some(tracer) = &self.env.tracer {
2201 tracer.forget_plugin(record.id.0);
2202 }
2203 // CI.1: announce the unload AFTER teardown reversed every contribution, so
2204 // a handler tears down its own dependent setup against a plugin that's
2205 // already gone from the registries.
2206 if let Some(bus) = &self.env.bus {
2207 bus.publish(lattice_protocol::Event::PluginUnloaded {
2208 name: record.name.clone(),
2209 id: record.id.0,
2210 });
2211 }
2212 tracing::info!(
2213 plugin = %record.name,
2214 id = record.id.0,
2215 ?report,
2216 "plugin unloaded"
2217 );
2218 Some(report)
2219 }
2220
2221 /// Reload the plugin named `target`: [`unload`](Self::unload) it, then
2222 /// re-[`load_path`](Self::load_path) from its recorded source directory —
2223 /// minting a fresh `Store` with a fresh, untripped `Quarantine` (the reload
2224 /// contract, teardown.rs §"Why no reload method"). Errors if `target` names
2225 /// no loaded plugin ([`NotLoaded`](PluginLoaderError::NotLoaded)) or it has
2226 /// no on-disk source ([`NotReloadable`](PluginLoaderError::NotReloadable)).
2227 pub async fn reload(
2228 &self,
2229 target: &str,
2230 tier: TrustTier,
2231 ) -> Result<PluginId, PluginLoaderError> {
2232 // Capture the source dir before unloading (unload removes the record).
2233 let dir = {
2234 let loaded = self
2235 .loaded
2236 .lock()
2237 .expect("plugin-loader loaded-set mutex poisoned");
2238 let record = loaded
2239 .iter()
2240 .find(|r| record_matches(r, target))
2241 .ok_or_else(|| PluginLoaderError::NotLoaded(target.to_string()))?;
2242 record
2243 .source_dir
2244 .clone()
2245 .ok_or_else(|| PluginLoaderError::NotReloadable(record.name.clone()))?
2246 };
2247 self.unload(target);
2248 self.load_path(&dir, tier).await
2249 }
2250
2251 /// Load the `init` config if it isn't loaded, or reload it if it is — the
2252 /// idempotent "make init reflect what's on disk" the auto-reload watcher
2253 /// (PL8.D.4) fires on every change to `<config>/lattice/init/`. First good
2254 /// build loads; a rebuild reloads (unbinding the old keymaps / commands and
2255 /// re-applying); a *broken* rebuild leaves `init` unloaded (reload unloads
2256 /// before it fails to re-load), which the next good build heals — `is_loaded`
2257 /// is then false, so this loads rather than reloads. Errors propagate for the
2258 /// caller to log; never panics.
2259 pub async fn sync_init(
2260 &self,
2261 init_dir: &std::path::Path,
2262 tier: TrustTier,
2263 ) -> Result<PluginId, PluginLoaderError> {
2264 if self.is_loaded(INIT_PLUGIN_ID) {
2265 self.reload(INIT_PLUGIN_ID, tier).await
2266 } else {
2267 self.load_path(init_dir, tier).await
2268 }
2269 }
2270
2271 /// The `source_dir` of the loaded `init` plugin, if it is loaded — the dir
2272 /// `reload_config` rebuilds and reloads from. `None` before init's first
2273 /// load (the caller then falls back to [`default_init_dir`](crate::default_init_dir)).
2274 fn init_source_dir(&self) -> Option<std::path::PathBuf> {
2275 let loaded = self.loaded.lock().ok()?;
2276 loaded
2277 .iter()
2278 .find(|r| r.name == INIT_PLUGIN_ID)?
2279 .source_dir
2280 .clone()
2281 }
2282
2283 /// Rebuild the user's `init.rs` **in place**, then (re)load it — the
2284 /// source → artifact step `:reload-config` and the plugins view's
2285 /// rebuild-of-`init` need and that the bare artifact reload
2286 /// ([`reload`](Self::reload) / [`load_path`](Self::load_path)) cannot do.
2287 ///
2288 /// This mirrors the boot path ([`install::build_init`](crate::install) +
2289 /// [`sync_init`](Self::sync_init)). Before it existed, `:reload-config`
2290 /// re-instantiated the **stale** on-disk `init.wasm`, so an edited option
2291 /// (e.g. `tabstop`) took effect only after a full restart — the one path
2292 /// that recompiled. `init`'s `SourceRecord` is `Unknown` (it is discovered
2293 /// directly, never "installed", so it has no `.source` marker) and it
2294 /// compiles in place rather than staging into the plugin cache, so it
2295 /// cannot go through the generic [`rebuild_with`](Self::rebuild_with)
2296 /// pipeline — hence this dedicated seam.
2297 ///
2298 /// Loads at the [`Bundled`](TrustTier::Bundled) tier the boot path uses —
2299 /// the user's own config is trusted, not a third-party plugin.
2300 ///
2301 /// A build failure with a previous artifact present keeps the last good
2302 /// config running and reports [`ConfigBuildStatus::BuildFailed`] with the
2303 /// compiler error (an `Ok` whose [`applied_new_config`] is `false`); a build
2304 /// failure with **no** previous artifact surfaces as `Err` — there is
2305 /// nothing to load.
2306 ///
2307 /// [`applied_new_config`]: ReloadConfigReport::applied_new_config
2308 pub async fn reload_config(&self) -> Result<ReloadConfigReport, PluginLoaderError> {
2309 // Rebuild exactly what is loaded: prefer the loaded `init` record's own
2310 // source dir (which `sync_init` → `reload` reloads from), so the build
2311 // target and the reload target can never diverge; fall back to the
2312 // default config dir for the first load (init not yet loaded).
2313 let init_dir = self
2314 .init_source_dir()
2315 .or_else(crate::default_init_dir)
2316 .ok_or_else(|| {
2317 PluginLoaderError::Discovery(
2318 "no config directory for init.rs (set XDG_CONFIG_HOME / HOME)".to_string(),
2319 )
2320 })?;
2321 // Compile source → artifact in place first (the step reload cannot do).
2322 let outcome = crate::install::build_init(&init_dir).await;
2323 let build = match &outcome {
2324 // No cargo project: a hand-built init.wasm is loaded as-is.
2325 None => ConfigBuildStatus::HandBuilt,
2326 Some(o) => match o.error() {
2327 // A `Fresh` build recompiled; a `Cached` one was already current.
2328 None => {
2329 if matches!(o, crate::build::BuildOutcome::Fresh { .. }) {
2330 ConfigBuildStatus::Rebuilt
2331 } else {
2332 ConfigBuildStatus::AlreadyCurrent
2333 }
2334 }
2335 // `StaleKept` / `Failed`: the build failed. If a previous
2336 // artifact survived (`StaleKept`) `sync_init` reloads it below;
2337 // if not (`Failed`, no artifact) the load errors and the `?`
2338 // propagates — either way the user gets the compiler error.
2339 Some(error) => ConfigBuildStatus::BuildFailed(error.to_string()),
2340 },
2341 };
2342 let id = self.sync_init(&init_dir, TrustTier::Bundled).await?;
2343 Ok(ReloadConfigReport { id, build })
2344 }
2345
2346 /// Spawn [`reload_config`](Self::reload_config) on the runtime and report the
2347 /// detailed result to `*messages*`: `info!` when the edited config applied,
2348 /// `warn!` when the build failed but the previous config still runs, `error!`
2349 /// when `init` did not load at all. The ex-command's `apply` returns
2350 /// immediately (it must not block the dispatch path); this is where the
2351 /// success/failure detail the user asked for lands.
2352 ///
2353 /// `info!` / `warn!` / `error!` route to `*messages*` via `MessagesLayer`
2354 /// (the one-shot, user-actionable log-level rule), so the compiler error on
2355 /// a failed rebuild is visible to `:messages` without a keypress.
2356 pub(crate) fn spawn_reload_config(self: &Arc<Self>) {
2357 let Some(runtime) = self.env.runtime.clone() else {
2358 tracing::warn!("no runtime wired; :reload-config cannot run");
2359 return;
2360 };
2361 let this = Arc::clone(self);
2362 runtime.spawn(async move {
2363 match this.reload_config().await {
2364 Ok(report) if report.applied_new_config() => {
2365 tracing::info!(id = report.id.0, "{}", report.summary());
2366 }
2367 Ok(report) => {
2368 // Build failed; the previous config is still running.
2369 tracing::warn!(id = report.id.0, "{}", report.summary());
2370 }
2371 Err(err) => tracing::error!(
2372 error = %error_chain(&err),
2373 "config reload FAILED — init.rs did not load"
2374 ),
2375 }
2376 });
2377 }
2378
2379 /// Reverse a plugin's registry contributions against the live registries.
2380 /// The `ArcSwap`-held registries (command / picker / mode) are RCU'd —
2381 /// snapshot-clone → `&mut` → [`PluginTeardown::unload`] → store — while the
2382 /// `Arc`-shared interior-mutable ones (config / keymap / bus) pass by
2383 /// reference. A missing registry handle (a partially-wired test loader)
2384 /// downgrades to a logged no-op reversal, never a panic.
2385 fn run_teardown(&self, teardown: &PluginTeardown) -> TeardownReport {
2386 // CR.3: the help registry is reversed FIRST, and deliberately outside
2387 // the all-or-nothing `let-else` below.
2388 //
2389 // Two reasons. It lives in `lattice-help`, so `PluginTeardown::unload`
2390 // — which is in `lattice-plugin-host` — cannot touch it without
2391 // pulling that crate across the boundary for one field. And it shares
2392 // nothing with the handles the `let-else` demands, so gating it on
2393 // them would mean an under-wired loader leaves a plugin's `:help`
2394 // pages behind after `:plugin-unload` reported success. Stale docs
2395 // for code that is gone is a worse failure than the partial teardown
2396 // that caused it, and a quieter one.
2397 // Over EVERY seam id — `help` is rarely a plugin's first seam, and
2398 // reversing only `plugin_id` is what left `auto-pair`'s pages behind.
2399 let provenances = teardown.provenances();
2400 let mut help_topics_removed = 0;
2401 if let Some(help_h) = self.env.help_topics.as_ref() {
2402 help_h.rcu(|current| {
2403 let mut next = (**current).clone();
2404 help_topics_removed = 0;
2405 for id in &provenances {
2406 help_topics_removed += next.unregister_plugin(id.0 as u64);
2407 }
2408 Arc::new(next)
2409 });
2410 }
2411 // LG.3c: same placement and reasoning as `help` above, with one
2412 // difference worth noting — the language registry is process-global,
2413 // so unlike every other registry here there is no handle that can be
2414 // absent and therefore no way for this to be silently skipped by an
2415 // under-wired loader. Leaving a language registered would be worse
2416 // than stale docs: a buffer would keep claiming a grammar its plugin
2417 // no longer provides.
2418 let languages_removed: usize = provenances
2419 .iter()
2420 .map(|id| lattice_syntax::plugin_lang::unregister_plugin(id.0 as u64))
2421 .sum();
2422 // CR.4: same placement, same reasoning — plus one of its own. Leaving
2423 // a plugin's section registered after unload would keep calling a
2424 // guest whose plugin is gone on every compose.
2425 let mut dashboard_sections_removed = 0;
2426 if let Some(dash_h) = self.env.dashboard_sections.as_ref() {
2427 dash_h.rcu(|current| {
2428 let mut next = (**current).clone();
2429 dashboard_sections_removed = 0;
2430 for id in &provenances {
2431 dashboard_sections_removed += next.unregister_plugin(id.0 as u64);
2432 }
2433 Arc::new(next)
2434 });
2435 }
2436 // TR.2b: same placement and reasoning as `help` above — the registry is
2437 // `Arc`-shared with interior mutability rather than one of the `&mut`
2438 // snapshots below, and leaving a name registered would be worse than
2439 // stale docs: the entry holds a client whose actor has ended, so the
2440 // chord would report a host error rather than "unknown source".
2441 let mut transient_sources_removed = 0;
2442 if let Some(tr_h) = self.env.transient_registry.as_ref() {
2443 for name in &teardown.transient_sources {
2444 if tr_h.unregister(name) {
2445 transient_sources_removed += 1;
2446 }
2447 }
2448 }
2449 // MV.1: same placement and reasoning as `help` and `transient` above,
2450 // and it is a correctness point rather than tidiness. The guard below
2451 // is all-or-nothing — a missing command registry skips the ENTIRE
2452 // unload — and a command registry is not a precondition for reversing a
2453 // VIEW registration. Leaving one registered is worse than stale docs:
2454 // `ProviderViewRegistry::register` refuses rather than replaces, so a
2455 // reload would hit `false` against the plugin's own dead opener and its
2456 // views would come back permanently broken.
2457 if let Some(pv_h) = self.env.provider_view_registry.as_ref() {
2458 for name in &teardown.provider_views {
2459 pv_h.unregister(name);
2460 }
2461 }
2462 let (
2463 Some(cmd_h),
2464 Some(pick_h),
2465 Some(mode_h),
2466 Some(config),
2467 Some(keymap),
2468 Some(bus),
2469 Some(deco_h),
2470 Some(ctx_h),
2471 Some(theme_h),
2472 Some(parsers_h),
2473 ) = (
2474 self.env.command_registry.as_ref(),
2475 self.env.picker_registry.as_ref(),
2476 self.env.mode_registry.as_ref(),
2477 self.env.config_registry.as_ref(),
2478 self.env.keymap.as_ref(),
2479 self.env.bus.as_ref(),
2480 self.env.decoration_registry.as_ref(),
2481 self.env.context_registry.as_ref(),
2482 self.env.theme_registry.as_ref(),
2483 self.env.parser_factories.as_ref(),
2484 )
2485 else {
2486 tracing::warn!(
2487 "plugin teardown skipped: loader missing a registry handle (partial unload)"
2488 );
2489 return TeardownReport {
2490 help_topics: help_topics_removed,
2491 dashboard_sections: dashboard_sections_removed,
2492 languages: languages_removed,
2493 transient_sources: transient_sources_removed,
2494 ..TeardownReport::default()
2495 };
2496 };
2497
2498 // Owned snapshots of the ArcSwap registries for the `&mut` unload needs.
2499 let mut commands = (**cmd_h.load()).clone();
2500 let mut pickers = (**pick_h.load()).clone();
2501 let mut modes = (**mode_h.load()).clone();
2502 let mut decorations = (**deco_h.load()).clone();
2503 let mut contexts = (**ctx_h.load()).clone();
2504 let report = {
2505 let mut media_reg = self
2506 .env
2507 .media_registry
2508 .as_ref()
2509 .map(|r| (**r.load()).clone())
2510 .unwrap_or_default();
2511 let mut agenda_reg = self
2512 .env
2513 .agenda_registry
2514 .as_ref()
2515 .map(|r| (**r.load()).clone())
2516 .unwrap_or_default();
2517 let mut reg = TeardownRegistries {
2518 // Reversed ABOVE the guard, not here — see the comment there.
2519 provider_views: None,
2520 media: &mut media_reg,
2521 agenda: &mut agenda_reg,
2522 commands: &mut commands,
2523 pickers: &mut pickers,
2524 modes: &mut modes,
2525 keymap,
2526 config,
2527 bus,
2528 decorations: &mut decorations,
2529 contexts: &mut contexts,
2530 theme: &**theme_h,
2531 // SG.3a: NOT part of the guard tuple above, for the same
2532 // reason as `modeline` below — a sign registry is not a
2533 // precondition for reversing a config option, and making it
2534 // one would turn every unload in a harness without one into a
2535 // silent no-op.
2536 signs: self.env.sign_registry.as_ref(),
2537 // OC.3: NOT part of the guard tuple above. That tuple is
2538 // all-or-nothing — a missing handle skips the ENTIRE unload —
2539 // and a modeline is not a precondition for reversing a config
2540 // option. `WiredSeams::all()` is where its absence is caught.
2541 modeline: self.env.modeline.as_ref(),
2542 // CM.6b: RCU'd inside `unload` (it holds the `ArcSwap`
2543 // handle directly rather than a `&mut` snapshot), because a
2544 // compilation run may be reading it concurrently and the
2545 // common case removes nothing at all.
2546 parsers: parsers_h,
2547 };
2548 let report = teardown.unload(&mut reg);
2549 // The media / agenda snapshots are `&mut` clones, so the reversal
2550 // has to be published back the same way the ArcSwap registries
2551 // below are. Missing this is how an unloaded producer keeps
2552 // contributing until the next reload.
2553 if let Some(h) = self.env.media_registry.as_ref() {
2554 h.store(Arc::new(media_reg));
2555 }
2556 if let Some(h) = self.env.agenda_registry.as_ref() {
2557 h.store(Arc::new(agenda_reg));
2558 }
2559 report
2560 };
2561 // Publish the reversed snapshots (RCU store).
2562 cmd_h.store(Arc::new(commands));
2563 pick_h.store(Arc::new(pickers));
2564 mode_h.store(Arc::new(modes));
2565 deco_h.store(Arc::new(decorations));
2566 ctx_h.store(Arc::new(contexts));
2567 let mut report = report;
2568 report.help_topics = help_topics_removed;
2569 report.transient_sources = transient_sources_removed;
2570 report.languages = languages_removed;
2571 report.dashboard_sections = dashboard_sections_removed;
2572 report
2573 }
2574
2575 /// Drain the picker seam: spawn the source actor, fetch its spec, register
2576 /// the `WasmPickerSource` into the picker registry by copy-on-write RCU, and
2577 /// spawn the actor's `run` loop on the runtime. Records the actor task +
2578 /// source id on `record` for teardown (PL8.C).
2579 async fn drain_picker(
2580 &self,
2581 component: &lattice_plugin_host::Component,
2582 manifest: &PluginManifest,
2583 tier: TrustTier,
2584 record: &mut LoadedRecord,
2585 ) -> Result<PluginId, PluginLoaderError> {
2586 let bus = self
2587 .env
2588 .bus
2589 .as_ref()
2590 .ok_or(PluginLoaderError::NotWired("picker-source"))?;
2591 let runtime = self
2592 .env
2593 .runtime
2594 .as_ref()
2595 .ok_or(PluginLoaderError::NotWired("picker-source"))?;
2596 let registry = self
2597 .env
2598 .picker_registry
2599 .as_ref()
2600 .ok_or(PluginLoaderError::NotWired("picker-source"))?;
2601
2602 let (client, actor) = self
2603 .host
2604 .spawn_picker_source(
2605 component,
2606 manifest,
2607 tier,
2608 PluginBudget::default(),
2609 bus,
2610 // OR.6: a source reads options to decide what it offers. Absent,
2611 // `get-option` answers `none` on this store and a
2612 // configuration-driven source reports itself unconfigured.
2613 self.env.config_registry.as_ref(),
2614 )
2615 .await?;
2616
2617 // Drive the actor's request loop on the multi-thread runtime FIRST —
2618 // `connect_all` below issues a `register-picker-sources()` guest call
2619 // over the client channel, which the actor must be running to answer
2620 // (else the await deadlocks). PO.2: attach the boundary tracer so the
2621 // actor emits a trace record per guest call (a no-op when unwired).
2622 let actor = actor.with_tracer(self.env.tracer.clone());
2623 // The host-issued id, captured before `client` moves into the
2624 // registration call — a plugin that declares NO sources still loaded,
2625 // and still has an identity to report.
2626 let plugin_id = client.id();
2627 let task = runtime.spawn(actor.run());
2628
2629 // OR.5b: registration is a guest call that may declare SEVERAL sources.
2630 // A trapping registration fails loudly rather than registering broken
2631 // sources; an empty list registers nothing, which is what a plugin that
2632 // declared nothing asked for.
2633 let sources = WasmPickerSource::connect_all(client).await?;
2634 let id = plugin_id;
2635 // Own the ids for the teardown tokens before the sources move into the
2636 // generators below.
2637 let source_ids: Vec<String> = sources.iter().map(|s| s.spec().id.to_string()).collect();
2638
2639 // Copy-on-write RCU into the wait-free registry: clone the current
2640 // snapshot, add every source, publish. Concurrent picker-open readers
2641 // keep seeing the old snapshot until the store lands — no lock on their
2642 // path. ONE rcu for the whole batch, so a plugin's sources appear
2643 // together rather than one snapshot at a time.
2644 let generators: Vec<Arc<dyn PickerSourceGenerator>> = sources
2645 .into_iter()
2646 .map(|s| Arc::new(s) as Arc<dyn PickerSourceGenerator>)
2647 .collect();
2648 registry.rcu(|current| {
2649 let mut next = (**current).clone();
2650 for generator in &generators {
2651 next.register_generator(generator.clone());
2652 }
2653 Arc::new(next)
2654 });
2655
2656 record.tasks.push(task);
2657 // Teardown tokens: the picker registry unregisters each source by id.
2658 record.teardown.picker_sources.extend(source_ids);
2659 Ok(id)
2660 }
2661
2662 /// MV.1 — drain the multibuffer-view seam: spawn the view actor, ask the
2663 /// guest which views it owns, and register a provider-view opener for each.
2664 ///
2665 /// ## Why the opener is sync and the build is not
2666 ///
2667 /// `ProviderViewOpener` is a synchronous closure called on the dispatch
2668 /// path; `build` is an async guest call that may read files. So the opener
2669 /// seats an empty view with an in-progress headerline and returns, and a
2670 /// spawned task awaits the guest and fills it — `providers/agenda.rs`'s
2671 /// shape, for its reason: a plugin's file reads must never land on the
2672 /// dispatch path.
2673 ///
2674 /// The fill publishes `MultibufferExcerptsReady`, which has a wake wired.
2675 /// Without it the rows would sit until the user happened to press a key,
2676 /// and the symptom would read as a rendering bug rather than a missing
2677 /// wake — the bug class this codebase has re-introduced repeatedly.
2678 async fn drain_multibuffer_views(
2679 &self,
2680 component: &lattice_plugin_host::Component,
2681 manifest: &PluginManifest,
2682 tier: TrustTier,
2683 record: &mut LoadedRecord,
2684 ) -> Result<PluginId, PluginLoaderError> {
2685 let bus = self
2686 .env
2687 .bus
2688 .as_ref()
2689 .ok_or(PluginLoaderError::NotWired("multibuffer-view-source"))?;
2690 let runtime = self
2691 .env
2692 .runtime
2693 .as_ref()
2694 .ok_or(PluginLoaderError::NotWired("multibuffer-view-source"))?;
2695 let providers = self
2696 .env
2697 .provider_view_registry
2698 .as_ref()
2699 .ok_or(PluginLoaderError::NotWired("multibuffer-view-source"))?;
2700 let mb_registry = self
2701 .env
2702 .multibuffer_registry
2703 .as_ref()
2704 .ok_or(PluginLoaderError::NotWired("multibuffer-view-source"))?;
2705
2706 let (client, actor) = self
2707 .host
2708 .spawn_multibuffer_view_source(
2709 component,
2710 manifest,
2711 tier,
2712 PluginBudget::default(),
2713 bus,
2714 self.env.config_registry.as_ref(),
2715 )
2716 .await?;
2717
2718 // Drive the actor FIRST — `register_views` is a guest call over the
2719 // client channel, which the actor must be running to answer.
2720 let actor = actor.with_tracer(self.env.tracer.clone());
2721 let plugin_id = client.id();
2722 let task = runtime.spawn(actor.run());
2723
2724 let specs = client.register_views().await?;
2725
2726 // SECURITY: the plugin's effective fs grant, resolved once for the
2727 // whole drain.
2728 //
2729 // An excerpt names a PATH the guest chose, and the host reads it to
2730 // build the source document. Without this, a plugin holding no fs
2731 // capability at all could name `/etc/passwd` — or a symlink pointing
2732 // there — and have the host read it into a buffer on its behalf. The
2733 // authorizer's own rule is that guest-named paths are checked at the
2734 // BOUNDARY, where provenance is still known; by the time an excerpt
2735 // reaches `lattice-multibuffer` the host no longer knows which plugin
2736 // asked, exactly as `EffectAuthorizer` documents for `WriteToFile`.
2737 let grant = lattice_plugin_host::capability::grant(manifest, tier).grant;
2738
2739 for spec in specs {
2740 let native = lattice_multibuffer::providers::plugin_view::PluginViewSpec {
2741 id: spec.id.clone(),
2742 doc: spec.doc.clone(),
2743 buffer_name: spec.buffer_name.clone(),
2744 view_mode: spec.view_mode.clone(),
2745 reuse: spec.reuse,
2746 };
2747 // MV.3: a `scan` view is driven by the host's walk over the
2748 // registered `scanned-excerpt-source`s — the agenda's machinery,
2749 // now parameterised by identity rather than by module constants. A
2750 // `pull` view calls the guest's `build`.
2751 let is_pull = matches!(
2752 spec.input,
2753 lattice_plugin_host::lattice::plugin_host::types::MultibufferViewInput::Pull
2754 );
2755
2756 let client = client.clone();
2757 let grant = grant.clone();
2758 let mb = (*mb_registry).clone();
2759 let bus = (*bus).clone();
2760 let runtime = runtime.clone();
2761 let view_id = spec.id.clone();
2762 // The view this opener made last time, so `reuse` re-enters its OWN
2763 // buffer rather than resolving a guest-chosen name — which would
2764 // let a plugin declare `*agenda*` and take over someone else's view.
2765 let last_view: Arc<std::sync::Mutex<Option<lattice_core::BufferId>>> =
2766 Arc::new(std::sync::Mutex::new(None));
2767 let opener: lattice_mode::ProviderViewOpener = Arc::new(
2768 move |activator: &mut dyn lattice_mode::ModeActivator,
2769 args: &lattice_grammar::Args| {
2770 // A scan view IS the agenda's shape, so it takes the
2771 // agenda's opener with this view's identity. Nothing about
2772 // the walk, the sort or the grouping differs — only who
2773 // named the view.
2774 if !is_pull {
2775 return lattice_multibuffer::providers::scan_view::open_scan_view(
2776 activator,
2777 &lattice_multibuffer::providers::scan_view::ScanViewIdentity {
2778 provider: native.id.clone(),
2779 buffer_name: native.buffer_name.clone(),
2780 view_mode: native.view_mode.clone(),
2781 no_rows_message: "no plugin provides rows for it".to_string(),
2782 },
2783 args,
2784 );
2785 }
2786 let previous = last_view.lock().ok().and_then(|g| *g);
2787 let outcome = lattice_multibuffer::providers::plugin_view::open_plugin_view(
2788 activator, &native, previous,
2789 );
2790 let lattice_mode::ProviderViewOutcome::Opened { view, .. } = outcome else {
2791 return outcome;
2792 };
2793 if let Ok(mut slot) = last_view.lock() {
2794 *slot = Some(view);
2795 }
2796 let args: Vec<String> = match args {
2797 lattice_grammar::Args::String(s) if !s.trim().is_empty() => {
2798 vec![s.trim().to_string()]
2799 }
2800 // Only the string-shaped values carry through: a view's
2801 // args are names and paths, and a bool or an int in the
2802 // list is a caller error rather than something to
2803 // stringify into a filter the guest will not recognise.
2804 lattice_grammar::Args::List(items) => items
2805 .iter()
2806 .filter_map(|v| v.as_str().map(str::to_string))
2807 .collect(),
2808 _ => Vec::new(),
2809 };
2810 let client = client.clone();
2811 let grant = grant.clone();
2812 let mb = mb.clone();
2813 let bus = bus.clone();
2814 let view_id = view_id.clone();
2815 runtime.spawn(async move {
2816 match client.build(view_id.clone(), args).await {
2817 Ok(Ok(result)) => {
2818 let spec_for_fill =
2819 lattice_multibuffer::providers::plugin_view::PluginViewSpec {
2820 id: view_id,
2821 doc: String::new(),
2822 buffer_name: String::new(),
2823 view_mode: None,
2824 reuse: true,
2825 };
2826 lattice_multibuffer::providers::plugin_view::fill_plugin_view(
2827 &mb,
2828 Some(&bus),
2829 view,
2830 &spec_for_fill,
2831 convert_view_result(result, &grant),
2832 );
2833 }
2834 // The guest's own words — the host has no vocabulary
2835 // for "nothing links here yet".
2836 Ok(Err(message)) => {
2837 lattice_multibuffer::providers::plugin_view::decline_plugin_view(
2838 &mb,
2839 Some(&bus),
2840 view,
2841 &format!("{view_id}: {message}"),
2842 );
2843 }
2844 Err(error) => {
2845 lattice_multibuffer::providers::plugin_view::decline_plugin_view(
2846 &mb,
2847 Some(&bus),
2848 view,
2849 &format!("{view_id}: {error}"),
2850 );
2851 }
2852 }
2853 });
2854 outcome
2855 },
2856 );
2857
2858 if !providers.register(&spec.id, opener) {
2859 // An id a NATIVE provider already owns. Refused with both names
2860 // — and the guest's OTHER views still register, because one bad
2861 // name must not cost a plugin its whole contribution.
2862 tracing::warn!(
2863 plugin = %manifest.id,
2864 view = %spec.id,
2865 "multibuffer view id is already registered; this view is unreachable"
2866 );
2867 continue;
2868 }
2869 record.teardown.provider_views.push(spec.id.clone());
2870 }
2871
2872 record.tasks.push(task);
2873 Ok(plugin_id)
2874 }
2875
2876 /// TR.2b — drain the transient seam: spawn the menu actor, ask the guest for
2877 /// the name it registers under, and install a `register_async` builder into
2878 /// the editor's `TransientSourceRegistry`. Records the actor task + the
2879 /// menu name on `record` for teardown.
2880 ///
2881 /// The `id()` call happens ONCE, here, because it keys the registry entry
2882 /// and cannot change. `build` is called per open — that is the point of the
2883 /// seam, and it is what makes a plugin menu context-aware the way magit's
2884 /// dispatch is.
2885 async fn drain_transient(
2886 &self,
2887 component: &lattice_plugin_host::Component,
2888 manifest: &PluginManifest,
2889 tier: TrustTier,
2890 record: &mut LoadedRecord,
2891 ) -> Result<PluginId, PluginLoaderError> {
2892 let bus = self
2893 .env
2894 .bus
2895 .as_ref()
2896 .ok_or(PluginLoaderError::NotWired("transient-source"))?;
2897 let runtime = self
2898 .env
2899 .runtime
2900 .as_ref()
2901 .ok_or(PluginLoaderError::NotWired("transient-source"))?;
2902 let registry = self
2903 .env
2904 .transient_registry
2905 .as_ref()
2906 .ok_or(PluginLoaderError::NotWired("transient-source"))?;
2907 // The builder resolves each row's command NAME at build time, so it
2908 // needs the live registry rather than a snapshot taken here — a menu
2909 // opened later must see commands registered later.
2910 let commands = self
2911 .env
2912 .command_registry
2913 .as_ref()
2914 .ok_or(PluginLoaderError::NotWired("transient-source"))?;
2915
2916 let (client, actor) = self
2917 .host
2918 .spawn_transient_source(
2919 component,
2920 manifest,
2921 tier,
2922 PluginBudget::default(),
2923 bus,
2924 self.env.config_registry.as_ref(),
2925 )
2926 .await?;
2927
2928 // Drive the actor's request loop FIRST — `menu_id()` below is a guest
2929 // call over the client channel, which the actor must be running to
2930 // answer (else the await deadlocks). Same ordering as `drain_picker`.
2931 let actor = actor.with_tracer(self.env.tracer.clone());
2932 let id = client.id();
2933 let task = runtime.spawn(actor.run());
2934
2935 // A menu with no name has nothing to register under, so a failed or
2936 // blank `id()` fails the drain loudly rather than registering an
2937 // unreachable menu.
2938 let name = self.host_transient_name(&client, &manifest.id).await?;
2939
2940 registry.register_async(
2941 name.clone(),
2942 lattice_plugin_host::transient_builder(
2943 client,
2944 (*commands).clone(),
2945 manifest.id.clone(),
2946 ),
2947 );
2948
2949 record.tasks.push(task);
2950 record.teardown.transient_sources.push(name.clone());
2951 tracing::debug!(
2952 plugin = %manifest.id,
2953 menu = %name,
2954 "transient plugin registered its menu"
2955 );
2956 Ok(id)
2957 }
2958
2959 /// Ask a transient guest for its menu name, rejecting a blank one.
2960 ///
2961 /// Split out so the failure reads as one sentence at the call site: a blank
2962 /// name would register a menu `Effect::OpenTransient` could never address,
2963 /// which is a plugin that loads "successfully" and contributes nothing.
2964 async fn host_transient_name(
2965 &self,
2966 client: &lattice_plugin_host::TransientClient,
2967 plugin: &str,
2968 ) -> Result<String, PluginLoaderError> {
2969 let name = client.menu_id().await?;
2970 let trimmed = name.trim();
2971 if trimmed.is_empty() {
2972 tracing::warn!(plugin, "transient plugin returned a blank menu name");
2973 return Err(PluginLoaderError::NotWired("transient-source"));
2974 }
2975 Ok(trimmed.to_string())
2976 }
2977
2978 /// Drain the config seam: run the guest's `register-options` against the live
2979 /// config registry (already interior-mutable — `:set` / `:describe-option` /
2980 /// `:customize` treat plugin options uniformly). One-shot: no actor to spawn.
2981 async fn drain_config(
2982 &self,
2983 component: &lattice_plugin_host::Component,
2984 manifest: &PluginManifest,
2985 tier: TrustTier,
2986 record: &mut LoadedRecord,
2987 ) -> Result<PluginId, PluginLoaderError> {
2988 let registry = self
2989 .env
2990 .config_registry
2991 .as_ref()
2992 .ok_or(PluginLoaderError::NotWired("config"))?;
2993 let (id, names) = self
2994 .host
2995 .spawn_config_plugin(component, manifest, tier, PluginBudget::default(), registry)
2996 .await?;
2997 tracing::debug!(plugin = %manifest.id, options = ?names, "config plugin registered options");
2998 // Teardown token: the config registry unregisters each option by name.
2999 record.teardown.config_options = names;
3000 Ok(id)
3001 }
3002
3003 /// Drain the events seam: register the guest's subscriptions on the bus and
3004 /// drive its `on-event` actor on the runtime (off the keystroke path). A
3005 /// trapping handler quarantines the plugin without touching the publisher or
3006 /// other subscribers (the event-seam isolation contract).
3007 async fn drain_events(
3008 &self,
3009 component: &lattice_plugin_host::Component,
3010 manifest: &PluginManifest,
3011 tier: TrustTier,
3012 record: &mut LoadedRecord,
3013 ) -> Result<PluginId, PluginLoaderError> {
3014 let bus = self
3015 .env
3016 .bus
3017 .as_ref()
3018 .ok_or(PluginLoaderError::NotWired("events"))?;
3019 let runtime = self
3020 .env
3021 .runtime
3022 .as_ref()
3023 .ok_or(PluginLoaderError::NotWired("events"))?;
3024
3025 let (subscriptions, actor) = self
3026 .host
3027 .spawn_event_plugin(
3028 component,
3029 manifest,
3030 tier,
3031 PluginBudget::event(),
3032 bus,
3033 self.env.config_registry.as_ref(),
3034 )
3035 .await?;
3036 let id = actor.id();
3037 // PO.2: attach the boundary tracer so the actor emits a trace record per
3038 // guest call (a no-op when unwired).
3039 let actor = actor.with_tracer(self.env.tracer.clone());
3040 let task = runtime.spawn(actor.run());
3041 record.tasks.push(task);
3042 tracing::debug!(
3043 plugin = %manifest.id,
3044 subscriptions = subscriptions.len(),
3045 "event plugin subscribed"
3046 );
3047 // Teardown token: the bus unsubscribes each subscription id.
3048 record.teardown.subscriptions = subscriptions;
3049 Ok(id)
3050 }
3051
3052 /// Drain the grammar seam: instantiate the `grammar-plugin` component, drive
3053 /// its `register-grammar` export, and register the resulting native specs
3054 /// into the runtime-mutable command registry (B3a/B3b).
3055 ///
3056 /// The grammar seam is the **synchronous** one (the PH7.7 fork): each
3057 /// contributed motion / operator / text-object / ex-command carries a sync
3058 /// trampoline the dispatcher fires on keystroke, so — unlike the async actor
3059 /// seams (picker / events) — there is *no* actor `run()` loop to spawn. The
3060 /// command registry itself owns the guest `Store` (inside the boxed
3061 /// trampolines the specs carry), so registering the set is all that keeps the
3062 /// plugin's grammar alive; teardown (PL8.C) unregisters by `plugin_id`.
3063 ///
3064 /// Registration is **load → clone → register → store**, *not* an
3065 /// [`rcu`](arc_swap::ArcSwap::rcu): [`register_all`] consumes the set (the
3066 /// specs own non-`Clone` boxed trampolines) so it cannot run inside a
3067 /// retrying `rcu` closure. B3a made `CommandRegistry: Clone` (Arc'd spec
3068 /// closures) precisely so this snapshot clone is cheap (Arc bumps, no deep
3069 /// copy). Loads are serialized — boot discovery is a sequential
3070 /// [`discover_and_load`](Self::discover_and_load) loop, and the PL8.C
3071 /// `:plugin-load` ex-command dispatches one at a time — so the load→store
3072 /// window carries no lost-write race against a concurrent grammar
3073 /// registration. A malformed spec fails loudly with `PluginHostError`
3074 /// (mapped to [`PluginLoaderError::Host`]); a *runtime* `apply` trap degrades
3075 /// to a graceful no-op inside the trampoline (`CommandError::Plugin`), never
3076 /// a host crash.
3077 ///
3078 /// [`register_all`]: lattice_plugin_host::GrammarContributionSet::register_all
3079 fn drain_grammar(
3080 &self,
3081 component: &lattice_plugin_host::Component,
3082 manifest: &PluginManifest,
3083 tier: TrustTier,
3084 ) -> Result<PluginId, PluginLoaderError> {
3085 let bus = self
3086 .env
3087 .bus
3088 .as_ref()
3089 .ok_or(PluginLoaderError::NotWired("grammar"))?;
3090 let registry = self
3091 .env
3092 .command_registry
3093 .as_ref()
3094 .ok_or(PluginLoaderError::NotWired("grammar"))?;
3095
3096 // SYNC end to end (no async host import to drive) — instantiation +
3097 // `register-grammar` run at load time, off the keystroke path (the
3098 // sibling `self.host.compile` above is likewise a synchronous call).
3099 // PO.3: attach the boundary tracer so the sync grammar trampoline emits a
3100 // gated boundary-trace record per guest call (zero cost at the default
3101 // gate — a single relaxed-atomic load; design §4).
3102 let set = self.host.instantiate_grammar_plugin(
3103 component,
3104 manifest,
3105 tier,
3106 bus,
3107 self.env.tracer.as_ref(),
3108 // AP.3: the shared editor config registry, so a grammar action can
3109 // read an option (auto-pair's `auto-pair.style` gate).
3110 self.env.config_registry.as_ref(),
3111 )?;
3112 let id = set.plugin_id();
3113 let count = set.len();
3114
3115 // load → clone → register → store. `register_all` consumes `set`, so a
3116 // retrying `rcu` closure is impossible; the serialized-load invariant
3117 // (doc above) makes the plain swap race-free in practice.
3118 let mut next = (**registry.load()).clone();
3119 let operator_chords = set.register_all(&mut next);
3120 registry.store(Arc::new(next));
3121
3122 // CM.2: an operator that registered but has no keys is indistinguishable
3123 // from one that never loaded — so a declared chord with nowhere to land
3124 // fails the load rather than being skipped, the `agenda_registry`
3125 // contract.
3126 if !operator_chords.is_empty() {
3127 // Degrades, like the two cases below it. The first draft made this
3128 // `NotWired` on the `agenda_registry` precedent, and that precedent
3129 // does not transfer: an agenda plugin whose registry is missing
3130 // contributes NOTHING — its whole purpose is the view. A chord is
3131 // one contribution among many, and refusing the load costs the
3132 // plugin's motions, text objects and actions to punish a missing
3133 // keybinding. A harness with no keymap would kill every grammar
3134 // plugin too, which is how this was found.
3135 let Some(wirer) = self.env.operator_chords.as_ref() else {
3136 tracing::warn!(
3137 plugin = %manifest.id,
3138 "operator chord(s) declared but no chord wirer is wired; \
3139 the operator is reachable by name only"
3140 );
3141 return Ok(id);
3142 };
3143 // The chords belong to the plugin's own minor mode, not to the
3144 // universal grammar: bound at `Builtin` they would outlive
3145 // `:set <id>.enabled=false` and point at a handler that is gone.
3146 // A chord with no mode to scope to has nowhere legitimate to land:
3147 // `Builtin` is the universal grammar and a plugin does not belong
3148 // there.
3149 //
3150 // But this DEGRADES rather than failing the load, for the same
3151 // reason a withheld capability does — it is a plugin-authoring
3152 // error, not broken infrastructure, and the rest of the plugin's
3153 // contributions are fine. Only a missing wirer (above) fails the
3154 // load, because that is a host wiring bug.
3155 //
3156 // Caught by `discovered_grammar_plugin_registers_and_dispatches_
3157 // through_the_registry`: the fixture gained a chord, that test's
3158 // manifest declares no modes, and the first draft of this refused
3159 // the whole plugin. One contribution that cannot be bound must not
3160 // cost the other nine.
3161 let Some(mode) = manifest.default_modes.first() else {
3162 tracing::warn!(
3163 plugin = %manifest.id,
3164 "operator chord(s) declared but the manifest has no \
3165 `default_modes` to scope them to; the operator is \
3166 reachable by name only"
3167 );
3168 return Ok(id);
3169 };
3170 // CM.2: the chord is a declared capability. `grant` is pure over
3171 // (manifest, tier), so this resolves exactly what every seam spawn
3172 // resolves internally.
3173 //
3174 // Withheld is NOT a load failure — distinct from the missing-wirer
3175 // case above. That one is broken infrastructure; this one is the
3176 // user's decision, and `register-binding`'s standing contract is
3177 // that a plugin never silently mis-binds. The operator stays
3178 // registered and reachable by name.
3179 if !lattice_plugin_host::grant(manifest, tier)
3180 .grant
3181 .grammar_chord
3182 {
3183 tracing::info!(
3184 plugin = %manifest.id,
3185 "operator chord(s) not bound: `grammar:chord` was not \
3186 granted. The operator is still reachable by name."
3187 );
3188 return Ok(id);
3189 }
3190
3191 let mode_id = lattice_mode::ModeId::new(mode);
3192 for c in operator_chords {
3193 if let Err(err) = wirer.wire(
3194 c.operator,
3195 &c.chord,
3196 c.doubled,
3197 mode_id,
3198 id.0,
3199 &manifest.id,
3200 false,
3201 ) {
3202 tracing::warn!(
3203 plugin = %manifest.id,
3204 chord = %c.chord,
3205 %err,
3206 "operator chord could not be bound; the operator is \
3207 reachable by name only"
3208 );
3209 }
3210 }
3211 }
3212
3213 tracing::debug!(
3214 plugin = %manifest.id,
3215 contributions = count,
3216 "grammar plugin registered its motions / operators / text-objects / ex-commands"
3217 );
3218 // No teardown token needed: `PluginTeardown::unload` unconditionally
3219 // removes every `SourceLayer::Plugin(id)` command by provenance (grammar
3220 // here + the modes seam's `:<mode>` toggles).
3221 Ok(id)
3222 }
3223
3224 /// Drain the modes seam: instantiate the `modes-plugin` component, drive its
3225 /// `register-modes` export, and register each accepted minor mode into the
3226 /// runtime-mutable mode registry (B2), binding each mode's declared keymap
3227 /// into its own `MinorMode` layer.
3228 ///
3229 /// A registered mode is **declarative data** — id / kind / activation policy
3230 /// / capability requirements + keymap bindings that resolve to *existing*
3231 /// commands — so once `spawn_mode_plugin` copies it into the registry, the
3232 /// guest `Store` drops (no actor task, no live callback, nothing to keep
3233 /// alive); teardown (PL8.C) removes the modes + keymap layers by `plugin_id`.
3234 ///
3235 /// Registration RCUs the mode registry — **load → clone → spawn → store**.
3236 /// `spawn_mode_plugin` takes `&mut ModeRegistry` (registration drains after
3237 /// its async `register-modes`) so it holds the borrow across an `.await`;
3238 /// passing a local owned snapshot clone keeps that sound, and it can't run in
3239 /// a retrying `rcu` closure anyway (it instantiates a guest). B2 made
3240 /// `ModeRegistry` an `ArcSwap` handle + `Clone` for exactly this. The
3241 /// `commands` snapshot (read-only, for bind-time command resolution) and the
3242 /// interior-mutable `keymap` handle come straight from the wired services.
3243 /// A missing service degrades the modes seam to a logged skip
3244 /// (`NotWired("modes")`), never a boot abort; a `register-modes` trap maps to
3245 /// `PluginLoaderError::Host` and skips only this plugin.
3246 async fn drain_mode(
3247 &self,
3248 component: &lattice_plugin_host::Component,
3249 manifest: &PluginManifest,
3250 tier: TrustTier,
3251 record: &mut LoadedRecord,
3252 ) -> Result<PluginId, PluginLoaderError> {
3253 let mode_registry = self
3254 .env
3255 .mode_registry
3256 .as_ref()
3257 .ok_or(PluginLoaderError::NotWired("modes"))?;
3258 let keymap = self
3259 .env
3260 .keymap
3261 .as_ref()
3262 .ok_or(PluginLoaderError::NotWired("modes"))?;
3263 let commands = self
3264 .env
3265 .command_registry
3266 .as_ref()
3267 .ok_or(PluginLoaderError::NotWired("modes"))?;
3268
3269 // Read-only command snapshot for keymap-binding resolution; owned so it
3270 // outlives the `&mut next` borrow across the await.
3271 let commands_snapshot = commands.load_full();
3272 // load → clone → spawn → store (see the RCU note above).
3273 let mut next = (**mode_registry.load()).clone();
3274 let (id, mode_ids) = self
3275 .host
3276 .spawn_mode_plugin(
3277 component,
3278 manifest,
3279 tier,
3280 PluginBudget::default(),
3281 &mut next,
3282 &commands_snapshot,
3283 keymap,
3284 // MO.1: resolves each declared option override's name + value
3285 // against the registry `:set` writes to. Absent in a loader
3286 // built without config (the minimal harnesses) — the overrides
3287 // are then skipped with a warning naming the mode, the same
3288 // logged-skip every other unwired handle takes.
3289 self.env.config_registry.as_deref(),
3290 )
3291 .await?;
3292 mode_registry.store(Arc::new(next));
3293
3294 // Give each plugin-registered mode the SAME `:<mode-name>` toggle
3295 // ex-command native modes get at boot (`register_mode_toggle_commands`)
3296 // — so plugin modes are togglable by name uniformly and the
3297 // `:describe-mode` / `:list-modes` "Toggle with `:<mode-id>`" hint is
3298 // honest. Registered under `SourceLayer::Plugin(id)` so unload's
3299 // `unregister_plugin` reverses them; the registry `generation` bump the
3300 // registration triggers refreshes `:`-command completion. RCU the
3301 // command registry (load → clone → register → store), mirroring
3302 // `drain_grammar` above.
3303 if !mode_ids.is_empty() {
3304 let mut next_cmds = (**commands.load()).clone();
3305 for mode in &mode_ids {
3306 let name = mode.to_string();
3307 next_cmds.register_plugin_ex_command(
3308 id.0,
3309 &name,
3310 lattice_grammar::registry::MODE_TOGGLE_COMMAND_DOC,
3311 lattice_grammar::registry::mode_toggle_ex_command_spec(&name),
3312 );
3313 }
3314 commands.store(Arc::new(next_cmds));
3315 }
3316
3317 tracing::debug!(
3318 plugin = %manifest.id,
3319 modes = ?mode_ids,
3320 "mode plugin registered its minor modes"
3321 );
3322 // Teardown tokens: each mode reverses via `ModeRegistry::unregister` +
3323 // `KeymapHandle::remove_layer(MinorMode(id))`; the `:<mode>` toggle
3324 // ex-commands registered above reverse via `unregister_plugin(id)` in
3325 // `PluginTeardown::unload` (provenance-driven, no per-command token).
3326 record.teardown.modes.extend(mode_ids);
3327 Ok(id)
3328 }
3329
3330 /// Drain the completion seam: spawn the source actor, wrap the plugin's
3331 /// `WasmCompletionSource` as a native async `CompletionSourceContribution`,
3332 /// and register a loader-owned universal [`PluginCompletionMode`] carrying it
3333 /// into the runtime-mutable mode registry (option A).
3334 ///
3335 /// Completion is mode-attached across the whole editor (the aggregator
3336 /// `recompute_active_completion_sources_for` walks the mode registry calling
3337 /// `completion_sources()`), so the source rides a mode rather than a parallel
3338 /// registry. The async `generate` runs on the spawned actor (off the
3339 /// keystroke path); matching / ranking / annotation stay native, so paramount
3340 /// #1 holds. The `WasmCompletionSource` actor mirrors the picker seam — driven
3341 /// on the runtime, recorded on `record` for unload (PL8.C).
3342 ///
3343 /// Runtime-visibility caveat: a Universal mode contributes on a buffer only
3344 /// once it is *active* there and the completion-source cache is recomputed
3345 /// (on mode-activation transitions). At boot, discovery runs before buffers
3346 /// open, so the first cache build includes it; a plugin loaded *after* buffers
3347 /// are open reaches new buffers immediately but needs a re-activation +
3348 /// recompute pass for existing ones — that pass lands with the PL8.C
3349 /// `:plugin-load` / reload surface. A missing service degrades the seam to a
3350 /// logged skip (`NotWired`); a spawn/connect trap maps to
3351 /// `PluginLoaderError::Host` and skips only this plugin.
3352 async fn drain_completion(
3353 &self,
3354 component: &lattice_plugin_host::Component,
3355 manifest: &PluginManifest,
3356 tier: TrustTier,
3357 record: &mut LoadedRecord,
3358 ) -> Result<PluginId, PluginLoaderError> {
3359 let bus = self
3360 .env
3361 .bus
3362 .as_ref()
3363 .ok_or(PluginLoaderError::NotWired("completion-source"))?;
3364 let runtime = self
3365 .env
3366 .runtime
3367 .as_ref()
3368 .ok_or(PluginLoaderError::NotWired("completion-source"))?;
3369 let mode_registry = self
3370 .env
3371 .mode_registry
3372 .as_ref()
3373 .ok_or(PluginLoaderError::NotWired("completion-source"))?;
3374
3375 let (client, actor) = self
3376 .host
3377 .spawn_completion_source(
3378 component,
3379 manifest,
3380 tier,
3381 PluginBudget::default(),
3382 bus,
3383 self.env.config_registry.as_ref(),
3384 )
3385 .await?;
3386 // Drive the actor FIRST — `connect` issues a `spec()` guest call over the
3387 // client channel, which the actor must be running to answer.
3388 // PO.2: attach the boundary tracer so the actor emits a trace record per
3389 // guest call (a no-op when unwired).
3390 let actor = actor.with_tracer(self.env.tracer.clone());
3391 let task = runtime.spawn(actor.run());
3392 let source = WasmCompletionSource::connect(client).await?;
3393 let id = source.plugin_id();
3394 let source_id = source.id().to_string();
3395
3396 let contribution = CompletionSourceContribution {
3397 // OR.7: carried from the guest's own spec. Hardcoding `false`
3398 // here would make the flag unreachable from WASM, which is the
3399 // only place it is needed.
3400 accepts_non_word_query: source.accepts_non_word_query(),
3401 id: SourceId::new(&source_id),
3402 default_priority: PLUGIN_COMPLETION_DEFAULT_PRIORITY,
3403 auto_trigger: true,
3404 trigger_chars: Vec::new(),
3405 popup_filter_chord: None,
3406 kind: CompletionSourceKind::Async(Arc::new(source)),
3407 };
3408
3409 // RCU-register the carrier mode (load → clone → register → store; B2 made
3410 // ModeRegistry an ArcSwap handle + Clone). The `-mode` suffix satisfies
3411 // the registry's naming gate.
3412 let mode_id = format!("{}-completion-mode", manifest.id);
3413 let mode = PluginCompletionMode {
3414 id: ModeId::new(&mode_id),
3415 source: contribution,
3416 };
3417 let carrier_id = ModeId::new(&mode_id);
3418 let mut next = (**mode_registry.load()).clone();
3419 match next.register(mode) {
3420 Ok(_) => mode_registry.store(Arc::new(next)),
3421 Err(error) => {
3422 // An id collision (a mode already owns `<id>-completion-mode`)
3423 // leaves the source unreachable — abort the actor and fail loudly
3424 // rather than leak a dangling task or claim a phantom load.
3425 task.abort();
3426 tracing::warn!(
3427 plugin = %manifest.id,
3428 mode = %mode_id,
3429 %error,
3430 "completion carrier mode id collision; source unreachable, skipped"
3431 );
3432 return Err(PluginLoaderError::NothingLoaded);
3433 }
3434 }
3435
3436 record.tasks.push(task);
3437 // Teardown token: unregistering the carrier mode drops the source; the
3438 // actor task is aborted separately from `record.tasks`.
3439 record.teardown.modes.push(carrier_id);
3440 tracing::debug!(
3441 plugin = %manifest.id,
3442 source = %source_id,
3443 mode = %mode_id,
3444 "completion plugin registered its source on a universal carrier mode"
3445 );
3446 Ok(id)
3447 }
3448
3449 /// Drain the keymap seam (PL8.D): bind the plugin's user keybindings into
3450 /// `KeymapLayer::User` and record them as teardown tokens (unbound on unload).
3451 ///
3452 /// Direct registration, like config — the seam binds into the shared,
3453 /// interior-mutable [`KeymapHandle`] during `register-keymap`, so there is no
3454 /// RCU and no actor task. The command-registry snapshot resolves each
3455 /// binding's command name at bind time; an unregistered command / unparseable
3456 /// chord / withheld `KeymapCapability::User` binds nothing (logged, no trap).
3457 /// The first consumer is the user's `init.rs` (PL8.D.3).
3458 async fn drain_keymap(
3459 &self,
3460 component: &lattice_plugin_host::Component,
3461 manifest: &PluginManifest,
3462 tier: TrustTier,
3463 record: &mut LoadedRecord,
3464 ) -> Result<PluginId, PluginLoaderError> {
3465 let keymap = self
3466 .env
3467 .keymap
3468 .as_ref()
3469 .ok_or(PluginLoaderError::NotWired("keymap"))?;
3470 let commands = self
3471 .env
3472 .command_registry
3473 .as_ref()
3474 .ok_or(PluginLoaderError::NotWired("keymap"))?;
3475
3476 // Owned command snapshot for bind-time command-name resolution.
3477 let commands_snapshot = commands.load_full();
3478 let (id, tokens) = self
3479 .host
3480 .spawn_keymap_plugin(
3481 component,
3482 manifest,
3483 tier,
3484 PluginBudget::default(),
3485 keymap,
3486 &commands_snapshot,
3487 )
3488 .await?;
3489
3490 tracing::debug!(
3491 plugin = %manifest.id,
3492 bindings = tokens.len(),
3493 "keymap plugin bound user keybindings into KeymapLayer::User"
3494 );
3495 // Teardown tokens: each binding is unbound from `KeymapLayer::User` on
3496 // unload / reload.
3497 record.teardown.keymap_bindings = tokens;
3498 Ok(id)
3499 }
3500
3501 /// Drain the decorations seam (PL8.E): spawn the producer actor, wrap the
3502 /// plugin's [`WasmDecorationSource`] as a native
3503 /// [`AsyncGutterDecorationSource`], and RCU-register it into the
3504 /// runtime-mutable [`GutterDecorationSourceRegistryHandle`] the host's
3505 /// per-tick refresh drives.
3506 ///
3507 /// The decoration producer is the one hot-path-sensitive seam: it must NEVER
3508 /// run at paint time (a per-frame WASM call would violate paramount #1). The
3509 /// host caches its output per buffer and the renderer reads only the cache;
3510 /// the async `gutter_decorations` runs on the spawned actor (off the
3511 /// keystroke path), mirroring the picker / completion actor seams — driven on
3512 /// the runtime, recorded on `record` for unload. A missing service degrades
3513 /// the seam to a logged skip (`NotWired`); a spawn trap maps to
3514 /// TC.2 — drain the context seam: spawn the producer actor, register the
3515 /// `WasmContextSource` into the context registry by copy-on-write RCU, and
3516 /// spawn the actor's `run` loop on the runtime. Records the actor task +
3517 /// source id on `record` for teardown. Mirror of [`Self::drain_decorations`]
3518 /// — a context source, like a decoration source, carries no id/doc metadata,
3519
3520 /// TC.4 — drain the theme seam: instantiate the component, drive its
3521 /// `register-theme-elements` export once, and record the namespaced element
3522 /// names for teardown. No actor and no registry RCU: elements are declared
3523 /// synchronously into the shared registry, like config options.
3524 /// PM.7b: run a config guest's `register-plugins` export and record the
3525 /// plugins it declared.
3526 ///
3527 /// The specs are *declarations*. Nothing is resolved, cloned, built or
3528 /// downloaded here — see `plugin_manager_host` for why that split is
3529 /// load-bearing. The boot task drains them via
3530 /// [`PluginLoader::take_required`] and runs the pipeline off-thread.
3531 ///
3532 /// A guest that declares the seam and requires nothing is fine: the drain
3533 /// is empty and the load still counts (the plugin loaded, it just asked
3534 /// for no company).
3535 async fn drain_require(
3536 &self,
3537 component: &lattice_plugin_host::Component,
3538 manifest: &PluginManifest,
3539 tier: TrustTier,
3540 _record: &mut LoadedRecord,
3541 ) -> Result<PluginId, PluginLoaderError> {
3542 let (id, specs) = self
3543 .host
3544 .spawn_plugin_manager_plugin(component, manifest, PluginBudget::default(), tier)
3545 .await?;
3546 tracing::debug!(
3547 plugin = %manifest.id,
3548 count = specs.len(),
3549 "config guest declared plugins via require"
3550 );
3551 if !specs.is_empty()
3552 && let Ok(mut queue) = self.required.lock()
3553 {
3554 queue.extend(specs.into_iter().map(pipeline::to_required_spec));
3555 }
3556 // The seam contributes no registry entries, so there is nothing for
3557 // teardown to reverse — unloading the guest cannot un-install the
3558 // plugins it asked for, any more than removing a package list
3559 // uninstalls the packages.
3560 Ok(id)
3561 }
3562
3563 async fn drain_theme(
3564 &self,
3565 component: &lattice_plugin_host::Component,
3566 manifest: &PluginManifest,
3567 tier: TrustTier,
3568 record: &mut LoadedRecord,
3569 ) -> Result<PluginId, PluginLoaderError> {
3570 let registry = self
3571 .env
3572 .theme_registry
3573 .as_ref()
3574 .ok_or(PluginLoaderError::NotWired("theme"))?;
3575 let (id, elements) = self
3576 .host
3577 .spawn_theme_plugin(component, manifest, tier, PluginBudget::default(), registry)
3578 .await?;
3579 tracing::debug!(
3580 plugin = %manifest.id,
3581 id = id.0,
3582 elements = elements.len(),
3583 "theme plugin registered its elements"
3584 );
3585 record.teardown.theme_elements = elements;
3586 Ok(id)
3587 }
3588
3589 /// SG.3a — drain the sign seam: instantiate the component, drive its
3590 /// `register-signs` export once, and record the namespaced sign names for
3591 /// teardown. No actor and no registry RCU beyond the declaration itself:
3592 /// signs are declared synchronously into the shared registry, like theme
3593 /// elements and config options.
3594 ///
3595 /// Drains at rank 2, after `theme`, so a plugin that registers the element
3596 /// its signs name has already done so — its signs then paint in their own
3597 /// colours on the first frame rather than falling back to `gutter.sign`
3598 /// until something republishes.
3599 async fn drain_signs(
3600 &self,
3601 component: &lattice_plugin_host::Component,
3602 manifest: &PluginManifest,
3603 tier: TrustTier,
3604 record: &mut LoadedRecord,
3605 ) -> Result<PluginId, PluginLoaderError> {
3606 let registry = self
3607 .env
3608 .sign_registry
3609 .as_ref()
3610 .ok_or(PluginLoaderError::NotWired("signs"))?;
3611 let (id, signs) = self
3612 .host
3613 .spawn_sign_plugin(component, manifest, tier, PluginBudget::default(), registry)
3614 .await?;
3615 tracing::debug!(
3616 plugin = %manifest.id,
3617 id = id.0,
3618 signs = signs.len(),
3619 "sign plugin declared its signs"
3620 );
3621 record.teardown.signs = signs;
3622 Ok(id)
3623 }
3624
3625 /// CM.6b — the `error-parser` seam's drain.
3626 ///
3627 /// Mints the factory (which instantiates once, so a component that
3628 /// cannot start fails the load rather than silently contributing
3629 /// nothing to every build) and RCU-registers it into the compilation
3630 /// parser-factory registry.
3631 ///
3632 /// Takes no `record`: teardown is by **provenance**, not by token —
3633 /// `PluginTeardown` removes every factory carrying this plugin's
3634 /// host-issued id, exactly as it does for commands. There is no list
3635 /// to record and therefore none to forget.
3636 fn drain_error_parser(
3637 &self,
3638 component: &lattice_plugin_host::Component,
3639 manifest: &PluginManifest,
3640 tier: TrustTier,
3641 ) -> Result<PluginId, PluginLoaderError> {
3642 let registry = self
3643 .env
3644 .parser_factories
3645 .as_ref()
3646 .ok_or(PluginLoaderError::NotWired("error-parser"))?;
3647
3648 // The Reflex-class budget, not the lifecycle default: `feed` runs
3649 // once per captured line on a fast producer's critical path.
3650 let (id, factory) =
3651 self.host
3652 .error_parser_factory(component, manifest, tier, PluginBudget::grammar())?;
3653
3654 let factory: Arc<dyn lattice_compilation::CompilationParserFactory> = Arc::new(factory);
3655 registry.rcu(|current| {
3656 let mut next = (**current).clone();
3657 next.register(factory.clone());
3658 Arc::new(next)
3659 });
3660 tracing::debug!(
3661 plugin = %manifest.id,
3662 id = id.0,
3663 "error-parser plugin registered its parser factory"
3664 );
3665 Ok(id)
3666 }
3667
3668 /// CR.3 — the `help` seam's drain.
3669 ///
3670 /// Drives `register-help-topics` once, then RCU-registers what the guest
3671 /// declared into the help registry, each topic stamped with this plugin's
3672 /// host-issued id. Teardown is by that provenance
3673 /// (`HelpTopicRegistry::unregister_plugin`), so — like `error-parser` —
3674 /// there is no list to record on `record` and therefore none to forget.
3675 ///
3676 /// The guest is dropped when `spawn_help_plugin` returns: the bodies are
3677 /// already across as owned `String`s, so nothing about the plugin needs to
3678 /// be alive for `:help` to render its pages.
3679 ///
3680 /// A plugin that declared no topics still loads. It is a strange plugin,
3681 /// not a broken one, and failing the load would be a worse answer than a
3682 /// debug line.
3683 async fn drain_help(
3684 &self,
3685 component: &lattice_plugin_host::Component,
3686 manifest: &PluginManifest,
3687 tier: TrustTier,
3688 ) -> Result<PluginId, PluginLoaderError> {
3689 let registry = self
3690 .env
3691 .help_topics
3692 .as_ref()
3693 .ok_or(PluginLoaderError::NotWired("help"))?;
3694
3695 let (id, specs) = self
3696 .host
3697 .spawn_help_plugin(component, manifest, tier, PluginBudget::default())
3698 .await?;
3699
3700 let count = specs.len();
3701 if count > 0 {
3702 registry.rcu(|current| {
3703 let mut next = (**current).clone();
3704 for spec in &specs {
3705 next.register(lattice_help::topics::HelpTopic {
3706 name: spec.name.clone(),
3707 summary: spec.summary.clone(),
3708 // `Static` needs a `&'static str` and a plugin's body
3709 // is runtime data, so the owned-`String` variant is
3710 // the one that fits. `Dynamic` would be the wrong
3711 // shape twice over: it re-invokes a closure on every
3712 // open, and the guest it would have to call is gone.
3713 body: lattice_help::topics::HelpTopicBody::Owned(spec.body.clone()),
3714 related_command_patterns: spec.related_command_patterns.clone(),
3715 plugin_id: Some(id.0 as u64),
3716 });
3717 }
3718 Arc::new(next)
3719 });
3720 }
3721 tracing::debug!(
3722 plugin = %manifest.id,
3723 id = id.0,
3724 topics = count,
3725 "help plugin registered its topics"
3726 );
3727 Ok(id)
3728 }
3729
3730 /// LG.3c — the `language` seam's drain.
3731 ///
3732 /// Drives `register-languages` once, then compiles each declared grammar
3733 /// and registers it, stamped with this plugin's host-issued id. Teardown
3734 /// is by that provenance, so — like `error-parser` and `help` — there is
3735 /// no list to record on `record` and therefore none to forget.
3736 ///
3737 /// **The grammar is compiled HERE, after the guest's store is gone.**
3738 /// `spawn_language_plugin` returns plain bytes; turning them into a
3739 /// `tree_sitter::Language` costs ~100 ms of Cranelift, and doing it inside
3740 /// the guest call would hold a `wasmtime::Store` alive across it for no
3741 /// reason. This runs on the loader's off-boot-thread task, which is the
3742 /// only place that cost is acceptable — it is emphatically not on the
3743 /// keystroke or frame path.
3744 ///
3745 /// **One bad language costs only itself.** A grammar that fails to load or
3746 /// a query that fails to compile is logged with the offending language and
3747 /// reason named, and the plugin's other languages — and its other
3748 /// contributions — still register. A plugin that declared no languages
3749 /// still loads: strange, not broken.
3750 async fn drain_language(
3751 &self,
3752 component: &lattice_plugin_host::Component,
3753 manifest: &PluginManifest,
3754 tier: TrustTier,
3755 ) -> Result<PluginId, PluginLoaderError> {
3756 let (id, specs) = self
3757 .host
3758 .spawn_language_plugin(component, manifest, tier, PluginBudget::default())
3759 .await?;
3760
3761 let mut registered = 0usize;
3762 for spec in &specs {
3763 // Loaded by the GRAMMAR's export name, registered under the
3764 // LANGUAGE's name — they differ whenever a grammar's upstream
3765 // name is not the filetype's (`sequel` vs `sql`).
3766 let grammar =
3767 match lattice_syntax::wasm_grammar::load(&spec.grammar_name, &spec.grammar) {
3768 Ok(g) => g,
3769 Err(err) => {
3770 tracing::warn!(
3771 plugin = %manifest.id,
3772 language = %spec.name,
3773 %err,
3774 "plugin language rejected: grammar failed to load"
3775 );
3776 continue;
3777 }
3778 };
3779 let grammar_spec = lattice_syntax::GrammarSpec {
3780 grammar,
3781 highlights: spec.highlights.clone(),
3782 folds: spec.folds.clone(),
3783 injections: spec.injections.clone(),
3784 indents: spec.indents.clone(),
3785 textobjects: spec.textobjects.clone(),
3786 conceal_rules: spec.conceal_rules.clone(),
3787 };
3788 let exts: Vec<&str> = spec.extensions.iter().map(String::as_str).collect();
3789 // TK.1: hand over the theme registry so a query capture may
3790 // name a registered element. The `theme` seam drains before
3791 // this one, so a plugin's own elements already exist —
3792 // without that order a capture would resolve to
3793 // `Style::Default` and render unstyled, silently.
3794 match lattice_syntax::plugin_lang::register_with_grammar_themed(
3795 &spec.name,
3796 &exts,
3797 &grammar_spec,
3798 id.0 as u64,
3799 self.env.theme_registry.as_deref(),
3800 ) {
3801 Ok(_) => registered += 1,
3802 Err(err) => tracing::warn!(
3803 plugin = %manifest.id,
3804 language = %spec.name,
3805 %err,
3806 "plugin language rejected"
3807 ),
3808 }
3809 }
3810
3811 tracing::debug!(
3812 plugin = %manifest.id,
3813 id = id.0,
3814 declared = specs.len(),
3815 registered,
3816 "language plugin registered its languages"
3817 );
3818 Ok(id)
3819 }
3820
3821 /// CR.4 — the `dashboard` seam's drain.
3822 ///
3823 /// Instantiates one live guest per declared section and RCU-registers
3824 /// them, each stamped with this plugin's host-issued id. Teardown is by
3825 /// that provenance; CR.2's shadow stack means removing a plugin section
3826 /// that replaced a builtin resurfaces the builtin with no restore step.
3827 ///
3828 /// Synchronous, unlike every other declaration drain here: the world
3829 /// instantiates against the sync linker because `render-section` runs
3830 /// inside the compositor and must not suspend.
3831 fn drain_dashboard(
3832 &self,
3833 component: &lattice_plugin_host::Component,
3834 manifest: &PluginManifest,
3835 tier: TrustTier,
3836 ) -> Result<PluginId, PluginLoaderError> {
3837 let registry = self
3838 .env
3839 .dashboard_sections
3840 .as_ref()
3841 .ok_or(PluginLoaderError::NotWired("dashboard"))?;
3842
3843 // The Reflex-class budget, not the lifecycle default: `render-section`
3844 // runs on the actor thread during a Display-class action, and the fuel
3845 // cap is what bounds a pathological guest to a bounded stall.
3846 let (id, sections) = self.host.spawn_dashboard_sections(
3847 component,
3848 manifest,
3849 tier,
3850 PluginBudget::grammar(),
3851 )?;
3852
3853 let count = sections.len();
3854 if count > 0 {
3855 let sections: Vec<Arc<dyn lattice_dashboard::DashboardSection>> = sections
3856 .into_iter()
3857 .map(|s| Arc::new(s) as Arc<dyn lattice_dashboard::DashboardSection>)
3858 .collect();
3859 registry.rcu(|current| {
3860 let mut next = (**current).clone();
3861 for section in §ions {
3862 next.register(section.clone());
3863 }
3864 Arc::new(next)
3865 });
3866 }
3867 tracing::debug!(
3868 plugin = %manifest.id,
3869 id = id.0,
3870 sections = count,
3871 "dashboard plugin registered its sections"
3872 );
3873 Ok(id)
3874 }
3875 /// so there is no `connect` spec round-trip.
3876 async fn drain_context(
3877 &self,
3878 component: &lattice_plugin_host::Component,
3879 manifest: &PluginManifest,
3880 tier: TrustTier,
3881 record: &mut LoadedRecord,
3882 ) -> Result<PluginId, PluginLoaderError> {
3883 let bus = self
3884 .env
3885 .bus
3886 .as_ref()
3887 .ok_or(PluginLoaderError::NotWired("context"))?;
3888 let runtime = self
3889 .env
3890 .runtime
3891 .as_ref()
3892 .ok_or(PluginLoaderError::NotWired("context"))?;
3893 let registry = self
3894 .env
3895 .context_registry
3896 .as_ref()
3897 .ok_or(PluginLoaderError::NotWired("context"))?;
3898
3899 let (client, actor) = self
3900 .host
3901 .spawn_context_source(
3902 component,
3903 manifest,
3904 tier,
3905 PluginBudget::context(),
3906 bus,
3907 self.env.config_registry.as_ref(),
3908 )
3909 .await?;
3910 // PO.2: attach the boundary tracer so the actor emits a trace record per
3911 // guest call (a no-op when unwired).
3912 let actor = actor.with_tracer(self.env.tracer.clone());
3913 let task = runtime.spawn(actor.run());
3914 let source = WasmContextSource::new(client);
3915 let id = source.plugin_id();
3916
3917 // Copy-on-write RCU into the wait-free registry (load -> clone ->
3918 // register -> store), like the decoration seam. Concurrent host
3919 // refreshes keep reading the prior snapshot until the store lands.
3920 let producer: Arc<dyn AsyncContextSource> = Arc::new(source);
3921 registry.rcu(|current| {
3922 let mut next = (**current).clone();
3923 next.register(producer.clone());
3924 Arc::new(next)
3925 });
3926
3927 record.tasks.push(task);
3928 // Teardown token: the context registry unregisters this producer by id.
3929 record.teardown.context_sources.push(id.0 as u64);
3930 tracing::debug!(
3931 plugin = %manifest.id,
3932 id = id.0,
3933 "context plugin registered its context-scope producer"
3934 );
3935 Ok(id)
3936 }
3937
3938 /// IM.6b: drain the `media` seam — spawn the producer actor and register
3939 /// its source. The twin of [`Self::drain_decorations`]; a media provider
3940 /// carries no id/doc metadata either, so there is no `connect` round-trip.
3941 async fn drain_media(
3942 &self,
3943 component: &lattice_plugin_host::Component,
3944 manifest: &PluginManifest,
3945 tier: TrustTier,
3946 record: &mut LoadedRecord,
3947 ) -> Result<PluginId, PluginLoaderError> {
3948 let bus = self
3949 .env
3950 .bus
3951 .as_ref()
3952 .ok_or(PluginLoaderError::NotWired("media"))?;
3953 let runtime = self
3954 .env
3955 .runtime
3956 .as_ref()
3957 .ok_or(PluginLoaderError::NotWired("media"))?;
3958 let registry = self
3959 .env
3960 .media_registry
3961 .as_ref()
3962 .ok_or(PluginLoaderError::NotWired("media"))?;
3963
3964 let (client, actor) = self
3965 .host
3966 .spawn_media_source(component, manifest, tier, PluginBudget::default(), bus)
3967 .await?;
3968 let actor = actor.with_tracer(self.env.tracer.clone());
3969 let task = runtime.spawn(actor.run());
3970 let source = lattice_plugin_host::WasmMediaSource::new(client);
3971 let id = source.plugin_id();
3972
3973 // Copy-on-write RCU into the wait-free registry, like every other
3974 // producer seam: concurrent host refreshes keep reading the prior
3975 // snapshot until the store lands, so there is no lock on the read path.
3976 let producer: Arc<dyn lattice_mode::AsyncMediaSource> = Arc::new(source);
3977 registry.rcu(|current| {
3978 let mut next = (**current).clone();
3979 next.register(producer.clone());
3980 Arc::new(next)
3981 });
3982
3983 record.tasks.push(task);
3984 // Teardown token: the media registry unregisters this producer by id.
3985 record.teardown.media_sources.push(id.0 as u64);
3986 tracing::debug!(
3987 plugin = %manifest.id,
3988 id = id.0,
3989 "media plugin registered its inline-media producer"
3990 );
3991 Ok(id)
3992 }
3993
3994 /// OM.A1: instantiate the plugin's scanned-excerpt-source, resolve the extensions
3995 /// it claims ONCE, and register it as a producer.
3996 ///
3997 /// The `extensions()` round-trip happens here rather than per file for
3998 /// the reason `WasmScannedExcerptSource::extensions` records: the answer cannot
3999 /// change, and a scan already pays one boundary crossing per file.
4000 ///
4001 /// A source claiming NOTHING is registered anyway and logged at `warn`.
4002 /// Refusing the load would fail a plugin over one empty list while its
4003 /// other seams were fine; registering silently would leave a producer
4004 /// that can never contribute — the `NotWired` shape. The log is the
4005 /// middle answer, and it names the plugin.
4006 async fn drain_agenda(
4007 &self,
4008 component: &lattice_plugin_host::Component,
4009 manifest: &PluginManifest,
4010 tier: TrustTier,
4011 record: &mut LoadedRecord,
4012 ) -> Result<PluginId, PluginLoaderError> {
4013 let bus = self
4014 .env
4015 .bus
4016 .as_ref()
4017 .ok_or(PluginLoaderError::NotWired("scanned-excerpt-source"))?;
4018 let runtime = self
4019 .env
4020 .runtime
4021 .as_ref()
4022 .ok_or(PluginLoaderError::NotWired("scanned-excerpt-source"))?;
4023 let registry = self
4024 .env
4025 .agenda_registry
4026 .as_ref()
4027 .ok_or(PluginLoaderError::NotWired("scanned-excerpt-source"))?;
4028
4029 let (client, actor) = self
4030 .host
4031 .spawn_scan_source(
4032 component,
4033 manifest,
4034 tier,
4035 PluginBudget::default(),
4036 bus,
4037 self.env.config_registry.as_ref(),
4038 )
4039 .await?;
4040 let actor = actor.with_tracer(self.env.tracer.clone());
4041 let task = runtime.spawn(actor.run());
4042
4043 // Ask before registering: the producer is immutable once in the
4044 // registry, and a `claims()` that consults a lock per file would put
4045 // contention on the walk for an answer that never changes.
4046 let declared = client.extensions().await.unwrap_or_else(|e| {
4047 tracing::warn!(
4048 plugin = %manifest.id,
4049 error = %e,
4050 "scan source could not declare its file extensions; it will scan nothing"
4051 );
4052 Vec::new()
4053 });
4054 let extensions = lattice_plugin_host::normalise_extensions(declared);
4055 if extensions.is_empty() {
4056 tracing::warn!(
4057 plugin = %manifest.id,
4058 "scan source claims no file extensions; it will never be offered a file"
4059 );
4060 }
4061
4062 // OM.A3: the minor the provider activates on the agenda view, so the
4063 // source can act on its own rows. Resolved here for the same reason
4064 // `extensions` is — the provider reads it on every open, and it
4065 // cannot change. A `none` (or a failed call) is a source that only
4066 // produces rows, which is the ordinary case.
4067 let view_mode = match client.view_mode().await {
4068 Ok(m) => m.filter(|m| !m.trim().is_empty()),
4069 Err(e) => {
4070 tracing::debug!(
4071 plugin = %manifest.id,
4072 error = %e,
4073 "scan source declared no view mode"
4074 );
4075 None
4076 }
4077 };
4078 // OT.3b: persist this source's rows beside the plugin's own data, so a
4079 // restart does not reparse the whole project. A plugin whose id is not
4080 // a safe directory name gets no cache and simply scans as before.
4081 let source =
4082 lattice_plugin_host::WasmScannedExcerptSource::new(client, extensions, view_mode);
4083 let source = match self.host.plugin_data_dir(&manifest.id) {
4084 Some(dir) => source.with_cache(dir.as_path()),
4085 None => source,
4086 };
4087 let id = source.plugin_id();
4088
4089 // Copy-on-write RCU into the wait-free registry, like every other
4090 // producer seam: a scan already running keeps reading the prior
4091 // snapshot until the store lands, so there is no lock on the read
4092 // path.
4093 let producer: Arc<dyn lattice_mode::ScannedExcerptSource> = Arc::new(source);
4094 registry.rcu(|current| {
4095 let mut next = (**current).clone();
4096 next.register(producer.clone());
4097 Arc::new(next)
4098 });
4099
4100 record.tasks.push(task);
4101 record.teardown.agenda_sources.push(id.0 as u64);
4102 tracing::debug!(
4103 plugin = %manifest.id,
4104 id = id.0,
4105 "scan source registered its row producer"
4106 );
4107 Ok(id)
4108 }
4109
4110 /// `PluginLoaderError::Host` and skips only this plugin.
4111 async fn drain_decorations(
4112 &self,
4113 component: &lattice_plugin_host::Component,
4114 manifest: &PluginManifest,
4115 tier: TrustTier,
4116 record: &mut LoadedRecord,
4117 ) -> Result<PluginId, PluginLoaderError> {
4118 let bus = self
4119 .env
4120 .bus
4121 .as_ref()
4122 .ok_or(PluginLoaderError::NotWired("decorations"))?;
4123 let runtime = self
4124 .env
4125 .runtime
4126 .as_ref()
4127 .ok_or(PluginLoaderError::NotWired("decorations"))?;
4128 let registry = self
4129 .env
4130 .decoration_registry
4131 .as_ref()
4132 .ok_or(PluginLoaderError::NotWired("decorations"))?;
4133
4134 let (client, actor) = self
4135 .host
4136 .spawn_decoration_source(component, manifest, tier, PluginBudget::default(), bus)
4137 .await?;
4138 // Drive the producer actor on the multi-thread runtime (off the keystroke
4139 // path). Unlike picker / completion there is no `connect` spec round-trip
4140 // — a decoration source carries no id/doc metadata; it is a pure producer.
4141 // PO.2: attach the boundary tracer so the actor emits a trace record per
4142 // guest call (a no-op when unwired).
4143 let actor = actor.with_tracer(self.env.tracer.clone());
4144 let task = runtime.spawn(actor.run());
4145 // SG.3b: hand the producer the sign registry so a placement naming
4146 // one of the plugin's own signs resolves. The HANDLE, so a plugin
4147 // loading after this one still has its signs seen.
4148 let source = WasmDecorationSource::new(client, self.env.sign_registry.clone());
4149 let id = source.plugin_id();
4150
4151 // Copy-on-write RCU into the wait-free registry (load → clone → register
4152 // → store), like the picker seam. Concurrent host refreshes keep reading
4153 // the prior snapshot until the store lands — no lock on the read path.
4154 let producer: Arc<dyn AsyncGutterDecorationSource> = Arc::new(source);
4155 registry.rcu(|current| {
4156 let mut next = (**current).clone();
4157 next.register(producer.clone());
4158 Arc::new(next)
4159 });
4160
4161 record.tasks.push(task);
4162 // Teardown token: the decoration registry unregisters this producer by id.
4163 record.teardown.decoration_sources.push(id.0 as u64);
4164 tracing::debug!(
4165 plugin = %manifest.id,
4166 id = id.0,
4167 "decoration plugin registered its gutter-decoration producer"
4168 );
4169 Ok(id)
4170 }
4171}
4172
4173/// MV.1: the WIT view result, converted to what `lattice-multibuffer` consumes.
4174///
4175/// A free function rather than a `WitBoundary` impl because the native type
4176/// lives in `lattice-multibuffer`, which knows nothing about WIT — the
4177/// conversion belongs to whoever depends on both, which is the loader.
4178fn convert_view_result(
4179 wit: lattice_plugin_host::multibuffer_view_task::MultibufferViewResult,
4180 grant: &lattice_plugin_host::CapabilityGrant,
4181) -> lattice_multibuffer::providers::plugin_view::PluginViewResult {
4182 let mut denied = 0usize;
4183 let excerpts: Vec<_> = wit
4184 .excerpts
4185 .into_iter()
4186 .filter_map(|e| {
4187 let path = std::path::PathBuf::from(e.path);
4188 // SECURITY: the capability gate for a guest-named read path.
4189 //
4190 // `grant_permits_read` canonicalises the FILE first, so a symlink
4191 // inside a granted tree that points out of it is refused — the
4192 // ordering `a_symlink_out_of_the_grant_is_denied` pins, and the
4193 // reason gating on the parent alone is not enough.
4194 if !lattice_plugin_host::host_services::grant_permits_read(grant, &path) {
4195 denied += 1;
4196 // info!, not debug!: a plugin denied fs access is
4197 // user-actionable and sends the author to their manifest.
4198 tracing::info!(
4199 path = %path.display(),
4200 "multibuffer view excerpt denied: outside the plugin's fs grant"
4201 );
4202 return None;
4203 }
4204 Some(lattice_multibuffer::providers::plugin_view::PluginExcerpt {
4205 path,
4206 start_line: e.start_line,
4207 end_line: e.end_line,
4208 header: e.header,
4209 match_count: e.match_count,
4210 })
4211 })
4212 .collect();
4213 let summary = if denied == 0 {
4214 wit.summary
4215 } else {
4216 // Surfaced rather than silently shortened: a view that is quietly
4217 // missing rows because of a manifest is indistinguishable from a view
4218 // whose data is wrong.
4219 format!("{} ({denied} outside the plugin's fs grant)", wit.summary)
4220 };
4221 lattice_multibuffer::providers::plugin_view::PluginViewResult { excerpts, summary }
4222}
4223
4224#[cfg(test)]
4225mod error_chain_tests {
4226 use super::error_chain;
4227
4228 #[derive(Debug)]
4229 struct Layer(&'static str, Option<Box<Layer>>);
4230
4231 impl std::fmt::Display for Layer {
4232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4233 f.write_str(self.0)
4234 }
4235 }
4236
4237 impl std::error::Error for Layer {
4238 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
4239 self.1
4240 .as_deref()
4241 .map(|l| l as &(dyn std::error::Error + 'static))
4242 }
4243 }
4244
4245 /// The whole point: the CAUSE survives. `to_string()` on the outer error
4246 /// yields only "failed to instantiate the plugin component", which names
4247 /// the category and not the fault — the real
4248 /// `no export \`on-wake\` found` sits one link down and used to be dropped
4249 /// on the floor by both the log and the `:plugins` row.
4250 #[test]
4251 fn the_cause_survives_rather_than_only_the_category() {
4252 let err = Layer(
4253 "failed to instantiate the plugin component",
4254 Some(Box::new(Layer("no export `on-wake` found", None))),
4255 );
4256 assert_eq!(
4257 err.to_string(),
4258 "failed to instantiate the plugin component",
4259 "Display alone is the category — this is what was being reported"
4260 );
4261 assert_eq!(
4262 error_chain(&err),
4263 "failed to instantiate the plugin component: no export `on-wake` found"
4264 );
4265 }
4266
4267 #[test]
4268 fn an_error_with_no_cause_is_unchanged() {
4269 assert_eq!(error_chain(&Layer("just this", None)), "just this");
4270 }
4271
4272 /// A runaway or cyclic `source()` must not hang the loader — this runs on
4273 /// the boot path, where a hang is indistinguishable from a dead editor.
4274 #[test]
4275 fn a_very_long_chain_is_bounded_rather_than_walked_forever() {
4276 let mut err = Layer("innermost", None);
4277 for i in 0..40 {
4278 err = Layer(
4279 match i % 2 {
4280 0 => "wrap-a",
4281 _ => "wrap-b",
4282 },
4283 Some(Box::new(err)),
4284 );
4285 }
4286 let out = error_chain(&err);
4287 assert!(
4288 out.ends_with(": …"),
4289 "truncation is marked, not silent: {out}"
4290 );
4291 assert_eq!(
4292 out.matches(": ").count(),
4293 9,
4294 "eight causes plus the ellipsis: {out}"
4295 );
4296 }
4297}
4298
4299/// `update`'s arm table, tested without a loaded plugin behind it — the
4300/// refusal is a property of the source alone.
4301#[cfg(test)]
4302mod update_refusal_tests {
4303 use super::{resolve, update_refusal};
4304
4305 /// The only arm that refuses. A pin is the answer to "which commit", so
4306 /// there is nothing to update TO, and moving past it would discard what
4307 /// the user wrote in `init.rs`.
4308 #[test]
4309 fn update_declines_on_a_pinned_git_source_and_names_the_pin() {
4310 let reason = update_refusal(
4311 "demo",
4312 Some(&resolve::PluginSource::Git {
4313 url: "https://example.invalid/p.git".into(),
4314 rev: Some("abc123".into()),
4315 }),
4316 )
4317 .expect("a pinned source refuses");
4318 assert!(
4319 reason.contains("abc123"),
4320 "the refusal names the pin, so the user knows why nothing moved: {reason}"
4321 );
4322 }
4323
4324 /// The three that proceed, for the three different reasons they do:
4325 /// unpinned git is the case the verb exists for, a local directory IS the
4326 /// source, and a prebuilt artifact is re-downloaded by the resolver on
4327 /// every pass.
4328 #[test]
4329 fn update_proceeds_on_every_other_source() {
4330 for source in [
4331 resolve::PluginSource::Git {
4332 url: "https://example.invalid/p.git".into(),
4333 rev: None,
4334 },
4335 resolve::PluginSource::Local(std::path::PathBuf::from("/tmp/demo")),
4336 resolve::PluginSource::Prebuilt {
4337 url: "https://example.invalid/demo.wasm".into(),
4338 },
4339 ] {
4340 assert_eq!(
4341 update_refusal("demo", Some(&source)),
4342 None,
4343 "{source:?} has somewhere to update from"
4344 );
4345 }
4346 }
4347
4348 /// A record with no recorded source falls through to `rebuild_with`, which
4349 /// owns the "no buildable source" message — two functions must not both
4350 /// answer the same question differently.
4351 #[test]
4352 fn update_leaves_a_sourceless_record_to_the_rebuild_path() {
4353 assert_eq!(update_refusal("demo", None), None);
4354 }
4355}
4356
4357/// [`BulkReport`]'s arithmetic and the sentence it produces. Pure over the
4358/// legs, so the counting is pinned without running a build behind it.
4359#[cfg(test)]
4360mod bulk_report_tests {
4361 use super::{BulkLeg, BulkReport};
4362
4363 fn report(legs: &[(&str, BulkLeg)]) -> BulkReport {
4364 BulkReport {
4365 legs: legs
4366 .iter()
4367 .map(|(n, l)| ((*n).to_string(), l.clone()))
4368 .collect(),
4369 }
4370 }
4371
4372 /// The common run says one number. Zeroes are noise that makes the count
4373 /// that matters harder to find.
4374 #[test]
4375 fn an_all_succeeded_run_reports_only_the_one_count() {
4376 let r = report(&[("a", BulkLeg::Done), ("b", BulkLeg::Done)]);
4377 assert_eq!(r.summary("updated"), "2 updated");
4378 }
4379
4380 /// Skipped is reported and is NOT failed — the distinction is the reason
4381 /// `BulkLeg` is an enum rather than a `Result`. `4 updated, 2 pinned`
4382 /// reads as success; `4 updated, 2 failed` sends someone hunting.
4383 #[test]
4384 fn skipped_is_counted_apart_from_failed() {
4385 let r = report(&[
4386 ("a", BulkLeg::Done),
4387 ("b", BulkLeg::Skipped("pinned to abc123".into())),
4388 ("c", BulkLeg::Failed("build broke".into())),
4389 ]);
4390 assert_eq!(r.done(), 1);
4391 assert_eq!(r.skipped(), 1);
4392 assert_eq!(r.failed(), 1);
4393 assert_eq!(r.summary("updated"), "1 updated, 1 skipped, 1 failed");
4394 }
4395
4396 /// Only failures are worth a `warn!` line each, and each must name its
4397 /// plugin — a summary saying `2 failed` with no names is not actionable.
4398 #[test]
4399 fn failures_name_the_plugin_and_the_reason() {
4400 let r = report(&[
4401 ("a", BulkLeg::Done),
4402 ("b", BulkLeg::Failed("build broke".into())),
4403 ("c", BulkLeg::Skipped("bundled".into())),
4404 ]);
4405 assert_eq!(r.failures(), vec![("b", "build broke")]);
4406 }
4407
4408 /// An editor with no plugins says so rather than `0 updated`, which reads
4409 /// like something went wrong.
4410 #[test]
4411 fn an_empty_run_says_there_was_nothing_to_do() {
4412 assert_eq!(
4413 BulkReport::default().summary("updated"),
4414 "no plugins loaded"
4415 );
4416 }
4417}
4418
4419/// `clean`'s safety rules, against a real directory tree.
4420///
4421/// Every test here is a clause that, if dropped, deletes something the user
4422/// wanted — which is why they are tested one clause at a time rather than as
4423/// one "it works" case.
4424#[cfg(test)]
4425mod removable_tests {
4426 #![allow(clippy::unwrap_used)]
4427 use super::removable_under;
4428 use std::collections::HashSet;
4429 use std::path::{Path, PathBuf};
4430 use std::sync::atomic::{AtomicUsize, Ordering};
4431
4432 static COUNTER: AtomicUsize = AtomicUsize::new(0);
4433
4434 fn tempdir(tag: &str) -> PathBuf {
4435 // A counter as well as the pid: a timestamp alone collides under a
4436 // parallel `cargo test`.
4437 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
4438 let dir =
4439 std::env::temp_dir().join(format!("lattice-clean-{tag}-{}-{n}", std::process::id()));
4440 let _ = std::fs::remove_dir_all(&dir);
4441 std::fs::create_dir_all(&dir).unwrap();
4442 dir
4443 }
4444
4445 /// A staged plugin directory: a marker, and something to delete.
4446 fn staged(root: &Path, name: &str, marker: bool) {
4447 let dir = root.join(name);
4448 std::fs::create_dir_all(&dir).unwrap();
4449 std::fs::write(dir.join(format!("{name}.wasm")), b"\0asm").unwrap();
4450 if marker {
4451 std::fs::write(dir.join(".source"), "kind = local\npath = /tmp/x\n").unwrap();
4452 }
4453 }
4454
4455 fn keep(names: &[&str]) -> HashSet<String> {
4456 names.iter().map(|n| (*n).to_string()).collect()
4457 }
4458
4459 #[test]
4460 fn an_unclaimed_staged_directory_with_provenance_is_removable() {
4461 let root = tempdir("basic");
4462 staged(&root, "ghost", true);
4463 let got = removable_under(&root, &keep(&[]));
4464 assert_eq!(got.len(), 1);
4465 assert_eq!(got[0].0, "ghost");
4466 }
4467
4468 /// Clause 1 + 2. A plugin that FAILED to load is still one the user asked
4469 /// for: cleaning it would turn "my plugin is broken" into "my plugin is
4470 /// gone", and take the error message in the view with it.
4471 #[test]
4472 fn a_loaded_or_failed_plugin_is_never_removable() {
4473 let root = tempdir("claimed");
4474 staged(&root, "loaded", true);
4475 staged(&root, "failed", true);
4476 staged(&root, "ghost", true);
4477 let got = removable_under(&root, &keep(&["loaded", "failed"]));
4478 assert_eq!(
4479 got.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
4480 vec!["ghost"]
4481 );
4482 }
4483
4484 /// Clause 3. `init` is the user's own configuration, not a plugin, and it
4485 /// never appears in the loaded set under that name — so without naming it
4486 /// explicitly, clean would delete the user's config.
4487 #[test]
4488 fn the_users_init_directory_is_never_removable() {
4489 let root = tempdir("init");
4490 staged(&root, "init", true);
4491 assert!(removable_under(&root, &keep(&["init"])).is_empty());
4492 }
4493
4494 /// Clause 4. Provenance is what makes removal recoverable. A hand-staged
4495 /// directory has no `.source`, and deleting it destroys the only copy.
4496 #[test]
4497 fn a_directory_without_provenance_is_left_alone() {
4498 let root = tempdir("nomarker");
4499 staged(&root, "handmade", false);
4500 assert!(
4501 removable_under(&root, &keep(&[])).is_empty(),
4502 "no `.source` marker ⇒ nothing to re-install from ⇒ never delete"
4503 );
4504 }
4505
4506 /// Files beside the plugin directories are not plugins and are not
4507 /// candidates — only directories are.
4508 #[test]
4509 fn a_stray_file_in_the_root_is_not_a_candidate() {
4510 let root = tempdir("stray");
4511 std::fs::write(root.join("notes.txt"), "hello").unwrap();
4512 assert!(removable_under(&root, &keep(&[])).is_empty());
4513 }
4514
4515 /// A missing plugins root is an empty answer, not an error: a user who has
4516 /// never installed a plugin has no directory, and `:plugin-clean` there
4517 /// should say "nothing to clean".
4518 #[test]
4519 fn a_missing_root_is_empty_rather_than_an_error() {
4520 let root = tempdir("gone");
4521 let missing = root.join("nope");
4522 assert!(removable_under(&missing, &keep(&[])).is_empty());
4523 }
4524
4525 /// The removal itself: what was listed goes, and the directory is really
4526 /// gone rather than emptied.
4527 #[test]
4528 fn cleaning_a_listed_name_removes_its_directory() {
4529 let root = tempdir("remove");
4530 staged(&root, "ghost", true);
4531 let listed = removable_under(&root, &keep(&[]));
4532 let report = super::clean_listed(&listed, &["ghost".to_string()]);
4533 assert_eq!(report.done(), 1);
4534 assert!(!root.join("ghost").exists(), "the directory is gone");
4535 }
4536
4537 /// The re-check. A confirmation left sitting while a plugin loaded must
4538 /// not delete the plugin that just arrived — so a name that has stopped
4539 /// being removable is skipped, and its files are still there afterwards.
4540 #[test]
4541 fn a_name_that_stopped_being_removable_is_skipped_not_deleted() {
4542 let root = tempdir("recheck");
4543 staged(&root, "arrived", true);
4544 // Listed when nothing claimed it...
4545 let listed = removable_under(&root, &keep(&[]));
4546 assert_eq!(listed.len(), 1);
4547 // ...but by the time the user confirms, it has loaded.
4548 let now = removable_under(&root, &keep(&["arrived"]));
4549 let report = super::clean_listed(&now, &["arrived".to_string()]);
4550 assert_eq!(report.skipped(), 1, "skipped, not failed");
4551 assert_eq!(report.done(), 0);
4552 assert!(
4553 root.join("arrived").is_dir(),
4554 "the plugin that just loaded still has its files"
4555 );
4556 }
4557}