lattice_completion/source_registration.rs
1//! Unified source registration — slice `3c.unify.picker-generator-trait-unify`
2//! (7a).
3//!
4//! Defines the shapes that picker, cmdline-completion, and
5//! (post-WASM-host) plugins all use to register a candidate
6//! source. The design comes out of the LSP cross-check in
7//! `docs/dev/architecture/completion-pipeline-unification.md`
8//! § "LSP cross-check: the design that survives".
9//!
10//! ## Status
11//!
12//! Slice 7a lands the **shapes**: traits, enums, the
13//! `SourceRegistration` bundle. No first-party source migration
14//! yet (that's 7b); no registry integration (that's 7c); no
15//! `:picker <name>` cutover (that's 7d). This file exists so
16//! 7b can migrate against a stable target.
17//!
18//! ## End-state architecture
19//!
20//! ```text
21//! lattice-completion::
22//! CandidateGenerator // existing — pull-based source
23//! AcceptHandler // NEW — stateless accept dispatch
24//! AcceptAction // NEW — what the host should do on accept
25//! CandidateSourceKind // NEW — pull (Generator) vs push (PreSupplied)
26//! SourceRegistration // NEW — the bundle: generator/accept/spec/overrides
27//! SourceSpec // NEW — metadata (id, doc, args-schema, live flag)
28//!
29//! lattice-picker::
30//! PickerSourceGenerator // KEEPS WORKING during migration
31//! // First-party sources will impl CandidateGenerator + AcceptHandler;
32//! // PickerSourceGenerator becomes a deprecated thin adapter,
33//! // then retires once 7d cuts the registry over.
34//! ```
35
36use std::path::PathBuf;
37use std::sync::Arc;
38
39use crate::candidate::RawCandidate;
40use crate::registry::{AnnotatorId, MatcherId, RankerId};
41use crate::traits::CandidateGenerator;
42
43/// Metadata for a source. Returned by the source-registration's
44/// `spec` field; used for `:describe-picker` / `:picker <Tab>` /
45/// `:apropos` introspection.
46#[derive(Debug, Clone)]
47pub struct SourceSpec {
48 /// Stable id — `:picker <id>` invokes the source. Stable
49 /// across versions; renames break user keybindings.
50 pub id: String,
51 /// User-facing one-line summary. Shown in `:picker <Tab>`
52 /// completion and `:describe-picker` rows.
53 pub doc: String,
54 /// Whether the source accepts positional args via
55 /// `:picker <id> <args...>`. `None` ⇒ no args; `Some(schema)`
56 /// declares positional shape. v1 keeps the schema opaque
57 /// (description string); a future slice can grow this to a
58 /// typed validator.
59 pub args_schema: Option<ArgsSchema>,
60 /// `true` ⇒ the source's results refresh on every query
61 /// change (e.g. `:picker grep` where the external process
62 /// IS the filter). Picker bypasses fuzzy-refilter in live
63 /// mode.
64 pub live: bool,
65}
66
67/// Opaque positional-arg schema. v1 placeholder — description
68/// string. A future slice can grow this to a typed validator
69/// (number of required args, whether trailing args are allowed,
70/// etc.).
71#[derive(Debug, Clone)]
72pub struct ArgsSchema {
73 pub description: String,
74}
75
76/// How candidates flow into the pipeline. Picker calls have two
77/// shapes (per the LSP cross-check): synchronous enumeration
78/// (`gen:files` walks the FS per filter) vs. push-from-async
79/// (LSP picker host-builds rows from an async response before
80/// opening the picker).
81#[derive(Clone)]
82pub enum CandidateSourceKind {
83 /// Pull-based — `generate(ctx)` runs per `Pipeline::run`.
84 /// First-party uses: Files, Buffers, Commands, Lines,
85 /// Jumps, Marks, Registers, Outline, RecentFiles. Plugin
86 /// uses: anything synchronously enumerable.
87 Generator(Arc<dyn CandidateGenerator>),
88
89 /// Push-based — caller supplies the candidate set up front
90 /// (typically from an async response). The pipeline treats
91 /// the supplied Vec as a fixed input and runs only the
92 /// match + rank + annotate stages.
93 /// First-party uses: every LSP picker (references /
94 /// definitions / completion / code-actions / code-lens /
95 /// color-presentation / instances / show-message-request).
96 /// Plugin uses: anything async / network-driven.
97 PreSupplied(Arc<Vec<RawCandidate>>),
98}
99
100impl std::fmt::Debug for CandidateSourceKind {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 Self::Generator(_) => write!(f, "Generator(<dyn CandidateGenerator>)"),
104 Self::PreSupplied(rows) => write!(f, "PreSupplied({} candidates)", rows.len()),
105 }
106 }
107}
108
109/// Translate a chosen candidate into a typed action the host
110/// dispatches. Stateless: the handler reads the candidate, the
111/// host runs the action. State lookup for indexed variants
112/// (LSP completion / code-actions / etc.) happens at dispatch
113/// time inside the host, where `Editor::pending_*_items` is in
114/// scope.
115///
116/// See the design doc § "Implication: AcceptHandler is
117/// stateless" for the cross-check that validated this shape.
118///
119/// **Return type.** `Result<AcceptAction, String>` mirrors
120/// today's `PickerSourceGenerator::accept -> SourceResult<...>`.
121/// `Err` flows to the host's error echo; the source stays alive.
122/// Most picker sources will use [`DefaultAcceptHandler`] which
123/// reads `RawCandidate::accept_action` set at candidate-build
124/// time — the per-source impl is only needed when accept needs
125/// context-dependent transformation.
126pub trait AcceptHandler: Send + Sync {
127 fn accept(&self, candidate: &RawCandidate) -> Result<AcceptAction, String>;
128}
129
130/// Default handler: reads `RawCandidate::accept_action` set at
131/// candidate-build time. Covers every picker source whose
132/// candidates carry their action directly — which is the
133/// expected case after slice 7b.
134///
135/// Sources only need their own [`AcceptHandler`] impl when the
136/// accept action depends on context the candidate doesn't
137/// carry — e.g. a source that pivots on the current major
138/// mode, or one that wants to transform the action based on
139/// modifier keys at accept time.
140pub struct DefaultAcceptHandler;
141
142impl AcceptHandler for DefaultAcceptHandler {
143 fn accept(&self, candidate: &RawCandidate) -> Result<AcceptAction, String> {
144 candidate
145 .accept_action
146 .as_deref()
147 .cloned()
148 .ok_or_else(|| "candidate has no accept_action set".to_string())
149 }
150}
151
152/// What the host should do when the user accepts a candidate.
153/// Cleanup-and-rename of today's `lattice_picker::RoutingPayload`
154/// plus new variants for cmdline-completion (`InsertText`) and
155/// plugin extensibility (`Custom`).
156///
157/// **Stateless variants** carry the full payload — no host
158/// lookup required. The accept handler returns one of these
159/// directly; the host's dispatch arm reads the payload and
160/// runs the action.
161///
162/// **Stateful variants** carry an `AcceptToken` (opaque marker
163/// the host uses to find the right `pending_*` table) plus an
164/// index. The host's dispatch arm resolves the token to a
165/// pending-table reference, looks up the item by index,
166/// applies it.
167///
168/// **Custom** is the plugin escape hatch — the plugin's accept
169/// handler returns `Custom(Box::new(MyType { ... }))`; the
170/// plugin's matching dispatch handler downcasts to `MyType`
171/// and applies its own logic.
172// PartialEq for AcceptAction is hand-implemented below
173// (`Custom` carries `Arc<dyn Any>` which can't be compared
174// structurally — pointer-equality is the only sensible
175// fallback). The rest of the variants derive structural
176// equality via the helper enum's auto-generated arms; we just
177// override the Custom arm. Hand-rolling the impl over deriving
178// keeps the equality semantics explicit.
179#[derive(Debug, Clone)]
180pub enum AcceptAction {
181 // -------- Stateless: candidate carries the full payload --------
182 /// Hand `path` to `App::do_edit(Some(path), false)`.
183 /// First-party: `:picker files`, `:picker recent`.
184 OpenFile { path: PathBuf },
185
186 /// Activate buffer `id` in the current pane.
187 /// First-party: `:picker buffers`.
188 SwitchBuffer { id: lattice_core::BufferId },
189
190 /// Jump to `(path, line, col)` via `App::jump_to_file_line_col`.
191 /// First-party: LSP references / definitions / type-defs /
192 /// implementations / declaration / diagnostics.
193 JumpToFileLocation { path: PathBuf, line: u32, col: u32 },
194
195 /// Jump to `(line, col)` in an already-open buffer.
196 /// First-party: `:picker lines`, `:picker jumps`.
197 JumpInBuffer {
198 buffer_id: lattice_core::BufferId,
199 line: u32,
200 col: u32,
201 },
202
203 /// Invoke ex-command `id` with `args`.
204 /// First-party: `:picker commands` (the command palette).
205 InvokeCommand {
206 id: String,
207 args: lattice_grammar::args::Args,
208 },
209
210 /// Paste named register `name` at the cursor.
211 /// First-party: `:picker registers`.
212 PasteRegister { name: char },
213
214 /// Jump to mark `name` via `App::do_jump_mark`.
215 /// First-party: `:picker marks`.
216 JumpToMark { name: char },
217
218 /// Expand snippet `id` at the cursor.
219 /// First-party: `:picker snippets`.
220 ExpandSnippet { id: String },
221
222 /// Open `*lsp:<server_id>*` (the per-server log buffer) in
223 /// the current pane.
224 /// First-party: `:lsp-log` / `:lsp-server-log` picker.
225 OpenLspLog {
226 server_id: String,
227 workspace: PathBuf,
228 },
229
230 /// Open `*lsp:<server_id>:trace*` (the trace ring view)
231 /// without flipping the trace toggle.
232 /// First-party: `:lsp-trace-log` picker.
233 OpenLspTraceLog {
234 server_id: String,
235 workspace: PathBuf,
236 },
237
238 // -------- Stateful: host resolves by (token, index) --------
239 /// Accept an LSP completion item from a pending request.
240 /// First-party: `:complete`.
241 AcceptIndexedCompletion { token: AcceptToken, index: u32 },
242
243 /// Accept an LSP code-action item.
244 /// First-party: `:code-actions` / `gA`.
245 AcceptIndexedCodeAction { token: AcceptToken, index: u32 },
246
247 /// Accept an LSP code-lens item.
248 /// First-party: `:lsp-code-lens`.
249 AcceptIndexedCodeLens { token: AcceptToken, index: u32 },
250
251 /// Accept a color-presentation item.
252 /// First-party: `:lsp-color-presentation`.
253 AcceptColorPresentation { token: AcceptToken, index: u32 },
254
255 /// Reply to a server-initiated `window/showMessageRequest`
256 /// with the selected action index. Host looks up the
257 /// pending oneshot by `request_id` on `server_id`.
258 AcceptShowMessageAction {
259 request_id: u32,
260 server_id: String,
261 index: u32,
262 },
263
264 // -------- Cmdline-completion --------
265 /// Replace `cmdline[replace_start..]` with `text`. Used by
266 /// the cmdline-completion popup when the user presses
267 /// `<CR>` on a candidate. Currently handled inline in
268 /// `Editor::do_command_line_accept_completion` — this
269 /// variant moves that logic into the unified action enum so
270 /// plugin-registered cmdline sources can use the same
271 /// dispatch path.
272 InsertText { text: String, replace_start: usize },
273
274 // -------- Plugin extension --------
275 /// Plugin-defined opaque payload. The plugin's accept
276 /// handler returns `Custom(...)`; the plugin's matching
277 /// dispatch handler (registered alongside the source)
278 /// downcasts to its known type and applies its own logic.
279 Custom(CustomAcceptPayload),
280}
281
282impl PartialEq for AcceptAction {
283 fn eq(&self, other: &Self) -> bool {
284 use AcceptAction::*;
285 match (self, other) {
286 (OpenFile { path: a }, OpenFile { path: b }) => a == b,
287 (SwitchBuffer { id: a }, SwitchBuffer { id: b }) => a == b,
288 (
289 JumpToFileLocation {
290 path: pa,
291 line: la,
292 col: ca,
293 },
294 JumpToFileLocation {
295 path: pb,
296 line: lb,
297 col: cb,
298 },
299 ) => pa == pb && la == lb && ca == cb,
300 (
301 JumpInBuffer {
302 buffer_id: ba,
303 line: la,
304 col: ca,
305 },
306 JumpInBuffer {
307 buffer_id: bb,
308 line: lb,
309 col: cb,
310 },
311 ) => ba == bb && la == lb && ca == cb,
312 (InvokeCommand { id: ia, args: aa }, InvokeCommand { id: ib, args: ab }) => {
313 ia == ib && aa == ab
314 }
315 (PasteRegister { name: a }, PasteRegister { name: b }) => a == b,
316 (JumpToMark { name: a }, JumpToMark { name: b }) => a == b,
317 (ExpandSnippet { id: a }, ExpandSnippet { id: b }) => a == b,
318 (
319 OpenLspLog {
320 server_id: sa,
321 workspace: wa,
322 },
323 OpenLspLog {
324 server_id: sb,
325 workspace: wb,
326 },
327 ) => sa == sb && wa == wb,
328 (
329 OpenLspTraceLog {
330 server_id: sa,
331 workspace: wa,
332 },
333 OpenLspTraceLog {
334 server_id: sb,
335 workspace: wb,
336 },
337 ) => sa == sb && wa == wb,
338 (
339 AcceptIndexedCompletion {
340 token: ta,
341 index: ia,
342 },
343 AcceptIndexedCompletion {
344 token: tb,
345 index: ib,
346 },
347 ) => ta == tb && ia == ib,
348 (
349 AcceptIndexedCodeAction {
350 token: ta,
351 index: ia,
352 },
353 AcceptIndexedCodeAction {
354 token: tb,
355 index: ib,
356 },
357 ) => ta == tb && ia == ib,
358 (
359 AcceptIndexedCodeLens {
360 token: ta,
361 index: ia,
362 },
363 AcceptIndexedCodeLens {
364 token: tb,
365 index: ib,
366 },
367 ) => ta == tb && ia == ib,
368 (
369 AcceptColorPresentation {
370 token: ta,
371 index: ia,
372 },
373 AcceptColorPresentation {
374 token: tb,
375 index: ib,
376 },
377 ) => ta == tb && ia == ib,
378 (
379 AcceptShowMessageAction {
380 request_id: ra,
381 server_id: sa,
382 index: ia,
383 },
384 AcceptShowMessageAction {
385 request_id: rb,
386 server_id: sb,
387 index: ib,
388 },
389 ) => ra == rb && sa == sb && ia == ib,
390 (
391 InsertText {
392 text: ta,
393 replace_start: ra,
394 },
395 InsertText {
396 text: tb,
397 replace_start: rb,
398 },
399 ) => ta == tb && ra == rb,
400 // Custom: opaque Arc<dyn Any>. Pointer-equality is
401 // the only sensible compare. Two different Arc
402 // instances of equivalent data compare !=; that's
403 // intentional — equality on type-erased data isn't
404 // meaningful without downcasting.
405 (Custom(a), Custom(b)) => Arc::ptr_eq(&a.0, &b.0),
406 _ => false,
407 }
408 }
409}
410
411/// Opaque payload for `AcceptAction::Custom`. Wrapper exists
412/// to give `AcceptAction` `Debug` + `Clone` impls that don't
413/// require the inner type to expose those bounds.
414///
415/// Uses `Arc` (not `Box`) so `AcceptAction: Clone` works —
416/// cloning a `Custom` bumps the inner Arc's refcount. The host
417/// downcasts via `Arc::downcast::<MyType>()` (or the
418/// `Arc<dyn Any>` equivalent) at dispatch time.
419pub struct CustomAcceptPayload(pub Arc<dyn std::any::Any + Send + Sync>);
420
421impl std::fmt::Debug for CustomAcceptPayload {
422 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423 write!(f, "CustomAcceptPayload(<opaque>)")
424 }
425}
426
427impl Clone for CustomAcceptPayload {
428 fn clone(&self) -> Self {
429 Self(Arc::clone(&self.0))
430 }
431}
432
433/// Opaque marker the host uses to find the right `pending_*`
434/// table for a stateful AcceptAction. v1: a `u64` that the host
435/// generates per-LSP-request; the LSP cache keys on the same
436/// token. Plugins use the same scheme for their own pending
437/// tables.
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
439pub struct AcceptToken(pub u64);
440
441impl AcceptToken {
442 pub fn new(value: u64) -> Self {
443 Self(value)
444 }
445}
446
447/// The substrate: bundle of every input the pipeline needs to
448/// produce + dispatch a source's candidates.
449///
450/// Lives in `lattice-completion` (the abstraction layer); the
451/// host (`lattice-host`) holds the per-app `CompletionRegistry`
452/// and stores registrations there. Both surfaces — picker and
453/// cmdline-completion — consume registrations through the
454/// registry.
455///
456/// **Two lifecycles** (cross-check § "Two registration
457/// lifecycles"):
458///
459/// - **Persistent**: registered at boot via
460/// `CompletionRegistry::register_source`, lives until
461/// shutdown. First-party Files / Buffers / Commands etc.
462/// Plugin sources whose data is synchronously enumerable.
463///
464/// - **Transient**: constructed per-use by the host (LSP
465/// pickers building from async responses) or a plugin (async-
466/// fetch), passed directly to `Picker::open_with(reg)`,
467/// dropped after accept/dismiss. Same shape; just shorter
468/// lifetime.
469pub struct SourceRegistration {
470 pub spec: SourceSpec,
471 pub kind: CandidateSourceKind,
472 /// Accept handler. `None` ⇒ candidate selection is
473 /// effectively a no-op (rare; cmdline-completion sources
474 /// today don't carry an explicit handler — the accept logic
475 /// is inline. Slice 7c migrates those to explicit handlers
476 /// returning `AcceptAction::InsertText`).
477 pub accept: Option<Arc<dyn AcceptHandler>>,
478 /// Per-source matcher override. `None` ⇒ use the registry
479 /// default.
480 pub matcher_override: Option<MatcherId>,
481 /// Per-source ranker chain override. Empty ⇒ use the
482 /// registry default chain.
483 pub ranker_overrides: Vec<RankerId>,
484 /// Additional annotators to run on this source's
485 /// candidates, in registration order. Appended to (not
486 /// replacing) the registry default annotators.
487 pub annotator_extras: Vec<AnnotatorId>,
488}
489
490#[cfg(test)]
491mod tests {
492 #![allow(clippy::unwrap_used)]
493 use super::*;
494 use crate::candidate::{CandidateKind, RawCandidate};
495
496 /// Construct a registration with the minimum fields; verifies
497 /// the shape compiles + the variants are reachable.
498 #[test]
499 fn presupplied_registration_compiles() {
500 let rows = vec![RawCandidate::plain("hi", CandidateKind::Plain)];
501 let reg = SourceRegistration {
502 spec: SourceSpec {
503 id: "test:probe".to_string(),
504 doc: "probe".to_string(),
505 args_schema: None,
506 live: false,
507 },
508 kind: CandidateSourceKind::PreSupplied(Arc::new(rows)),
509 accept: None,
510 matcher_override: None,
511 ranker_overrides: Vec::new(),
512 annotator_extras: Vec::new(),
513 };
514 assert_eq!(reg.spec.id, "test:probe");
515 assert!(matches!(reg.kind, CandidateSourceKind::PreSupplied(_)));
516 }
517
518 /// `AcceptAction::Custom` constructs and survives a Debug
519 /// print without panicking.
520 #[test]
521 fn accept_action_custom_payload_debug_prints() {
522 let action = AcceptAction::Custom(CustomAcceptPayload(Arc::new(42_i32)));
523 let s = format!("{action:?}");
524 assert!(s.contains("Custom"));
525 assert!(s.contains("opaque"));
526 }
527
528 /// `AcceptAction` is Clone — Custom variant clones via Arc
529 /// refcount bump.
530 #[test]
531 fn accept_action_clones() {
532 let inner = Arc::new(7_i32);
533 let a = AcceptAction::Custom(CustomAcceptPayload(inner.clone()));
534 let b = a.clone();
535 // Both still print as opaque; the underlying Arc has
536 // refcount >= 2.
537 let _ = format!("{a:?}");
538 let _ = format!("{b:?}");
539 assert!(Arc::strong_count(&inner) >= 3);
540 }
541
542 /// `AcceptToken` is Ord + Hash so the host can use it as a
543 /// HashMap key (e.g. pending-table lookup).
544 #[test]
545 fn accept_token_is_hash_and_ord() {
546 let a = AcceptToken::new(1);
547 let b = AcceptToken::new(2);
548 assert!(a < b);
549 let mut set = std::collections::HashSet::new();
550 set.insert(a);
551 set.insert(b);
552 assert_eq!(set.len(), 2);
553 }
554
555 /// `DefaultAcceptHandler` reads `RawCandidate::accept_action`
556 /// when present.
557 #[test]
558 fn default_handler_returns_candidate_accept_action() {
559 let mut c = RawCandidate::plain("buf:7", CandidateKind::Buffer);
560 c.accept_action = Some(Box::new(AcceptAction::SwitchBuffer {
561 id: lattice_core::BufferId(7),
562 }));
563 let handler = DefaultAcceptHandler;
564 let action = handler.accept(&c).expect("should succeed");
565 assert!(matches!(action, AcceptAction::SwitchBuffer { .. }));
566 }
567
568 /// `DefaultAcceptHandler` errors when the candidate carries
569 /// no `accept_action` — the host echoes the error and the
570 /// source stays alive.
571 #[test]
572 fn default_handler_errors_when_action_missing() {
573 let c = RawCandidate::plain("plain", CandidateKind::Plain);
574 let handler = DefaultAcceptHandler;
575 let err = handler.accept(&c).unwrap_err();
576 assert!(err.contains("no accept_action"));
577 }
578
579 /// Stateless variants round-trip through Debug.
580 #[test]
581 fn accept_action_stateless_variants_debug() {
582 let actions = [
583 AcceptAction::OpenFile {
584 path: PathBuf::from("/tmp/x"),
585 },
586 AcceptAction::SwitchBuffer {
587 id: lattice_core::BufferId(7),
588 },
589 AcceptAction::PasteRegister { name: 'a' },
590 AcceptAction::JumpToMark { name: 'm' },
591 ];
592 for a in actions {
593 let _ = format!("{a:?}");
594 }
595 }
596}