Skip to main content

lattice_picker/
source.rs

1//! Picker source registry (metadata layer).
2//!
3//! Each picker source — `files`, `recent`, `lines`, `marks`,
4//! `lsp-references`, ... — registers a [`PickerSourceSpec`]
5//! into a [`PickerRegistry`] at boot. The registry powers
6//! three things:
7//!
8//! 1. **`:picker <Tab>` completion.** The cmdline source-id
9//!    completion mode iterates the registry and surfaces every
10//!    registered source as a candidate.
11//! 2. **Source-arg completion.** Once the source id is resolved,
12//!    arg-2+ completion consults the source's
13//!    [`ArgSpec::completion`] hooks — same `gen:*` completion
14//!    sources every other ex-command uses.
15//! 3. **`:describe-picker` introspection.** Walks the registry
16//!    to render `:describe-picker` (and `:describe-picker <id>`
17//!    for per-source detail).
18//!
19//! The registry only holds **metadata** at this stage. The
20//! `PickerSourceGenerator` trait (slice 4 in
21//! `docs/dev/architecture/picker.md`) elevates the registry to
22//! hold generator trait objects so source dispatch is registry-
23//! driven end-to-end. Today the App still owns the
24//! `source_id → method` dispatch table; the registry just
25//! supplies the names + arg schemas the grammar needs.
26//!
27//! ## WIT mirror (Phase 7)
28//!
29//! When the plugin host lands, WIT-imported sources register
30//! their spec record into the same `PickerRegistry`. Plugin
31//! sources are indistinguishable from first-party at the
32//! registry level — both appear under `:picker <Tab>` and
33//! flow through the same dispatch.
34//!
35//! The registry interface is therefore deliberately small:
36//! `register`, `get`, `iter`. Nothing host-specific leaks in.
37
38use std::borrow::Cow;
39use std::collections::HashMap;
40use std::future::Future;
41use std::pin::Pin;
42
43use lattice_completion::candidate::RawCandidate;
44use lattice_grammar::args::ArgSpec;
45use tokio::sync::mpsc;
46
47use crate::RoutingPayload;
48use crate::context::PickerContext;
49use crate::outcome::PickerAcceptOutcome;
50
51/// Static metadata describing one picker source.
52///
53/// `id` is the stable name the user types after `:picker`
54/// (e.g. `files`, `lsp-references`). `doc` is one line shown
55/// in `:describe-picker` and next to the id in cmdline
56/// completion. `args_schema` describes positional args after
57/// the source id — same `ArgSpec` machinery the rest of the
58/// grammar uses, so `:picker grep <pat> <Tab>` completes
59/// through the existing `gen:*` source plumbing.
60#[derive(Debug, Clone)]
61pub struct PickerSourceSpec {
62    /// PL8.F: `Cow<'static, str>` — builtins pass zero-cost `Cow::Borrowed`
63    /// literals; a plugin source (crossing WIT) passes `Cow::Owned` that frees
64    /// on `PickerRegistry::unregister`, replacing the old `Box::leak` intern.
65    pub id: Cow<'static, str>,
66    pub doc: Cow<'static, str>,
67    pub args_schema: Vec<ArgSpec>,
68    /// Parameter-hint line shown while the user is typing args
69    /// after the source id. Empty string = no hint (the
70    /// cmdline falls back to per-arg `ArgSpec::doc`).
71    pub args_hint: Cow<'static, str>,
72    /// True if this source re-executes its data fetch on every
73    /// (debounced) query change instead of returning a fixed
74    /// candidate set the picker fuzzy-filters. Live sources
75    /// own their own filtering -- the grep binary IS the
76    /// filter -- so the picker bypasses its built-in fuzzy
77    /// refilter for them. Sources opting in must also
78    /// implement [`PickerSourceGenerator::on_query_changed`].
79    pub live: bool,
80    /// OR.5: when set, the picker offers one synthetic **create** row whenever
81    /// the query is non-empty — the offer to make the thing the user was
82    /// looking for and did not find. `%s` in the label is replaced by the
83    /// query.
84    ///
85    /// The row is pinned last and never ranked (see `Picker::push_create_row`),
86    /// and accepting it routes `RoutingPayload::Create { query }` back to this
87    /// source's `accept`, which decides what creation means. The picker never
88    /// knows: org-roam mints a node, another source might make a file. `None` —
89    /// every source but roam's — behaves exactly as before.
90    pub create_label: Option<Cow<'static, str>>,
91    /// PP.2: this source's results are scoped to a project / workspace root,
92    /// so the picker prompt names the root it is operating on.
93    ///
94    /// **A declaration, not an inference.** The host resolves a root for every
95    /// picker open (`build_picker_context` always fills `workspace_root`), so
96    /// it could show one everywhere — and a path on `buffers`, `commands` or
97    /// `marks`, whose results span every project you have open, is noise on a
98    /// surface that has one line to be read. Only the source knows whether the
99    /// root is part of what the list MEANS.
100    ///
101    /// Set it when the answer to *"would these results be different in another
102    /// project?"* is yes: `files`, `grep`, every magit source (scoped to the
103    /// buffer's repository). Leave it off when the list is global
104    /// (`buffers`, `recent`, `projects`), buffer-local (`lines`, `outline`) or
105    /// registry-wide (`commands`, `snippets`, `colorscheme`).
106    ///
107    /// `dir-pick` deliberately declines it despite being path-shaped: its
108    /// QUERY is the directory it is listing, so the prompt already says where
109    /// it is and a root beside that would be a second, staler answer.
110    pub rooted: bool,
111    /// PD.1: `<C-d>` — the ex-command that REMOVES the selected row from
112    /// whatever backs this list. `None` (every source but `projects` today)
113    /// leaves `<C-d>` doing nothing at all.
114    ///
115    /// **The source owns the verb; the host owns only the key.** `<C-s>` /
116    /// `<C-v>` / `<C-t>` are host concerns — the host knows how to open a
117    /// thing in a split without asking anyone. Deletion is not: only the
118    /// source knows that removing a row from `projects` means forgetting a
119    /// root, and that removing one from a future `snippets` would mean
120    /// something else entirely. Naming a command is how a source says so,
121    /// and it is the same routing its rows already take
122    /// (`PickerAcceptOutcome::InvokeCommand`), so a plugin declaring this
123    /// needs no new seam and no new capability.
124    ///
125    /// The command is invoked with the selected row's routing ARGUMENT — the
126    /// root, for a project row. A row whose routing carries no stable argument
127    /// gets a no-op rather than a command with an empty one; see
128    /// `Editor::row_delete_argument` for exactly which routings qualify.
129    ///
130    /// **Deleting is not destructive to the filesystem and must not be.**
131    /// `projects` forgets a path; oil and the file tree are where deleting a
132    /// directory lives. A source whose delete verb touched disk would make
133    /// `<C-d>` mean two very different things depending on which picker had
134    /// focus, which is exactly the inconsistency this repo's key rules exist
135    /// to prevent.
136    pub delete_command: Option<Cow<'static, str>>,
137    /// PH.1: the `:help` topic `<C-h>` opens while this source's picker has
138    /// focus. `None` falls through to the `picker-<id>` convention and then to
139    /// the general `picker` page (see `Editor::do_picker_help`).
140    ///
141    /// **A declaration, so several sources can share one page** — the magit
142    /// sources are one family with one set of keys, and a page per source
143    /// would be six copies of the same table. The convention is the peer a
144    /// plugin can meet without this field crossing WIT: register a topic
145    /// named `picker-<id>` through the help seam — the host namespaces it to
146    /// `<plugin>.picker-<id>` — and it is found through
147    /// [`PickerSourceGenerator::owner_plugin`].
148    pub help_topic: Option<Cow<'static, str>>,
149}
150
151impl PickerSourceSpec {
152    /// Sugar for declaring a no-arg picker source (`files`,
153    /// `recent`, `buffers`, etc.).
154    pub fn no_args(id: impl Into<Cow<'static, str>>, doc: impl Into<Cow<'static, str>>) -> Self {
155        Self {
156            id: id.into(),
157            doc: doc.into(),
158            args_schema: Vec::new(),
159            args_hint: Cow::Borrowed(""),
160            live: false,
161            create_label: None,
162            rooted: false,
163            delete_command: None,
164            help_topic: None,
165        }
166    }
167
168    /// Builder-style: PH.1's [`help_topic`](Self::help_topic) — the page
169    /// `<C-h>` opens.
170    pub fn with_help_topic(mut self, topic: impl Into<Cow<'static, str>>) -> Self {
171        self.help_topic = Some(topic.into());
172        self
173    }
174
175    /// Builder-style: offer a create row whenever the query is non-empty.
176    /// `%s` in `label` is replaced by the query.
177    pub fn with_create_label(mut self, label: impl Into<Cow<'static, str>>) -> Self {
178        self.create_label = Some(label.into());
179        self
180    }
181
182    /// Builder-style: mark this source as live (`:picker grep`
183    /// today; future live LSP workspace-symbols, etc.). The
184    /// picker will bypass its fuzzy refilter for live sources
185    /// and the host will call
186    /// [`PickerSourceGenerator::on_query_changed`] on each
187    /// debounced keystroke.
188    pub fn with_live(mut self, live: bool) -> Self {
189        self.live = live;
190        self
191    }
192
193    /// Builder-style: PP.2's [`rooted`](Self::rooted) — the prompt names the
194    /// root these results are scoped to.
195    pub fn with_rooted(mut self, rooted: bool) -> Self {
196        self.rooted = rooted;
197        self
198    }
199
200    /// Builder-style: PD.1's [`delete_command`](Self::delete_command) — the
201    /// ex-command `<C-d>` runs on the selected row.
202    pub fn with_delete_command(mut self, command: impl Into<Cow<'static, str>>) -> Self {
203        self.delete_command = Some(command.into());
204        self
205    }
206}
207
208/// Registry of every picker source the `:picker <id>` ex-command
209/// can dispatch to. Populated at boot by each feature crate's
210/// `register_picker_sources` entry point.
211///
212/// Re-registering an id overwrites the previous entry — last
213/// writer wins. In practice each id is registered exactly once
214/// at boot; the overwrite semantics make tests trivial to write
215/// (`register` twice with different specs to assert the second
216/// wins).
217/// `Clone` so the host can hold the registry behind an `Arc<ArcSwap<_>>` and
218/// register plugin sources at runtime by copy-on-write RCU (clone → mutate →
219/// store) — the same wait-free-read / rare-write idiom the snippet registry
220/// uses. Reads stay lock-free; a plugin load / unload swaps a fresh registry
221/// in. Cloning is cheap: the sources map holds `&'static str` keys and
222/// `Arc`-shared generators.
223#[derive(Debug, Default, Clone)]
224pub struct PickerRegistry {
225    // PL8.F: keyed by `Cow<'static, str>` (was `&'static str`) so a plugin
226    // source's owned id frees on `unregister` — lookups still take `&str` via
227    // `Cow: Borrow<str>`.
228    sources: HashMap<Cow<'static, str>, RegistryEntry>,
229}
230
231/// The runtime-mutable registry handle the editor holds and shares as a service.
232/// `ArcSwap` gives wait-free reads on the picker-open path and copy-on-write RCU
233/// writes for runtime plugin-source registration (`:plugin-load` /
234/// boot-discovery): clone the current registry, `register_generator` /
235/// `unregister`, then `store` the new snapshot. The plugin loader
236/// (`lattice-plugin-loader`) reaches this via `service::<PickerRegistryHandle>()`
237/// and RCU-registers each loaded picker plugin's `WasmPickerSource`.
238pub type PickerRegistryHandle = std::sync::Arc<arc_swap::ArcSwap<PickerRegistry>>;
239
240/// Registry entry: either metadata-only (slice 12 path,
241/// retained for tab-completion of source ids whose generator
242/// isn't yet wired) or metadata + generator (the canonical
243/// slice 13 path -- dispatch resolves the generator from
244/// here).
245///
246/// Metadata-only entries land while the App still owns the
247/// imperative `open_*_picker` dispatch table; full
248/// generator entries flow through the trait-driven path. As
249/// each source migrates to a `PickerSourceGenerator` impl
250/// the registry transitions from metadata-only to full.
251#[derive(Clone)]
252pub struct RegistryEntry {
253    pub spec: PickerSourceSpec,
254    pub generator: Option<std::sync::Arc<dyn PickerSourceGenerator>>,
255}
256
257impl std::fmt::Debug for RegistryEntry {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        f.debug_struct("RegistryEntry")
260            .field("spec", &self.spec.id)
261            .field("has_generator", &self.generator.is_some())
262            .finish()
263    }
264}
265
266impl PickerRegistry {
267    pub fn new() -> Self {
268        Self::default()
269    }
270
271    /// Register a metadata-only entry. Used for sources whose
272    /// imperative App-side `open_*_picker` method still drives
273    /// dispatch (slice 12 path). Migrates to
274    /// [`Self::register_generator`] when the source's
275    /// `PickerSourceGenerator` impl lands.
276    pub fn register(&mut self, spec: PickerSourceSpec) {
277        let id = spec.id.clone();
278        self.sources.insert(
279            id,
280            RegistryEntry {
281                spec,
282                generator: None,
283            },
284        );
285    }
286
287    /// Register a source with both metadata and a generator
288    /// trait object. The spec is read from `generator.spec()`
289    /// so callers don't repeat themselves. Canonical path for
290    /// fully trait-driven sources.
291    pub fn register_generator(&mut self, generator: std::sync::Arc<dyn PickerSourceGenerator>) {
292        let spec = generator.spec().clone();
293        let id = spec.id.clone();
294        self.sources.insert(
295            id,
296            RegistryEntry {
297                spec,
298                generator: Some(generator),
299            },
300        );
301    }
302
303    /// Remove a registered source by its id, the teardown seam for a plugin
304    /// reload / unload (PH7.12b). The registry keys on `spec.id` and carries no
305    /// plugin-id provenance, so removal is by id — the host's teardown bundle
306    /// records the id it registered and drives this. Idempotent: `false` if no
307    /// source was registered under `id` (a second unload, or a never-registered
308    /// id). Without it a reload would leave the previous generator's dead entry
309    /// behind (calls fail `PluginGone`) and the interned id string keeps
310    /// leaking across reloads (audit F6, freed by the per-plugin pool in
311    /// PH7.12b.2).
312    pub fn unregister(&mut self, id: &str) -> bool {
313        let before = self.sources.len();
314        self.sources.retain(|k, _| *k != id);
315        self.sources.len() != before
316    }
317
318    pub fn get(&self, id: &str) -> Option<&PickerSourceSpec> {
319        self.sources.get(id).map(|e| &e.spec)
320    }
321
322    /// Borrow the full registry entry (metadata + generator
323    /// slot). Used by the dispatcher to fetch both pieces at
324    /// once during the migration window.
325    pub fn entry(&self, id: &str) -> Option<&RegistryEntry> {
326        self.sources.get(id)
327    }
328
329    /// Look up the registered generator for a source id.
330    /// `None` if the id isn't registered, or if it's a
331    /// metadata-only entry (slice 12 / pre-migration source).
332    pub fn generator(&self, id: &str) -> Option<&std::sync::Arc<dyn PickerSourceGenerator>> {
333        self.sources.get(id).and_then(|e| e.generator.as_ref())
334    }
335
336    /// Walk every registered source in id-sorted order.
337    /// Deterministic for tab-completion and `:describe-picker`
338    /// listings; tests can rely on the order.
339    pub fn iter(&self) -> impl Iterator<Item = (&str, &PickerSourceSpec)> + '_ {
340        // PL8.F: keys are `Cow` now — borrow each as `&str` (was `.copied()` on
341        // `&'static str` keys). `self.sources[id]` still indexes via
342        // `Cow: Borrow<str>`.
343        let mut ids: Vec<&str> = self.sources.keys().map(|k| k.as_ref()).collect();
344        ids.sort_unstable();
345        ids.into_iter().map(move |id| (id, &self.sources[id].spec))
346    }
347
348    pub fn ids(&self) -> impl Iterator<Item = &str> + '_ {
349        let mut ids: Vec<&str> = self.sources.keys().map(|k| k.as_ref()).collect();
350        ids.sort_unstable();
351        ids.into_iter()
352    }
353
354    pub fn len(&self) -> usize {
355        self.sources.len()
356    }
357
358    pub fn is_empty(&self) -> bool {
359        self.sources.is_empty()
360    }
361}
362
363/// Source-side fallible result. Errors are user-facing
364/// strings the host echoes verbatim. Wrapping in
365/// `Result<...,String>` rather than a typed error keeps the
366/// WIT mirror (Phase 7) trivial -- plugin-emitted errors
367/// cross the boundary as strings.
368pub type SourceResult<T> = Result<T, String>;
369
370/// One batch of `(candidate, routing payload)` pairs from a
371/// source. Identical shape across all three init flavors;
372/// the host's seat / append paths use it uniformly.
373pub type CandidateBatch = Vec<(RawCandidate, RoutingPayload)>;
374
375/// One-shot async future a source returns when the
376/// candidate set requires off-thread work (LSP request,
377/// large directory walk, etc.). `'static + Send` because
378/// the source extracted everything it needs from the
379/// context during the synchronous `init` call and moved it
380/// into the closure.
381pub type CandidateFuture = Pin<Box<dyn Future<Output = SourceResult<CandidateBatch>> + Send>>;
382
383/// Streaming source channel. Sources spawn a producer task
384/// during `init` and return the receiver; the host pumps
385/// each batch into the picker incrementally so the popup
386/// updates as results arrive (live-grep, future
387/// live-LSP-completion).
388pub type CandidateStream = mpsc::UnboundedReceiver<SourceResult<CandidateBatch>>;
389
390/// One-shot async future a source returns from [`accept_async`] when
391/// translating the chosen routing payload requires off-thread work — the
392/// motivating case is a WASM plugin source whose `accept` is an async guest
393/// call bound to its actor task (`lattice-plugin-host`). Same `'static + Send`
394/// contract as [`CandidateFuture`]: the source extracts everything it needs
395/// (the projected context, the routing token) during the synchronous
396/// `accept_async` prelude and moves it into the closure.
397///
398/// [`accept_async`]: PickerSourceGenerator::accept_async
399pub type AcceptFuture = Pin<Box<dyn Future<Output = SourceResult<PickerAcceptOutcome>> + Send>>;
400
401/// Three init shapes covering every Phase 4-8 picker
402/// pattern. The choice is per-invocation -- a single source
403/// can return different shapes depending on args (e.g. a
404/// future grep source could `Inline` an empty result on
405/// empty pattern, `Stream` otherwise).
406pub enum PickerInitResult {
407    /// Sync sources (files, recent, lines, marks, registers,
408    /// jumps, commands, snippets, tree-sitter outline).
409    Inline(CandidateBatch),
410    /// One-shot async (LSP references / definitions /
411    /// symbols / code actions / diagnostics snapshot).
412    Future(CandidateFuture),
413    /// Multi-batch streaming (live-grep subprocess, future
414    /// live LSP completion).
415    Stream(CandidateStream),
416}
417
418impl std::fmt::Debug for PickerInitResult {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        match self {
421            PickerInitResult::Inline(batch) => {
422                f.debug_struct("Inline").field("len", &batch.len()).finish()
423            }
424            PickerInitResult::Future(_) => f.debug_struct("Future").finish_non_exhaustive(),
425            PickerInitResult::Stream(_) => f.debug_struct("Stream").finish_non_exhaustive(),
426        }
427    }
428}
429
430/// Source generators implement this trait. Registered into
431/// [`PickerRegistry`] at boot; the `:picker <source>`
432/// dispatcher looks up by `spec().id` and calls `init` to
433/// obtain candidates, then `accept` to translate the user's
434/// chosen routing payload into a typed outcome.
435///
436/// **Lifetime story.** `init` and `accept` borrow `&self`
437/// (so the generator must be `Sync`) and the
438/// `PickerContext<'_>` for the duration of the synchronous
439/// call. The borrow is released the moment the method
440/// returns; any captured async work owns its own clones
441/// (extract URIs, positions, Arc handles, etc. into the
442/// closure during the sync prelude).
443///
444/// **Send + Sync.** The registry stores generators as
445/// `Arc<dyn PickerSourceGenerator>` and shares them across
446/// the App + tokio task threads. Both bounds are required.
447pub trait PickerSourceGenerator: Send + Sync {
448    /// Generator metadata. Returned by reference so the
449    /// registry can stamp it into `:describe-picker` /
450    /// `:picker <Tab>` listings without cloning.
451    fn spec(&self) -> &PickerSourceSpec;
452
453    /// Build the candidate set. Sync prelude: read what's
454    /// needed from `ctx`, clone into async captures if
455    /// necessary, return the appropriate `PickerInitResult`
456    /// variant. Synchronous errors (no active buffer when
457    /// one was required, args validation failure) return
458    /// `Err`; the host echoes the error and leaves the
459    /// picker closed.
460    ///
461    /// `args` is the tokens the user typed after the source
462    /// id (`:picker files /tmp/foo` -> `&["/tmp/foo"]`).
463    /// Sources interpret them against their declared
464    /// [`PickerSourceSpec::args_schema`]; the grammar layer
465    /// doesn't pre-validate because per-source arg shapes
466    /// vary too much.
467    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult>;
468
469    /// Translate the user's chosen routing payload into a
470    /// typed `PickerAcceptOutcome` the host applies. The
471    /// generator owns the mapping from its emitted
472    /// routing-payload variant(s) to outcome(s); mismatch
473    /// returns `Err`, which the host echoes.
474    fn accept(
475        &self,
476        ctx: &PickerContext<'_>,
477        routing: &RoutingPayload,
478    ) -> SourceResult<PickerAcceptOutcome>;
479
480    /// Async-accept hook. Default `None` means "this source resolves `accept`
481    /// synchronously" — every native source takes this path and is unchanged.
482    ///
483    /// A source whose accept translation requires off-thread work returns
484    /// `Some(future)`; the host spawns it (never blocking the actor thread) and
485    /// applies the resolved outcome via the pending-accept drain, exactly the
486    /// way [`init`](Self::init)'s [`PickerInitResult::Future`] is drained. The
487    /// motivating case is a WASM plugin source (`lattice-plugin-host`): its
488    /// guest `accept` export is async and bound to the plugin's actor task, so
489    /// there is no synchronous path from the keystroke to plugin code
490    /// (paramount #4) — a slow/hostile plugin accept can never freeze the UI.
491    ///
492    /// Like `init`, this is a **synchronous prelude**: read what's needed from
493    /// `ctx` + `routing`, project/clone into the returned `'static` future, and
494    /// release the borrows. A source that returns `Some` here still implements
495    /// the sync [`accept`](Self::accept) (the host prefers `accept_async` when
496    /// it returns `Some`, so that body is the fallback / tripwire only).
497    fn accept_async(
498        &self,
499        _ctx: &PickerContext<'_>,
500        _routing: &RoutingPayload,
501    ) -> Option<AcceptFuture> {
502        None
503    }
504
505    /// Live-source hook: re-fetch candidates when the user's
506    /// query changes. Default `None` means "static source --
507    /// host uses fuzzy refilter over the candidate set
508    /// returned by `init`". Returning `Some(result)` from a
509    /// source whose `spec().live == true` replaces the
510    /// picker's candidate set wholesale; the host wires up
511    /// debouncing and cancels any in-flight invocation when a
512    /// fresh query arrives.
513    ///
514    /// The host calls this only after the picker has been
515    /// seated by `init`. Sources that opt in MUST set
516    /// `spec().live = true`; the two declarations stay
517    /// paired because the picker decides whether to bypass
518    /// fuzzy-refilter purely from `spec().live`, while the
519    /// host decides whether to invoke this hook from the same
520    /// flag.
521    fn on_query_changed(
522        &self,
523        _ctx: &PickerContext<'_>,
524        _query: &str,
525    ) -> Option<SourceResult<PickerInitResult>> {
526        None
527    }
528
529    /// PC.10: `<C-l>` — refine the query from the selected candidate instead
530    /// of accepting it.
531    ///
532    /// For a source whose candidates are *containers* — a directory, and later
533    /// perhaps a namespace or a tree node — "go into this" and "choose this"
534    /// are different questions, and a picker with only `<CR>` can ask one of
535    /// them. Returning `Some(query)` replaces the picker's query and re-lists;
536    /// the picker stays open and nothing is accepted.
537    ///
538    /// **`None` is the default and means "this source has no notion of going
539    /// deeper"**, which is every source but `dir-pick` today. `<C-l>` in those
540    /// pickers does nothing at all rather than doing something approximate —
541    /// a key that means "descend" in one picker and "almost descend" in
542    /// another is worse than one that means nothing in the second.
543    ///
544    /// Only meaningful for a `live` source: a static source's candidate set
545    /// comes from `init` and is fuzzy-refiltered, so rewriting the query
546    /// filters the same rows rather than fetching new ones. The default
547    /// keeps every such source out of this path entirely.
548    fn descend(&self, _ctx: &PickerContext<'_>, _candidate: &RawCandidate) -> Option<String> {
549        None
550    }
551
552    /// PC.10: `<C-w>` (`<C-h>` until PH.1) — [`descend`](Self::descend)'s peer, one level out.
553    ///
554    /// Takes the QUERY rather than a candidate: going up is a statement about
555    /// where you are, and the row you happen to have selected has nothing to
556    /// do with it.
557    ///
558    /// **A hook rather than a generic query edit**, which is a deviation from
559    /// this slice's plan and the reason is `grep`. The plan reasoned that
560    /// "delete back through the previous `/`" needs no source involvement
561    /// because it is generic over any path-shaped query — true, but *live* is
562    /// not the same as *path-shaped*, and `grep` is live. A generic `<C-h>`
563    /// would silently truncate a grep pattern at a slash. Opting in keeps
564    /// `<C-h>` meaningless everywhere it has no meaning, which is the same
565    /// rule `descend` follows.
566    fn ascend(&self, _query: &str) -> Option<String> {
567        None
568    }
569
570    /// PP.3: `<CR>` on a row that is a SIGNPOST rather than a destination.
571    ///
572    /// Returning `Some(query)` means "this row is navigation": the host
573    /// replaces the query and re-lists, the picker stays open, and nothing is
574    /// accepted — no outcome resolved, no MRU recorded, no `PickerAccepted`
575    /// published, because none of that happened.
576    ///
577    /// **Distinct from [`descend`](Self::descend), which it resembles.**
578    /// `descend` answers for every row that *contains* things, and its key
579    /// (`<C-l>`) asks "go into the thing I have selected". This answers for
580    /// rows that are not things at all. `dir-pick`'s `../` is the whole
581    /// motivating case: every other row in that picker is a directory you
582    /// might be choosing, and `../` is a way out — so `<C-l>` answers for all
583    /// of them and this answers only for `../`.
584    ///
585    /// The default `None` keeps `<CR>` meaning "take this row" everywhere it
586    /// already does, which is every picker but that one.
587    ///
588    /// Only meaningful for a `live` source, for `descend`'s reason: a static
589    /// source's rows come from `init` and rewriting its query would fuzzy-
590    /// filter them rather than fetch new ones.
591    fn accept_navigates(
592        &self,
593        _ctx: &PickerContext<'_>,
594        _candidate: &RawCandidate,
595    ) -> Option<String> {
596        None
597    }
598
599    /// PP.1: the query the picker opens WITH.
600    ///
601    /// `None` — the default — leaves the host's rule in place: a live source's
602    /// first argument seeds the query (`:picker grep foo` opens on `foo`),
603    /// everything else opens empty.
604    ///
605    /// A source overrides this when its query is not a *filter* but a
606    /// *position*. `dir-pick`'s query is the directory being listed, so an
607    /// empty one means the prompt cannot say where you are while every row
608    /// can — and the first `<C-h>` has nothing to take a level off. Seeding it
609    /// makes the prompt read `dir-pick> ~/` and the ascend/descend keys work
610    /// from the first keystroke rather than the second.
611    ///
612    /// Only consulted for a `live` source, for [`descend`](Self::descend)'s
613    /// reason: a static source's rows come from `init` and a seeded query
614    /// would fuzzy-filter them instead of re-listing.
615    fn initial_query(&self, _args: &[String]) -> Option<String> {
616        None
617    }
618
619    /// T.12: live-preview hook — invoked as the picker SELECTION moves to
620    /// a candidate (before accept). Default None = no preview. A source
621    /// returns an outcome the host applies immediately for a live preview;
622    /// the host restores prior state on <Esc>.
623    ///
624    /// **This runs on the editor's actor thread, synchronously.** Read
625    /// what is already in hand and return; a source that must *do*
626    /// something to answer (spawn git, read a file, walk an index)
627    /// declares [`preview_debounce`](Self::preview_debounce) so the host
628    /// asks only once the selection has settled.
629    fn preview(
630        &self,
631        _ctx: &PickerContext<'_>,
632        _routing: &RoutingPayload,
633    ) -> Option<crate::outcome::PickerPreviewOutcome> {
634        None
635    }
636
637    /// MG.54: how long the selection must sit still before the host
638    /// calls [`preview`](Self::preview). `None` (the default) = call it
639    /// inline on every selection move, which is right for a source whose
640    /// preview is a cheap projection of data it already holds.
641    ///
642    /// **What this buys is not a faster call — it is no call at all.**
643    /// Arrowing through candidates restarts the timer; a source that
644    /// spawns a subprocess to answer therefore spawns ZERO of them while
645    /// the user is scrolling, and exactly one when they stop. That is
646    /// what makes a synchronous, subprocess-backed preview viable: there
647    /// is never an in-flight call to cancel and never a stale result to
648    /// race, because the only call ever made is for the candidate the
649    /// user is already sitting on.
650    ///
651    /// The residual cost is real and deliberate: a keystroke arriving
652    /// while the settled fetch is running waits for it. Bounded to one
653    /// fetch per settle, never a queue. A source declaring this owes its
654    /// user an option to turn the feature off, and a guard on how much
655    /// work the fetch can be.
656    fn preview_debounce(&self) -> Option<std::time::Duration> {
657        None
658    }
659
660    /// PH.1: the host-issued id of the plugin that contributed this source,
661    /// `None` for a native one.
662    ///
663    /// Exists for `<C-h>`: a plugin's help topics are namespaced
664    /// (`project.picker-projects`), so the host finds a plugin source's page
665    /// by asking for a topic ending `.picker-<id>` **registered by this same
666    /// plugin**. The ownership check is the point — a suffix match alone
667    /// would let any plugin answer `<C-h>` for another plugin's picker.
668    ///
669    /// This is the id of the plugin's PICKER seam; its help topics carry its
670    /// HELP seam's id. The host compares the plugins both resolve to
671    /// (`PluginMetaRegistry::primary_of`), never the raw ids.
672    fn owner_plugin(&self) -> Option<u64> {
673        None
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    #![allow(clippy::unwrap_used)]
680
681    use super::*;
682
683    fn spec(id: &'static str) -> PickerSourceSpec {
684        PickerSourceSpec::no_args(id, "test source")
685    }
686
687    #[test]
688    fn new_registry_is_empty() {
689        let reg = PickerRegistry::new();
690        assert!(reg.is_empty());
691        assert_eq!(reg.len(), 0);
692        assert!(reg.get("files").is_none());
693    }
694
695    #[test]
696    fn register_and_get_round_trip() {
697        let mut reg = PickerRegistry::new();
698        reg.register(spec("files"));
699        assert_eq!(reg.len(), 1);
700        let got = reg.get("files").unwrap();
701        assert_eq!(got.id, "files");
702        assert_eq!(got.doc, "test source");
703    }
704
705    #[test]
706    fn iter_yields_sources_in_id_order() {
707        let mut reg = PickerRegistry::new();
708        reg.register(spec("recent"));
709        reg.register(spec("buffers"));
710        reg.register(spec("files"));
711        reg.register(spec("lines"));
712        let ids: Vec<&str> = reg.iter().map(|(id, _)| id).collect();
713        assert_eq!(ids, vec!["buffers", "files", "lines", "recent"]);
714    }
715
716    #[test]
717    fn re_registering_same_id_overwrites_previous_entry() {
718        let mut reg = PickerRegistry::new();
719        reg.register(PickerSourceSpec::no_args("files", "first"));
720        reg.register(PickerSourceSpec::no_args("files", "second"));
721        assert_eq!(reg.len(), 1);
722        assert_eq!(reg.get("files").unwrap().doc, "second");
723    }
724
725    #[test]
726    fn ids_iterator_matches_iter_keys() {
727        let mut reg = PickerRegistry::new();
728        reg.register(spec("zeta"));
729        reg.register(spec("alpha"));
730        reg.register(spec("mu"));
731        let ids: Vec<&str> = reg.ids().collect();
732        assert_eq!(ids, vec!["alpha", "mu", "zeta"]);
733    }
734
735    #[test]
736    fn unregister_removes_only_the_named_source() {
737        let mut reg = PickerRegistry::new();
738        reg.register(spec("files"));
739        reg.register(spec("recent"));
740        assert_eq!(reg.len(), 2);
741
742        // Removes the named source, leaves the other; reports it was present.
743        assert!(reg.unregister("files"));
744        assert_eq!(reg.len(), 1);
745        assert!(reg.get("files").is_none());
746        assert!(reg.get("recent").is_some());
747
748        // Idempotent: a second unload (or an unknown id) removes nothing.
749        assert!(!reg.unregister("files"));
750        assert!(!reg.unregister("never-registered"));
751    }
752
753    /// Slice 13a: a no-op test generator that confirms the
754    /// trait is object-safe (storable as `Arc<dyn ...>`) and
755    /// the `init` / `accept` calling convention compiles. The
756    /// generator returns an empty inline batch and a NoOp
757    /// outcome; real sources land in slice 13c.
758    struct TestGenerator {
759        spec: PickerSourceSpec,
760    }
761
762    impl PickerSourceGenerator for TestGenerator {
763        fn spec(&self) -> &PickerSourceSpec {
764            &self.spec
765        }
766
767        fn init(
768            &self,
769            _ctx: &PickerContext<'_>,
770            _args: &[String],
771        ) -> SourceResult<PickerInitResult> {
772            Ok(PickerInitResult::Inline(Vec::new()))
773        }
774
775        fn accept(
776            &self,
777            _ctx: &PickerContext<'_>,
778            _routing: &RoutingPayload,
779        ) -> SourceResult<PickerAcceptOutcome> {
780            Ok(PickerAcceptOutcome::NoOp)
781        }
782    }
783
784    /// Trait-object usability check. If this compiles, the
785    /// trait is object-safe and the registry can hold it.
786    #[test]
787    fn picker_source_generator_is_object_safe() {
788        use std::sync::Arc;
789
790        let g: Arc<dyn PickerSourceGenerator> = Arc::new(TestGenerator {
791            spec: PickerSourceSpec::no_args("test", "test generator"),
792        });
793        assert_eq!(g.spec().id, "test");
794    }
795
796    /// `PickerInitResult` Debug doesn't leak the future /
797    /// stream internals -- guards against accidental
798    /// `Debug` bounds creeping in on `CandidateFuture`.
799    #[test]
800    fn picker_init_result_debug_is_terse() {
801        let inline: PickerInitResult = PickerInitResult::Inline(Vec::new());
802        let d = format!("{inline:?}");
803        assert!(d.contains("Inline"));
804        assert!(d.contains("len: 0"));
805    }
806
807    /// Stream + future variant smoke: confirm they at least
808    /// construct + Debug without panicking.
809    #[test]
810    fn picker_init_result_stream_and_future_construct() {
811        let (_tx, rx) = mpsc::unbounded_channel();
812        let s = PickerInitResult::Stream(rx);
813        assert!(format!("{s:?}").contains("Stream"));
814
815        let f: PickerInitResult =
816            PickerInitResult::Future(Box::pin(async { Ok::<CandidateBatch, String>(Vec::new()) }));
817        assert!(format!("{f:?}").contains("Future"));
818    }
819}