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}