lattice_completion/source.rs
1//! Mode-driven completion sources (insert-completion.md §12).
2//!
3//! The trait surface every minor mode uses to contribute one or
4//! more completion sources via `Mode::completion_sources()`. The
5//! v1 architecture (§3 -- §11) hardcodes the source set inside
6//! `lattice-ui-tui::app::completion::populate_insert_completion_sync`
7//! and the bespoke `do_async_insert_completion_requests` host code;
8//! §12 of the design doc lays out the migration to a uniform
9//! mode-contribution shape that:
10//!
11//! - lets WASM plugins register a completion source the same way
12//! they register any other mode contribution (options, keymap,
13//! subscriptions, decorations);
14//! - relocates each source impl into the crate that owns its
15//! feature (LSP source -> `lattice-lsp`, snippet source ->
16//! `lattice-snippet`, etc.);
17//! - keeps the per-keystroke hot path identical by caching the
18//! active source set per buffer in an `ActiveCompletionSources`
19//! buffer-local (§12.4).
20//!
21//! CSM.1 lands the type surface only -- nothing in the editor
22//! references these types yet. CSM.2 -- CSM.8 wire production
23//! code through them one source at a time.
24//!
25//! ## Two source shapes
26//!
27//! - [`SyncCompletionSource`]: cheap, blocking. The aggregator
28//! calls `produce()` directly on the popup-open / refilter
29//! path. Buffer-words, snippets, tree-sitter symbols, path
30//! completion all fit here -- microsecond-scale walks.
31//! - [`AsyncCompletionSource`]: produces candidates via a future
32//! that pushes into a host-supplied [`CandidateSink`]. LSP
33//! (multi-server fan-out + isIncomplete refresh) is the
34//! driving case; plugin sources that round-trip to a backend
35//! use the same shape.
36//!
37//! The crate stays runtime-agnostic -- the async trait hands
38//! back a `Pin<Box<dyn Future + Send>>` and the host (TUI today,
39//! plugin host later) runs it on whatever executor it owns. No
40//! tokio dependency in `lattice-completion`.
41
42use std::fmt;
43use std::future::Future;
44use std::pin::Pin;
45use std::sync::Arc;
46
47use lattice_protocol::CancellationToken;
48use lattice_protocol::Position;
49
50use crate::candidate::RawCandidate;
51use crate::insert::{CompletionTrigger, InsertContext, SourceId};
52
53/// One source's contribution to the active completion set for a
54/// buffer. Returned from `lattice_mode::Mode::completion_sources` (the
55/// new declarative contribution method on `Mode` -- see
56/// `lattice-mode`). The aggregator's per-buffer cache holds a
57/// list of these; the active-source resolver in the host
58/// recomputes the cache only on mode-activation / -deactivation
59/// transitions, so the keystroke-frequency refilter pays an
60/// O(1) buffer-local lookup.
61///
62/// Cloning is cheap: every field is either `Copy` or an `Arc`.
63#[derive(Clone)]
64pub struct CompletionSourceContribution {
65 /// Stable identifier (`"gen:lsp-completion"`,
66 /// `"gen:buffer-words"`, ...). Surfaces in `:set
67 /// completion.source.<id>.priority=...`, in `:describe-mode
68 /// <name>` output, and in the per-language sources allowlist.
69 pub id: SourceId,
70 /// Default priority bucket. Higher buckets sort above lower;
71 /// the host's per-buffer `priority_for_source` override may
72 /// replace this value before the ranker reads it.
73 pub default_priority: u32,
74 /// Whether typing identifier chars opens the popup with this
75 /// source included. Manual triggers (`<C-x><C-o>` /
76 /// `<C-Space>`) always include every enabled source.
77 pub auto_trigger: bool,
78 /// Server-advertised or source-supplied characters that
79 /// should fire this source. Empty = "fire on identifier-
80 /// threshold or manual." The LSP source populates this from
81 /// `completionProvider.triggerCharacters` at activation.
82 pub trigger_chars: Vec<char>,
83 /// CSM.K1 (insert-completion.md §12): single-char filter
84 /// chord inside `completion-popup-mode`. `Some('o')` ⇒
85 /// `<C-o>` while the popup is live narrows the rendered
86 /// candidate set to *only* this source's contributions
87 /// (mnemonic: o for omni → LSP). `None` ⇒ no dedicated
88 /// chord; the source still participates in the unfiltered
89 /// all-sources view + any TOML allowlist.
90 ///
91 /// CSM.K2 wires the binding -- `completion-popup-mode`'s
92 /// keymap walks `ActiveCompletionSources` at push time and
93 /// registers `<C-?>` for every contribution whose
94 /// `popup_filter_chord` is `Some`. Until then this field
95 /// is plumbing: CSM.4 -- CSM.8 fill it in for each migrated
96 /// source so CSM.K2 can ship one slice's worth of behavior
97 /// at a time.
98 pub popup_filter_chord: Option<char>,
99 /// OR.7: this source can still match after a non-word character
100 /// is typed, so the popup must not dismiss on one.
101 ///
102 /// The host closes the popup as soon as the query contains a byte
103 /// that is not a word character — the right default, because a
104 /// query is normally an identifier and vim closes there too. It is
105 /// wrong for a source whose candidates are *phrases*: org-roam
106 /// completes node titles like "Honey Garlic Chicken Breast", and
107 /// the popup used to die at the first space, which made every
108 /// multi-word title unreachable.
109 ///
110 /// Per-source rather than global because the dismissal is right
111 /// for buffer-words and identifiers; the host ORs the flag across
112 /// the round, so one phrase source keeps the popup alive and every
113 /// other source simply stops matching, which is the correct
114 /// outcome for them.
115 pub accepts_non_word_query: bool,
116 /// The actual producer -- sync or async.
117 pub kind: CompletionSourceKind,
118}
119
120impl fmt::Debug for CompletionSourceContribution {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 f.debug_struct("CompletionSourceContribution")
123 .field("id", &self.id)
124 .field("default_priority", &self.default_priority)
125 .field("auto_trigger", &self.auto_trigger)
126 .field("trigger_chars", &self.trigger_chars)
127 .field("popup_filter_chord", &self.popup_filter_chord)
128 .field("accepts_non_word_query", &self.accepts_non_word_query)
129 .field("kind", &self.kind.kind_label())
130 .finish()
131 }
132}
133
134/// Discriminator for [`CompletionSourceContribution::kind`]. The
135/// aggregator dispatches on this: `Sync` sources are called
136/// inline during refilter; `Async` sources are spawned at popup-
137/// open + on `isIncomplete` refresh, with the host running the
138/// returned future on its executor.
139#[derive(Clone)]
140pub enum CompletionSourceKind {
141 Sync(Arc<dyn SyncCompletionSource>),
142 Async(Arc<dyn AsyncCompletionSource>),
143}
144
145impl CompletionSourceKind {
146 /// Short human-readable tag for debug / `:describe-mode`
147 /// output (`"sync"` or `"async"`).
148 pub fn kind_label(&self) -> &'static str {
149 match self {
150 CompletionSourceKind::Sync(_) => "sync",
151 CompletionSourceKind::Async(_) => "async",
152 }
153 }
154}
155
156/// Cheap, blocking completion source. Implementations walk the
157/// buffer / a small registry / a cached symbol table and return
158/// the matching `RawCandidate`s in a single call. The aggregator
159/// invokes [`Self::produce`] once per refilter (once per popup-
160/// query change). Sources strictly more expensive than ~100 us
161/// per call should be [`AsyncCompletionSource`]s instead.
162///
163/// This is the post-§12 successor to the v1
164/// [`crate::insert::InsertSource`] trait -- new sources should
165/// target this trait; existing impls migrate per the slice
166/// plan (CSM.4 -- CSM.7).
167pub trait SyncCompletionSource: Send + Sync + fmt::Debug {
168 /// Produce raw candidates for the supplied context. Cheap
169 /// (microseconds); the aggregator calls this whenever the
170 /// popup's query string changes.
171 fn produce(&self, ctx: &InsertContext<'_>) -> Vec<RawCandidate>;
172}
173
174/// Asynchronous completion source. Used for sources that must
175/// round-trip (LSP), watch a long-running task, or otherwise
176/// can't deliver candidates synchronously. The aggregator calls
177/// [`Self::produce_async`] at popup-open and on every
178/// `isIncomplete` re-fire; the returned future pushes
179/// candidates into a host-supplied [`CandidateSink`] as they
180/// arrive.
181///
182/// The trait deliberately hands the host a generic `Future` --
183/// `lattice-completion` does not depend on tokio. The host
184/// (`lattice-ui-tui` today, the plugin runtime later) drives
185/// the future on its own executor and is free to cancel via
186/// the supplied [`CancellationToken`].
187pub trait AsyncCompletionSource: Send + Sync + fmt::Debug {
188 /// Build the producer future. The future:
189 ///
190 /// - reads the snapshot (which carries the cursor / query /
191 /// trigger; the source's own struct captures anything else
192 /// it needs -- handles, URIs, server lookups);
193 /// - pushes each `RawCandidate` into `sink` as it arrives;
194 /// - checks `token.is_cancelled()` at await points and bails
195 /// without further pushes when set;
196 /// - resolves when the source has no more candidates to
197 /// produce (the aggregator marks the source "done" and
198 /// stops awaiting further work for this popup instance).
199 ///
200 /// `Arc<dyn CandidateSink>` is used (rather than borrowed
201 /// `&dyn CandidateSink`) so the future can outlive the
202 /// caller's stack frame -- it crosses the spawn boundary.
203 fn produce_async(
204 &self,
205 ctx: InsertContextSnapshot,
206 sink: Arc<dyn CandidateSink>,
207 token: CancellationToken,
208 ) -> Pin<Box<dyn Future<Output = ()> + Send>>;
209}
210
211/// Host-supplied mailbox an [`AsyncCompletionSource`] pushes
212/// candidates into. The host's `lattice-ui-tui` impl wraps a
213/// `tokio::sync::mpsc::UnboundedSender<SinkEvent>` so the
214/// per-frame drain merges async-source pushes into
215/// `InsertCompletionState::raw`; a WASM-plugin host wraps
216/// whatever its runtime provides.
217///
218/// `push` is fire-and-forget: a closed channel (popup
219/// dismissed) silently drops the candidate. Sources should
220/// rely on cancellation, not push success, to know when to
221/// stop.
222pub trait CandidateSink: Send + Sync {
223 fn push(&self, candidate: RawCandidate);
224 /// CSM.8b: async source signals that the result set is
225 /// server-truncated and the host should re-fire on
226 /// subsequent keystrokes (LSP `isIncomplete: true`).
227 /// Default impl is a no-op so sources that don't need
228 /// the signal (snippets, plugins with bounded result
229 /// sets) don't have to care. The LSP source calls this
230 /// once per `produce_async` invocation when any server
231 /// reported `isIncomplete`.
232 fn mark_incomplete(&self) {
233 // default: no-op
234 }
235}
236
237/// Owned snapshot of `InsertContext` for crossing the
238/// spawn / future boundary. The borrowed
239/// [`crate::insert::InsertContext`] can't cross the spawn (it
240/// holds `&Buffer` / `&str`); the snapshot copies the fields
241/// the source actually needs.
242///
243/// CSM.1 ships the minimal snapshot (cursor / anchor / query /
244/// trigger / case-sensitivity). Sources that need richer
245/// context (e.g. tree-sitter walking the buffer's syntax tree)
246/// will either:
247///
248/// - extend this struct with the relevant owned field
249/// (a `Buffer` clone is `O(1)` thanks to ropey's
250/// structural sharing);
251/// - or capture the data they need through their own struct's
252/// fields at construction (the LSP source captures
253/// `LspSupervisorHandle`, the snippet source captures
254/// `Arc<SnippetRegistry>`).
255#[derive(Debug, Clone)]
256pub struct InsertContextSnapshot {
257 pub cursor: Position,
258 pub anchor: Position,
259 pub query: String,
260 pub trigger: CompletionTrigger,
261 pub case_sensitive: bool,
262 /// Active buffer's language id (CSM.5). See
263 /// [`InsertContext::language`].
264 pub language: String,
265 /// Pre-computed tree-sitter symbols (CSM.6). See
266 /// [`InsertContext::tree_sitter_symbols`].
267 pub tree_sitter_symbols: Vec<String>,
268 /// Cursor sits inside a string scope (CSM.7). See
269 /// [`InsertContext::path_context`].
270 pub path_context: bool,
271 /// Base directory for path-source filesystem walks (CSM.7).
272 /// See [`InsertContext::buffer_dir`].
273 pub buffer_dir: Option<std::path::PathBuf>,
274 /// CSM.8b: generic buffer URI as a string. Populated by
275 /// the host when the active buffer maps to a filesystem
276 /// (or virtual) URI; `None` for scratch / unsaved
277 /// buffers. The LSP source parses it back via
278 /// `lsp_types::Uri::from_str`.
279 pub uri: Option<String>,
280 /// OR.7: the text from the start of the cursor's line up to
281 /// the cursor, verbatim. The one field that lets an
282 /// out-of-process source decide *whether it applies at all*
283 /// without the host growing a per-source context flag.
284 ///
285 /// `path_context` above is the shape this replaces: a bool
286 /// the host computes on one source's behalf, so teaching the
287 /// host a new context means teaching it that source's syntax.
288 /// A source that only fires inside `[[…]]` (org-roam links),
289 /// after `#+` (org keywords), or inside a fenced block reads
290 /// this string and answers for itself; the host stays ignorant
291 /// of every one of those. Native sources have `buffer` and
292 /// never need it — this exists for the WASM seam, which has
293 /// only what crosses.
294 ///
295 /// Note what it does NOT change: the popup's replacement
296 /// region is still `[anchor, cursor]`, computed once at
297 /// open. A source whose candidates would replace more than
298 /// that must decline unless the anchor already covers what it
299 /// means to replace — which it can now check, because it can
300 /// see the text between the two.
301 pub line_before_cursor: String,
302 /// CSM.8b: pre-computed LSP position
303 /// (line, character) in UTF-16 encoding. Populated by
304 /// the host when an LSP server is attached and the
305 /// buffer's URI is known. The source uses it directly
306 /// to build `lsp_types::Position`; sources that need a
307 /// different encoding ignore this.
308 pub lsp_position: Option<(u32, u32)>,
309}
310
311impl InsertContextSnapshot {
312 /// Take an owned snapshot of `ctx`. Cheap-ish -- clones
313 /// `query` + `language` + the `tree_sitter_symbols` slice;
314 /// the other fields are `Copy`.
315 pub fn from_context(ctx: &InsertContext<'_>) -> Self {
316 Self {
317 cursor: ctx.cursor,
318 anchor: ctx.anchor,
319 query: ctx.query.to_string(),
320 trigger: ctx.trigger.clone(),
321 case_sensitive: ctx.case_sensitive,
322 language: ctx.language.to_string(),
323 tree_sitter_symbols: ctx.tree_sitter_symbols.to_vec(),
324 path_context: ctx.path_context,
325 buffer_dir: ctx.buffer_dir.map(|p| p.to_path_buf()),
326 uri: ctx.uri.map(|s| s.to_string()),
327 line_before_cursor: line_before_cursor(ctx.buffer, ctx.cursor),
328 lsp_position: ctx.lsp_position,
329 }
330 }
331}
332
333/// OR.7: drain payload for the **async completion fan-out** — every
334/// [`AsyncCompletionSource`] the active modes contribute, not only
335/// LSP's.
336///
337/// This was `lattice_lsp::cache::InsertCompletionLspOutcome`, and the
338/// name was load-bearing in the wrong direction: it described the one
339/// source the host actually drove, and so nothing made it obvious that
340/// a WASM completion source could register a carrier mode, spawn an
341/// actor, connect an adapter, and still never have `generate` called.
342/// The type is named for the fan-out now because the fan-out is what
343/// it carries.
344#[derive(Debug, Clone)]
345pub enum AsyncCompletionOutcome {
346 Items {
347 candidates: Vec<crate::RawCandidate>,
348 /// Every source that took part in this round, whether or not
349 /// it produced anything. The drain drops these sources'
350 /// previous candidates before adding `candidates`, which is
351 /// what makes a re-fire *replace* rather than duplicate.
352 ///
353 /// A source that answered with nothing must still appear here
354 /// — otherwise its stale rows from the previous round survive
355 /// a round in which it deliberately declined.
356 sources: Vec<SourceId>,
357 /// LSP's `isIncomplete`. Plugin sources leave it false; the
358 /// host re-fires on each keystroke while any source sets it.
359 is_incomplete: bool,
360 },
361 /// The fan-out had nothing to run (no async sources enabled for
362 /// this buffer). Distinct from `Items` with an empty list, which
363 /// means sources ran and declined — that one still has to clear
364 /// their previous rows.
365 Nothing,
366}
367
368/// The text from the start of `cursor`'s line up to `cursor`.
369///
370/// Public because the host builds an [`InsertContextSnapshot`]
371/// directly in one place (the async fan-out) rather than through
372/// [`InsertContextSnapshot::from_context`], and the two must agree
373/// — a second hand-rolled slice is how they would drift.
374///
375/// Byte-clamped rather than trusting `cursor.byte`: an out-of-range
376/// cursor yields the whole line (or `""` for a missing line), never
377/// a panic on a completion path.
378pub fn line_before_cursor(buffer: &lattice_core::Buffer, cursor: Position) -> String {
379 let line = buffer.line(cursor.line).unwrap_or_default();
380 let upto = (cursor.byte as usize).min(line.len());
381 // `get` rather than slicing: `upto` can land inside a multi-byte
382 // char when the cursor is mid-grapheme, and a panic here would
383 // take out the popup.
384 line.get(..upto).unwrap_or(&line).to_string()
385}
386
387#[cfg(test)]
388mod tests {
389 #![allow(clippy::unwrap_used)]
390 use super::*;
391 use crate::candidate::CandidateKind;
392 use lattice_core::Buffer;
393
394 /// A minimal sync source for trait-shape tests. Production
395 /// sources will live in their feature crates (CSM.4 -- CSM.7).
396 #[derive(Debug)]
397 struct EchoSync {
398 id: SourceId,
399 items: Vec<String>,
400 }
401
402 impl SyncCompletionSource for EchoSync {
403 fn produce(&self, _ctx: &InsertContext<'_>) -> Vec<RawCandidate> {
404 self.items
405 .iter()
406 .map(|s| RawCandidate::plain(s.clone(), CandidateKind::Plain))
407 .collect()
408 }
409 }
410
411 /// A trivial async source: pushes one fixed candidate then
412 /// resolves. Real async sources (LSP) drive their work loop
413 /// inside the future.
414 #[derive(Debug)]
415 struct EchoAsync {
416 candidate: String,
417 }
418
419 impl AsyncCompletionSource for EchoAsync {
420 fn produce_async(
421 &self,
422 _ctx: InsertContextSnapshot,
423 sink: Arc<dyn CandidateSink>,
424 _token: CancellationToken,
425 ) -> Pin<Box<dyn Future<Output = ()> + Send>> {
426 let text = self.candidate.clone();
427 Box::pin(async move {
428 sink.push(RawCandidate::plain(text, CandidateKind::Plain));
429 })
430 }
431 }
432
433 #[test]
434 fn sync_source_produces_candidates() {
435 let buffer = Buffer::empty();
436 let ctx = InsertContext {
437 buffer: &buffer,
438 cursor: Position::ZERO,
439 anchor: Position::ZERO,
440 query: "",
441 trigger: &CompletionTrigger::Manual,
442 case_sensitive: false,
443 language: "",
444 tree_sitter_symbols: &[],
445 path_context: false,
446 buffer_dir: None,
447 uri: None,
448 lsp_position: None,
449 };
450 let src = EchoSync {
451 id: SourceId::new("gen:echo"),
452 items: vec!["alpha".into(), "beta".into()],
453 };
454 let candidates = src.produce(&ctx);
455 assert_eq!(candidates.len(), 2);
456 assert_eq!(candidates[0].text, "alpha");
457 let _ = src.id;
458 }
459
460 #[test]
461 fn contribution_is_constructible_and_debug_readable() {
462 let contribution = CompletionSourceContribution {
463 accepts_non_word_query: false,
464 id: SourceId::new("gen:echo"),
465 default_priority: 100,
466 auto_trigger: true,
467 trigger_chars: vec!['.'],
468 popup_filter_chord: Some('e'),
469 kind: CompletionSourceKind::Sync(Arc::new(EchoSync {
470 id: SourceId::new("gen:echo"),
471 items: vec!["x".into()],
472 })),
473 };
474 assert_eq!(contribution.id.as_str(), "gen:echo");
475 assert_eq!(contribution.kind.kind_label(), "sync");
476 // Debug doesn't panic + names the relevant fields.
477 let dbg = format!("{contribution:?}");
478 assert!(dbg.contains("gen:echo"));
479 assert!(dbg.contains("sync"));
480 }
481
482 #[test]
483 fn async_source_pushes_observable_via_shared_handle() {
484 // Lightweight executor for the test: poll the future once
485 // with a no-op waker. `EchoAsync` doesn't `.await`, so one
486 // poll completes it. Keeps the crate free of an executor
487 // dep; real async drive happens host-side on tokio.
488 use std::sync::Mutex;
489 use std::task::{Context, Poll, Wake};
490 struct SharedSink {
491 received: Arc<Mutex<Vec<String>>>,
492 }
493 impl CandidateSink for SharedSink {
494 fn push(&self, candidate: RawCandidate) {
495 self.received.lock().unwrap().push(candidate.text);
496 }
497 }
498 struct Noop;
499 impl Wake for Noop {
500 fn wake(self: Arc<Self>) {}
501 }
502 let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
503 let sink: Arc<dyn CandidateSink> = Arc::new(SharedSink {
504 received: received.clone(),
505 });
506 let src = EchoAsync {
507 candidate: "lsp-pushed-item".into(),
508 };
509 let buffer = Buffer::empty();
510 let ctx = InsertContext {
511 buffer: &buffer,
512 cursor: Position::ZERO,
513 anchor: Position::ZERO,
514 query: "",
515 trigger: &CompletionTrigger::Manual,
516 case_sensitive: false,
517 language: "",
518 tree_sitter_symbols: &[],
519 path_context: false,
520 buffer_dir: None,
521 uri: None,
522 lsp_position: None,
523 };
524 let snap = InsertContextSnapshot::from_context(&ctx);
525 let mut fut = src.produce_async(snap, sink, CancellationToken::never());
526 let waker = Arc::new(Noop).into();
527 let mut cx = Context::from_waker(&waker);
528 assert!(matches!(fut.as_mut().poll(&mut cx), Poll::Ready(())));
529 let pushed = received.lock().unwrap();
530 assert_eq!(pushed.as_slice(), &["lsp-pushed-item".to_string()]);
531 }
532
533 #[test]
534 fn snapshot_clones_query_owns_the_rest() {
535 let buffer = Buffer::empty();
536 let ctx = InsertContext {
537 buffer: &buffer,
538 cursor: Position::new(2, 7),
539 anchor: Position::new(2, 4),
540 query: "foo",
541 trigger: &CompletionTrigger::IdentifierThreshold,
542 case_sensitive: true,
543 language: "rust",
544 tree_sitter_symbols: &[],
545 path_context: false,
546 buffer_dir: None,
547 uri: None,
548 lsp_position: None,
549 };
550 let snap = InsertContextSnapshot::from_context(&ctx);
551 assert_eq!(snap.cursor, Position::new(2, 7));
552 assert_eq!(snap.anchor, Position::new(2, 4));
553 assert_eq!(snap.query, "foo");
554 assert!(snap.case_sensitive);
555 assert_eq!(snap.language, "rust");
556 assert!(matches!(
557 snap.trigger,
558 CompletionTrigger::IdentifierThreshold
559 ));
560 }
561}