Skip to main content

lattice_plugin_host/
help_host.rs

1//! The `help` guest→host topic-registration seam (CR.3).
2//!
3//! Design:
4//! [`contributable-registries.md`](../../../docs/dev/architecture/contributable-registries.md)
5//! §3.1, and the `help` WIT interface.
6//!
7//! A help-contributing plugin implements the `help-plugin` world: it
8//! **imports** the `help` API (`register-topic`) and **exports**
9//! `register-help-topics`, which the host calls once to drive declaration.
10//! The `theme-plugin` precedent, shape for shape.
11//!
12//! ## What this module does NOT do
13//!
14//! It does not touch `lattice-help`. The seam collects plain
15//! [`HelpTopicSpec`] values and hands them back; `lattice-plugin-loader`
16//! turns them into `HelpTopic`s and RCU-registers them into the
17//! `HelpTopicRegistryHandle`. That keeps the dependency pointing the way it
18//! already does — the loader is the crate that knows about the editor's
19//! native registries, the host is the crate that knows about wasm — and
20//! costs nothing, because a help body is inert data on both sides of that
21//! line.
22//!
23//! ## Namespacing is host-side, from host ground truth
24//!
25//! [`namespaced_topic_name`] derives the registered name from the manifest
26//! id the HOST holds, never from anything the guest passes. A guest cannot
27//! name a topic outside its own namespace, so collisions with builtins and
28//! between plugins are structurally impossible rather than a policy the
29//! loader has to enforce and a user has to debug.
30
31/// One topic a guest declared, ready for the loader to register.
32///
33/// Plain data — deliberately not a `lattice_help::HelpTopic`, see the module
34/// docs. `name` is already namespaced.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct HelpTopicSpec {
37    /// The registered (namespaced) topic name — what `:help <name>` opens.
38    pub name: String,
39    pub summary: String,
40    /// Markdown, as the guest baked it into its component.
41    pub body: String,
42    /// Substring patterns `:describe-command` matches against command names
43    /// to emit a `See also` cross-link.
44    pub related_command_patterns: Vec<String>,
45}
46
47use crate::{
48    Component, PluginBudget, PluginHost, PluginHostError, PluginManifest, TrustTier, arm_store,
49    classify_trap,
50};
51
52pub(crate) mod bindings {
53    wasmtime::component::bindgen!({
54        world: "help-plugin",
55        path: "../lattice-wit/wit",
56        // Wired into the same async linker as WASI + the `help` host func, so
57        // the export is async (the `theme-plugin` / `config-plugin`
58        // precedent). Registration is off every hot path, so async costs
59        // nothing.
60        exports: { default: async },
61        with: {
62            "lattice:plugin-host/logging": crate::lattice::plugin_host::logging,
63            "lattice:plugin-host/project": crate::lattice::plugin_host::project,
64        },
65    });
66}
67
68/// The registered name for a topic a plugin declared.
69///
70/// Auto-namespaced by plugin id, with one refinement for the common case: a
71/// `name` that is empty, or that already equals the plugin id, lands at the
72/// **bare** id. So a one-page plugin is `:help fugitive`, not `:help
73/// fugitive.fugitive` — which is what a vim user expects and what plain
74/// prefixing would have produced.
75///
76/// Returns `None` when the plugin has no identity, which is a harness shape
77/// rather than a plugin error; the caller reports it as a rejection so the
78/// topic is skipped rather than registered unnamespaced.
79pub fn namespaced_topic_name(plugin_id: &str, name: &str) -> Option<String> {
80    let plugin_id = plugin_id.trim();
81    if plugin_id.is_empty() {
82        return None;
83    }
84    let name = name.trim();
85    if name.is_empty() || name == plugin_id {
86        return Some(plugin_id.to_string());
87    }
88    Some(format!("{plugin_id}.{name}"))
89}
90
91/// Validate a guest's topic declaration, or reject it.
92///
93/// Guest output is untrusted. The two rejections are the ones a buggy guest
94/// actually produces: a body that is empty or blank (a page that opens to
95/// nothing looks like a broken editor, not a broken plugin), and a name the
96/// host cannot namespace. Both are `Err`, which the seam turns into the WIT
97/// `err` — the plugin's OTHER topics still register.
98pub fn validate_topic(
99    plugin_id: &str,
100    name: &str,
101    summary: &str,
102    body: &str,
103    related_commands: Vec<String>,
104) -> Result<HelpTopicSpec, String> {
105    let Some(name) = namespaced_topic_name(plugin_id, name) else {
106        return Err("register-topic requires a plugin identity".to_string());
107    };
108    if body.trim().is_empty() {
109        return Err(format!(
110            "register-topic({name}): body is empty; a topic that opens to \
111             nothing reads as a broken editor"
112        ));
113    }
114    Ok(HelpTopicSpec {
115        name,
116        summary: summary.to_string(),
117        body: body.to_string(),
118        related_command_patterns: related_commands
119            .into_iter()
120            .filter(|p| !p.trim().is_empty())
121            .collect(),
122    })
123}
124
125impl PluginHost {
126    /// Instantiate a `help-plugin` component under its capability grant,
127    /// drive its `register-help-topics` export once, and return the
128    /// host-issued id plus the topics it declared.
129    ///
130    /// Mirror of [`spawn_theme_plugin`](Self::spawn_theme_plugin). Nothing
131    /// about the guest outlives this call: the bodies are already across, so
132    /// the `Store` is dropped when the function returns and reading `:help`
133    /// never touches wasm again.
134    pub async fn spawn_help_plugin(
135        &self,
136        component: &Component,
137        manifest: &PluginManifest,
138        tier: TrustTier,
139        budget: PluginBudget,
140    ) -> Result<(crate::PluginId, Vec<HelpTopicSpec>), PluginHostError> {
141        let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
142        for denied in &outcome.denied {
143            tracing::warn!(
144                plugin = %manifest.id,
145                capability = ?denied,
146                "help plugin loaded with a withheld capability (reduced function)"
147            );
148        }
149        let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
150        let bindings = bindings::HelpPlugin::instantiate_async(&mut store, component, &self.linker)
151            .await
152            .map_err(|e| PluginHostError::Instantiate(e.into()))?;
153
154        let id = self.alloc_id();
155        store.data_mut().log_ctx = self.log_ctx_for(id);
156
157        arm_store(&mut store, budget)?;
158        bindings
159            .call_register_help_topics(&mut store)
160            .await
161            .map_err(|source| PluginHostError::Trap {
162                func: "register-help-topics",
163                kind: classify_trap(&source),
164                source: source.into(),
165            })?;
166
167        let topics = std::mem::take(&mut store.data_mut().help_contributions);
168        Ok((id, topics))
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn a_sub_page_is_namespaced() {
178        assert_eq!(
179            namespaced_topic_name("fugitive", "status").as_deref(),
180            Some("fugitive.status")
181        );
182    }
183
184    /// The refinement that keeps the common case readable. Both spellings a
185    /// one-page plugin plausibly uses must land on the bare id.
186    #[test]
187    fn the_single_page_case_keeps_the_bare_id() {
188        assert_eq!(
189            namespaced_topic_name("fugitive", "").as_deref(),
190            Some("fugitive")
191        );
192        assert_eq!(
193            namespaced_topic_name("fugitive", "fugitive").as_deref(),
194            Some("fugitive"),
195            "a plugin naming its page after itself must not get `fugitive.fugitive`"
196        );
197    }
198
199    /// The property namespacing exists for: a guest cannot name a topic
200    /// outside its own namespace, so it cannot shadow a builtin page.
201    #[test]
202    fn a_guest_cannot_squat_a_builtin_name() {
203        let full = namespaced_topic_name("evil", "buffers").expect("named");
204        assert_eq!(full, "evil.buffers");
205        assert_ne!(full, "buffers");
206    }
207
208    #[test]
209    fn a_topic_with_no_plugin_identity_is_rejected() {
210        assert!(namespaced_topic_name("", "usage").is_none());
211        assert!(namespaced_topic_name("   ", "usage").is_none());
212        assert!(validate_topic("", "usage", "s", "body", Vec::new()).is_err());
213    }
214
215    #[test]
216    fn an_empty_body_is_rejected() {
217        assert!(validate_topic("p", "usage", "s", "", Vec::new()).is_err());
218        assert!(validate_topic("p", "usage", "s", "  \n\t ", Vec::new()).is_err());
219    }
220
221    #[test]
222    fn a_well_formed_topic_converts() {
223        let spec = validate_topic(
224            "fugitive",
225            "status",
226            "The status buffer.",
227            "# Status\n\nHello.",
228            vec!["magit-".to_string(), "  ".to_string()],
229        )
230        .expect("accepted");
231        assert_eq!(spec.name, "fugitive.status");
232        assert_eq!(spec.summary, "The status buffer.");
233        assert_eq!(spec.body, "# Status\n\nHello.");
234        // Blank patterns are dropped: a pattern that is whitespace is a
235        // substring of every command name, so keeping it would cross-link
236        // this topic from every `:describe-command`.
237        assert_eq!(spec.related_command_patterns, vec!["magit-".to_string()]);
238    }
239}