Skip to main content

lattice_plugin_host/
sign_host.rs

1//! The `signs` guest→host sign-declaration seam (SG.3a).
2//!
3//! A sign-contributing plugin implements the `sign-plugin` world: it
4//! **imports** the `signs` API (`define-sign`) and **exports** `register-signs`,
5//! which the host calls once to drive declaration. This module holds the
6//! `bindgen!` for that world plus the host-side conversion from the WIT
7//! `sign-spec` to a native [`SignDefinition`](lattice_mode::SignDefinition),
8//! factored out so it is unit-testable without a `Store` (the `theme_host`
9//! precedent).
10//!
11//! **The canonical API is the WIT** (`signs.wit`) — any component-model
12//! language calls `define-sign` directly. A plugin's sign lands in the SAME
13//! registry native producers use, so it is styled through the ordinary theme
14//! registry, contends for the mark cell by the same `priority` rule, and needs
15//! NO host kind-branch.
16//!
17//! See `docs/dev/architecture/gutter-signs.md`.
18
19use lattice_mode::{SignDefinition, SignRegistry, SignRegistryHandle};
20
21use crate::{
22    Component, PluginBudget, PluginHost, PluginHostError, PluginManifest, TrustTier, arm_store,
23    classify_trap,
24};
25
26pub(crate) mod bindings {
27    wasmtime::component::bindgen!({
28        world: "sign-plugin",
29        path: "../lattice-wit/wit",
30        // Wired into the same async linker as WASI + the `signs` host funcs, so
31        // the export is async (the `theme-plugin` precedent). Registration is
32        // off every hot path, so async costs nothing.
33        exports: { default: async },
34        with: {
35            "lattice:plugin-host/logging": crate::lattice::plugin_host::logging,
36        },
37    });
38}
39
40use bindings::lattice::plugin_host::signs as wit;
41
42/// Convert a WIT `sign-spec` plus its namespaced name to the native
43/// definition.
44///
45/// Total — every WIT shape maps, so there is no failure mode here and
46/// `define-sign`'s `err` is reserved for identity-level rejections. The glyph
47/// is NOT truncated here: `SignDefinition::glyph_char` does that at paint time,
48/// so a future gutter that can afford a wider cell does not need this
49/// conversion changed, and `:describe-sign` can still show what the plugin
50/// actually declared.
51pub fn sign_definition_from_wit(name: String, spec: wit::SignSpec) -> SignDefinition {
52    SignDefinition {
53        name,
54        text: spec.text,
55        fallback: spec.fallback,
56        theme_element: spec.theme_element,
57        priority: spec.priority,
58        // An empty column means the default rather than a column named "",
59        // so a guest that does not care can leave the field alone and land
60        // where every sign landed before columns existed.
61        column: if spec.column.is_empty() {
62            lattice_mode::SIGN_COLUMN_MARK.to_string()
63        } else {
64            spec.column
65        },
66    }
67}
68
69/// The `define-sign` host-service body. Registers into the SAME registry native
70/// producers use, namespaced by plugin id so unload can reverse it.
71///
72/// Returns the registered (namespaced) name so the caller can record a teardown
73/// token.
74///
75/// Copy-on-write against the `ArcSwap`: definitions are written at load and
76/// read on the render path, so the write clones and stores rather than locking
77/// anything a frame might wait on.
78pub fn define_plugin_sign(
79    registry: &SignRegistryHandle,
80    plugin_id: &str,
81    name: &str,
82    spec: wit::SignSpec,
83) -> String {
84    let full = format!("{plugin_id}.{name}");
85    let mut next: SignRegistry = (**registry.load()).clone();
86    next.define(sign_definition_from_wit(full.clone(), spec));
87    registry.store(std::sync::Arc::new(next));
88    full
89}
90
91impl PluginHost {
92    /// Instantiate a `sign-plugin` component under its capability grant, drive
93    /// its `register-signs` export once, and return the host-issued id plus the
94    /// sign names it declared (the teardown tokens).
95    ///
96    /// Mirror of [`spawn_theme_plugin`](Self::spawn_theme_plugin): the registry
97    /// is wired onto `PluginState` BEFORE the export runs so the guest's
98    /// imported `define-sign` reaches it.
99    pub async fn spawn_sign_plugin(
100        &self,
101        component: &Component,
102        manifest: &PluginManifest,
103        tier: TrustTier,
104        budget: PluginBudget,
105        registry: &SignRegistryHandle,
106    ) -> Result<(crate::PluginId, Vec<String>), PluginHostError> {
107        let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
108        for denied in &outcome.denied {
109            tracing::warn!(
110                plugin = %manifest.id,
111                capability = ?denied,
112                "sign plugin loaded with a withheld capability (reduced function)"
113            );
114        }
115        let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
116        let bindings = bindings::SignPlugin::instantiate_async(&mut store, component, &self.linker)
117            .await
118            .map_err(|e| PluginHostError::Instantiate(e.into()))?;
119
120        let id = self.alloc_id();
121        store.data_mut().sign_registry = Some(registry.clone());
122        store.data_mut().log_ctx = self.log_ctx_for(id);
123
124        arm_store(&mut store, budget)?;
125        bindings
126            .call_register_signs(&mut store)
127            .await
128            .map_err(|source| PluginHostError::Trap {
129                func: "register-signs",
130                kind: classify_trap(&source),
131                source: source.into(),
132            })?;
133
134        let signs = std::mem::take(&mut store.data_mut().sign_contributions);
135        Ok((id, signs))
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    #![allow(clippy::unwrap_used, clippy::panic)]
142
143    use super::*;
144
145    fn spec(text: &str, fallback: &str, priority: i32) -> wit::SignSpec {
146        wit::SignSpec {
147            text: text.to_string(),
148            fallback: fallback.to_string(),
149            theme_element: "debugger.breakpoint".to_string(),
150            priority,
151            column: String::new(),
152        }
153    }
154
155    fn registry() -> SignRegistryHandle {
156        std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(SignRegistry::new()))
157    }
158
159    #[test]
160    fn declaration_namespaces_by_plugin_id() {
161        let reg = registry();
162        let full = define_plugin_sign(&reg, "debugger", "breakpoint", spec("\u{f111}", "●", 20));
163        assert_eq!(full, "debugger.breakpoint");
164        assert!(reg.load().id_of("debugger.breakpoint").is_some());
165        // Unnamespaced must NOT exist — a plugin cannot squat a bare name or
166        // shadow a native producer's sign.
167        assert!(reg.load().id_of("breakpoint").is_none());
168    }
169
170    #[test]
171    fn redefining_keeps_the_id_so_a_reload_does_not_orphan_placements() {
172        // A plugin reloading with a new glyph must not strand the placements
173        // already in flight. They keep resolving and start painting the new
174        // glyph, which is what "redefine" should mean.
175        let reg = registry();
176        define_plugin_sign(&reg, "debugger", "breakpoint", spec("\u{f111}", "●", 20));
177        let before = reg.load().id_of("debugger.breakpoint").unwrap();
178        define_plugin_sign(&reg, "debugger", "breakpoint", spec("\u{f192}", "◆", 20));
179        let after = reg.load().id_of("debugger.breakpoint").unwrap();
180        assert_eq!(before, after, "a redefinition must keep the id");
181        assert_eq!(reg.load().get(after).unwrap().fallback, "◆");
182    }
183
184    #[test]
185    fn a_namespace_is_removed_whole_on_unload() {
186        // What teardown reverses. `undefine_prefix` is the reason a plugin's
187        // definitions need no individual tracking anywhere else.
188        let reg = registry();
189        define_plugin_sign(&reg, "debugger", "breakpoint", spec("\u{f111}", "●", 20));
190        define_plugin_sign(&reg, "debugger", "current-line", spec("\u{f105}", "▶", 30));
191        define_plugin_sign(&reg, "other", "mark", spec("\u{f111}", "◆", 5));
192        let mut next: SignRegistry = (**reg.load()).clone();
193        next.undefine_prefix("debugger.");
194        reg.store(std::sync::Arc::new(next));
195        assert!(reg.load().id_of("debugger.breakpoint").is_none());
196        assert!(reg.load().id_of("debugger.current-line").is_none());
197        assert!(
198            reg.load().id_of("other.mark").is_some(),
199            "unloading one plugin must not take another's signs with it"
200        );
201    }
202
203    #[test]
204    fn the_spec_crosses_without_losing_the_fallback_palette() {
205        // Both palettes have to survive: the theme decides the colour, the
206        // font capability decides the glyph, and a crossing that dropped the
207        // fallback would render tofu for every user without a patched font.
208        let def = sign_definition_from_wit("p.mark".to_string(), spec("\u{f111}", "●", 7));
209        assert_eq!(def.name, "p.mark");
210        assert_eq!(def.glyph(true), "\u{f111}");
211        assert_eq!(def.glyph(false), "●");
212        assert_eq!(def.priority, 7);
213        assert_eq!(def.theme_element, "debugger.breakpoint");
214    }
215
216    /// A wide glyph is not rejected at the boundary — it is truncated at paint
217    /// time. The plugin's declaration is preserved so a future wider cell (or
218    /// `:describe-sign`) still sees what it actually asked for.
219    #[test]
220    fn a_wide_glyph_crosses_intact_and_truncates_at_paint_time() {
221        let def = sign_definition_from_wit("p.wide".to_string(), spec("ab", "cd", 1));
222        assert_eq!(def.text, "ab", "the declaration survives the crossing");
223        assert_eq!(def.glyph_char(true), 'a', "but the gutter takes one cell");
224        assert_eq!(def.glyph_char(false), 'c');
225    }
226}