Skip to main content

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, &registry, &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            &registry,
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            &registry,
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()), &registry, "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}