1use 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 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
42pub 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 column: if spec.column.is_empty() {
62 lattice_mode::SIGN_COLUMN_MARK.to_string()
63 } else {
64 spec.column
65 },
66 }
67}
68
69pub 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 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(®, "debugger", "breakpoint", spec("\u{f111}", "●", 20));
163 assert_eq!(full, "debugger.breakpoint");
164 assert!(reg.load().id_of("debugger.breakpoint").is_some());
165 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 let reg = registry();
176 define_plugin_sign(®, "debugger", "breakpoint", spec("\u{f111}", "●", 20));
177 let before = reg.load().id_of("debugger.breakpoint").unwrap();
178 define_plugin_sign(®, "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 let reg = registry();
189 define_plugin_sign(®, "debugger", "breakpoint", spec("\u{f111}", "●", 20));
190 define_plugin_sign(®, "debugger", "current-line", spec("\u{f105}", "▶", 30));
191 define_plugin_sign(®, "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 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 #[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}