Skip to main content

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}