lattice_plugin_host/completion_source.rs
1//! PH7.6 — the `WasmCompletionSource` adapter (the async-produce path).
2//!
3//! Wraps a completion plugin's [`CompletionClient`] bridge. Unlike the picker
4//! adapter, this is NOT an `Arc<dyn CandidateGenerator>` inserted into the
5//! synchronous completion pipeline — a WASM `generate` is async + actor-bound,
6//! and matching/annotation run *per candidate* on the keystroke path (paramount
7//! #1). Instead, following the LSP-completion precedent (`pipeline.rs`
8//! `match_and_rank` "pre-supplies rows from async LSP responses"), this adapter
9//! **produces candidates asynchronously**; the host then runs the NATIVE
10//! `match_and_rank` over them (matching / ranking / annotation stay native).
11//! Option A, locked with Dhruva — see `wit/completion-source.wit`.
12
13use std::future::Future;
14use std::pin::Pin;
15use std::sync::Arc;
16
17use lattice_completion::candidate::RawCandidate;
18use lattice_completion::source::{AsyncCompletionSource, CandidateSink, InsertContextSnapshot};
19use lattice_protocol::CancellationToken;
20
21use crate::WitBoundary;
22use crate::completion_task::GenerateContext as WitGenerateContext;
23use crate::{CompletionClient, PluginHostError};
24
25/// An async completion producer over a plugin's [`CompletionClient`]. Cheap to
26/// clone (the client is an mpsc `Sender` clone + cached id/doc); every clone
27/// talks to the same actor.
28#[derive(Clone)]
29pub struct WasmCompletionSource {
30 client: CompletionClient,
31 /// The source id + doc, converted once at [`connect`](Self::connect) — the
32 /// `(name, doc)` `insert_generator` stamps when a host wires this in.
33 id: String,
34 doc: String,
35 accepts_non_word_query: bool,
36}
37
38impl WasmCompletionSource {
39 /// Fetch the plugin's `spec` through the bridge and build the adapter. Async
40 /// because the one-time spec fetch is a guest call; a dead actor is a typed
41 /// error, so a bad plugin fails registration loudly.
42 pub async fn connect(client: CompletionClient) -> Result<Self, PluginHostError> {
43 let spec = client.spec().await?;
44 Ok(Self {
45 client,
46 id: spec.id,
47 doc: spec.doc,
48 accepts_non_word_query: spec.accepts_non_word_query,
49 })
50 }
51
52 /// The completion source's id (the `insert_generator` name).
53 pub fn id(&self) -> &str {
54 &self.id
55 }
56
57 /// The completion source's doc string.
58 pub fn doc(&self) -> &str {
59 &self.doc
60 }
61
62 /// OR.7: whether this source keeps matching once the query picks up a
63 /// non-word character (a phrase source — org-roam's node titles).
64 pub fn accepts_non_word_query(&self) -> bool {
65 self.accepts_non_word_query
66 }
67
68 /// The host-issued id of the plugin behind this source.
69 pub fn plugin_id(&self) -> crate::PluginId {
70 self.client.id()
71 }
72
73 /// Produce raw candidates for `prefix` — the async generator. The result is
74 /// native [`RawCandidate`]s the host feeds through `match_and_rank`
75 /// (matching/ranking/annotation stay native). The outer host error (trap /
76 /// plugin-gone) and the inner guest WIT `err` both collapse to the `String`
77 /// the completion machinery logs; a candidate that fails to cross (malformed
78 /// record) fails the whole batch as a typed error, never a silent drop.
79 pub async fn generate(&self, ctx: &InsertContextSnapshot) -> Result<Vec<RawCandidate>, String> {
80 let ctx = WitGenerateContext {
81 prefix: ctx.query.clone(),
82 case_sensitive: ctx.case_sensitive,
83 // OR.7: the two fields that let a source decide whether it
84 // applies at all. Without them every plugin source fires in
85 // every buffer on every prefix.
86 line_before_cursor: ctx.line_before_cursor.clone(),
87 language: ctx.language.clone(),
88 };
89 let wit = match self.client.generate(ctx).await {
90 Ok(inner) => inner?,
91 Err(host_err) => return Err(format!("completion plugin: {host_err}")),
92 };
93 wit.into_iter().map(RawCandidate::from_wit).collect()
94 }
95}
96
97impl std::fmt::Debug for WasmCompletionSource {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 f.debug_struct("WasmCompletionSource")
100 .field("id", &self.id)
101 .finish_non_exhaustive()
102 }
103}
104
105/// PH7.6 → PL8.B: the adapter that lets a WASM completion source ride a mode's
106/// `completion_sources()` like any native async source (LSP's precedent). The
107/// aggregator drives `produce_async` at popup-open / `isIncomplete` refresh; the
108/// async `generate` runs on the source's actor (spawned by the loader on the
109/// multi-thread runtime), **never** the keystroke path — matching / ranking /
110/// annotation stay native (the host runs `match_and_rank` over the pushed
111/// candidates), so paramount #1 holds.
112impl AsyncCompletionSource for WasmCompletionSource {
113 fn produce_async(
114 &self,
115 ctx: InsertContextSnapshot,
116 sink: Arc<dyn CandidateSink>,
117 token: CancellationToken,
118 ) -> Pin<Box<dyn Future<Output = ()> + Send>> {
119 // Cheap clone (mpsc `Sender` + cached id/doc) — the future outlives the
120 // aggregator's stack frame as it crosses the spawn boundary.
121 let source = self.clone();
122 Box::pin(async move {
123 if token.is_cancelled() {
124 return;
125 }
126 // A host trap / plugin-gone (outer) or a guest WIT `err` (inner) both
127 // collapse to a logged zero-candidate result — never a panic, never a
128 // poisoned popup (§8 graceful degradation).
129 match source.generate(&ctx).await {
130 Ok(candidates) => {
131 for candidate in candidates {
132 if token.is_cancelled() {
133 return;
134 }
135 sink.push(candidate);
136 }
137 }
138 Err(err) => tracing::debug!(
139 source = %source.id,
140 error = %err,
141 "wasm completion source produced no candidates"
142 ),
143 }
144 })
145 }
146}