Skip to main content

lattice_plugin_host/
keymap_host.rs

1//! The `keymap` guest→host binding-registration seam (PL8.D.1).
2//!
3//! A keymap plugin implements the `keymap-plugin` world: it **imports** the
4//! `keymap` API (`register-binding`) and **exports** `register-keymap` (the host
5//! calls it once to drive registration). This module holds the `bindgen!` for
6//! that world plus the host-side bind logic — factored here (the `config_host`
7//! precedent) so it is unit-testable without a `Store`.
8//!
9//! **The canonical API is the WIT** (`keymap.wit`) — any component-model language
10//! calls `register-binding` directly. The first consumer is the user's `init.rs`:
11//! a plain global keybind is the one config kind with no other seam. Bindings
12//! land in [`KeymapLayer::User`](lattice_keymap::KeymapLayer::User), gated by
13//! [`KeymapCapability::User`](lattice_keymap::KeymapCapability::User) — above the
14//! built-in vim grammar, never `KeymapLayer::Builtin` (the standing
15//! keymap-ownership rule). Binding *resolution* on every keystroke stays native
16//! (the `KeymapHandle` trie), so there is no per-keystroke WASM — the seam rides
17//! the async linker, registration-only.
18
19use std::sync::Arc;
20
21use lattice_grammar::source::SourceLocation;
22use lattice_grammar::{CommandInvocation, CommandRegistry};
23use lattice_keymap::{BindingMode, KeymapCapability, KeymapHandle, KeymapLayer};
24
25use crate::{
26    Component, PluginBudget, PluginHost, PluginHostError, PluginId, PluginManifest, TrustTier,
27    arm_store, classify_trap,
28};
29
30pub(crate) mod bindings {
31    wasmtime::component::bindgen!({
32        world: "keymap-plugin",
33        path: "../lattice-wit/wit",
34        // `register-keymap` is wired into the SAME async linker as WASI + the
35        // `keymap` host func (`lib.rs`), so the export is async (the `config`
36        // world precedent). Registration is off any hot path, so async is free;
37        // the `register-binding` host func itself is a sync, non-trapping `bool`.
38        exports: { default: async },
39    });
40}
41
42/// A user keybinding the plugin registered — the teardown token (PL8.D.2): the
43/// `KeymapLayer::User` entry for `(mode, chord)` is unbound on unload. `chord`
44/// is the vim-notation string as the guest supplied it (re-parsed at unbind).
45#[derive(Debug, Clone)]
46pub struct KeymapBindingToken {
47    pub mode: BindingMode,
48    pub chord: String,
49}
50
51/// The editor handles a keymap plugin's `register-binding` needs, set on
52/// [`PluginState`](crate::PluginState) before `register-keymap` runs.
53pub(crate) struct KeymapBindCtx {
54    pub keymap: KeymapHandle,
55    pub commands: Arc<CommandRegistry>,
56    pub plugin_id: PluginId,
57}
58
59/// The `register-binding` host-service body: resolve `command` against the
60/// command registry, then bind `chord` in `mode` into `KeymapLayer::User` under
61/// `KeymapCapability::User`, stamped with the plugin's provenance. Returns
62/// `false` (binding nothing) on an unregistered command, an unparseable chord, or
63/// a withheld User-layer capability — logged, never a panic (graceful
64/// degradation). Factored out of the `Host` impl so it is testable without a
65/// guest / `Store`.
66pub(crate) fn bind_user_keybinding(
67    keymap: &KeymapHandle,
68    commands: &CommandRegistry,
69    plugin_id: u32,
70    mode: BindingMode,
71    chord: &str,
72    command: &str,
73) -> bool {
74    let Some(command_id) = commands.id_by_name(command) else {
75        tracing::warn!(
76            command,
77            chord,
78            "user keybinding skipped: command not registered"
79        );
80        return false;
81    };
82    match keymap.try_bind_chord_string(
83        KeymapCapability::User,
84        KeymapLayer::User,
85        mode,
86        chord,
87        CommandInvocation::of(command_id),
88        SourceLocation::plugin(plugin_id),
89    ) {
90        Ok(()) => true,
91        Err(error) => {
92            tracing::warn!(chord, command, %error, "user keybinding skipped");
93            false
94        }
95    }
96}
97
98impl PluginHost {
99    /// Instantiate a `keymap-plugin` component under its capability grant, run its
100    /// `register-keymap` export to bind user keybindings into `keymap`
101    /// (`KeymapLayer::User`), and return the tokens for teardown. Grant /
102    /// data-dir / WASI are identical to
103    /// [`instantiate_plugin`](PluginHost::instantiate_plugin).
104    ///
105    /// The keymap handle + command-registry snapshot are wired onto the `Store`
106    /// BEFORE `register-keymap` runs, so the guest's imported `register-binding`
107    /// reaches them. Bindings land synchronously (no drain / actor); the returned
108    /// tokens are what the loader's teardown unbinds on unload.
109    pub async fn spawn_keymap_plugin(
110        &self,
111        component: &Component,
112        manifest: &PluginManifest,
113        tier: TrustTier,
114        budget: PluginBudget,
115        keymap: &KeymapHandle,
116        commands: &Arc<CommandRegistry>,
117    ) -> Result<(PluginId, Vec<KeymapBindingToken>), PluginHostError> {
118        let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
119        for denied in &outcome.denied {
120            tracing::warn!(
121                plugin = %manifest.id,
122                capability = ?denied,
123                "keymap plugin loaded with a withheld capability (reduced function)"
124            );
125        }
126        let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
127        let bindings =
128            bindings::KeymapPlugin::instantiate_async(&mut store, component, &self.linker)
129                .await
130                .map_err(|e| PluginHostError::Instantiate(e.into()))?;
131        let plugin_id = self.alloc_id();
132
133        // Wire the bind context BEFORE `register-keymap` runs so the guest's
134        // imported `register-binding` reaches the live keymap + command registry.
135        store.data_mut().keymap_ctx = Some(KeymapBindCtx {
136            keymap: keymap.clone(),
137            commands: Arc::clone(commands),
138            plugin_id,
139        });
140        // PO.5: route this plugin's `logging` calls into the tracer (Layer 2) —
141        // before register-keymap, so the guest may narrate from there.
142        store.data_mut().log_ctx = self.log_ctx_for(plugin_id);
143
144        arm_store(&mut store, budget)?;
145        bindings
146            .call_register_keymap(&mut store)
147            .await
148            .map_err(|source| PluginHostError::Trap {
149                func: "register-keymap",
150                kind: classify_trap(&source),
151                source: source.into(),
152            })?;
153
154        let tokens = store.data_mut().keymap_contributions.drain(..).collect();
155        Ok((plugin_id, tokens))
156    }
157}
158
159/// Project the WIT `binding-mode` onto the native [`BindingMode`] — the same
160/// mapping the `modes` seam uses (the plugin-facing subset).
161pub(crate) fn project_binding_mode(
162    wit: bindings::lattice::plugin_host::keymap::BindingMode,
163) -> BindingMode {
164    use bindings::lattice::plugin_host::keymap::BindingMode as Wit;
165    match wit {
166        Wit::Normal => BindingMode::Normal,
167        Wit::Insert => BindingMode::Insert,
168        Wit::Visual => BindingMode::Visual,
169        Wit::Select => BindingMode::Select,
170        Wit::Replace => BindingMode::Replace,
171        Wit::Command => BindingMode::Command,
172        Wit::Search => BindingMode::Search,
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    #![allow(clippy::unwrap_used, clippy::panic)]
179
180    use super::*;
181    use lattice_grammar::registry::CommandRegistry;
182
183    /// A registry with one real ex-command the binding can resolve to.
184    fn registry_with_write() -> CommandRegistry {
185        let mut r = CommandRegistry::new();
186        let _ = lattice_grammar::ex_commands::populate(&mut r);
187        r
188    }
189
190    #[test]
191    fn binds_a_user_keybinding_into_the_user_layer() {
192        let commands = registry_with_write();
193        let keymap = KeymapHandle::new();
194        // `ex:write` is a populated builtin; bind `<leader>w` to it.
195        let bound = bind_user_keybinding(
196            &keymap,
197            &commands,
198            7,
199            BindingMode::Normal,
200            "<C-s>",
201            "ex:write",
202        );
203        assert!(bound, "a well-formed binding to a real command lands");
204        assert!(
205            keymap.binding_count() >= 1,
206            "the User-layer binding is live"
207        );
208    }
209
210    #[test]
211    fn unknown_command_binds_nothing() {
212        let commands = registry_with_write();
213        let keymap = KeymapHandle::new();
214        assert!(
215            !bind_user_keybinding(
216                &keymap,
217                &commands,
218                7,
219                BindingMode::Normal,
220                "<C-s>",
221                "nope:nope"
222            ),
223            "an unregistered command binds nothing"
224        );
225        assert_eq!(keymap.binding_count(), 0, "no binding leaked");
226    }
227
228    #[test]
229    fn unparseable_chord_binds_nothing() {
230        let commands = registry_with_write();
231        let keymap = KeymapHandle::new();
232        assert!(
233            !bind_user_keybinding(
234                &keymap,
235                &commands,
236                7,
237                BindingMode::Normal,
238                "<not-a-chord",
239                "ex:write"
240            ),
241            "a malformed chord binds nothing"
242        );
243        assert_eq!(keymap.binding_count(), 0);
244    }
245}