Skip to main content

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}