Skip to main content

lattice_plugin_host/
mode_host.rs

1//! The `modes` guest→host mode-declaration seam (PH7.11a).
2//!
3//! A mode plugin implements the `modes-plugin` world: it **imports** the `modes`
4//! API (`register-mode`) and **exports** `register-modes` (the host calls it once
5//! to drive declaration). This module holds the `bindgen!` for that world plus
6//! the host-side registration logic — building a marker [`Mode`] impl
7//! ([`PluginMode`], the `EmacsKeysMode` template) from the declaration and
8//! registering it into the SAME [`ModeRegistry`](lattice_mode::ModeRegistry)
9//! builtins use, so `:describe-mode` / mode introspection treat it uniformly.
10//!
11//! **The canonical API is the WIT** (`modes.wit`) — any component-model language
12//! calls `register-mode` directly. The mapping + register logic live here so they
13//! are unit-testable without a `Store` (the `config_host` / `host_services`
14//! precedent).
15//!
16//! Registration flow (the `register-grammar` drain precedent): `register-mode`
17//! records the declaration into the Store's [`ModeContributions`]; after the
18//! guest's `register-modes` export returns, [`PluginHost::spawn_mode_plugin`]
19//! drains them and registers each into a `&mut ModeRegistry` (registration needs
20//! `&mut`, not a live handle — unlike config's `Arc<ConfigRegistry>`).
21//!
22//! PH7.11a lands mode declaration + registration; PH7.11b the keymap bindings.
23//! **OM.2 adds major modes**, because a plugin-contributed *language* can get a
24//! major no other way: `major_mode_id_for_lang` is a hand-written match over the
25//! `Lang` enum and has no arm for `Lang::Plugin(_)`. A declared major claims its
26//! language through `target-language`, the registry indexes it
27//! (`ModeRegistry::find_major_for_lang`), and a document of that language
28//! activates it through the same resolver a built-in major uses.
29//!
30//! Lifecycle callbacks, decorations and typed option-overrides remain deferred
31//! (fragment / Phase 8).
32
33use lattice_config::ConfigRegistry;
34use lattice_grammar::source::SourceLocation;
35use lattice_grammar::{CommandInvocation, CommandRegistry};
36use lattice_keymap::{BindingMode, KeymapCapability, KeymapHandle, KeymapLayer};
37use lattice_mode::{
38    ActivationPolicy, CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
39    ModeRegistry, OptionOverride, OptionOverrideSet, OverridePriority,
40};
41
42use crate::{
43    Component, PluginBudget, PluginHost, PluginHostError, PluginId, PluginManifest, TrustTier,
44    arm_store, classify_trap,
45};
46
47pub(crate) mod bindings {
48    wasmtime::component::bindgen!({
49        world: "modes-plugin",
50        path: "../lattice-wit/wit",
51        // `register-modes` is wired into the same async linker as WASI + the
52        // `modes` host func, so the export is async (the `config` / `events`
53        // precedent: async export, sync `register-mode` host func).
54        exports: { default: async },
55    });
56}
57
58/// Major vs minor — the host-side mirror of the WIT `mode-kind`, kept as a plain
59/// enum (no bindgen type) so the registration logic is unit-testable.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub(crate) enum PluginModeKind {
62    Major,
63    Minor,
64}
65
66/// One keymap binding a mode contributes (PH7.11b) — the native projection of
67/// the WIT `mode-keymap-binding`. `command` is resolved by name against the
68/// `CommandRegistry` at bind time.
69pub(crate) struct PluginKeymapBinding {
70    pub mode: BindingMode,
71    pub chord: String,
72    pub command: String,
73}
74
75/// A native intermediate for one declared mode, projected from the WIT
76/// `mode-declaration` at the Host-impl boundary so the register logic here needs
77/// no bindgen types.
78pub(crate) struct PluginModeDecl {
79    pub id: String,
80    pub kind: PluginModeKind,
81    pub policy: ActivationPolicy,
82    pub caps: CapabilitySet,
83    pub keymap: Vec<PluginKeymapBinding>,
84    /// OM.2: the language a MAJOR claims (`Some("org")`). Ignored on a minor.
85    pub target_language: Option<String>,
86    /// MO.1: options this mode sets for its own buffers, still as the strings
87    /// the guest sent. Resolved against the `ConfigRegistry` at drain, for the
88    /// same reason `keymap`'s command names are: a declaration is data, and the
89    /// registry it must agree with is native.
90    pub options: Vec<PluginModeOverride>,
91}
92
93/// MO.1: one option override a mode declares — the native projection of the WIT
94/// `mode-option-override`, before any name or value has been resolved.
95pub(crate) struct PluginModeOverride {
96    pub name: String,
97    pub value: String,
98    pub priority: OverridePriority,
99}
100
101/// The per-plugin accumulator the `modes::Host` impl records into during
102/// `register-modes` (`lib.rs`). Drained by [`PluginHost::spawn_mode_plugin`]
103/// after the export returns (the `GrammarContributions` precedent).
104#[derive(Default)]
105pub(crate) struct ModeContributions {
106    recorded: Vec<PluginModeDecl>,
107}
108
109impl ModeContributions {
110    /// Record a declaration (the `register-mode` host-func body).
111    pub fn record(&mut self, decl: PluginModeDecl) {
112        self.recorded.push(decl);
113    }
114
115    /// Drain the recorded declarations, leaving the accumulator empty.
116    pub fn take(&mut self) -> Vec<PluginModeDecl> {
117        std::mem::take(&mut self.recorded)
118    }
119}
120
121/// A plugin-declared mode — a marker `Mode` (the `EmacsKeysMode` shape): it
122/// carries an id + kind + activation policy + capability requirements, allocates
123/// no per-buffer resources (`Guard = ()`), and its `on_activate` is a no-op. The
124/// mode's *behavior* is composed from the other seams — keymap bindings (PH7.11b)
125/// bind its chords to commands; action bodies arrive via the grammar
126/// `register-action` trampoline (PH7.7). Lifecycle callbacks are Phase 8.
127struct PluginMode {
128    id: ModeId,
129    kind: ModeKind,
130    policy: ActivationPolicy,
131    caps: CapabilitySet,
132    /// OM.2: the language this mode is the default major for. Already filtered
133    /// to majors by `register_plugin_mode`, and filtered again by the registry
134    /// — belt and braces, because installing a minor as a buffer's major is
135    /// not a failure that announces itself.
136    target_language: Option<String>,
137    /// MO.1: the options this mode sets for its own buffers, already resolved to
138    /// `(TypeId, typed value)` at registration.
139    ///
140    /// Resolved once here rather than on each `options()` call because the trait
141    /// requires the answer be pure — *"same return value every call"* — and
142    /// because `options()` is read on every layer recompute, which is every mode
143    /// activation and every buffer switch. Doing registry lookups and value
144    /// parsing there would put string work on a path that currently does none.
145    options: OptionOverrideSet,
146}
147
148impl Mode for PluginMode {
149    type Guard = ();
150
151    fn id(&self) -> ModeId {
152        self.id
153    }
154
155    fn kind(&self) -> ModeKind {
156        self.kind
157    }
158
159    fn target_language(&self) -> Option<&str> {
160        self.target_language.as_deref()
161    }
162
163    fn activation_policy(&self) -> ActivationPolicy {
164        self.policy.clone()
165    }
166
167    fn required_capabilities(&self) -> CapabilitySet {
168        self.caps
169    }
170
171    /// MO.1. Nothing downstream distinguishes this from a native mode's set —
172    /// `recompute_options_for_buffer` reads it through the same `DynMode` blanket
173    /// impl, tags it with the same `OptionOrigin::ModeContribution`, and the same
174    /// conflict policy applies. A plugin mode is not special here, which is the
175    /// point: mode ownership means owning the surface, not getting a parallel one.
176    fn options(&self) -> OptionOverrideSet {
177        self.options.clone()
178    }
179
180    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
181        Box::pin(async { Ok(()) })
182    }
183}
184
185/// MO.1: resolve a mode's declared overrides against the native option registry.
186///
187/// Each entry is put through **`parse_for_buffer_local`, the same parse + validate
188/// `:setlocal name=value` uses** — deliberately, so a mode and a user cannot
189/// disagree about what a value means, and so an option's own validator is the one
190/// that judges it. It resolves and coerces without writing, returning exactly the
191/// `(TypeId, erased value)` an `OptionOverride` is made of.
192///
193/// **Skip-and-warn per entry, never per set.** One unresolvable name must not
194/// cost a mode its other options — the rule `bind_mode_keymap` already follows
195/// for an unknown command, and the transient seam for a bad row. The warning
196/// names the mode AND the option, because the failure it describes is otherwise
197/// a buffer that quietly behaves wrong, which is the exact class of
198/// silent-nothing this codebase keeps paying for.
199///
200/// `None` for `config` means the host was built without a config registry (the
201/// minimal test harnesses). Declared overrides are then skipped as a set, with
202/// one warning naming the mode — an absent handle is a logged skip, the
203/// documented behaviour for every other seam handle.
204fn resolve_mode_options(
205    config: Option<&ConfigRegistry>,
206    mode_id: &str,
207    declared: &[PluginModeOverride],
208) -> OptionOverrideSet {
209    if declared.is_empty() {
210        return OptionOverrideSet::default();
211    }
212    let Some(config) = config else {
213        tracing::warn!(
214            mode = mode_id,
215            count = declared.len(),
216            "register-mode: option overrides skipped — no config registry wired"
217        );
218        return OptionOverrideSet::default();
219    };
220
221    let mut set = OptionOverrideSet::with_capacity(declared.len());
222    for ov in declared {
223        // Always the `name=value` spelling, so this is `parse_set`'s Assign arm
224        // rather than the bare-name / `no`-prefix vim shorthands. A mode
225        // declares a value; it does not get the shorthand grammar, which would
226        // make `{ name, value }` mean two different things.
227        let spec = format!("{}={}", ov.name, ov.value);
228        match config.parse_for_buffer_local(&spec) {
229            Ok((type_id, value, canonical)) => {
230                tracing::debug!(
231                    mode = mode_id,
232                    option = %canonical,
233                    value = %ov.value,
234                    "register-mode: option override resolved"
235                );
236                set.push(OptionOverride {
237                    option_type_id: type_id,
238                    value,
239                    priority: ov.priority,
240                });
241            }
242            Err(error) => tracing::warn!(
243                mode = mode_id,
244                option = %ov.name,
245                value = %ov.value,
246                %error,
247                "register-mode: option override skipped; the mode's other options still apply"
248            ),
249        }
250    }
251    set
252}
253
254/// The `register-mode` host-service body (PH7.11a; majors OM.2). Builds a
255/// [`PluginMode`] from `decl` and registers it into the SAME `ModeRegistry`
256/// builtins use, returning the registered [`ModeId`] on success. Returns `None`
257/// (registering nothing) when `ModeRegistry::register` rejects it (missing
258/// `-mode` suffix, id collision) — logged, never a panic (graceful
259/// degradation).
260///
261/// A `target-language` on a MINOR is dropped with a warning rather than
262/// carried: a buffer has exactly one major, so indexing a minor's claim would
263/// install it as that major. The registry refuses the same thing independently;
264/// saying it here means the plugin author reads a message naming their mode.
265pub(crate) fn register_plugin_mode(
266    registry: &mut ModeRegistry,
267    config: Option<&ConfigRegistry>,
268    decl: &PluginModeDecl,
269) -> Option<ModeId> {
270    let kind = match decl.kind {
271        PluginModeKind::Major => ModeKind::Major,
272        PluginModeKind::Minor => ModeKind::Minor,
273    };
274    let target_language = match (decl.kind, decl.target_language.as_deref()) {
275        (PluginModeKind::Major, lang) => lang.map(str::to_owned),
276        (PluginModeKind::Minor, Some(lang)) => {
277            tracing::warn!(
278                mode = %decl.id,
279                %lang,
280                "register-mode: target-language ignored on a minor mode; only a \
281                 major can own a language (a minor rides one via activation-policy)"
282            );
283            None
284        }
285        (PluginModeKind::Minor, None) => None,
286    };
287    let mode = PluginMode {
288        id: ModeId::new(&decl.id),
289        kind,
290        policy: decl.policy.clone(),
291        caps: decl.caps,
292        target_language,
293        options: resolve_mode_options(config, &decl.id, &decl.options),
294    };
295    // CI.3: a plugin mode registers **available but not enabled** — the user
296    // enables it (`enable-mode` / init.rs), the plugin author does not seize
297    // auto-activation (config-and-init.md §6).
298    match registry.register_available(mode) {
299        Ok(id) => Some(id),
300        Err(error) => {
301            tracing::warn!(mode = %decl.id, %error, "register-mode rejected by the registry");
302            None
303        }
304    }
305}
306
307/// Bind a plugin mode's declared keymap into its OWN layer (PH7.11b),
308/// returning the number of bindings that landed. Each binding resolves its
309/// command name against `commands` and installs a capability-gated write with
310/// [`KeymapCapability::OwnedLayer`] — so the mode can write ONLY its own layer
311/// (the write-gate). An unparseable chord, an unknown command, or a capability
312/// denial skips that one binding with a `warn!` (graceful degradation), never a
313/// panic. Provenance is `SourceLocation::plugin(plugin_id)` — host-issued, so the
314/// binding traces to the plugin (§6).
315///
316/// OM.2: which layer follows the mode's KIND — `MajorMode(id)` for a major,
317/// `MinorMode(id)` for a minor. Both are gated tries keyed by mode id, merged
318/// in active-modes order at lookup, so a minor overlays its major and neither
319/// touches the built-in vim grammar.
320pub(crate) fn bind_mode_keymap(
321    keymap: &KeymapHandle,
322    commands: &CommandRegistry,
323    plugin_id: u32,
324    mode_id: &ModeId,
325    kind: ModeKind,
326    bindings: &[PluginKeymapBinding],
327) -> usize {
328    let capability = KeymapCapability::OwnedLayer { mode_id: *mode_id };
329    let layer = match kind {
330        ModeKind::Major => KeymapLayer::MajorMode(*mode_id),
331        ModeKind::Minor => KeymapLayer::MinorMode(*mode_id),
332    };
333    let mut bound = 0;
334    for binding in bindings {
335        let Some(command_id) = commands.id_by_name(&binding.command) else {
336            tracing::warn!(
337                mode = %mode_id.as_str(),
338                command = %binding.command,
339                "mode keymap binding skipped: command not registered"
340            );
341            continue;
342        };
343        match keymap.try_bind_chord_string(
344            capability,
345            layer,
346            binding.mode,
347            &binding.chord,
348            CommandInvocation::of(command_id),
349            SourceLocation::plugin(plugin_id),
350        ) {
351            Ok(()) => bound += 1,
352            Err(error) => {
353                tracing::warn!(
354                    mode = %mode_id.as_str(),
355                    chord = %binding.chord,
356                    %error,
357                    "mode keymap binding skipped"
358                );
359            }
360        }
361    }
362    bound
363}
364
365impl PluginHost {
366    /// Instantiate a `modes-plugin` component under its capability grant, run its
367    /// `register-modes` export to declare modes, register each into `registry`,
368    /// and return the successfully-registered [`ModeId`]s. Grant / data-dir /
369    /// WASI are identical to
370    /// [`instantiate_plugin`](PluginHost::instantiate_plugin) (shared
371    /// `build_plugin_wasi` + `new_store`).
372    ///
373    /// Registration is drained AFTER `register-modes` returns because
374    /// `ModeRegistry::register` needs `&mut ModeRegistry` — unlike config's live
375    /// `Arc<ConfigRegistry>` handle. A declaration the registry rejects is logged +
376    /// skipped (not in the returned ids); the teardown seam (PH7.12) will
377    /// remove a plugin's modes.
378    pub async fn spawn_mode_plugin(
379        &self,
380        component: &Component,
381        manifest: &PluginManifest,
382        tier: TrustTier,
383        budget: PluginBudget,
384        registry: &mut ModeRegistry,
385        commands: &CommandRegistry,
386        keymap: &KeymapHandle,
387        config: Option<&ConfigRegistry>,
388    ) -> Result<(PluginId, Vec<ModeId>), PluginHostError> {
389        let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
390        for denied in &outcome.denied {
391            tracing::warn!(
392                plugin = %manifest.id,
393                capability = ?denied,
394                "mode plugin loaded with a withheld capability (reduced function)"
395            );
396        }
397        let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
398        let bindings =
399            bindings::ModesPlugin::instantiate_async(&mut store, component, &self.linker)
400                .await
401                .map_err(|e| PluginHostError::Instantiate(e.into()))?;
402        let plugin_id = self.alloc_id();
403        // PO.5: route this plugin's `logging` calls into the tracer (Layer 2) —
404        // before register-modes, so the guest may narrate from there.
405        store.data_mut().log_ctx = self.log_ctx_for(plugin_id);
406
407        arm_store(&mut store, budget)?;
408        bindings
409            .call_register_modes(&mut store)
410            .await
411            .map_err(|source| PluginHostError::Trap {
412                func: "register-modes",
413                kind: classify_trap(&source),
414                source: source.into(),
415            })?;
416
417        // Register each mode, then bind its keymap into its OWN layer
418        // (PH7.11b, capability-gated) — `MajorMode` or `MinorMode` per the
419        // declared kind (OM.2). A mode the registry rejects contributes no
420        // keymap (its layer never exists).
421        let recorded = store.data_mut().mode_contributions.take();
422        let mut ids = Vec::with_capacity(recorded.len());
423        for decl in recorded {
424            if let Some(id) = register_plugin_mode(registry, config, &decl) {
425                let kind = match decl.kind {
426                    PluginModeKind::Major => ModeKind::Major,
427                    PluginModeKind::Minor => ModeKind::Minor,
428                };
429                bind_mode_keymap(keymap, commands, plugin_id.0, &id, kind, &decl.keymap);
430                ids.push(id);
431            }
432        }
433        // Surface the host-issued `plugin_id` alongside the accepted modes: the
434        // loader records it for provenance (`:list-plugins`) + teardown-by-id
435        // (PL8.C), consistent with the other seam spawns (picker / config /
436        // events). The modes themselves are declarative data now living in the
437        // registry, so the guest `store` / `bindings` drop here — no handle to
438        // keep alive.
439        Ok((plugin_id, ids))
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    #![allow(clippy::unwrap_used, clippy::panic)]
446
447    use super::*;
448
449    fn minor(id: &str) -> PluginModeDecl {
450        PluginModeDecl {
451            id: id.to_string(),
452            kind: PluginModeKind::Minor,
453            policy: ActivationPolicy::Manual,
454            caps: CapabilitySet::empty(),
455            keymap: Vec::new(),
456            target_language: None,
457            options: Vec::new(),
458        }
459    }
460
461    /// OM.2: a major claiming a language — the org shape.
462    fn major_for(id: &str, lang: &str) -> PluginModeDecl {
463        PluginModeDecl {
464            id: id.to_string(),
465            kind: PluginModeKind::Major,
466            policy: ActivationPolicy::Manual,
467            caps: CapabilitySet::empty(),
468            keymap: Vec::new(),
469            target_language: Some(lang.to_string()),
470            options: Vec::new(),
471        }
472    }
473
474    #[test]
475    fn registers_a_minor_mode_into_the_registry() {
476        let mut registry = ModeRegistry::default();
477        let id = register_plugin_mode(&mut registry, None, &minor("git-blame-mode"))
478            .expect("a well-formed minor mode registers");
479        assert_eq!(id.as_str(), "git-blame-mode");
480        assert!(registry.is_registered(ModeId::new("git-blame-mode")));
481    }
482
483    #[test]
484    fn a_bare_id_without_the_mode_suffix_is_rejected() {
485        let mut registry = ModeRegistry::default();
486        assert!(
487            register_plugin_mode(&mut registry, None, &minor("git-blame")).is_none(),
488            "the registry enforces the `-mode` suffix"
489        );
490        assert!(!registry.is_registered(ModeId::new("git-blame")));
491    }
492
493    /// OM.2 — the inverse of what this test used to assert. A plugin
494    /// contributing a language must be able to contribute its major, because
495    /// the host's `major_mode_id_for_lang` table has no arm for a language it
496    /// has never heard of.
497    #[test]
498    fn a_major_registers_and_claims_its_language() {
499        let mut registry = ModeRegistry::default();
500        let id = register_plugin_mode(&mut registry, None, &major_for("org-mode", "org"))
501            .expect("a well-formed major registers");
502        assert_eq!(id.as_str(), "org-mode");
503        assert_eq!(
504            registry.get(id).expect("registered").kind(),
505            ModeKind::Major,
506            "it registers AS a major, not silently downgraded to a minor"
507        );
508        assert_eq!(
509            registry.find_major_for_lang("org"),
510            Some(id),
511            "and the language index resolves documents onto it"
512        );
513    }
514
515    /// A major need not claim a language — that is manual activation, and the
516    /// index must stay empty rather than gaining a `None` key.
517    #[test]
518    fn a_major_without_a_language_registers_but_claims_nothing() {
519        let mut registry = ModeRegistry::default();
520        let mut decl = major_for("scratch-mode", "unused");
521        decl.target_language = None;
522        let id = register_plugin_mode(&mut registry, None, &decl).expect("registers");
523        assert_eq!(
524            registry.get(id).expect("registered").kind(),
525            ModeKind::Major
526        );
527        assert_eq!(registry.find_major_for_lang("unused"), None);
528    }
529
530    /// A minor's language claim is dropped, not honoured — indexing it would
531    /// install the minor as every org buffer's major.
532    #[test]
533    fn a_minor_claiming_a_language_registers_without_the_claim() {
534        let mut registry = ModeRegistry::default();
535        let mut decl = minor("org-todo-mode");
536        decl.target_language = Some("org".to_string());
537        let id =
538            register_plugin_mode(&mut registry, None, &decl).expect("the mode still registers");
539        assert_eq!(
540            registry.get(id).expect("registered").kind(),
541            ModeKind::Minor
542        );
543        assert_eq!(
544            registry.find_major_for_lang("org"),
545            None,
546            "the claim was dropped, so org documents do not resolve onto a minor"
547        );
548    }
549
550    /// The write-gate follows the kind: a major's bindings land in its own
551    /// `MajorMode` layer under the same `OwnedLayer` capability a minor uses.
552    #[test]
553    fn a_majors_keymap_lands_in_its_own_major_layer() {
554        let keymap = KeymapHandle::new();
555        let mut commands = CommandRegistry::new();
556        let _ = lattice_grammar::ex_commands::populate(&mut commands);
557        let mode_id = ModeId::new("org-mode");
558        let bindings = vec![PluginKeymapBinding {
559            mode: BindingMode::Normal,
560            chord: "<C-s>".to_string(),
561            command: "ex:write".to_string(),
562        }];
563
564        let bound = bind_mode_keymap(&keymap, &commands, 3, &mode_id, ModeKind::Major, &bindings);
565        assert_eq!(bound, 1, "the binding landed");
566
567        let chord = lattice_protocol::parse_chord_sequence("<C-s>").unwrap();
568        assert!(
569            matches!(
570                keymap.lookup_with_context(BindingMode::Normal, &chord, &[mode_id]),
571                lattice_keymap::LookupResult::Bound { .. }
572            ),
573            "resolves when the major is active"
574        );
575        assert!(
576            matches!(
577                keymap.lookup_with_context(BindingMode::Normal, &chord, &[]),
578                lattice_keymap::LookupResult::Unbound
579            ),
580            "a major's layer is gated too — it is not always-on (K.1.c)"
581        );
582    }
583
584    #[test]
585    fn a_duplicate_id_is_rejected_keeping_the_original() {
586        let mut registry = ModeRegistry::default();
587        assert!(register_plugin_mode(&mut registry, None, &minor("dup-mode")).is_some());
588        assert!(
589            register_plugin_mode(&mut registry, None, &minor("dup-mode")).is_none(),
590            "a second registration under the same id is refused"
591        );
592    }
593
594    #[test]
595    fn capabilities_and_policy_are_carried_onto_the_registered_mode() {
596        let mut registry = ModeRegistry::default();
597        let decl = PluginModeDecl {
598            id: "lsp-lens-mode".to_string(),
599            kind: PluginModeKind::Minor,
600            policy: ActivationPolicy::Universal,
601            caps: CapabilitySet::LSP | CapabilitySet::DIAGNOSTICS,
602            keymap: Vec::new(),
603            target_language: None,
604            options: Vec::new(),
605        };
606        let id = register_plugin_mode(&mut registry, None, &decl).unwrap();
607        let mode = registry.get(id).expect("registered");
608        assert!(matches!(
609            mode.activation_policy(),
610            ActivationPolicy::Universal
611        ));
612        assert_eq!(
613            mode.required_capabilities(),
614            CapabilitySet::LSP | CapabilitySet::DIAGNOSTICS
615        );
616    }
617
618    /// A registry carrying the real native options, which is what a mode's
619    /// declared override has to resolve against.
620    fn config() -> ConfigRegistry {
621        let r = ConfigRegistry::new();
622        r.init_from_linkme();
623        r
624    }
625
626    fn override_of(name: &str, value: &str) -> PluginModeOverride {
627        PluginModeOverride {
628            name: name.to_string(),
629            value: value.to_string(),
630            priority: OverridePriority::Normal,
631        }
632    }
633
634    /// MO.1, the hole this closes: a plugin mode can finally say what its
635    /// buffers need. Org's `foldmethod = syntax` is the consumer — before this
636    /// it worked only because the user happened to `:set` it globally, which
637    /// made org's folding correct by coincidence on one machine.
638    #[test]
639    fn a_declared_option_override_reaches_the_registered_mode() {
640        use lattice_config::FoldMethodOption;
641        use lattice_core::FoldMethod;
642
643        let cfg = config();
644        let mut registry = ModeRegistry::default();
645        let mut decl = major_for("org-mode", "org");
646        decl.options = vec![override_of("foldmethod", "syntax")];
647
648        let id = register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
649        let mode = registry.get(id).expect("registered");
650
651        let set = mode.options();
652        assert_eq!(set.len(), 1, "the override crossed");
653        let ov = set.iter().next().expect("one override");
654        assert_eq!(
655            ov.option_type_id,
656            std::any::TypeId::of::<FoldMethodOption>(),
657            "resolved to the NATIVE option's identity, not a name the mode kept"
658        );
659        assert_eq!(
660            ov.downcast_value::<FoldMethod>(),
661            Some(&FoldMethod::Syntax),
662            "and the string was coerced by the option's own parser"
663        );
664        assert_eq!(ov.priority, OverridePriority::Normal);
665    }
666
667    /// Declaring an override must not touch the user's global setting. It is a
668    /// resolution *layer*, not a write — the distinction users ask about, and
669    /// the one that would make a mode able to silently reconfigure the editor.
670    #[test]
671    fn an_override_does_not_write_the_global_option() {
672        use lattice_config::FoldMethodOption;
673        use lattice_core::FoldMethod;
674
675        let cfg = config();
676        let before = *cfg.get_typed::<FoldMethodOption>().expect("registered");
677        assert_eq!(before, FoldMethod::Manual, "sanity: the shipped default");
678
679        let mut registry = ModeRegistry::default();
680        let mut decl = major_for("org-mode", "org");
681        decl.options = vec![override_of("foldmethod", "syntax")];
682        register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
683
684        assert_eq!(
685            *cfg.get_typed::<FoldMethodOption>().expect("registered"),
686            FoldMethod::Manual,
687            "the global value is untouched — the override is a layer"
688        );
689    }
690
691    /// One bad entry must not cost a mode its other options. The same rule
692    /// `bind_mode_keymap` follows for an unknown command, and the reason is the
693    /// same: a mode half-configured because of one typo behaves wrong in a way
694    /// nothing announces.
695    #[test]
696    fn an_unresolvable_override_is_skipped_and_the_rest_apply() {
697        use lattice_config::Number;
698
699        let cfg = config();
700        let mut registry = ModeRegistry::default();
701        let mut decl = minor("test-opts-mode");
702        decl.options = vec![
703            override_of("no-such-option-anywhere", "true"),
704            override_of("number", "false"),
705        ];
706
707        let id = register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
708        let set = registry.get(id).expect("registered").options();
709
710        assert_eq!(set.len(), 1, "the bad entry is dropped, not the set");
711        assert_eq!(
712            set.iter().next().expect("one").option_type_id,
713            std::any::TypeId::of::<Number>(),
714            "and it is the GOOD one that survived"
715        );
716    }
717
718    /// A value the option's own validator rejects is skipped the same way — the
719    /// judgement is the option's, so a mode cannot smuggle a value past the
720    /// check `:set` would have applied.
721    #[test]
722    fn a_value_the_option_rejects_is_skipped() {
723        let cfg = config();
724        let mut registry = ModeRegistry::default();
725        let mut decl = minor("test-badvalue-mode");
726        decl.options = vec![override_of("foldmethod", "definitely-not-a-fold-method")];
727
728        let id = register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
729        assert!(
730            registry.get(id).expect("registered").options().is_empty(),
731            "an invalid value contributes nothing"
732        );
733    }
734
735    /// **The known hole, asserted so it is a decision and not a surprise.** An
736    /// option the plugin itself registered through the config seam has no native
737    /// type identity, and `OptionOverride` is keyed by `TypeId`. It is skipped
738    /// with a warning naming it. `plugin-mode-options.md` §3c is the fix, and it
739    /// waits for a consumer.
740    #[test]
741    fn a_plugin_declared_option_cannot_yet_be_overridden() {
742        let cfg = config();
743        assert!(
744            crate::config_host::register_plugin_option(
745                &cfg,
746                "testplug.inline-images",
747                crate::config_host::PluginOptionKind::Boolean,
748                "false",
749                "a plugin-registered option",
750            ),
751            "sanity: the option registers"
752        );
753        assert!(
754            cfg.lookup("testplug.inline-images").is_some(),
755            "sanity: and is findable by name"
756        );
757
758        let mut registry = ModeRegistry::default();
759        let mut decl = minor("test-ownopt-mode");
760        decl.options = vec![override_of("testplug.inline-images", "true")];
761
762        let id = register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
763        assert!(
764            registry.get(id).expect("registered").options().is_empty(),
765            "a plugin's own option has no TypeId, so it cannot be an override yet"
766        );
767    }
768
769    /// Priority crosses rather than being flattened to Normal. A mode that
770    /// genuinely must out-rank a peer has to be able to say so, and silently
771    /// demoting it would make the conflict policy lie.
772    #[test]
773    fn priority_crosses_the_seam() {
774        let cfg = config();
775        let mut registry = ModeRegistry::default();
776        let mut decl = minor("test-priority-mode");
777        decl.options = vec![PluginModeOverride {
778            name: "number".to_string(),
779            value: "false".to_string(),
780            priority: OverridePriority::High,
781        }];
782
783        let id = register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
784        let set = registry.get(id).expect("registered").options();
785        assert_eq!(
786            set.iter().next().expect("one").priority,
787            OverridePriority::High
788        );
789    }
790
791    /// No config registry wired — the minimal harnesses. The mode still
792    /// registers; only its options are skipped. Refusing the mode outright would
793    /// turn a missing handle into a missing feature.
794    #[test]
795    fn without_a_config_registry_the_mode_still_registers() {
796        let mut registry = ModeRegistry::default();
797        let mut decl = minor("test-noconfig-mode");
798        decl.options = vec![override_of("number", "false")];
799
800        let id = register_plugin_mode(&mut registry, None, &decl).expect("registers anyway");
801        assert!(registry.get(id).expect("registered").options().is_empty());
802    }
803
804    #[test]
805    fn keymap_binding_lands_in_the_owned_layer_and_resolves() {
806        use lattice_keymap::LookupResult;
807
808        // A command the binding targets by name.
809        let mut commands = CommandRegistry::new();
810        let _ = lattice_grammar::ex_commands::populate(&mut commands);
811        let target = "ex:write";
812        assert!(
813            commands.id_by_name(target).is_some(),
814            "sanity: target exists"
815        );
816
817        let keymap = KeymapHandle::new();
818        let mode_id = ModeId::new("git-blame-mode");
819        let bindings = vec![PluginKeymapBinding {
820            mode: BindingMode::Normal,
821            chord: "<C-s>".to_string(),
822            command: target.to_string(),
823        }];
824        let bound = bind_mode_keymap(&keymap, &commands, 7, &mode_id, ModeKind::Minor, &bindings);
825        assert_eq!(bound, 1, "the well-formed binding landed");
826
827        // Build the lookup chord the same way the binding parsed it.
828        let chord = lattice_protocol::parse_chord_sequence("<C-s>").expect("chord parses");
829
830        // The binding resolves ONLY when the mode is active (gated layer).
831        assert!(
832            matches!(
833                keymap.lookup_with_context(BindingMode::Normal, &chord, &[mode_id]),
834                LookupResult::Bound { .. }
835            ),
836            "the chord resolves in the mode's owned layer when active"
837        );
838        assert!(
839            matches!(
840                keymap.lookup_with_context(BindingMode::Normal, &chord, &[]),
841                LookupResult::Unbound
842            ),
843            "with the mode inactive the gated binding does not fire"
844        );
845    }
846
847    #[test]
848    fn keymap_binding_to_an_unknown_command_is_skipped() {
849        let commands = CommandRegistry::new();
850        let keymap = KeymapHandle::new();
851        let bindings = vec![PluginKeymapBinding {
852            mode: BindingMode::Normal,
853            chord: "gx".to_string(),
854            command: "ex:does-not-exist".to_string(),
855        }];
856        let bound = bind_mode_keymap(
857            &keymap,
858            &commands,
859            1,
860            &ModeId::new("x-mode"),
861            ModeKind::Minor,
862            &bindings,
863        );
864        assert_eq!(
865            bound, 0,
866            "an unknown command binds nothing (logged + skipped)"
867        );
868    }
869}