Skip to main content

lattice_plugin_host/
theme_host.rs

1//! The `theme` guest→host element-declaration seam (TC.4).
2//!
3//! A theme-contributing plugin implements the `theme-plugin` world: it
4//! **imports** the `theme` API (`register-element`) and **exports**
5//! `register-theme-elements`, which the host calls once to drive declaration.
6//! This module holds the `bindgen!` for that world plus the host-side
7//! conversion from the WIT `style-spec` to a native
8//! [`StyleSpec`](lattice_theme::StyleSpec), factored out so it is unit-testable
9//! without a `Store` (the `config_host` precedent).
10//!
11//! **The canonical API is the WIT** (`theme.wit`) — any component-model
12//! language calls `register-element` directly. A plugin element lands in the
13//! SAME registry builtins use, so themes override it, `:customize` edits it and
14//! `:describe-element` documents it with NO host kind-branch.
15//!
16//! This closes `theme-system.md`'s deferred WIT-registration item, which was
17//! designed there and waited for a real consumer.
18
19use lattice_theme::{ColorRef, ElementName, ElementOwner, ModifierSet, StyleSpec, ThemeRegistry};
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: "theme-plugin",
29        path: "../lattice-wit/wit",
30        // Wired into the same async linker as WASI + the `theme` host funcs, so
31        // the export is async (the `config-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::theme as wit;
41
42/// Convert a WIT colour reference to the native one.
43///
44/// `literal-rgb` crosses as a packed `0xRRGGBB` rather than three bytes: it is
45/// the form every other colour in the boundary already uses (`VirtualRow::bg`,
46/// the `ui` mirror), so a plugin that computed a colour once can pass it
47/// everywhere without repacking.
48fn color_ref_from_wit(c: wit::ColorRef) -> ColorRef {
49    match c {
50        wit::ColorRef::Palette(key) => ColorRef::Palette(key.into()),
51        wit::ColorRef::LiteralRgb(rgb) => ColorRef::Literal(lattice_theme::Color::Rgb(
52            ((rgb >> 16) & 0xff) as u8,
53            ((rgb >> 8) & 0xff) as u8,
54            (rgb & 0xff) as u8,
55        )),
56        wit::ColorRef::Default => ColorRef::Default,
57    }
58}
59
60/// Convert a WIT `style-spec` to the native [`StyleSpec`].
61///
62/// Total — every WIT shape maps, so there is no failure mode here and the
63/// `register-element` `err` is reserved for registry-level rejections. `family`
64/// and `weight` have no WIT counterpart by design (see `theme.wit`), so they
65/// stay `None`; a plugin cannot set them, which is the intended surface rather
66/// than a lossy conversion.
67pub fn style_spec_from_wit(spec: wit::StyleSpec) -> StyleSpec {
68    StyleSpec {
69        inherit: spec.inherit.map(ElementName::from),
70        fg: spec.fg.map(color_ref_from_wit),
71        bg: spec.bg.map(color_ref_from_wit),
72        modifiers: ModifierSet {
73            bold: spec.modifiers.bold,
74            italic: spec.modifiers.italic,
75            underline: spec.modifiers.underline,
76            dim: spec.modifiers.dim,
77            reverse: spec.modifiers.reverse,
78        },
79        scale: spec.scale,
80        family: None,
81        weight: None,
82    }
83}
84
85/// The `register-element` host-service body. Registers into the SAME registry
86/// builtins use, owned by the plugin so unload can reverse it.
87///
88/// Returns the registered (namespaced) name so the caller can record a teardown
89/// token.
90pub fn register_plugin_element(
91    registry: &dyn ThemeRegistry,
92    plugin_id: &str,
93    name: &str,
94    doc: &str,
95    spec: StyleSpec,
96) -> String {
97    let full = format!("{plugin_id}.{name}");
98    // `doc` must be `&'static str` for the registry, and a plugin's doc is
99    // runtime data — leak it. Bounded by the element count of the loaded
100    // plugins (tens), declared once at load, so this is a one-time cost per
101    // element rather than a growing leak; the alternative is widening the
102    // native registry's doc field for a case only plugins have.
103    let doc: &'static str = Box::leak(doc.to_string().into_boxed_str());
104    registry.register(
105        ElementName::from(full.clone()),
106        ElementOwner::Plugin(plugin_id.to_string().into()),
107        spec,
108        doc,
109    );
110    full
111}
112
113/// TK.5: the `set-element-override` body — an override for an element this
114/// plugin owns, above the theme.
115///
116/// **Ownership is checked, not assumed.** Namespacing already bounds what a
117/// plugin can name, so this check should be unreachable; it exists because
118/// "should be unreachable" is exactly the reasoning that makes a security
119/// boundary depend on a call site staying correct. Refusing here means a
120/// future caller that forgets to namespace is refused rather than allowed to
121/// restyle a builtin.
122pub fn set_plugin_element_override(
123    registry: &dyn ThemeRegistry,
124    plugin_id: &str,
125    name: &str,
126    spec: StyleSpec,
127) -> Result<(), String> {
128    let full = format!("{plugin_id}.{name}");
129    let element = ElementName::from(full.clone());
130    let Some(info) = registry.describe(&element) else {
131        // A typo is a named refusal rather than an override that lands
132        // nowhere and looks like the feature not working.
133        return Err(format!(
134            "set-element-override: `{full}` is not a registered element"
135        ));
136    };
137    match &info.owner {
138        ElementOwner::Plugin(owner) if owner.as_ref() == plugin_id => {}
139        _ => {
140            return Err(format!(
141                "set-element-override: `{full}` is not owned by `{plugin_id}`"
142            ));
143        }
144    }
145    registry.set_override(element, spec);
146    Ok(())
147}
148
149impl PluginHost {
150    /// Instantiate a `theme-plugin` component under its capability grant, drive
151    /// its `register-theme-elements` export once, and return the host-issued id
152    /// plus the element names it registered (the teardown tokens).
153    ///
154    /// Mirror of [`spawn_config_plugin`](Self::spawn_config_plugin): the
155    /// registry is wired onto `PluginState` BEFORE the export runs so the
156    /// guest's imported `register-element` reaches it.
157    pub async fn spawn_theme_plugin(
158        &self,
159        component: &Component,
160        manifest: &PluginManifest,
161        tier: TrustTier,
162        budget: PluginBudget,
163        registry: &lattice_theme::ThemeRegistryHandle,
164    ) -> Result<(crate::PluginId, Vec<String>), PluginHostError> {
165        let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
166        for denied in &outcome.denied {
167            tracing::warn!(
168                plugin = %manifest.id,
169                capability = ?denied,
170                "theme plugin loaded with a withheld capability (reduced function)"
171            );
172        }
173        let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
174        let bindings =
175            bindings::ThemePlugin::instantiate_async(&mut store, component, &self.linker)
176                .await
177                .map_err(|e| PluginHostError::Instantiate(e.into()))?;
178
179        let id = self.alloc_id();
180        store.data_mut().theme_registry = Some(registry.clone());
181        store.data_mut().log_ctx = self.log_ctx_for(id);
182
183        arm_store(&mut store, budget)?;
184        bindings
185            .call_register_theme_elements(&mut store)
186            .await
187            .map_err(|source| PluginHostError::Trap {
188                func: "register-theme-elements",
189                kind: classify_trap(&source),
190                source: source.into(),
191            })?;
192
193        let elements = std::mem::take(&mut store.data_mut().theme_contributions);
194        Ok((id, elements))
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    #![allow(clippy::unwrap_used, clippy::panic)]
201
202    use super::*;
203
204    #[test]
205    fn a_palette_reference_survives_the_crossing() {
206        let spec = style_spec_from_wit(wit::StyleSpec {
207            inherit: Some("listing.file".to_string()),
208            fg: Some(wit::ColorRef::Palette("blue".to_string())),
209            bg: None,
210            modifiers: wit::ModifierSet {
211                bold: None,
212                italic: None,
213                underline: None,
214                dim: None,
215                reverse: None,
216            },
217            scale: None,
218        });
219        assert_eq!(spec.inherit, Some(ElementName::from("listing.file")));
220        // A palette KEY, not a resolved colour — this is what makes a plugin's
221        // element re-colour on `:colorscheme`.
222        assert!(matches!(spec.fg, Some(ColorRef::Palette(_))));
223    }
224
225    #[test]
226    fn a_literal_rgb_unpacks_to_the_right_channels() {
227        let spec = style_spec_from_wit(wit::StyleSpec {
228            inherit: None,
229            fg: Some(wit::ColorRef::LiteralRgb(0x11_22_33)),
230            bg: Some(wit::ColorRef::Default),
231            modifiers: wit::ModifierSet {
232                bold: None,
233                italic: None,
234                underline: None,
235                dim: None,
236                reverse: None,
237            },
238            scale: None,
239        });
240        // Channel order is the one place a packed colour can silently invert.
241        assert!(matches!(
242            spec.fg,
243            Some(ColorRef::Literal(lattice_theme::Color::Rgb(
244                0x11, 0x22, 0x33
245            )))
246        ));
247        assert!(matches!(spec.bg, Some(ColorRef::Default)));
248    }
249
250    #[test]
251    fn modifiers_keep_their_three_states() {
252        let spec = style_spec_from_wit(wit::StyleSpec {
253            inherit: None,
254            fg: None,
255            bg: None,
256            modifiers: wit::ModifierSet {
257                bold: Some(true),
258                // `Some(false)` must survive as "clear an inherited bold",
259                // NOT collapse to `None` (unspecified). Emacs faces
260                // distinguish the two and so does the resolver.
261                italic: Some(false),
262                underline: None,
263                dim: None,
264                reverse: None,
265            },
266            scale: Some(1.5),
267        });
268        assert_eq!(spec.modifiers.bold, Some(true));
269        assert_eq!(spec.modifiers.italic, Some(false));
270        assert_eq!(spec.modifiers.underline, None);
271        assert_eq!(spec.scale, Some(1.5));
272    }
273
274    #[test]
275    fn registration_namespaces_by_plugin_id() {
276        let reg = lattice_theme::InMemoryThemeRegistry::new(lattice_theme::default_palette());
277        let full = register_plugin_element(
278            &reg,
279            "treesitter-context",
280            "background",
281            "The context strip backdrop.",
282            StyleSpec::new().fg(ColorRef::Palette("overlay".into())),
283        );
284        assert_eq!(full, "treesitter-context.background");
285        assert!(
286            reg.id(&ElementName::from("treesitter-context.background"))
287                .is_some(),
288            "the namespaced element is registered, so a theme can override it \
289             and `:customize` can list it"
290        );
291        // Unnamespaced must NOT exist — a plugin cannot squat a bare name.
292        assert!(reg.id(&ElementName::from("background")).is_none());
293    }
294}
295
296#[cfg(test)]
297mod tk5_tests {
298    #![allow(clippy::unwrap_used, clippy::panic)]
299    use super::*;
300    use lattice_theme::{ColorRef, InMemoryThemeRegistry, StyleSpec, default_palette};
301
302    fn rig() -> InMemoryThemeRegistry {
303        // The POPULATED palette, not `Palette::default()` — an empty one
304        // has no `yellow` or `orange`, so every palette reference resolves
305        // to `None` and an override would be indistinguishable from a
306        // default. The first version of this test asserted `None != None`.
307        let r = InMemoryThemeRegistry::new(default_palette());
308        register_plugin_element(
309            &r,
310            "org",
311            "todo.WAITING",
312            "a TODO state",
313            StyleSpec {
314                fg: Some(ColorRef::Palette("yellow".into())),
315                ..Default::default()
316            },
317        );
318        r
319    }
320
321    /// The point of the slice: an override BEATS the element's default,
322    /// which is what a default alone could never express — a default sits
323    /// below the active theme, so a plugin could not say "the user
324    /// configured this and it must win".
325    #[test]
326    fn tk5_an_override_beats_the_registered_default() {
327        let r = rig();
328        let name = ElementName::from("org.todo.WAITING".to_string());
329        let before = r.resolved().get(r.id(&name).unwrap()).fg;
330
331        set_plugin_element_override(
332            &r,
333            "org",
334            "todo.WAITING",
335            StyleSpec {
336                fg: Some(ColorRef::Palette("orange".into())),
337                ..Default::default()
338            },
339        )
340        .expect("the plugin owns this element");
341
342        let after = r.resolved().get(r.id(&name).unwrap()).fg;
343        assert_ne!(before, after, "the override must change the resolved style");
344    }
345
346    /// Namespacing already bounds what a plugin can name, so this refusal
347    /// should be unreachable. It is checked anyway, because "should be
348    /// unreachable" is exactly the reasoning that makes a boundary depend on
349    /// every call site staying correct.
350    #[test]
351    fn tk5_a_plugin_cannot_override_an_element_it_does_not_own() {
352        let r = rig();
353        r.register(
354            ElementName::from_static("pane.separator"),
355            ElementOwner::Core,
356            StyleSpec::default(),
357            "a builtin",
358        );
359        // Reaching past the namespace, as a miswired caller would.
360        let err = set_plugin_element_override(&r, "pane", "separator", StyleSpec::default())
361            .expect_err("a builtin is not the plugin's to restyle");
362        assert!(err.contains("not owned by"), "{err}");
363    }
364
365    /// A typo must be a named refusal rather than an override that lands
366    /// nowhere and reads as the feature not working.
367    #[test]
368    fn tk5_overriding_an_unregistered_element_says_so() {
369        let r = rig();
370        let err = set_plugin_element_override(&r, "org", "todo.NOPE", StyleSpec::default())
371            .expect_err("no such element");
372        assert!(err.contains("not a registered element"), "{err}");
373    }
374}