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}