lattice_plugin_host/transient_source.rs
1//! TR.2b — the `WasmTransientSource` adapter (boundary + registry builder).
2//!
3//! Turns a transient plugin's [`TransientClient`] bridge into the closure the
4//! [`TransientSourceRegistry`](lattice_picker::TransientSourceRegistry) stores.
5//! Everything about the guest ends here: the registry sees a
6//! `Fn(&TransientContext) -> TransientBuildFuture` and cannot tell a plugin
7//! menu from a native one.
8//!
9//! Three things happen at the boundary, and each is a decision:
10//!
11//! - **The context projects host→guest only** (`project_transient_context`),
12//! the `picker-context` precedent. A builder reads where the menu was opened
13//! from; it never sends one back.
14//! - **A row's command crosses as a NAME**, resolved against the
15//! `CommandRegistry` here. A `CommandId` is host-issued and a plugin must not
16//! be able to forge one — the same rule the `register_*` seams follow.
17//! - **An unresolvable name drops that row**, with a `debug!`, and the rest of
18//! the menu survives. A plugin whose sixth row references a command it failed
19//! to register should still get the other five; refusing the whole menu makes
20//! one bad row cost the feature.
21//!
22//! Design: `docs/dev/architecture/plugin-transients.md` §5–6.
23
24use lattice_grammar::CommandRegistryHandle;
25use lattice_picker::{
26 TransientBuildFuture, TransientContext as NativeTransientContext,
27 TransientGroup as NativeTransientGroup, TransientItem as NativeTransientItem,
28 TransientItemKind as NativeTransientItemKind, TransientSpec as NativeTransientSpec,
29};
30
31use crate::WitBoundary;
32use crate::transient_task::{
33 TransientClient, TransientContext as WitTransientContext, TransientItemKind as WitItemKind,
34 TransientSpec as WitTransientSpec,
35};
36
37/// Project a live [`TransientContext`](NativeTransientContext) into its owned
38/// WIT mirror. Host→guest only.
39///
40/// Fallible only because of TR.3a's `args`: an `Args` variant that has no WIT
41/// form is a typed error rather than a silently-dropped field, which would
42/// hand the builder `none` and have it build the wrong menu.
43pub fn project_transient_context(
44 ctx: &NativeTransientContext,
45) -> Result<WitTransientContext, String> {
46 Ok(WitTransientContext {
47 major_mode: ctx.major_mode.clone(),
48 minor_modes: ctx.minor_modes.clone(),
49 buffer: ctx.buffer.map(|b| b.0),
50 // TR.3a: what the open was FOR — the row's args when a menu drilled
51 // down into another. A projection failure would silently hand the
52 // builder `none` and it would build the wrong menu, so it is a typed
53 // error like every other boundary conversion.
54 args: ctx.args.to_wit()?,
55 })
56}
57
58/// Convert a guest-built spec into the native one, resolving each action row's
59/// command name against `registry`.
60///
61/// Never fails: a row the host cannot honour is dropped, not escalated. The
62/// only way a plugin loses its whole menu is by returning an `err` from
63/// `build`, which is a statement rather than an accident.
64pub fn spec_from_wit(
65 wit: WitTransientSpec,
66 registry: &CommandRegistryHandle,
67 plugin: &str,
68) -> NativeTransientSpec {
69 let commands = registry.load();
70 let groups = wit
71 .groups
72 .into_iter()
73 .map(|g| NativeTransientGroup {
74 label: g.label,
75 items: g
76 .items
77 .into_iter()
78 .filter_map(|item| {
79 let kind = match item.kind {
80 WitItemKind::Dismiss => NativeTransientItemKind::Dismiss,
81 // TR.3b: a field. `source` is `None` — the
82 // picker-backed variant (pick the value from a
83 // registered picker source) stays deferred; a
84 // free-text prompt is what a template question is.
85 WitItemKind::Argument(a) => NativeTransientItemKind::Argument {
86 name: a.name,
87 default: a.default,
88 prompt: a.prompt,
89 source: None,
90 },
91 WitItemKind::Action(action) => {
92 let Some(command) = commands.id_by_name(&action.command) else {
93 // `debug!` and not `warn!`: this fires per row
94 // of a menu the user just opened, and the row
95 // simply not being there is the visible signal.
96 tracing::debug!(
97 plugin,
98 command = %action.command,
99 key = ?item.key,
100 "transient row names an unregistered command; dropping the row"
101 );
102 return None;
103 };
104 let args = match lattice_grammar::Args::from_wit(action.args) {
105 Ok(args) => args,
106 Err(e) => {
107 tracing::debug!(
108 plugin,
109 command = %action.command,
110 error = %e,
111 "transient row's args did not cross; dropping the row"
112 );
113 return None;
114 }
115 };
116 NativeTransientItemKind::Action { command, args }
117 }
118 };
119 Some(NativeTransientItem {
120 key: item.key,
121 label: item.label,
122 description: item.description,
123 kind,
124 })
125 })
126 .collect(),
127 })
128 .collect();
129
130 NativeTransientSpec {
131 title: wit.title,
132 groups,
133 // A closure has no WIT form, so a guest menu has no live preview pane.
134 // Stated in the design fragment rather than discovered here.
135 preview: None,
136 footer: wit.footer,
137 }
138}
139
140/// The registry builder for a transient plugin: projects the open context
141/// synchronously, then awaits the guest's `build` on the plugin's own actor
142/// task and converts the result.
143///
144/// The synchronous prelude matters — it is what lets the returned future be
145/// `'static`, so nothing borrows the editor across the await.
146pub fn transient_builder(
147 client: TransientClient,
148 registry: CommandRegistryHandle,
149 plugin: String,
150) -> impl Fn(&NativeTransientContext) -> TransientBuildFuture + Send + Sync + 'static {
151 move |ctx: &NativeTransientContext| {
152 let wit_ctx = project_transient_context(ctx);
153 let client = client.clone();
154 let registry = registry.clone();
155 let plugin = plugin.clone();
156 Box::pin(async move {
157 let wit_ctx = wit_ctx?;
158 // The host surface (trap / gone / quarantined) and the guest's own
159 // `err` both mean the menu does not open. They are kept distinct in
160 // the message so the echo says which.
161 let wit = match client.build(wit_ctx).await {
162 Ok(inner) => inner?,
163 Err(host_err) => return Err(format!("{plugin}: {host_err}")),
164 };
165 Ok(spec_from_wit(wit, ®istry, &plugin))
166 })
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 #![allow(clippy::unwrap_used)]
173
174 use super::*;
175 use crate::transient_task::{
176 TransientGroup as WitGroup, TransientItem as WitItem, TransientItemKind,
177 };
178 use lattice_core::BufferId;
179 use std::sync::Arc;
180
181 use crate::lattice::plugin_host::types::{Args as WitArgs, TransientAction};
182
183 fn registry_with(names: &[&str]) -> CommandRegistryHandle {
184 let mut reg = lattice_grammar::CommandRegistry::new();
185 for name in names {
186 reg.register_action(
187 name,
188 "test action",
189 lattice_grammar::registry::ActionSpec {
190 args_schema: Vec::new(),
191 apply: Arc::new(|_| Ok(lattice_grammar::Effect::None)),
192 },
193 );
194 }
195 Arc::new(arc_swap::ArcSwap::from_pointee(reg))
196 }
197
198 fn action_row(key: &str, command: &str, arg: Option<&str>) -> WitItem {
199 WitItem {
200 key: vec![key.to_string()],
201 label: key.to_string(),
202 description: String::new(),
203 kind: TransientItemKind::Action(TransientAction {
204 command: command.to_string(),
205 args: match arg {
206 Some(a) => WitArgs::String(a.to_string()),
207 None => WitArgs::None,
208 },
209 }),
210 }
211 }
212
213 fn spec(items: Vec<WitItem>) -> WitTransientSpec {
214 WitTransientSpec {
215 title: "Capture".into(),
216 groups: vec![WitGroup {
217 label: "Templates".into(),
218 items,
219 }],
220 footer: Some("q quit".into()),
221 }
222 }
223
224 /// The happy path: rows cross with their key, label, args, and a resolved
225 /// `CommandId`.
226 #[test]
227 fn an_action_row_crosses_with_its_command_resolved_and_its_args_intact() {
228 let registry = registry_with(&["org-capture-key"]);
229 let native = spec_from_wit(
230 spec(vec![action_row("t", "org-capture-key", Some("todo"))]),
231 ®istry,
232 "org",
233 );
234
235 assert_eq!(native.title, "Capture");
236 assert_eq!(native.footer.as_deref(), Some("q quit"));
237 let item = &native.groups[0].items[0];
238 assert_eq!(item.key, vec!["t".to_string()]);
239 match &item.kind {
240 NativeTransientItemKind::Action { command, args } => {
241 assert_eq!(
242 Some(*command),
243 registry.load().id_by_name("org-capture-key")
244 );
245 assert!(matches!(args, lattice_grammar::Args::String(s) if s == "todo"));
246 }
247 other => panic!("expected an action row, got {other:?}"),
248 }
249 }
250
251 /// The failure rule that matters: one unresolvable row costs that row, not
252 /// the menu. The alternative — refusing the whole spec — makes a single
253 /// typo in a plugin's sixth row take the other five with it.
254 #[test]
255 fn an_unresolvable_command_drops_only_its_own_row() {
256 let registry = registry_with(&["org-capture-key"]);
257 let native = spec_from_wit(
258 spec(vec![
259 action_row("t", "org-capture-key", Some("todo")),
260 action_row("x", "org-command-that-never-registered", None),
261 WitItem {
262 key: vec!["q".into()],
263 label: "quit".into(),
264 description: String::new(),
265 kind: TransientItemKind::Dismiss,
266 },
267 ]),
268 ®istry,
269 "org",
270 );
271
272 let keys: Vec<&str> = native.groups[0]
273 .items
274 .iter()
275 .map(|i| i.key[0].as_str())
276 .collect();
277 assert_eq!(
278 keys,
279 vec!["t", "q"],
280 "the bad row is gone and the good ones survive"
281 );
282 }
283
284 /// A guest spec never carries a preview: the native field is a closure, and
285 /// a closure has no WIT form.
286 #[test]
287 fn a_guest_spec_has_no_preview() {
288 let registry = registry_with(&[]);
289 let native = spec_from_wit(spec(Vec::new()), ®istry, "org");
290 assert!(native.preview.is_none());
291 }
292
293 /// The context projects field-for-field. `buffer` is the one that could
294 /// silently rot: it is `Option<BufferId>` natively and `option<u32>` over
295 /// the wire.
296 #[test]
297 fn the_context_projects_both_mode_axes_and_the_buffer() {
298 let ctx = NativeTransientContext {
299 major_mode: Some("org-mode".into()),
300 minor_modes: vec!["org-global-mode".into(), "auto-pair-mode".into()],
301 buffer: Some(BufferId(7)),
302 args: Default::default(),
303 };
304 let wit = project_transient_context(&ctx).expect("projects");
305 assert_eq!(wit.major_mode.as_deref(), Some("org-mode"));
306 assert_eq!(wit.minor_modes.len(), 2);
307 assert_eq!(wit.buffer, Some(7));
308
309 let empty =
310 project_transient_context(&NativeTransientContext::default()).expect("projects");
311 assert_eq!(empty.major_mode, None);
312 assert!(empty.minor_modes.is_empty());
313 assert_eq!(empty.buffer, None);
314 }
315}