Skip to main content

lattice_picker/
picker_sources.rs

1//! First-party picker source generators -- renderer-neutral; live
2//! in `lattice-picker` next to the `PickerSourceGenerator` trait
3//! and the `PickerRegistry` they register against. Symmetric with
4//! how feature crates already organise their sources
5//! (`lattice_snippet::picker_sources`, future
6//! `lattice_lsp::picker_sources`).
7//!
8//! Each source's state is reachable through `PickerContext` (the
9//! snapshot passed to `init` / `accept`) or via an `Arc`-cloned
10//! registry handle captured at construction (`CommandsSource` ->
11//! `CommandRegistry`, `GrepSource` -> `ConfigRegistry`). The trait
12//! surface stays state-handle-free.
13//!
14//! Slice 5.7.B.0 migrated this module out of `lattice-ui-tui`. The
15//! `walk_files_for_picker` helper (file-system walk for `:picker
16//! files`) lives here too -- it has no renderer dependency, and the
17//! only consumer today is `FilesSource`; the earlier ui-tui
18//! location was an accident of where the picker first landed.
19
20use std::sync::Arc;
21
22use lattice_completion::{
23    Annotation, AnnotationSegment, CandidateKind, KeybindingSource, KeymapReverseLookup,
24    RawCandidate,
25};
26use lattice_config::ConfigRegistry;
27use lattice_grammar::CommandRegistryHandle;
28use lattice_grammar::args::{ArgDefault, ArgSpec, Args};
29use lattice_grammar::command::{CommandKind, LatencyClass};
30use lattice_protocol::KeyChord;
31
32use crate::{
33    PickerAcceptOutcome, PickerContext, PickerInitResult, PickerSourceGenerator, PickerSourceSpec,
34    RoutingPayload, SourceResult,
35};
36
37/// Format an ex-command's `args_schema` as the marginalia
38/// args hint -- emacs-style `<arg>` for required, `[<arg>]`
39/// for optional. Empty for no-arg commands. Used by
40/// `:picker commands` to fill the args column.
41fn format_args_hint(schema: &[ArgSpec]) -> String {
42    schema
43        .iter()
44        .map(|arg| match arg.default {
45            ArgDefault::Required => format!("<{}>", arg.name),
46            _ => format!("[<{}>]", arg.name),
47        })
48        .collect::<Vec<_>>()
49        .join(" ")
50}
51
52/// Format unix mode bits like `ls -l` (`-rw-r--r--`,
53/// `drwxr-xr-x`, `lrwxrwxrwx`). On platforms without unix
54/// mode bits, falls back to a six-char `<file>` / `<ro>`
55/// marker so the column stays width-aligned.
56// MARG §8: theme slot keys for file-metadata marginalia segments.
57// Must match the elements registered in `lattice-theme` (MR.2).
58const SLOT_PERM_TYPE: &str = "completion.annotation.perm.type";
59const SLOT_PERM_READ: &str = "completion.annotation.perm.read";
60const SLOT_PERM_WRITE: &str = "completion.annotation.perm.write";
61const SLOT_PERM_EXEC: &str = "completion.annotation.perm.exec";
62const SLOT_PERM_SPECIAL: &str = "completion.annotation.perm.special";
63const SLOT_PERM_NONE: &str = "completion.annotation.perm.none";
64const SLOT_SIZE: &str = "completion.annotation.size";
65const SLOT_MTIME: &str = "completion.annotation.mtime";
66
67fn perm_seg(ch: char, slot: &str) -> AnnotationSegment {
68    AnnotationSegment {
69        text: ch.to_string().into(),
70        slot: slot.into(),
71    }
72}
73
74/// MARG §8: build the `drwxr-xr-x` permission string as one segment
75/// per bit class, each tagged with its theme slot (the eza / `ls
76/// --color` coloring). The bit→slot policy lives here, once; both
77/// renderers just resolve each segment's slot. Returns 10 segments on
78/// unix (type char + 9 perm bits, with setuid/setgid/sticky folded
79/// into the exec positions as s/S/t/T); a 4-char `<ro>`/`<rw>` label on
80/// other platforms.
81fn perm_segments(meta: &std::fs::Metadata) -> Vec<AnnotationSegment> {
82    #[cfg(unix)]
83    {
84        use std::os::unix::fs::{FileTypeExt, PermissionsExt};
85        let mode = meta.permissions().mode();
86        let ft = meta.file_type();
87        let kind = if ft.is_dir() {
88            'd'
89        } else if ft.is_symlink() {
90            'l'
91        } else if ft.is_block_device() {
92            'b'
93        } else if ft.is_char_device() {
94            'c'
95        } else if ft.is_fifo() {
96            'p'
97        } else if ft.is_socket() {
98            's'
99        } else {
100            '-'
101        };
102        let mut out = Vec::with_capacity(10);
103        out.push(perm_seg(kind, SLOT_PERM_TYPE));
104        let rbit = |out: &mut Vec<AnnotationSegment>, set: bool| {
105            out.push(if set {
106                perm_seg('r', SLOT_PERM_READ)
107            } else {
108                perm_seg('-', SLOT_PERM_NONE)
109            });
110        };
111        let wbit = |out: &mut Vec<AnnotationSegment>, set: bool| {
112            out.push(if set {
113                perm_seg('w', SLOT_PERM_WRITE)
114            } else {
115                perm_seg('-', SLOT_PERM_NONE)
116            });
117        };
118        // exec-or-special: a set special bit (setuid/setgid/sticky)
119        // shows `lower` when exec is also set, `upper` otherwise.
120        let xbit = |out: &mut Vec<AnnotationSegment>,
121                    exec: bool,
122                    special: bool,
123                    lower: char,
124                    upper: char| {
125            if special {
126                out.push(perm_seg(
127                    if exec { lower } else { upper },
128                    SLOT_PERM_SPECIAL,
129                ));
130            } else if exec {
131                out.push(perm_seg('x', SLOT_PERM_EXEC));
132            } else {
133                out.push(perm_seg('-', SLOT_PERM_NONE));
134            }
135        };
136        rbit(&mut out, mode & 0o400 != 0);
137        wbit(&mut out, mode & 0o200 != 0);
138        xbit(&mut out, mode & 0o100 != 0, mode & 0o4000 != 0, 's', 'S');
139        rbit(&mut out, mode & 0o040 != 0);
140        wbit(&mut out, mode & 0o020 != 0);
141        xbit(&mut out, mode & 0o010 != 0, mode & 0o2000 != 0, 's', 'S');
142        rbit(&mut out, mode & 0o004 != 0);
143        wbit(&mut out, mode & 0o002 != 0);
144        xbit(&mut out, mode & 0o001 != 0, mode & 0o1000 != 0, 't', 'T');
145        out
146    }
147    #[cfg(not(unix))]
148    {
149        let label = if meta.permissions().readonly() {
150            "<ro>"
151        } else {
152            "<rw>"
153        };
154        label.chars().map(|c| perm_seg(c, SLOT_PERM_TYPE)).collect()
155    }
156}
157
158/// MARG §8: the file-metadata marginalia for one entry — a per-bit
159/// `perm` cell, a `size` cell, and (when `mtime` is available) an
160/// `mtime` cell, each an `Annotation::Styled` the renderer color-codes
161/// from its theme slot. Column order is fixed by `category_order`
162/// (perm → size → mtime). Single home so the file/dir picker and its
163/// test agree on the exact annotation set.
164fn metadata_annotations(meta: &std::fs::Metadata) -> Vec<Annotation> {
165    let mut annotations = vec![
166        Annotation::Styled {
167            category: "perm".into(),
168            segments: perm_segments(meta),
169        },
170        Annotation::Styled {
171            category: "size".into(),
172            segments: vec![AnnotationSegment {
173                text: format_size(meta.len()).into(),
174                slot: SLOT_SIZE.into(),
175            }],
176        },
177    ];
178    if let Ok(mt) = meta.modified() {
179        annotations.push(Annotation::Styled {
180            category: "mtime".into(),
181            segments: vec![AnnotationSegment {
182                text: format_mtime_relative(mt).into(),
183                slot: SLOT_MTIME.into(),
184            }],
185        });
186    }
187    annotations
188}
189
190// MARG §9: theme slot keys for the picker-rollout marginalia families.
191// Must match the elements registered in `lattice-theme` (MP.1). The
192// bit→slot / class→slot policy lives here once; renderers stay dumb.
193const SLOT_LOC_PATH: &str = "completion.annotation.location.path";
194const SLOT_LOC_LINE: &str = "completion.annotation.location.line";
195const SLOT_LOC_COL: &str = "completion.annotation.location.col";
196const SLOT_STATUS_DIRTY: &str = "completion.annotation.status.dirty";
197const SLOT_STATUS_ACTIVE: &str = "completion.annotation.status.active";
198const SLOT_LATENCY_REFLEX: &str = "completion.annotation.latency.reflex";
199const SLOT_LATENCY_DISPLAY: &str = "completion.annotation.latency.display";
200const SLOT_LATENCY_BACKGROUND: &str = "completion.annotation.latency.background";
201
202/// MARG §9: a marginalia segment from string text + a slot key.
203fn txt_seg(text: impl Into<String>, slot: &str) -> AnnotationSegment {
204    AnnotationSegment {
205        text: text.into().into(),
206        slot: slot.into(),
207    }
208}
209
210/// MARG §9: a colored `path:line:col` location cell — dim path, accent
211/// line, dim column, with the `:` separators riding the dim slots. A
212/// `None` path yields `line[:col]` (line/outline pickers); a `None`
213/// column drops the trailing `:col` (line-only pickers). The policy
214/// lives here so grep / jumps / outline / lines / marks (and the future
215/// LSP locations picker) share one coloring. Substrate helper, not a
216/// `Document` trait method — only specific picker sources consume it.
217fn location_segments(path: Option<&str>, line: u32, col: Option<u32>) -> Vec<AnnotationSegment> {
218    let mut out = Vec::with_capacity(5);
219    if let Some(p) = path {
220        out.push(txt_seg(p, SLOT_LOC_PATH));
221        out.push(txt_seg(":", SLOT_LOC_PATH));
222    }
223    out.push(txt_seg(line.to_string(), SLOT_LOC_LINE));
224    if let Some(c) = col {
225        out.push(txt_seg(":", SLOT_LOC_COL));
226        out.push(txt_seg(c.to_string(), SLOT_LOC_COL));
227    }
228    out
229}
230
231/// MARG §9: a `location` marginalia cell (`Styled`) wrapping
232/// [`location_segments`]. The shared shape for every coordinate picker.
233fn location_annotation(path: Option<&str>, line: u32, col: Option<u32>) -> Annotation {
234    Annotation::Styled {
235        category: "location".into(),
236        segments: location_segments(path, line, col),
237    }
238}
239
240/// PH.2: clone the host-collected syntax-highlight spans for
241/// buffer `line`, clipped to `display_len` (the candidate's
242/// shown byte length — the line text with its trailing `\n`
243/// trimmed). Spans are already line-relative `DisplaySpan`s, so
244/// they map 1:1 onto a `:picker lines` row whose `display` *is*
245/// the line. Absent / out-of-range line → no spans (plain
246/// preview). A clip landing mid-codepoint is dropped by the
247/// renderer's char-boundary guard, never a panic.
248fn display_spans_for_line(
249    highlights: &[Vec<lattice_completion::DisplaySpan>],
250    line: u32,
251    display_len: usize,
252) -> Vec<lattice_completion::DisplaySpan> {
253    let Some(spans) = highlights.get(line as usize) else {
254        return Vec::new();
255    };
256    spans
257        .iter()
258        .filter(|s| s.range.start < display_len)
259        .map(|s| lattice_completion::DisplaySpan {
260            range: s.range.start..s.range.end.min(display_len),
261            style: s.style,
262        })
263        .collect()
264}
265
266/// PH.2: project a line's syntax spans onto a symbol name shown
267/// in `:picker outline`. The symbol `display` is the name alone
268/// — a substring of its line starting at byte `col` — so the
269/// line-relative spans are clipped to `[col, col + name_len)`
270/// and shifted to be name-relative. Non-overlapping spans drop;
271/// partial overlaps clip. Absent line / no overlap → no spans
272/// (plain preview).
273fn display_spans_for_symbol(
274    highlights: &[Vec<lattice_completion::DisplaySpan>],
275    line: u32,
276    col: u32,
277    name_len: usize,
278) -> Vec<lattice_completion::DisplaySpan> {
279    let Some(spans) = highlights.get(line as usize) else {
280        return Vec::new();
281    };
282    let col = col as usize;
283    let end_bound = col.saturating_add(name_len);
284    spans
285        .iter()
286        .filter_map(|s| {
287            let start = s.range.start.max(col);
288            let end = s.range.end.min(end_bound);
289            if start >= end {
290                return None;
291            }
292            Some(lattice_completion::DisplaySpan {
293                range: (start - col)..(end - col),
294                style: s.style,
295            })
296        })
297        .collect()
298}
299
300/// MARG §9: buffer status markers — an active `•` and/or a dirty `+`,
301/// each in its own slot. Empty when neither applies (clean, inactive).
302fn status_segments(dirty: bool, active: bool) -> Vec<AnnotationSegment> {
303    let mut out = Vec::with_capacity(2);
304    if active {
305        out.push(txt_seg("•", SLOT_STATUS_ACTIVE));
306    }
307    if dirty {
308        out.push(txt_seg("+", SLOT_STATUS_DIRTY));
309    }
310    out
311}
312
313/// MARG §9: a single latency-class marginalia segment, color-coded by
314/// the canonical `lattice_grammar` latency class (no duplicate enum).
315fn latency_segment(class: LatencyClass) -> AnnotationSegment {
316    let (text, slot) = match class {
317        LatencyClass::Reflex => ("[reflex]", SLOT_LATENCY_REFLEX),
318        LatencyClass::Display => ("[display]", SLOT_LATENCY_DISPLAY),
319        LatencyClass::Background => ("[background]", SLOT_LATENCY_BACKGROUND),
320    };
321    txt_seg(text, slot)
322}
323
324/// MARG §9: slot for the command argument-hint marginalia cell.
325const SLOT_ARGS: &str = "completion.annotation.args";
326
327/// MARG §9: slot for the buffer-id (`#N`) marginalia cell.
328const SLOT_BUFFER_ID: &str = "completion.annotation.buffer-id";
329
330/// MARG §9: slot for the register / mark name marginalia cell.
331const SLOT_REGISTER: &str = "completion.annotation.register";
332
333/// Format a byte size with a single-letter SI-ish suffix
334/// (`72` / `1.4K` / `70k` / `12M` / `4.2G`), matching the
335/// `ls -h` convention. Uses 1024-based units. Capped at 5
336/// chars so the size column has a stable width.
337fn format_size(bytes: u64) -> String {
338    const KB: u64 = 1024;
339    const MB: u64 = KB * 1024;
340    const GB: u64 = MB * 1024;
341    if bytes < KB {
342        format!("{bytes}")
343    } else if bytes < MB {
344        let k = bytes as f64 / KB as f64;
345        if k < 10.0 {
346            format!("{k:.1}K")
347        } else {
348            format!("{}K", bytes / KB)
349        }
350    } else if bytes < GB {
351        let m = bytes as f64 / MB as f64;
352        if m < 10.0 {
353            format!("{m:.1}M")
354        } else {
355            format!("{}M", bytes / MB)
356        }
357    } else {
358        let g = bytes as f64 / GB as f64;
359        if g < 10.0 {
360            format!("{g:.1}G")
361        } else {
362            format!("{}G", bytes / GB)
363        }
364    }
365}
366
367/// Format a `SystemTime` as a relative-to-now phrase
368/// (`28 hours ago`, `3 days ago`, `just now`). Stable
369/// across reasonable clock skew (negative durations clamp
370/// to "just now" rather than producing nonsense). Returns
371/// a fixed-format string so columns align.
372fn format_mtime_relative(mtime: std::time::SystemTime) -> String {
373    let now = std::time::SystemTime::now();
374    let secs = match now.duration_since(mtime) {
375        Ok(d) => d.as_secs(),
376        Err(_) => return "just now".to_string(),
377    };
378    if secs < 60 {
379        "just now".to_string()
380    } else if secs < 60 * 60 {
381        let m = secs / 60;
382        if m == 1 {
383            "1 minute ago".to_string()
384        } else {
385            format!("{m} minutes ago")
386        }
387    } else if secs < 60 * 60 * 36 {
388        // Hours up to 36h, matching moment.js / emacs
389        // marginalia convention (so a file edited yesterday
390        // afternoon reads "28 hours ago" instead of
391        // jumping to "1 day ago" at the 24h boundary).
392        let h = secs / (60 * 60);
393        if h == 1 {
394            "1 hour ago".to_string()
395        } else {
396            format!("{h} hours ago")
397        }
398    } else if secs < 60 * 60 * 24 * 30 {
399        let d = secs / (60 * 60 * 24);
400        if d == 1 {
401            "1 day ago".to_string()
402        } else {
403            format!("{d} days ago")
404        }
405    } else if secs < 60 * 60 * 24 * 365 {
406        let mo = secs / (60 * 60 * 24 * 30);
407        if mo == 1 {
408            "1 month ago".to_string()
409        } else {
410            format!("{mo} months ago")
411        }
412    } else {
413        let y = secs / (60 * 60 * 24 * 365);
414        if y == 1 {
415            "1 year ago".to_string()
416        } else {
417            format!("{y} years ago")
418        }
419    }
420}
421
422/// `:picker files [root]`. Walks `root` (or the workspace
423/// root from the context) and emits one row per regular file
424/// under the standard ignore set (`.git`, `target`,
425/// `node_modules`, `dist`, `.cache`). Capped at
426/// `FILE_PICKER_MAX_ENTRIES` (5000) -- larger workspaces fall
427/// back to `:picker grep`.
428/// The root a rooted picker walks: the caller's explicit argument when there
429/// is one, else the resolved workspace root.
430///
431/// `~` expands, as it does everywhere else a user writes a path. Without it
432/// `:picker files ~/notes` walked a directory literally named `~`, found
433/// nothing, and reported "no files under ~/notes" — a message that blames the
434/// directory for being empty rather than the path for never having resolved.
435///
436/// A relative argument is left relative: `canonicalize` resolves it against
437/// the process cwd, which is what a relative path typed at a picker prompt
438/// already meant, and this is a bug fix rather than a change of meaning.
439fn explicit_root_or(args: &[String], workspace_root: &std::path::Path) -> std::path::PathBuf {
440    match args.first() {
441        Some(p) if !p.is_empty() => std::path::PathBuf::from(lattice_core::home::expand_tilde(p)),
442        _ => workspace_root.to_path_buf(),
443    }
444}
445
446pub struct FilesSource {
447    pub spec: PickerSourceSpec,
448}
449
450impl FilesSource {
451    pub fn new() -> Self {
452        use lattice_grammar::args::{ArgDefault, ArgKind, ArgSpec};
453        Self {
454            spec: PickerSourceSpec {
455                create_label: None,
456                delete_command: None,
457                help_topic: None,
458                id: "files".into(),
459                // PP.2: the list IS the project. `:files` in one checkout and
460                // `:files` in another answer entirely differently, and nothing
461                // else on screen says which one answered.
462                rooted: true,
463                doc: "File picker rooted at the active buffer's PROJECT (recursive). Pass an explicit path to override.".into(),
464                args_hint: "[root]".into(),
465                args_schema: vec![ArgSpec {
466                    name: "root".into(),
467                    kind: ArgKind::String,
468                    doc: "Directory to walk recursively. Absent = the active buffer's project root.".into(),
469                    prompt: "root:".into(),
470                    default: ArgDefault::None,
471                    completion: Some("gen:files".into()),
472                    picker: None,
473                }],
474                live: false,
475            },
476        }
477    }
478}
479
480impl Default for FilesSource {
481    fn default() -> Self {
482        Self::new()
483    }
484}
485
486impl PickerSourceGenerator for FilesSource {
487    fn spec(&self) -> &PickerSourceSpec {
488        &self.spec
489    }
490
491    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
492        // PR.4: the active buffer's PROJECT root, resolved by the host
493        // (`picker_workspace_root_path`).
494        //
495        // The two answers this replaces were both wrong in opposite
496        // directions, and the history is worth keeping because the
497        // pendulum swung once already: an early slice used the active
498        // document's parent, which "behaved unintuitively for projects
499        // spread across many subdirectories"; the fix was the process
500        // cwd, which is right only if you launched the editor in the
501        // tree you are editing. The project root is the answer both
502        // were reaching for.
503        //
504        // An explicit `:picker files <path>` still wins — that is the
505        // user saying "not that project, this one".
506        let root = explicit_root_or(args, &ctx.workspace_root);
507        let canonical_root = std::fs::canonicalize(&root).unwrap_or(root.clone());
508        // Slice C: serve the warmed session cache instantly when present. A
509        // stale entry (older than the TTL) is still served immediately, with a
510        // background re-walk kicked off so the NEXT open reflects on-disk
511        // changes — the open never blocks on the walk once warmed. A cold cache
512        // walks now and populates it. All of this is off the UI thread (init
513        // runs on a picker worker); the refresh thread keeps it that way.
514        let entries = match cached_files(&canonical_root) {
515            Some((cached, stale)) => {
516                if stale {
517                    let refresh_root = canonical_root.clone();
518                    std::thread::spawn(move || warm_files_cache(&refresh_root));
519                }
520                cached
521            }
522            None => {
523                let walked = walk_files_for_picker(&canonical_root);
524                if let Ok(mut cache) = file_walk_cache().lock() {
525                    cache.insert(
526                        canonical_root.clone(),
527                        (walked.clone(), std::time::Instant::now()),
528                    );
529                }
530                walked
531            }
532        };
533        if entries.is_empty() {
534            return Err(format!(
535                "files: no files under {}",
536                canonical_root.display()
537            ));
538        }
539        // MARG §8: the marginalia (perms / size / mtime) is attached as typed
540        // `Annotation::Styled` cells — the renderer color-codes each per its
541        // theme slot (per-bit permission colors, gold size, green mtime). The
542        // metadata was gathered by `walk_files_for_picker` DURING the parallel
543        // walk (reusing the walk's own stat), so there is no separate ≤5000-stat
544        // pass here — that eager pass was the bulk of the first-open latency.
545        // The candidate `display` is just the path (so fuzzy matching runs on
546        // the path, not the metadata text); column alignment comes from
547        // `AnnotationColumns`. A file whose metadata could not be read carries
548        // no annotations → blank cells, the path still shows.
549        let pairs = entries
550            .into_iter()
551            .map(|(abs, meta)| {
552                let rel = abs
553                    .strip_prefix(&canonical_root)
554                    .map(|p| p.to_path_buf())
555                    .unwrap_or_else(|_| abs.clone());
556                let rel_display = rel.display().to_string();
557                let annotations = meta.as_ref().map(metadata_annotations).unwrap_or_default();
558                let mut cand = RawCandidate::plain(rel_display, CandidateKind::Plain);
559                cand.annotations = annotations;
560                // Slice 7b.2: typed accept payload.
561                cand.accept_action = Some(Box::new(lattice_completion::AcceptAction::OpenFile {
562                    path: abs.clone(),
563                }));
564                (cand, RoutingPayload::OpenFile { path: abs })
565            })
566            .collect();
567        Ok(PickerInitResult::Inline(pairs))
568    }
569
570    fn accept(
571        &self,
572        _ctx: &PickerContext<'_>,
573        routing: &RoutingPayload,
574    ) -> SourceResult<PickerAcceptOutcome> {
575        match routing {
576            RoutingPayload::OpenFile { path } => {
577                Ok(PickerAcceptOutcome::OpenFile { path: path.clone() })
578            }
579            other => Err(format!("files: unexpected routing payload {other:?}")),
580        }
581    }
582}
583
584/// `:picker file-pick [root]`. MG.53.e — the same walk as
585/// [`FilesSource`], accepting to a **value** instead of to an open
586/// buffer.
587///
588/// The two differ only in what accept means, and that difference is the
589/// whole reason this exists: `FilesSource` hands its path to `do_edit`,
590/// i.e. it opens the file, where a caller asking "which file?" needs the
591/// path itself. magit's `File (repo-relative):` argument was a free-text
592/// prompt because of that one mismatch — the listing was always
593/// reusable, the accept never was.
594///
595/// The path is emitted **relative to the walk root**, because the
596/// consumers are git commands and git addresses files repo-relatively.
597/// An absolute path would work by luck for the common case (the root is
598/// the repo) and break the moment it is not.
599///
600/// Registered in the host rather than in `lattice-magit` so every
601/// provider wanting "choose a file, then act" reaches it through the
602/// same `PickerSourceSpec` surface, including WASM ones. A magit-local
603/// copy would have put a second consumer of the repo file walk inside a
604/// feature crate and bought nothing but a smaller diff.
605pub struct FilePickSource {
606    pub spec: PickerSourceSpec,
607}
608
609/// The source id, shared by the generator and every declaration that
610/// names it — one constant so a rename cannot leave a transient
611/// pointing at a source that no longer exists.
612pub const FILE_PICK_SOURCE: &str = "file-pick";
613
614impl FilePickSource {
615    pub fn new() -> Self {
616        use lattice_grammar::args::{ArgDefault, ArgKind, ArgSpec};
617        Self {
618            spec: PickerSourceSpec {
619                create_label: None,
620                delete_command: None,
621                help_topic: None,
622                id: FILE_PICK_SOURCE.into(),
623                // Same walk as `files`, so the same root and the same reason.
624                rooted: true,
625                doc: "Pick a file and supply its path as a value (for a transient argument or \
626                      other caller awaiting one). Lists the same files as `files`; differs only \
627                      in that accepting yields the path rather than opening it."
628                    .into(),
629                args_hint: "[root]".into(),
630                args_schema: vec![ArgSpec {
631                    name: "root".into(),
632                    kind: ArgKind::String,
633                    doc: "Directory to walk recursively. Absent = the active buffer's \
634                          project root. Picked paths are relative to it."
635                        .into(),
636                    prompt: "root:".into(),
637                    default: ArgDefault::None,
638                    completion: Some("gen:files".into()),
639                    picker: None,
640                }],
641                live: false,
642            },
643        }
644    }
645}
646
647impl Default for FilePickSource {
648    fn default() -> Self {
649        Self::new()
650    }
651}
652
653/// PC.9: `dir-pick` — [`FilePickSource`]'s directory peer. Browse to a
654/// directory and supply its path as a value.
655///
656/// ## Incremental, not a walk
657///
658/// The candidates are the children of the directory the query names, filtered
659/// by the basename it ends with — `gen:directories`' model, which is what
660/// emacs's `read-directory-name` does. It shares the implementation with that
661/// generator ([`lattice_completion::builtins::generators::path_entries`])
662/// rather than copying it, so `<Tab>` on the `:` line and this picker cannot
663/// disagree about what listing a path means.
664///
665/// [`walk_files_for_picker`] with directories instead of files was the obvious
666/// alternative and is the wrong one here: it has no depth cap and a flat
667/// [`FILE_PICKER_MAX_ENTRIES`] ceiling, so pointed anywhere near a home
668/// directory it stops somewhere arbitrary and the directory you wanted may
669/// simply not be in the list. Incremental has no ceiling, reaches any depth,
670/// and opens in one `read_dir`.
671///
672/// ## It starts at HOME, where `file-pick` starts at the workspace root
673///
674/// Not an inconsistency. Picking a *file* is nearly always picking one in the
675/// project you are in, so the workspace root is the useful default. Picking a
676/// *directory* is nearly always about going somewhere you are **not** — the
677/// motivating case is choosing a project you have never opened — and rooting
678/// that at the project you are already in would make the common case start in
679/// the one place it does not want.
680///
681/// An explicit `start` argument still wins, and `:picker dir-pick .` is the
682/// spelling for "here".
683///
684/// ## Tilde survives into the rows on purpose
685///
686/// A row's `text` keeps whatever spelling the query used (`~/src/…`), because
687/// that is what the user is reading and typing against, and descending
688/// re-lists from it unchanged. The value handed back on accept is the
689/// **expanded** absolute path off `CandidateData::File`, because a consumer
690/// resolving it has no obligation to know about `~`.
691pub struct DirPickSource {
692    pub spec: PickerSourceSpec,
693}
694
695/// The source id, shared by the generator and every declaration that names it.
696pub const DIR_PICK_SOURCE: &str = "dir-pick";
697
698/// What PP.1's go-up row displays — and, since PP.3, how it is RECOGNISED.
699///
700/// One constant rather than two literals: `accept_navigates` decides from the
701/// display, so a row that rendered `..` while the hook looked for `../` would
702/// be a `<CR>` that silently went back to choosing the parent.
703pub const PARENT_ROW_DISPLAY: &str = "../";
704
705impl DirPickSource {
706    pub fn new() -> Self {
707        use lattice_grammar::args::{ArgDefault, ArgKind, ArgSpec};
708        Self {
709            spec: PickerSourceSpec {
710                create_label: None,
711                delete_command: None,
712                help_topic: None,
713                id: DIR_PICK_SOURCE.into(),
714                // PP.2: NOT rooted, despite being the most path-shaped source
715                // there is. Its query is the directory it is listing, so the
716                // prompt already says where it is — a root beside that would
717                // be a second answer to the same question, and a staler one
718                // (it would name where browsing STARTED, not where you are).
719                rooted: false,
720                doc: "Browse to a directory and supply its path as a value (for a transient \
721                      argument, a command argument, or other caller awaiting one). Lists one \
722                      level at a time: `<Tab>` (or `<C-l>`) descends into the selected \
723                      directory, `<C-w>` goes up, `<CR>` chooses — except on the `../` row, \
724                      where it goes up."
725                    .into(),
726                args_hint: "[start]".into(),
727                args_schema: vec![ArgSpec {
728                    name: "start".into(),
729                    kind: ArgKind::String,
730                    doc: "Directory to start browsing from. Absent = the home directory, \
731                          because choosing a directory is usually about going somewhere you \
732                          are not."
733                        .into(),
734                    prompt: "start:".into(),
735                    default: ArgDefault::None,
736                    completion: Some("gen:directories".into()),
737                    picker: None,
738                }],
739                // The source owns its filtering: the query is a PATH, and fuzzy
740                // matching a path prefix against bare child names would rank
741                // `~/src/dh` against `dhruvasagar` rather than listing what is
742                // under `~/src/`.
743                live: true,
744            },
745        }
746    }
747
748    /// The prefix `path_entries` should list for `query`.
749    ///
750    /// An empty query means "show me `start`", and it is spelled as a prefix
751    /// ending in `/` so the rows come back carrying their full path rather
752    /// than bare names — which is what makes the first `<C-l>` work like every
753    /// later one.
754    fn prefix_for(start: &str, query: &str) -> String {
755        if query.is_empty() {
756            let trimmed = start.trim_end_matches('/');
757            format!("{trimmed}/")
758        } else {
759            query.to_string()
760        }
761    }
762
763    /// The directory one level above `prefix`, spelled the way the query
764    /// spells it.
765    ///
766    /// Shared by `<C-w>` and the `../` row so the two cannot disagree about
767    /// where "up" is — two ways to go up that arrive somewhere different is
768    /// the kind of inconsistency nobody reports and everybody trips on.
769    ///
770    /// The trailing `/` comes off first, or `~/src/` would resolve its own
771    /// last component and go nowhere.
772    fn parent_of(prefix: &str) -> Option<String> {
773        let trimmed = prefix.strip_suffix('/').unwrap_or(prefix);
774        if trimmed.is_empty() {
775            // `/`. There is nothing above the root, and pretending otherwise
776            // would silently relocate the user somewhere they did not ask for.
777            return None;
778        }
779        match trimmed.rfind('/') {
780            // `/tmp` → `/`, keeping the separator that makes it a listing.
781            Some(0) => Some("/".to_string()),
782            Some(i) => Some(trimmed[..=i].to_string()),
783            // No separator left in the spelling: `~`, the one case where the
784            // query's own text cannot name its parent. Resolve it and answer
785            // absolutely, rather than reporting that the home directory has no
786            // parent — ascend (then on `<C-h>`) at `~/` used to clear the query, which re-listed
787            // `~/` and so read as a key that did nothing.
788            //
789            // A bare word (a query the user typed over) is not a path we can
790            // resolve, and guessing at one would move them somewhere arbitrary.
791            None => {
792                let absolute = lattice_core::home::expand_tilde(trimmed);
793                let path = std::path::Path::new(&absolute);
794                if !path.is_absolute() {
795                    return None;
796                }
797                path.parent().map(|p| {
798                    let s = p.to_string_lossy();
799                    if s.ends_with('/') {
800                        s.into_owned()
801                    } else {
802                        format!("{s}/")
803                    }
804                })
805            }
806        }
807    }
808
809    /// PP.1: the `../` row.
810    ///
811    /// An ORDINARY row whose text is the parent's path, which is what makes it
812    /// need no special-casing anywhere else: `<C-l>` descends into it because
813    /// the text ends in `/`, and `<CR>` supplies the parent because that is
814    /// what every other row does with its own path. A synthetic "go up" row
815    /// with its own accept semantics would be a second answer to a question
816    /// `descend` already answers.
817    ///
818    /// **Only when `prefix` names a whole directory** (it ends in `/`). Once
819    /// the user has typed a basename the listing is a filter over children,
820    /// and a `../` surviving the filter would be the one row in it that is not
821    /// a match.
822    fn parent_row(prefix: &str) -> Option<(RawCandidate, RoutingPayload)> {
823        if !prefix.ends_with('/') {
824            return None;
825        }
826        let parent = Self::parent_of(prefix)?;
827        let expanded = std::path::PathBuf::from(lattice_core::home::expand_tilde(&parent));
828        Some((
829            RawCandidate {
830                insert_text: None,
831                text: parent,
832                // `../`, not the path it resolves to. The path is already in
833                // the prompt (the query); what this row adds is the verb — and
834                // since PP.3 the display is also how `accept_navigates`
835                // recognises the row, hence the constant.
836                display: PARENT_ROW_DISPLAY.to_string(),
837                // Built the way `path_entries` builds a directory — same kind,
838                // same `CandidateData::File`, same empty annotations — because
839                // everything downstream (the icon, `descend`, the accept) reads
840                // those and must not be able to tell this row apart.
841                kind: CandidateKind::Directory,
842                data: lattice_completion::CandidateData::File {
843                    path: expanded.clone(),
844                    is_dir: true,
845                    size: None,
846                },
847                source: None,
848                accept_action: None,
849                annotations: Vec::new(),
850                display_spans: Vec::new(),
851            },
852            RoutingPayload::SuppliedValue {
853                value: expanded.to_string_lossy().to_string(),
854            },
855        ))
856    }
857
858    /// Rows for `prefix`. Directories only, each carrying its expanded
859    /// absolute path as the value it supplies, `../` first.
860    ///
861    /// **`../` belongs to a directory that exists.** A query naming nothing
862    /// yields an empty list — this source's contract, and the reason it does
863    /// not spend its life reporting failure while you type a path — and a
864    /// lone `../` there would suggest the path resolved when it did not. The
865    /// `is_dir` stat is paid only when the listing came back empty, which is
866    /// the one case where "no children" and "no directory" are not the same
867    /// thing.
868    fn rows(prefix: &str) -> Vec<(RawCandidate, RoutingPayload)> {
869        let children = Self::child_rows(prefix);
870        let parent = if children.is_empty()
871            && !std::path::Path::new(&lattice_core::home::expand_tilde(prefix)).is_dir()
872        {
873            None
874        } else {
875            Self::parent_row(prefix)
876        };
877        parent.into_iter().chain(children).collect()
878    }
879
880    /// The real entries — everything [`rows`](Self::rows) lists apart from
881    /// `../`. Split out because `init`'s "cannot read this directory" check
882    /// asks whether the listing is empty, and a `../` row is present whether
883    /// or not the directory can be read.
884    fn child_rows(prefix: &str) -> Vec<(RawCandidate, RoutingPayload)> {
885        lattice_completion::builtins::generators::path_entries(prefix, false, false)
886            .into_iter()
887            .map(|cand| {
888                // The expanded path off the entry, not `cand.text` — the text
889                // may be spelled with `~` and a consumer resolving it should
890                // not have to know that.
891                let value = match &cand.data {
892                    lattice_completion::CandidateData::File { path, .. } => {
893                        path.to_string_lossy().to_string()
894                    }
895                    // `path_entries` only ever emits `File`; if that changes,
896                    // the row's own text is the honest fallback rather than a
897                    // panic on a picker keystroke.
898                    _ => cand.text.clone(),
899                };
900                (cand, RoutingPayload::SuppliedValue { value })
901            })
902            .collect()
903    }
904
905    /// Where browsing begins: the explicit argument, else home.
906    fn start_dir(args: &[String]) -> String {
907        match args.first() {
908            Some(p) if !p.is_empty() => p.clone(),
909            _ => "~".to_string(),
910        }
911    }
912}
913
914impl Default for DirPickSource {
915    fn default() -> Self {
916        Self::new()
917    }
918}
919
920impl PickerSourceGenerator for DirPickSource {
921    fn spec(&self) -> &PickerSourceSpec {
922        &self.spec
923    }
924
925    fn init(&self, _ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
926        let start = Self::start_dir(args);
927        let prefix = Self::prefix_for(&start, "");
928        // An unreadable start IS an error, unlike an unreadable query: the
929        // caller named this one, and opening an empty picker over a directory
930        // that does not exist would report nothing at all.
931        //
932        // Asked of the CHILDREN, not of `rows`: PP.1's `../` is present
933        // whether or not the directory can be read, so `rows` is never empty
934        // and this check would never fire again.
935        if Self::child_rows(&prefix).is_empty()
936            && !std::path::Path::new(&lattice_core::home::expand_tilde(&start)).is_dir()
937        {
938            return Err(format!("{DIR_PICK_SOURCE}: cannot read {start}"));
939        }
940        Ok(PickerInitResult::Inline(Self::rows(&prefix)))
941    }
942
943    /// Re-list on every keystroke. An unreadable query yields an EMPTY list,
944    /// not an error: half a typed path names nothing yet, and that is the
945    /// state the user is in for most of the keystrokes — erroring on it would
946    /// mean the picker spends its life reporting failure.
947    fn on_query_changed(
948        &self,
949        _ctx: &PickerContext<'_>,
950        query: &str,
951    ) -> Option<SourceResult<PickerInitResult>> {
952        // No args here — a live source is a shared generator with no per-open
953        // state, so `start` is unavailable once the query is non-empty. It
954        // does not need to be: a non-empty query is itself an absolute or
955        // tilde-spelled path, because that is what the rows carry.
956        let prefix = if query.is_empty() {
957            Self::prefix_for("~", "")
958        } else {
959            query.to_string()
960        };
961        Some(Ok(PickerInitResult::Inline(Self::rows(&prefix))))
962    }
963
964    /// `<C-l>`: the selected row's own text becomes the query, so the next
965    /// listing is of its children. It already ends in `/` — `path_entries`
966    /// puts one on every directory — which is exactly the prefix that lists a
967    /// directory's contents rather than its siblings.
968    fn descend(&self, _ctx: &PickerContext<'_>, candidate: &RawCandidate) -> Option<String> {
969        candidate
970            .text
971            .ends_with('/')
972            .then(|| candidate.text.clone())
973    }
974
975    /// PP.3: `<CR>` on `../` GOES UP. It does not choose the parent.
976    ///
977    /// PP.1 shipped the other reading — `../` is an ordinary row, so `<CR>`
978    /// supplies its path like every other row does — and it was wrong in the
979    /// way that only shows up in use. `../` reads as a verb, every file
980    /// browser there is (netrw, oil, ranger, lf, telescope-file-browser)
981    /// treats `<CR>` on `..` as "go up", and the UX-convention rule says
982    /// muscle memory wins on a surface like this one.
983    ///
984    /// What it looked like in practice: `<CR>` on `../` at `~/` supplied
985    /// `/Users`, which the project flow then refused — an error message where
986    /// the user had asked to go up a level.
987    ///
988    /// Only this row. Every other row in this picker is a directory you might
989    /// be choosing, and `<CR>` still chooses it.
990    fn accept_navigates(
991        &self,
992        _ctx: &PickerContext<'_>,
993        candidate: &RawCandidate,
994    ) -> Option<String> {
995        (candidate.display == PARENT_ROW_DISPLAY).then(|| candidate.text.clone())
996    }
997
998    /// `<C-w>`: drop the last path component.
999    ///
1000    /// [`parent_of`](Self::parent_of) does the work, shared with the `../`
1001    /// row so the key and the row cannot land in different places. `/` stays
1002    /// a fixed point — `parent_of` answers `None` there, and this returns the
1003    /// query unchanged so the host recognises it and spends no re-query.
1004    fn ascend(&self, query: &str) -> Option<String> {
1005        if query == "/" {
1006            return Some("/".to_string());
1007        }
1008        Self::parent_of(query)
1009    }
1010
1011    /// PP.1: open on the start directory rather than on an empty query.
1012    ///
1013    /// The query IS the directory being listed here, so an empty one leaves
1014    /// the prompt unable to say where you are — every row carries a path and
1015    /// the one line meant to orient you carries nothing. It also left ascend
1016    /// with no last component to drop, so the first press did nothing and the
1017    /// second worked.
1018    ///
1019    /// The trailing `/` is what makes it a LISTING rather than a filter:
1020    /// `path_entries("~/src")` lists `~`'s children whose names start with
1021    /// `src`, where `path_entries("~/src/")` lists what is inside. Seeding
1022    /// the un-slashed form is the bug this normalisation exists to prevent,
1023    /// and `:picker dir-pick /tmp` walked straight into it.
1024    fn initial_query(&self, args: &[String]) -> Option<String> {
1025        Some(Self::prefix_for(&Self::start_dir(args), ""))
1026    }
1027
1028    fn accept(
1029        &self,
1030        _ctx: &PickerContext<'_>,
1031        routing: &RoutingPayload,
1032    ) -> SourceResult<PickerAcceptOutcome> {
1033        match routing {
1034            RoutingPayload::SuppliedValue { value } => Ok(PickerAcceptOutcome::FillCaller {
1035                text: value.clone(),
1036            }),
1037            other => Err(format!(
1038                "{DIR_PICK_SOURCE}: unexpected routing payload {other:?}"
1039            )),
1040        }
1041    }
1042}
1043
1044impl PickerSourceGenerator for FilePickSource {
1045    fn spec(&self) -> &PickerSourceSpec {
1046        &self.spec
1047    }
1048
1049    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
1050        // PR.4: as above — the resolved project root, with an explicit
1051        // argument still winning.
1052        let root = explicit_root_or(args, &ctx.workspace_root);
1053        let canonical_root = std::fs::canonicalize(&root).unwrap_or(root.clone());
1054        let entries = walk_files_for_picker(&canonical_root);
1055        if entries.is_empty() {
1056            return Err(format!(
1057                "{FILE_PICK_SOURCE}: no files under {}",
1058                canonical_root.display()
1059            ));
1060        }
1061        let pairs = entries
1062            .into_iter()
1063            .map(|(abs, _meta)| {
1064                let rel = abs
1065                    .strip_prefix(&canonical_root)
1066                    .map(|p| p.to_path_buf())
1067                    .unwrap_or_else(|_| abs.clone());
1068                let rel_display = rel.display().to_string();
1069                // No `accept_action`: this source supplies a value, and
1070                // an `AcceptAction::OpenFile` here would let the
1071                // completion layer open the file behind the caller's
1072                // back — the exact confusion this source exists to
1073                // avoid.
1074                let cand = RawCandidate::plain(rel_display.clone(), CandidateKind::Plain);
1075                (cand, RoutingPayload::SuppliedValue { value: rel_display })
1076            })
1077            .collect();
1078        Ok(PickerInitResult::Inline(pairs))
1079    }
1080
1081    fn accept(
1082        &self,
1083        _ctx: &PickerContext<'_>,
1084        routing: &RoutingPayload,
1085    ) -> SourceResult<PickerAcceptOutcome> {
1086        match routing {
1087            RoutingPayload::SuppliedValue { value } => Ok(PickerAcceptOutcome::FillCaller {
1088                text: value.clone(),
1089            }),
1090            other => Err(format!(
1091                "{FILE_PICK_SOURCE}: unexpected routing payload {other:?}"
1092            )),
1093        }
1094    }
1095}
1096
1097/// `:picker yank-ring`. YR.4 — the yank ring and the live named
1098/// registers, in one list.
1099///
1100/// Both are "text you already copied", and which of the two a given
1101/// piece of text is in is an implementation detail of how you copied it.
1102/// Splitting them across two pickers would make the user answer that
1103/// question before they can look.
1104///
1105/// Accept returns the text through [`PickerAcceptOutcome::FillCaller`],
1106/// so where it lands is whatever opened the picker — the document, the
1107/// `:` line, a prompt, a transient argument, another picker's query. The
1108/// source does not know and must not decide.
1109pub struct YankRingSource {
1110    pub spec: PickerSourceSpec,
1111}
1112
1113pub const YANK_RING_SOURCE: &str = "yank-ring";
1114
1115impl YankRingSource {
1116    pub fn new() -> Self {
1117        Self {
1118            spec: PickerSourceSpec::no_args(
1119                YANK_RING_SOURCE,
1120                "Yank ring and named registers — pick previously copied text and \
1121                 insert it wherever the picker was opened from.",
1122            ),
1123        }
1124    }
1125}
1126
1127impl Default for YankRingSource {
1128    fn default() -> Self {
1129        Self::new()
1130    }
1131}
1132
1133impl PickerSourceGenerator for YankRingSource {
1134    fn spec(&self) -> &PickerSourceSpec {
1135        &self.spec
1136    }
1137
1138    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1139        let mut pairs: Vec<(RawCandidate, RoutingPayload)> = Vec::new();
1140
1141        // Ring first, newest first: the thing you just copied is the
1142        // thing you are most likely reaching for.
1143        for (i, (content, linewise)) in ctx.yank_ring.iter().enumerate() {
1144            let mut cand = RawCandidate::plain(one_line_preview(content), CandidateKind::Plain);
1145            cand.annotations = vec![
1146                // Position is the address you would have used: the newest
1147                // entry is what `"0` will name once YR.2 lands.
1148                Annotation::Styled {
1149                    category: "register".into(),
1150                    segments: vec![txt_seg(format!("{i}"), SLOT_REGISTER)],
1151                },
1152                // Kind is not decoration. A linewise entry pastes on its
1153                // own line and a charwise one pastes inline, so hiding it
1154                // makes paste unpredictable at the exact moment the user
1155                // is choosing between two rows that look alike.
1156                Annotation::Styled {
1157                    category: "yank-kind".into(),
1158                    segments: vec![txt_seg(
1159                        if *linewise { "line" } else { "char" }.to_string(),
1160                        SLOT_REGISTER,
1161                    )],
1162                },
1163            ];
1164            pairs.push((
1165                cand,
1166                RoutingPayload::SuppliedValue {
1167                    value: content.clone(),
1168                },
1169            ));
1170        }
1171
1172        // Then the named registers, which are addressed rather than
1173        // recent. `ctx.registers` carries previews rather than full
1174        // content, so these rows can only offer what the preview holds —
1175        // noted here because it is a real limit, not an oversight: the
1176        // register's full text is re-read host-side by the paste path,
1177        // which this accept deliberately does not use.
1178        for (name, preview) in &ctx.registers {
1179            let mut cand = RawCandidate::plain(one_line_preview(preview), CandidateKind::Plain);
1180            cand.annotations = vec![Annotation::Styled {
1181                category: "register".into(),
1182                segments: vec![txt_seg(format!("\"{name}"), SLOT_REGISTER)],
1183            }];
1184            pairs.push((
1185                cand,
1186                RoutingPayload::SuppliedValue {
1187                    value: preview.clone(),
1188                },
1189            ));
1190        }
1191
1192        if pairs.is_empty() {
1193            return Err("yank-ring: nothing has been yanked or deleted yet".into());
1194        }
1195        Ok(PickerInitResult::Inline(pairs))
1196    }
1197
1198    fn accept(
1199        &self,
1200        _ctx: &PickerContext<'_>,
1201        routing: &RoutingPayload,
1202    ) -> SourceResult<PickerAcceptOutcome> {
1203        match routing {
1204            RoutingPayload::SuppliedValue { value } => Ok(PickerAcceptOutcome::FillCaller {
1205                text: value.clone(),
1206            }),
1207            other => Err(format!(
1208                "{YANK_RING_SOURCE}: unexpected routing payload {other:?}"
1209            )),
1210        }
1211    }
1212}
1213
1214/// Collapse an entry to one matchable, renderable line.
1215///
1216/// A yank is frequently multi-line, and a picker row is one line — so
1217/// without this the list renders broken and the fuzzy matcher scores
1218/// against embedded newlines. The full text is still what accept
1219/// returns; only the display is folded.
1220fn one_line_preview(text: &str) -> String {
1221    let flat: String = text
1222        .lines()
1223        .map(str::trim_end)
1224        .filter(|l| !l.is_empty())
1225        .collect::<Vec<_>>()
1226        .join(" ⏎ ");
1227    if flat.chars().count() > 120 {
1228        let head: String = flat.chars().take(117).collect();
1229        format!("{head}...")
1230    } else if flat.is_empty() {
1231        // Whitespace-only yanks are real and worth being able to pick
1232        // back; an empty row would be indistinguishable from a bug.
1233        format!("<{} blank chars>", text.chars().count())
1234    } else {
1235        flat
1236    }
1237}
1238
1239/// `:picker recent`. Walks `ctx.recent_files` (MRU, newest
1240/// first) and emits one row per path. Empty MRU returns
1241/// `Err("no recent files")` which the host echoes.
1242pub struct RecentFilesSource {
1243    pub spec: PickerSourceSpec,
1244}
1245
1246impl RecentFilesSource {
1247    pub fn new() -> Self {
1248        Self {
1249            spec: PickerSourceSpec::no_args(
1250                "recent",
1251                "Recently-edited files (MRU). Walks `App.recent_files`; accept edits the chosen path.",
1252            ),
1253        }
1254    }
1255}
1256
1257impl Default for RecentFilesSource {
1258    fn default() -> Self {
1259        Self::new()
1260    }
1261}
1262
1263impl PickerSourceGenerator for RecentFilesSource {
1264    fn spec(&self) -> &PickerSourceSpec {
1265        &self.spec
1266    }
1267
1268    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1269        if ctx.recent_files.is_empty() {
1270            return Err("no recent files".into());
1271        }
1272        let pairs = ctx
1273            .recent_files
1274            .iter()
1275            .map(|p| {
1276                let display = p.display().to_string();
1277                let mut cand = RawCandidate::plain(display, CandidateKind::Plain);
1278                // MP.3: same eza-style perm/size/mtime marginalia as the
1279                // file picker. A path that fails to stat (since-deleted MRU
1280                // entry) emits no metadata cells → blank, no error.
1281                if let Ok(meta) = std::fs::metadata(p) {
1282                    cand.annotations = metadata_annotations(&meta);
1283                }
1284                // Slice 7b.2: typed accept payload.
1285                cand.accept_action = Some(Box::new(lattice_completion::AcceptAction::OpenFile {
1286                    path: p.clone(),
1287                }));
1288                (cand, RoutingPayload::OpenFile { path: p.clone() })
1289            })
1290            .collect();
1291        Ok(PickerInitResult::Inline(pairs))
1292    }
1293
1294    fn accept(
1295        &self,
1296        _ctx: &PickerContext<'_>,
1297        routing: &RoutingPayload,
1298    ) -> SourceResult<PickerAcceptOutcome> {
1299        match routing {
1300            RoutingPayload::OpenFile { path } => {
1301                Ok(PickerAcceptOutcome::OpenFile { path: path.clone() })
1302            }
1303            other => Err(format!("recent: unexpected routing payload {other:?}")),
1304        }
1305    }
1306}
1307
1308/// `:picker buffers`. Walks `ctx.buffers` and emits one row
1309/// per registered buffer, with `(current)` marginalia on the
1310/// active one. Active buffer floats to the bottom of the
1311/// list so the alternate-buffer convention (`<C-^>`-style)
1312/// keeps working: the initial selection lands on the
1313/// alternate, not on the buffer the user already sees.
1314pub struct BuffersSource {
1315    pub spec: PickerSourceSpec,
1316}
1317
1318impl BuffersSource {
1319    pub fn new() -> Self {
1320        Self {
1321            spec: PickerSourceSpec::no_args(
1322                "buffers",
1323                "Live buffer switcher. Walks every entry in BufferRegistry; accept activates the chosen buffer.",
1324            ),
1325        }
1326    }
1327}
1328
1329impl Default for BuffersSource {
1330    fn default() -> Self {
1331        Self::new()
1332    }
1333}
1334
1335impl PickerSourceGenerator for BuffersSource {
1336    fn spec(&self) -> &PickerSourceSpec {
1337        &self.spec
1338    }
1339
1340    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1341        let active = ctx.active_buffer.buffer_id;
1342        // Float the active buffer to the bottom of the list
1343        // so the initial selection lands on the alternate.
1344        let mut entries: Vec<&crate::BufferEntry> = ctx.buffers.iter().collect();
1345        entries.sort_by_key(|e| (e.id == active, e.id));
1346        let pairs = entries
1347            .into_iter()
1348            .map(|e| {
1349                let path_display = e
1350                    .path
1351                    .as_ref()
1352                    .map(|p| p.display().to_string())
1353                    .unwrap_or_else(|| e.title.clone());
1354                // MP.3: the path is the matchable `display`; buffer-id,
1355                // dirty/active status, and kind become typed marginalia
1356                // (no inline `#id`/`[+]`/`(current)` markers). Column order
1357                // is fixed by `category_order` (kind → status → buffer-id).
1358                let mut cand = RawCandidate::plain(path_display, CandidateKind::Buffer);
1359                let mut annotations = vec![
1360                    Annotation::Kind(e.kind_label.clone().into()),
1361                    Annotation::Styled {
1362                        category: "buffer-id".into(),
1363                        segments: vec![txt_seg(format!("#{}", e.id), SLOT_BUFFER_ID)],
1364                    },
1365                ];
1366                let status = status_segments(e.dirty, e.id == active);
1367                if !status.is_empty() {
1368                    annotations.push(Annotation::Styled {
1369                        category: "status".into(),
1370                        segments: status,
1371                    });
1372                }
1373                cand.annotations = annotations;
1374                // Slice 7b.1: typed accept payload on the
1375                // candidate. Parallel to the existing
1376                // RoutingPayload (still emitted for the picker's
1377                // routing_meta lookup) — slice 7d's registry
1378                // cutover drops the parallel routing vec once
1379                // the host routes accept through
1380                // DefaultAcceptHandler.
1381                cand.accept_action =
1382                    Some(Box::new(lattice_completion::AcceptAction::SwitchBuffer {
1383                        id: lattice_core::BufferId(e.id),
1384                    }));
1385                (cand, RoutingPayload::Buffer { id: e.id })
1386            })
1387            .collect();
1388        Ok(PickerInitResult::Inline(pairs))
1389    }
1390
1391    fn accept(
1392        &self,
1393        _ctx: &PickerContext<'_>,
1394        routing: &RoutingPayload,
1395    ) -> SourceResult<PickerAcceptOutcome> {
1396        match routing {
1397            RoutingPayload::Buffer { id } => {
1398                Ok(PickerAcceptOutcome::SwitchBuffer { buffer_id: *id })
1399            }
1400            other => Err(format!("buffers: unexpected routing payload {other:?}")),
1401        }
1402    }
1403}
1404
1405/// `:picker lines`. Walks the active buffer's rope and emits
1406/// one row per logical line, displayed as `<lineno>: <text>`.
1407/// Accept jumps the cursor to that line via
1408/// `RoutingPayload::JumpInBuffer`. The buffer_id is captured
1409/// at picker-open so a sibling hover-preview can't accidentally
1410/// redirect the jump.
1411pub struct LinesSource {
1412    pub spec: PickerSourceSpec,
1413}
1414
1415impl LinesSource {
1416    pub fn new() -> Self {
1417        Self {
1418            spec: PickerSourceSpec::no_args(
1419                "lines",
1420                "Active buffer's lines. Type to filter; `<CR>` jumps to that line.",
1421            ),
1422        }
1423    }
1424}
1425
1426impl Default for LinesSource {
1427    fn default() -> Self {
1428        Self::new()
1429    }
1430}
1431
1432impl PickerSourceGenerator for LinesSource {
1433    fn spec(&self) -> &PickerSourceSpec {
1434        &self.spec
1435    }
1436
1437    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1438        let buffer = ctx.active_buffer.buffer;
1439        let buffer_id = ctx.active_buffer.buffer_id;
1440        // CV.3: content space. This used to hand-roll the
1441        // trailing-empty-line correction inline — the accessor is that
1442        // correction, named.
1443        let line_count = buffer.content_line_count();
1444        if line_count == 0 {
1445            return Err("lines: empty buffer".into());
1446        }
1447        let last = line_count - 1;
1448        let mut pairs = Vec::with_capacity(last as usize + 1);
1449        for line in 0..=last {
1450            let text = buffer.line(line).unwrap_or_default();
1451            let text = text.trim_end_matches('\n');
1452            // MP.4: the line text is the matchable `display` (and the
1453            // future PH.2 syntax-highlight target); the line number moves
1454            // to a `location` marginalia cell.
1455            let mut cand = RawCandidate::plain(text.to_string(), CandidateKind::Plain);
1456            cand.annotations = vec![location_annotation(None, line + 1, None)];
1457            // PH.2: syntax-color the line preview. The host pre-collected
1458            // per-line spans (line-relative byte offsets); the line text
1459            // *is* the `display`, so the spans map 1:1. Clip to the
1460            // trimmed display length (the trailing `\n` was stripped);
1461            // the renderer additionally guards char boundaries. No spans
1462            // for this line → plain preview.
1463            cand.display_spans = display_spans_for_line(
1464                &ctx.active_buffer.syntax_highlights,
1465                line,
1466                cand.display.len(),
1467            );
1468            // Slice 7b.4: typed accept payload.
1469            cand.accept_action = Some(Box::new(lattice_completion::AcceptAction::JumpInBuffer {
1470                buffer_id: lattice_core::BufferId(buffer_id),
1471                line,
1472                col: 0,
1473            }));
1474            pairs.push((
1475                cand,
1476                RoutingPayload::JumpInBuffer {
1477                    buffer_id,
1478                    line,
1479                    col: 0,
1480                },
1481            ));
1482        }
1483        Ok(PickerInitResult::Inline(pairs))
1484    }
1485
1486    fn accept(
1487        &self,
1488        _ctx: &PickerContext<'_>,
1489        routing: &RoutingPayload,
1490    ) -> SourceResult<PickerAcceptOutcome> {
1491        match routing {
1492            RoutingPayload::JumpInBuffer {
1493                buffer_id,
1494                line,
1495                col,
1496            } => Ok(PickerAcceptOutcome::JumpInBuffer {
1497                buffer_id: *buffer_id,
1498                line: *line,
1499                col: *col,
1500            }),
1501            other => Err(format!("lines: unexpected routing payload {other:?}")),
1502        }
1503    }
1504}
1505
1506/// `:picker jumps`. Walks `ctx.position_history` (unified
1507/// jump-list + mark-ring per §5.1.1) and emits one row per
1508/// entry, newest first. Accept emits `JumpInBuffer` so the
1509/// host's apply translator handles "activate buffer +
1510/// position cursor" uniformly. MRU is correctly absent for
1511/// these rows -- `routing_identity` returns `None` for
1512/// `JumpInBuffer` because coordinates drift.
1513pub struct JumpsSource {
1514    pub spec: PickerSourceSpec,
1515}
1516
1517impl JumpsSource {
1518    pub fn new() -> Self {
1519        Self {
1520            spec: PickerSourceSpec::no_args(
1521                "jumps",
1522                "Position-history ring (unified jump list + mark ring). Newest first; `<CR>` jumps to that entry.",
1523            ),
1524        }
1525    }
1526}
1527
1528impl Default for JumpsSource {
1529    fn default() -> Self {
1530        Self::new()
1531    }
1532}
1533
1534impl PickerSourceGenerator for JumpsSource {
1535    fn spec(&self) -> &PickerSourceSpec {
1536        &self.spec
1537    }
1538
1539    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1540        if ctx.position_history.is_empty() {
1541            return Err("jumps: position history is empty".into());
1542        }
1543        // Walk newest-first. The ring stores oldest-first
1544        // (push appends to the end) so reverse iteration is
1545        // the user-facing default.
1546        let pairs = ctx
1547            .position_history
1548            .iter()
1549            .rev()
1550            .map(|entry| {
1551                let source_tag = match entry.source {
1552                    crate::PositionSource::AutoJump => "auto".to_string(),
1553                    crate::PositionSource::ExplicitMark => "mark".to_string(),
1554                    crate::PositionSource::PluginPush => "plugin".to_string(),
1555                    crate::PositionSource::NamedMark(c) => format!("'{c}"),
1556                };
1557                // Resolve buffer_id to a display label via the
1558                // buffers snapshot; fall back to the raw id when
1559                // the buffer is no longer in the registry.
1560                let buf_label = ctx
1561                    .buffers
1562                    .iter()
1563                    .find(|b| b.id == entry.buffer_id)
1564                    .map(|b| {
1565                        b.path
1566                            .as_ref()
1567                            .map(|p| p.display().to_string())
1568                            .unwrap_or_else(|| b.title.clone())
1569                    })
1570                    .unwrap_or_else(|| format!("#{}", entry.buffer_id));
1571                // MP.4: buffer label is the matchable `display`; the
1572                // provenance tag becomes a `Source` cell and the
1573                // coordinates a `location` cell (line:col, no path — the
1574                // path/label is already the display).
1575                let mut cand = RawCandidate::plain(buf_label, CandidateKind::Plain);
1576                cand.annotations = vec![
1577                    Annotation::Source(source_tag.into()),
1578                    location_annotation(None, entry.line + 1, Some(entry.col + 1)),
1579                ];
1580                // Slice 7b.4: typed accept payload.
1581                cand.accept_action =
1582                    Some(Box::new(lattice_completion::AcceptAction::JumpInBuffer {
1583                        buffer_id: lattice_core::BufferId(entry.buffer_id),
1584                        line: entry.line,
1585                        col: entry.col,
1586                    }));
1587                (
1588                    cand,
1589                    RoutingPayload::JumpInBuffer {
1590                        buffer_id: entry.buffer_id,
1591                        line: entry.line,
1592                        col: entry.col,
1593                    },
1594                )
1595            })
1596            .collect();
1597        Ok(PickerInitResult::Inline(pairs))
1598    }
1599
1600    fn accept(
1601        &self,
1602        _ctx: &PickerContext<'_>,
1603        routing: &RoutingPayload,
1604    ) -> SourceResult<PickerAcceptOutcome> {
1605        match routing {
1606            RoutingPayload::JumpInBuffer {
1607                buffer_id,
1608                line,
1609                col,
1610            } => Ok(PickerAcceptOutcome::JumpInBuffer {
1611                buffer_id: *buffer_id,
1612                line: *line,
1613                col: *col,
1614            }),
1615            other => Err(format!("jumps: unexpected routing payload {other:?}")),
1616        }
1617    }
1618}
1619
1620/// `:picker commands`. Walks the App's `CommandRegistry`
1621/// and emits one row per registered ex-command (motions,
1622/// operators, etc. are not user-invocable through this
1623/// surface and stay out). Captures an `Arc<CommandRegistry>`
1624/// at construction time -- the registry doesn't live on
1625/// `PickerContext` because it's static App-wide state, not
1626/// per-invocation snapshot data.
1627pub struct CommandsSource {
1628    pub spec: PickerSourceSpec,
1629    /// B3b: the `ArcSwap` handle (not a boot snapshot) so the palette
1630    /// enumerates commands a plugin registered at runtime — `init`
1631    /// `.load()`s it per open, mirroring the `reverse` cache below.
1632    pub registry: CommandRegistryHandle,
1633    /// MP.2b: name → first-bound-chord reverse lookup. Captured
1634    /// at construction like `registry` (both are static
1635    /// App-wide facades, not per-open snapshot state — see the
1636    /// `PickerContext` module doc on why feature facades live
1637    /// here, not on the context). Each call reads the keymap's
1638    /// live `ArcSwap` reverse cache, so a `:map` / `:unmap`
1639    /// between picker opens is reflected without rebuilding the
1640    /// source.
1641    pub reverse: Arc<dyn KeymapReverseLookup>,
1642}
1643
1644impl CommandsSource {
1645    pub fn new(registry: CommandRegistryHandle, reverse: Arc<dyn KeymapReverseLookup>) -> Self {
1646        Self {
1647            spec: PickerSourceSpec::no_args(
1648                "commands",
1649                "Ex-command palette. Walks the CommandRegistry; `<CR>` invokes the chosen command.",
1650            ),
1651            registry,
1652            reverse,
1653        }
1654    }
1655}
1656
1657impl PickerSourceGenerator for CommandsSource {
1658    fn spec(&self) -> &PickerSourceSpec {
1659        &self.spec
1660    }
1661
1662    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1663        // Walk registry names, keep ex-commands, project to a
1664        // row carrying every marginalia column. Emacs
1665        // `marginalia.el`-style: name | args-hint | doc |
1666        // latency-tag, all right-padded to align across rows.
1667        // Mode-toggle ex-commands like `buffer-words-mode`
1668        // register without the `ex:` prefix; the projection
1669        // handles both.
1670        struct Row {
1671            user_facing: String,
1672            canonical: String,
1673            args_hint: String,
1674            doc: String,
1675            latency: LatencyClass,
1676        }
1677        // B3b: wait-free snapshot for this open; a runtime-registered
1678        // plugin command is enumerated on the next palette open.
1679        let registry = self.registry.load();
1680        let mut rows: Vec<Row> = registry
1681            .names()
1682            .filter_map(|canonical| {
1683                let spec = registry.lookup_by_name(canonical)?;
1684                if !matches!(spec.kind, CommandKind::ExCommand) {
1685                    return None;
1686                }
1687                let user_facing = canonical
1688                    .strip_prefix("ex:")
1689                    .unwrap_or(canonical)
1690                    .to_string();
1691                let args_hint = format_args_hint(&spec.args_schema);
1692                let one_line_doc: String = spec
1693                    .doc
1694                    .lines()
1695                    .next()
1696                    .unwrap_or("")
1697                    .chars()
1698                    .take(80)
1699                    .collect();
1700                Some(Row {
1701                    user_facing,
1702                    canonical: canonical.to_string(),
1703                    args_hint,
1704                    doc: one_line_doc,
1705                    latency: spec.latency_class,
1706                })
1707            })
1708            .collect();
1709        // Sort by user-facing name so the popup matches the
1710        // alphabetic order users see.
1711        rows.sort_by(|a, b| a.user_facing.cmp(&b.user_facing));
1712        if rows.is_empty() {
1713            return Err("commands: no ex-commands registered".into());
1714        }
1715        // MP.2: the command name is the matchable `display`; args-hint,
1716        // doc, and latency become typed marginalia (`AnnotationColumns`
1717        // owns alignment — no hand-padding). Column order is fixed by
1718        // `category_order` (args → doc → latency).
1719        let pairs = rows
1720            .into_iter()
1721            .map(|row| {
1722                let mut cand = RawCandidate::plain(row.user_facing.clone(), CandidateKind::Plain);
1723                let mut annotations: Vec<Annotation> = Vec::with_capacity(4);
1724                // MP.2b: the keybinding column. The reverse cache
1725                // stores one binding's chord *sequence* per command
1726                // (first-binding-wins), so a non-empty result is the
1727                // chord to surface — `marginalia.md` §6. Commands with
1728                // no Normal-mode chord push nothing (blank cell, no
1729                // zero-width span). Rendered leftmost regardless of
1730                // push order (category rank 0).
1731                //
1732                // MARG.3 (2026-07-15): use `chords_with_source` to
1733                // get mode provenance. Chords whose source
1734                // minor/major mode is not currently active are
1735                // filtered out. When a chord comes from an active
1736                // mode, a `Source` annotation carries the mode name.
1737                let chords_with_source = self.reverse.chords_with_source(&row.canonical);
1738                let visible_chords: Vec<KeyChord> = chords_with_source
1739                    .iter()
1740                    .filter(|(_, source)| match source {
1741                        KeybindingSource::AlwaysOn => true,
1742                        KeybindingSource::Mode(mode_name) => ctx.active_modes.contains(mode_name),
1743                    })
1744                    .map(|(chord, _)| *chord)
1745                    .collect();
1746                if !visible_chords.is_empty() {
1747                    annotations.push(Annotation::Keybinding(visible_chords));
1748                    // Show the source mode label for the first
1749                    // active chord. Builtin/User/Buffer chords
1750                    // omit the source column (it's implied).
1751                    let source_label = chords_with_source
1752                        .iter()
1753                        .find(|(_, source)| match source {
1754                            KeybindingSource::Mode(name) => ctx.active_modes.contains(name),
1755                            _ => false,
1756                        })
1757                        .map(|(_, source)| match source {
1758                            KeybindingSource::Mode(name) => name.clone(),
1759                            _ => unreachable!(),
1760                        });
1761                    if let Some(label) = source_label {
1762                        annotations.push(Annotation::Source(label));
1763                    }
1764                }
1765                if !row.args_hint.is_empty() {
1766                    annotations.push(Annotation::Styled {
1767                        category: "args".into(),
1768                        segments: vec![txt_seg(row.args_hint, SLOT_ARGS)],
1769                    });
1770                }
1771                if !row.doc.is_empty() {
1772                    annotations.push(Annotation::DocSnippet(row.doc.into()));
1773                }
1774                annotations.push(Annotation::Styled {
1775                    category: "latency".into(),
1776                    segments: vec![latency_segment(row.latency)],
1777                });
1778                cand.annotations = annotations;
1779                // Slice 7b.3: typed accept payload.
1780                cand.accept_action =
1781                    Some(Box::new(lattice_completion::AcceptAction::InvokeCommand {
1782                        id: row.canonical.clone(),
1783                        args: Args::None,
1784                    }));
1785                (
1786                    cand,
1787                    RoutingPayload::InvokeCommand {
1788                        id: row.canonical,
1789                        args: Args::None,
1790                    },
1791                )
1792            })
1793            .collect();
1794        Ok(PickerInitResult::Inline(pairs))
1795    }
1796
1797    fn accept(
1798        &self,
1799        _ctx: &PickerContext<'_>,
1800        routing: &RoutingPayload,
1801    ) -> SourceResult<PickerAcceptOutcome> {
1802        match routing {
1803            RoutingPayload::InvokeCommand { id, args } => Ok(PickerAcceptOutcome::InvokeCommand {
1804                id: id.clone(),
1805                args: args.clone(),
1806            }),
1807            other => Err(format!("commands: unexpected routing payload {other:?}")),
1808        }
1809    }
1810}
1811
1812/// `:picker history` — also reached via the `q:` Normal chord and
1813/// the `:history` ex-command (MB.3). Walks `ctx.command_history`
1814/// (the App's command-line history ring, stored oldest-first) and
1815/// emits one row per past command, **newest first**. `<CR>` loads
1816/// the chosen command into the editable `:` line via
1817/// [`RoutingPayload::LoadCommandLine`] — it does **not** execute;
1818/// the user tweaks (or `<C-x><C-e>` expands) then `<CR>`s. The
1819/// modern replacement for vim's command-line *window*: fuzzy-filter
1820/// past commands instead of scrolling a scratch buffer
1821/// (`docs/dev/architecture/rich-minibuffer.md` §4).
1822///
1823/// The history ring already collapses *consecutive* duplicates at
1824/// push time, so no dedup here; non-adjacent repeats (`:w` … `:w`)
1825/// stay as distinct rows, matching vim's `:history`.
1826pub struct CommandHistorySource {
1827    pub spec: PickerSourceSpec,
1828}
1829
1830impl CommandHistorySource {
1831    pub fn new() -> Self {
1832        Self {
1833            spec: PickerSourceSpec::no_args(
1834                "history",
1835                "Command-line history. `<CR>` loads the chosen command into the `:` line (does not execute).",
1836            ),
1837        }
1838    }
1839}
1840
1841impl Default for CommandHistorySource {
1842    fn default() -> Self {
1843        Self::new()
1844    }
1845}
1846
1847impl PickerSourceGenerator for CommandHistorySource {
1848    fn spec(&self) -> &PickerSourceSpec {
1849        &self.spec
1850    }
1851
1852    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1853        if ctx.command_history.is_empty() {
1854            return Err("history: no command-line history yet".into());
1855        }
1856        // Newest-first: the ring is stored oldest-first, so walk it
1857        // reversed. Empty-query order is insertion order, which after
1858        // the reverse floats the most-recent command to the top.
1859        let pairs = ctx
1860            .command_history
1861            .iter()
1862            .rev()
1863            .map(|entry| {
1864                let cand = RawCandidate::plain(entry.clone(), CandidateKind::Plain);
1865                (
1866                    cand,
1867                    RoutingPayload::LoadCommandLine {
1868                        text: entry.clone(),
1869                    },
1870                )
1871            })
1872            .collect();
1873        Ok(PickerInitResult::Inline(pairs))
1874    }
1875
1876    fn accept(
1877        &self,
1878        _ctx: &PickerContext<'_>,
1879        routing: &RoutingPayload,
1880    ) -> SourceResult<PickerAcceptOutcome> {
1881        match routing {
1882            RoutingPayload::LoadCommandLine { text } => {
1883                Ok(PickerAcceptOutcome::LoadCommandLine { text: text.clone() })
1884            }
1885            other => Err(format!("history: unexpected routing payload {other:?}")),
1886        }
1887    }
1888}
1889
1890/// PBH.5: `:picker pane-buffer-history` — also reached via
1891/// `:history pane-buffers`. Walks the ACTIVE pane's buffer trail
1892/// (`ctx.pane_buffer_history`, oldest-first as stored) and emits one
1893/// row per stop, **newest first**, marking the entry the walk cursor
1894/// currently sits on.
1895///
1896/// `<CR>` **moves the walk cursor** to the chosen stop rather than
1897/// recording a new visit — the picker is random access over the trail
1898/// that `<C-6>` / `<C-7>` step through, not a fresh navigation. Pushing
1899/// instead would append a duplicate and make forward unreachable,
1900/// exactly as an unsuppressed walk would.
1901///
1902/// Rows route by trail **index**, not buffer id: the same buffer can
1903/// appear at several stops, and picking the third must land on the
1904/// third.
1905pub struct PaneBufferHistorySource {
1906    pub spec: PickerSourceSpec,
1907}
1908
1909impl PaneBufferHistorySource {
1910    pub fn new() -> Self {
1911        Self {
1912            spec: PickerSourceSpec::no_args(
1913                "pane-buffer-history",
1914                "This pane's buffer history. `<CR>` walks to the chosen entry (does not record a new visit).",
1915            ),
1916        }
1917    }
1918}
1919
1920impl Default for PaneBufferHistorySource {
1921    fn default() -> Self {
1922        Self::new()
1923    }
1924}
1925
1926impl PickerSourceGenerator for PaneBufferHistorySource {
1927    fn spec(&self) -> &PickerSourceSpec {
1928        &self.spec
1929    }
1930
1931    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1932        if ctx.pane_buffer_history.is_empty() {
1933            return Err("pane-buffers: this pane has no buffer history yet".into());
1934        }
1935        // Newest-first: the trail is stored oldest-first, so walk it
1936        // reversed. Matches the command/search history sources, and puts
1937        // the stop you most recently left on top.
1938        let pairs = ctx
1939            .pane_buffer_history
1940            .iter()
1941            .rev()
1942            .map(|row| {
1943                let marker = if row.is_current { "*" } else { " " };
1944                let text = format!("{marker} {}:{}", row.label, row.line);
1945                let cand = RawCandidate::plain(text, CandidateKind::Buffer);
1946                (cand, RoutingPayload::PaneHistoryEntry { index: row.index })
1947            })
1948            .collect();
1949        Ok(PickerInitResult::Inline(pairs))
1950    }
1951
1952    fn accept(
1953        &self,
1954        _ctx: &PickerContext<'_>,
1955        routing: &RoutingPayload,
1956    ) -> SourceResult<PickerAcceptOutcome> {
1957        match routing {
1958            // A walk, not a visit: `SwitchBuffer` would activate the
1959            // buffer through the generic path and record a new visit.
1960            RoutingPayload::PaneHistoryEntry { index } => {
1961                Ok(PickerAcceptOutcome::WalkPaneHistory { index: *index })
1962            }
1963            other => Err(format!(
1964                "pane-buffer-history: unexpected routing payload {other:?}"
1965            )),
1966        }
1967    }
1968}
1969
1970/// MB.5: `:picker search-history` — also reached via the `q/` / `q?`
1971/// Normal chords and `:history search`. Walks `ctx.search_history`
1972/// (the App's search-line history ring, stored oldest-first) and
1973/// emits one row per past search term, **newest first**. `<CR>` loads
1974/// the chosen term into the editable `/` line via
1975/// [`RoutingPayload::LoadSearchLine`] — it does **not** execute.
1976pub struct SearchHistorySource {
1977    pub spec: PickerSourceSpec,
1978}
1979
1980impl SearchHistorySource {
1981    pub fn new() -> Self {
1982        Self {
1983            spec: PickerSourceSpec::no_args(
1984                "search-history",
1985                "Search-line history. `<CR>` loads the chosen term into the `/` search line (does not execute).",
1986            ),
1987        }
1988    }
1989}
1990
1991impl Default for SearchHistorySource {
1992    fn default() -> Self {
1993        Self::new()
1994    }
1995}
1996
1997impl PickerSourceGenerator for SearchHistorySource {
1998    fn spec(&self) -> &PickerSourceSpec {
1999        &self.spec
2000    }
2001
2002    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
2003        if ctx.search_history.is_empty() {
2004            return Err("history: no search-line history yet".into());
2005        }
2006        let pairs = ctx
2007            .search_history
2008            .iter()
2009            .rev()
2010            .map(|entry| {
2011                let cand = RawCandidate::plain((*entry).clone(), CandidateKind::Plain);
2012                (
2013                    cand,
2014                    RoutingPayload::LoadSearchLine {
2015                        text: (*entry).clone(),
2016                    },
2017                )
2018            })
2019            .collect();
2020        Ok(PickerInitResult::Inline(pairs))
2021    }
2022
2023    fn accept(
2024        &self,
2025        _ctx: &PickerContext<'_>,
2026        routing: &RoutingPayload,
2027    ) -> SourceResult<PickerAcceptOutcome> {
2028        match routing {
2029            RoutingPayload::LoadSearchLine { text } => {
2030                Ok(PickerAcceptOutcome::LoadSearchLine { text: text.clone() })
2031            }
2032            other => Err(format!(
2033                "search-history: unexpected routing payload {other:?}"
2034            )),
2035        }
2036    }
2037}
2038
2039/// `:picker registers`. Walks `ctx.registers` (`(name,
2040/// preview)` pairs already prepared by the host's
2041/// `build_picker_context`) and emits one row per register.
2042/// Accept emits `PasteRegister { name }`; the host routes
2043/// through `do_paste` with the chosen register pre-selected.
2044pub struct RegistersSource {
2045    pub spec: PickerSourceSpec,
2046}
2047
2048impl RegistersSource {
2049    pub fn new() -> Self {
2050        Self {
2051            spec: PickerSourceSpec::no_args(
2052                "registers",
2053                "Vim-style registers (unnamed, numbered, named). `<CR>` pastes the chosen register at the cursor.",
2054            ),
2055        }
2056    }
2057}
2058
2059impl Default for RegistersSource {
2060    fn default() -> Self {
2061        Self::new()
2062    }
2063}
2064
2065impl PickerSourceGenerator for RegistersSource {
2066    fn spec(&self) -> &PickerSourceSpec {
2067        &self.spec
2068    }
2069
2070    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
2071        if ctx.registers.is_empty() {
2072            return Err("registers: no registers set".into());
2073        }
2074        let pairs = ctx
2075            .registers
2076            .iter()
2077            .filter_map(|(name, preview)| {
2078                // Pick the first char of the name as the routing
2079                // key. Names are always one char today; future
2080                // multi-char keys (vim doesn't have any) would
2081                // need a richer routing variant.
2082                let ch = name.chars().next()?;
2083                // MP.4/§9: the register contents are the matchable
2084                // `display`; the register name (`"a`) is a `register`
2085                // marginalia cell.
2086                // `ctx.registers` carries FULL contents now, so the
2087                // display truncation happens here rather than upstream.
2088                let mut cand = RawCandidate::plain(one_line_preview(preview), CandidateKind::Plain);
2089                cand.annotations = vec![Annotation::Styled {
2090                    category: "register".into(),
2091                    segments: vec![txt_seg(format!("\"{name}"), SLOT_REGISTER)],
2092                }];
2093                // Slice 7b.5: typed accept payload.
2094                cand.accept_action =
2095                    Some(Box::new(lattice_completion::AcceptAction::PasteRegister {
2096                        name: ch,
2097                    }));
2098                Some((cand, RoutingPayload::PasteRegister { name: ch }))
2099            })
2100            .collect();
2101        Ok(PickerInitResult::Inline(pairs))
2102    }
2103
2104    fn accept(
2105        &self,
2106        _ctx: &PickerContext<'_>,
2107        routing: &RoutingPayload,
2108    ) -> SourceResult<PickerAcceptOutcome> {
2109        match routing {
2110            RoutingPayload::PasteRegister { name } => {
2111                Ok(PickerAcceptOutcome::PasteRegister { name: *name })
2112            }
2113            other => Err(format!("registers: unexpected routing payload {other:?}")),
2114        }
2115    }
2116}
2117
2118/// `:picker marks`. Walks `ctx.marks` (sorted by name in
2119/// `build_picker_context`) and emits one row per set mark.
2120/// Accept emits `JumpToMark { name }` which the host
2121/// resolves through `do_jump_mark` -- same path the `` ` ``
2122/// motion uses, so cursor placement + position-history push
2123/// match keyboard-driven behavior. MRU will key on
2124/// `mark:<name>` automatically when slice 14 lands.
2125pub struct MarksSource {
2126    pub spec: PickerSourceSpec,
2127}
2128
2129impl MarksSource {
2130    pub fn new() -> Self {
2131        Self {
2132            spec: PickerSourceSpec::no_args(
2133                "marks",
2134                "Vim-style marks. `<CR>` jumps to the mark via the same path as `` ` ``.",
2135            ),
2136        }
2137    }
2138}
2139
2140impl Default for MarksSource {
2141    fn default() -> Self {
2142        Self::new()
2143    }
2144}
2145
2146impl PickerSourceGenerator for MarksSource {
2147    fn spec(&self) -> &PickerSourceSpec {
2148        &self.spec
2149    }
2150
2151    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
2152        if ctx.marks.is_empty() {
2153            return Err("marks: no marks set".into());
2154        }
2155        let pairs = ctx
2156            .marks
2157            .iter()
2158            .map(|(name, pos)| {
2159                // MP.4: the mark name (`'a`) is the matchable `display`;
2160                // line:col becomes a `location` cell.
2161                let mut cand = RawCandidate::plain(format!("'{name}"), CandidateKind::Plain);
2162                cand.annotations =
2163                    vec![location_annotation(None, pos.line + 1, Some(pos.byte + 1))];
2164                // Slice 7b.5: typed accept payload.
2165                cand.accept_action = Some(Box::new(lattice_completion::AcceptAction::JumpToMark {
2166                    name: *name,
2167                }));
2168                (cand, RoutingPayload::JumpToMark { name: *name })
2169            })
2170            .collect();
2171        Ok(PickerInitResult::Inline(pairs))
2172    }
2173
2174    fn accept(
2175        &self,
2176        _ctx: &PickerContext<'_>,
2177        routing: &RoutingPayload,
2178    ) -> SourceResult<PickerAcceptOutcome> {
2179        match routing {
2180            RoutingPayload::JumpToMark { name } => {
2181                Ok(PickerAcceptOutcome::JumpToMark { name: *name })
2182            }
2183            other => Err(format!("marks: unexpected routing payload {other:?}")),
2184        }
2185    }
2186}
2187
2188/// `:picker grep <pattern>`. Shells out to a configurable
2189/// backend (`rg`, `ag`, `grep`, or `auto`-detected at
2190/// invocation time) and walks its output line-by-line.
2191///
2192/// Sync subprocess for v1 (matches Files / Recent design --
2193/// users invoke explicitly; brief wait is acceptable). The
2194/// `:picker grep` ergonomic equivalent of vertico-buffer
2195/// live-grep with prescient ranking ships once the async
2196/// init seat path lands; until then this is the simplest
2197/// path that respects the configurable-backend requirement.
2198///
2199/// Captures `Arc<ConfigRegistry>` at construction so the
2200/// backend choice is read at every invocation (lets the user
2201/// `:set picker.grep.backend = "ag"` mid-session and see
2202/// it take effect on the next `:picker grep`).
2203/// PH.3: off-thread syntax highlighter for grep preview lines.
2204/// `lattice-picker` deliberately has NO `lattice-syntax`
2205/// dependency (the structural off-thread guarantee — a source
2206/// physically cannot parse on the render thread). The host
2207/// injects a concrete impl that selects a grammar by the hit's
2208/// file extension and highlights the single preview line. Runs
2209/// on the grep blocking task; returns display-relative
2210/// `DisplaySpan`s, empty when no grammar matches (→ plain
2211/// preview). See `docs/dev/architecture/picker-preview-highlight.md` §7.
2212pub trait GrepPreviewHighlighter: Send + Sync {
2213    /// Highlight `line` as source for the file at `path`. `line`
2214    /// is the exact text shown as the candidate `display` (already
2215    /// trimmed), so returned spans are display-relative and need
2216    /// no offset. Empty result ⇒ plain preview.
2217    fn highlight_line(
2218        &self,
2219        path: &std::path::Path,
2220        line: &str,
2221    ) -> Vec<lattice_completion::DisplaySpan>;
2222}
2223
2224pub struct GrepSource {
2225    pub spec: PickerSourceSpec,
2226    pub config: Arc<ConfigRegistry>,
2227    /// PH.3: optional preview highlighter, captured at
2228    /// construction like `config`. `None` ⇒ plain previews
2229    /// (e.g. tests, or a host that doesn't wire syntax).
2230    pub highlighter: Option<Arc<dyn GrepPreviewHighlighter>>,
2231}
2232
2233impl GrepSource {
2234    pub fn new(
2235        config: Arc<ConfigRegistry>,
2236        highlighter: Option<Arc<dyn GrepPreviewHighlighter>>,
2237    ) -> Self {
2238        use lattice_grammar::args::{ArgDefault, ArgKind, ArgSpec};
2239        Self {
2240            spec: PickerSourceSpec {
2241                create_label: None,
2242                delete_command: None,
2243                help_topic: None,
2244                id: "grep".into(),
2245                // The search is run WITH the root as its cwd, so the root is
2246                // half of what a hit means.
2247                rooted: true,
2248                doc: "Live recursive text search via the configured backend (`rg`/`ag`/`grep`). Re-runs as you type; `<CR>` jumps to the chosen hit.".into(),
2249                args_hint: "[pattern]".into(),
2250                args_schema: vec![ArgSpec {
2251                    name: "pattern".into(),
2252                    kind: ArgKind::String,
2253                    doc: "Optional initial pattern. When given, seeds the picker prompt; without it, picker opens empty and runs the first grep on the first keystroke.".into(),
2254                    prompt: "pattern:".into(),
2255                    default: ArgDefault::None,
2256                    completion: None,
2257                    picker: None,
2258                }],
2259                // Slice 3: live source. Picker bypasses fuzzy
2260                // refilter (`run_grep` IS the filter); host
2261                // calls `on_query_changed` on each debounced
2262                // keystroke.
2263                live: true,
2264            },
2265            config,
2266            highlighter,
2267        }
2268    }
2269
2270    /// Resolve backend choice + max-hits from the config. Shared
2271    /// by `init` and `on_query_changed` so both routes honour
2272    /// the same `:set picker.grep.*` options.
2273    fn resolve_settings(&self) -> SourceResult<(String, usize)> {
2274        let backend_choice = self
2275            .config
2276            .get_typed::<lattice_config::core_options::PickerGrepBackend>()
2277            .map(|s| (*s).clone())
2278            .unwrap_or_else(|| "auto".to_string());
2279        let max_hits = self
2280            .config
2281            .get_typed::<lattice_config::core_options::PickerGrepMaxHits>()
2282            .map(|n| *n as usize)
2283            .unwrap_or(2000)
2284            .max(1);
2285        let resolved = resolve_grep_backend(&backend_choice)?;
2286        Ok((resolved, max_hits))
2287    }
2288
2289    /// Build a `CandidateFuture` that runs `run_grep` on
2290    /// tokio's blocking pool. Uses `spawn_blocking` because
2291    /// `run_grep` shells out via the std-sync `Command::output`
2292    /// API; running it on the async runtime's worker pool would
2293    /// pin a worker for the duration of the grep. The blocking
2294    /// pool is the right fit -- it's sized for exactly this
2295    /// kind of task.
2296    fn spawn_grep(
2297        binary: String,
2298        pattern: String,
2299        root: std::path::PathBuf,
2300        max_hits: usize,
2301        highlighter: Option<Arc<dyn GrepPreviewHighlighter>>,
2302    ) -> crate::CandidateFuture {
2303        Box::pin(async move {
2304            // PH.3: run BOTH the grep AND the per-hit syntax
2305            // highlighting inside the blocking closure — the
2306            // highlighting is CPU-bound (per-line tree-sitter parse)
2307            // and must not land on an async runtime worker. Off the
2308            // render thread by construction (the picker crate has no
2309            // syntax dep; the highlighter is host-injected).
2310            let join = tokio::task::spawn_blocking(move || {
2311                run_grep(&binary, &pattern, &root, max_hits)
2312                    .map(|hits| hits_to_pairs(hits, highlighter.as_deref()))
2313            })
2314            .await;
2315            match join {
2316                Ok(Ok(pairs)) => Ok(pairs),
2317                Ok(Err(e)) => Err(e),
2318                Err(e) => Err(format!("grep: task panicked: {e}")),
2319            }
2320        })
2321    }
2322}
2323
2324/// Convert raw grep hits into the picker's `(RawCandidate,
2325/// RoutingPayload)` pairs. Shared by the sync init() fast
2326/// path (no initial pattern → empty pairs) and the async
2327/// future path that the live grep flow drives. Empty input
2328/// → empty output; callers don't special-case.
2329fn hits_to_pairs(
2330    hits: Vec<GrepHit>,
2331    highlighter: Option<&dyn GrepPreviewHighlighter>,
2332) -> crate::CandidateBatch {
2333    hits.into_iter()
2334        .map(|hit| {
2335            // MP.4: the matched preview text is the matchable `display`;
2336            // path:line:col becomes a `location` marginalia cell.
2337            let path_display = hit.path.display().to_string();
2338            let preview = hit.preview.trim_start().to_string();
2339            let mut cand = RawCandidate::plain(preview.clone(), CandidateKind::Plain);
2340            cand.annotations = vec![location_annotation(
2341                Some(&path_display),
2342                hit.line + 1,
2343                Some(hit.col + 1),
2344            )];
2345            // PH.3: syntax-color the preview when a highlighter is wired
2346            // and a grammar matches the file. `display` IS the trimmed
2347            // preview, so spans come back display-relative; no grammar /
2348            // no spans → plain preview. Runs in the grep blocking task.
2349            if let Some(h) = highlighter {
2350                cand.display_spans = h.highlight_line(&hit.path, &preview);
2351            }
2352            // Slice 7b.6: typed accept payload. Grep hits jump
2353            // to file:line:col — same shape as LSP references /
2354            // definitions / diagnostics → JumpToFileLocation.
2355            cand.accept_action = Some(Box::new(
2356                lattice_completion::AcceptAction::JumpToFileLocation {
2357                    path: hit.path.clone(),
2358                    line: hit.line,
2359                    col: hit.col,
2360                },
2361            ));
2362            (
2363                cand,
2364                RoutingPayload::LspLocation {
2365                    path: hit.path,
2366                    line: hit.line,
2367                    col: hit.col,
2368                },
2369            )
2370        })
2371        .collect()
2372}
2373
2374impl PickerSourceGenerator for GrepSource {
2375    fn spec(&self) -> &PickerSourceSpec {
2376        &self.spec
2377    }
2378
2379    /// Slice 3: optional initial pattern. With no pattern the
2380    /// picker opens empty (no grep runs); the first keystroke
2381    /// triggers the live flow through `on_query_changed`.
2382    /// With an initial pattern the grep runs immediately --
2383    /// async via the Future variant so the UI thread doesn't
2384    /// park on the first invocation either. The host seeds
2385    /// `picker.query` with the initial pattern (live-source
2386    /// convention in `App::open_picker`), so subsequent
2387    /// keystrokes extend the same query.
2388    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
2389        let pattern = args.first().map(|s| s.trim()).filter(|s| !s.is_empty());
2390        let Some(pattern) = pattern else {
2391            return Ok(PickerInitResult::Inline(Vec::new()));
2392        };
2393        let (binary, max_hits) = self.resolve_settings()?;
2394        let root = ctx.workspace_root.to_path_buf();
2395        let fut = GrepSource::spawn_grep(
2396            binary,
2397            pattern.to_string(),
2398            root,
2399            max_hits,
2400            self.highlighter.clone(),
2401        );
2402        Ok(PickerInitResult::Future(fut))
2403    }
2404
2405    fn accept(
2406        &self,
2407        _ctx: &PickerContext<'_>,
2408        routing: &RoutingPayload,
2409    ) -> SourceResult<PickerAcceptOutcome> {
2410        match routing {
2411            RoutingPayload::LspLocation { path, line, col } => {
2412                Ok(PickerAcceptOutcome::JumpToLocation {
2413                    path: path.clone(),
2414                    line: *line,
2415                    col: *col,
2416                })
2417            }
2418            other => Err(format!("grep: unexpected routing payload {other:?}")),
2419        }
2420    }
2421
2422    /// Slice 3: live re-execution. The host's
2423    /// `drain_pending_live_picker_query` calls this every time
2424    /// the debounce expires; we trim, special-case the empty
2425    /// query (no grep, empty result -- clears the candidate
2426    /// list), and otherwise spawn the grep on the blocking
2427    /// pool. The Future variant lets the host cancel us if a
2428    /// newer keystroke fires before we finish.
2429    fn on_query_changed(
2430        &self,
2431        ctx: &PickerContext<'_>,
2432        query: &str,
2433    ) -> Option<SourceResult<PickerInitResult>> {
2434        let trimmed = query.trim();
2435        if trimmed.is_empty() {
2436            return Some(Ok(PickerInitResult::Inline(Vec::new())));
2437        }
2438        let settings = match self.resolve_settings() {
2439            Ok(s) => s,
2440            Err(e) => return Some(Err(e)),
2441        };
2442        let (binary, max_hits) = settings;
2443        let root = ctx.workspace_root.to_path_buf();
2444        let fut = GrepSource::spawn_grep(
2445            binary,
2446            trimmed.to_string(),
2447            root,
2448            max_hits,
2449            self.highlighter.clone(),
2450        );
2451        Some(Ok(PickerInitResult::Future(fut)))
2452    }
2453}
2454
2455/// One grep hit -- path + 0-based LSP-flavored line + 0-based
2456/// utf-8 byte column + the matching line's text (preview).
2457struct GrepHit {
2458    path: std::path::PathBuf,
2459    line: u32,
2460    col: u32,
2461    preview: String,
2462}
2463
2464/// Picks the grep binary from the user's `picker.grep.backend`
2465/// option. `"auto"` walks rg / ag / grep, returning the first
2466/// on PATH. Explicit names check that single binary; missing
2467/// returns `Err` so the user can re-configure.
2468fn resolve_grep_backend(choice: &str) -> SourceResult<String> {
2469    fn on_path(name: &str) -> bool {
2470        std::env::var_os("PATH")
2471            .map(|p| {
2472                std::env::split_paths(&p).any(|dir| {
2473                    // On Windows the binary is `rg.exe`; `Command::new("rg")`
2474                    // finds it, so the lookup must too, or no backend is
2475                    // ever found and the grep picker never opens.
2476                    dir.join(name).is_file()
2477                        || (cfg!(windows) && dir.join(format!("{name}.exe")).is_file())
2478                })
2479            })
2480            .unwrap_or(false)
2481    }
2482    if choice == "auto" {
2483        for candidate in ["rg", "ag", "grep"] {
2484            if on_path(candidate) {
2485                return Ok(candidate.to_string());
2486            }
2487        }
2488        return Err("grep: no backend on PATH (tried rg, ag, grep). \
2489             Set `picker.grep.backend` to a binary name."
2490            .into());
2491    }
2492    if on_path(choice) {
2493        Ok(choice.to_string())
2494    } else {
2495        Err(format!(
2496            "grep: backend `{choice}` not found on PATH \
2497             (configured via `picker.grep.backend`)"
2498        ))
2499    }
2500}
2501
2502/// Run `binary <pattern> <root>` with backend-appropriate
2503/// args and parse the output. Output formats:
2504/// - rg: `path:line:col:text`
2505/// - ag: `path:line:col:text`
2506/// - grep: `path:line:text` (no column; fall back to 0)
2507fn run_grep(
2508    binary: &str,
2509    pattern: &str,
2510    root: &std::path::Path,
2511    max_hits: usize,
2512) -> SourceResult<Vec<GrepHit>> {
2513    let mut cmd = std::process::Command::new(binary);
2514    match binary {
2515        "rg" => {
2516            cmd.args(["--no-heading", "--line-number", "--column", "--color=never"]);
2517        }
2518        "ag" => {
2519            cmd.args(["--noheading", "--column", "--nocolor"]);
2520        }
2521        "grep" => {
2522            cmd.args(["-rnH"]);
2523        }
2524        _ => {
2525            // Custom backend; assume an rg-compatible flag set.
2526            cmd.args(["--line-number", "--column"]);
2527        }
2528    }
2529    cmd.arg(pattern).arg(root);
2530    let output = cmd
2531        .output()
2532        .map_err(|e| format!("grep: spawning `{binary}` failed: {e}"))?;
2533    if !output.status.success() && output.stdout.is_empty() {
2534        // Some backends (`grep`, `rg`) return non-zero on
2535        // "no hits". Only treat as error when stderr has a
2536        // real message AND stdout is empty.
2537        let stderr = String::from_utf8_lossy(&output.stderr);
2538        if !stderr.trim().is_empty() {
2539            return Err(format!("grep: `{binary}` failed: {stderr}"));
2540        }
2541    }
2542    let stdout = String::from_utf8_lossy(&output.stdout);
2543    let mut hits = Vec::new();
2544    for raw_line in stdout.lines() {
2545        if hits.len() >= max_hits {
2546            break;
2547        }
2548        if let Some(hit) = parse_grep_line(binary, raw_line) {
2549            hits.push(hit);
2550        }
2551    }
2552    Ok(hits)
2553}
2554
2555/// Parse one output line. `path:line:col:text` for rg/ag,
2556/// `path:line:text` for grep. Path may itself contain colons
2557/// (Windows drive letters, or files with `:` in name); we
2558/// scan left-to-right for the first numeric `line` segment
2559/// and key off that rather than splitting blindly on colons.
2560fn parse_grep_line(binary: &str, raw: &str) -> Option<GrepHit> {
2561    let with_column = matches!(binary, "rg" | "ag") || binary.contains("rg");
2562    // Collect colon positions left-to-right; we'll walk pairs
2563    // looking for the first all-digits chunk between two
2564    // colons -- that's the line number, and everything before
2565    // is the path.
2566    let colon_idxs: Vec<usize> = raw
2567        .bytes()
2568        .enumerate()
2569        .filter_map(|(i, b)| (b == b':').then_some(i))
2570        .collect();
2571    for window in colon_idxs.windows(2) {
2572        let line_chunk = &raw[window[0] + 1..window[1]];
2573        if line_chunk.bytes().all(|b| b.is_ascii_digit()) && !line_chunk.is_empty() {
2574            let line: u32 = line_chunk.parse().ok()?;
2575            let path = &raw[..window[0]];
2576            if with_column {
2577                // Need a column next: look for another colon
2578                // after `window[1]` whose chunk between is all
2579                // digits.
2580                let after_line = window[1];
2581                let next_colon = colon_idxs.iter().find(|&&i| i > after_line)?;
2582                let col_chunk = &raw[after_line + 1..*next_colon];
2583                if col_chunk.bytes().all(|b| b.is_ascii_digit()) && !col_chunk.is_empty() {
2584                    let col: u32 = col_chunk.parse().ok()?;
2585                    let preview = raw[*next_colon + 1..].to_string();
2586                    return Some(GrepHit {
2587                        path: std::path::PathBuf::from(path),
2588                        line: line.saturating_sub(1),
2589                        col: col.saturating_sub(1),
2590                        preview,
2591                    });
2592                }
2593                continue;
2594            }
2595            // grep: path:line:text -- preview is everything
2596            // after the line's trailing colon.
2597            let preview = raw[window[1] + 1..].to_string();
2598            return Some(GrepHit {
2599                path: std::path::PathBuf::from(path),
2600                line: line.saturating_sub(1),
2601                col: 0,
2602                preview,
2603            });
2604        }
2605    }
2606    None
2607}
2608
2609/// `:picker outline`. Tree-sitter-driven symbol outline for
2610/// the active buffer. Reads `ctx.active_buffer.syntax_symbols`
2611/// (pre-collected by the host via
2612/// `Syntax::collect_symbol_locations`) and emits one row per
2613/// symbol, sorted by source position. Accept jumps the
2614/// cursor to the symbol via `JumpInBuffer`.
2615///
2616/// The LSP-flavored counterpart (`textDocument/documentSymbol`)
2617/// lives in `lattice-lsp::picker_sources` once the async-init
2618/// seat path lands; for now this source provides a
2619/// language-agnostic outline that works for every language
2620/// with a tree-sitter symbols query (`rust`, `python`,
2621/// `javascript` today; more as queries register).
2622pub struct OutlineSource {
2623    pub spec: PickerSourceSpec,
2624}
2625
2626impl OutlineSource {
2627    pub fn new() -> Self {
2628        Self {
2629            spec: PickerSourceSpec::no_args(
2630                "outline",
2631                "Tree-sitter symbol outline for the active buffer. `<CR>` jumps to the symbol.",
2632            ),
2633        }
2634    }
2635}
2636
2637impl Default for OutlineSource {
2638    fn default() -> Self {
2639        Self::new()
2640    }
2641}
2642
2643impl PickerSourceGenerator for OutlineSource {
2644    fn spec(&self) -> &PickerSourceSpec {
2645        &self.spec
2646    }
2647
2648    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
2649        if ctx.active_buffer.syntax_symbols.is_empty() {
2650            let lang = ctx.active_buffer.language.unwrap_or("plain");
2651            return Err(format!(
2652                "outline: no symbols (language `{lang}` has no tree-sitter query, or the parse tree is empty)"
2653            ));
2654        }
2655        let buffer_id = ctx.active_buffer.buffer_id;
2656        let pairs = ctx
2657            .active_buffer
2658            .syntax_symbols
2659            .iter()
2660            .map(|(name, line, col)| {
2661                // MP.4: the symbol name is the matchable `display`; the
2662                // line number moves to a `location` cell.
2663                let mut cand = RawCandidate::plain(name.clone(), CandidateKind::Plain);
2664                cand.annotations = vec![location_annotation(None, line + 1, None)];
2665                // PH.2: colour the symbol name with its line's syntax
2666                // spans, projected onto the name column.
2667                cand.display_spans = display_spans_for_symbol(
2668                    &ctx.active_buffer.syntax_highlights,
2669                    *line,
2670                    *col,
2671                    name.len(),
2672                );
2673                // Slice 7b.4: typed accept payload.
2674                cand.accept_action =
2675                    Some(Box::new(lattice_completion::AcceptAction::JumpInBuffer {
2676                        buffer_id: lattice_core::BufferId(buffer_id),
2677                        line: *line,
2678                        col: *col,
2679                    }));
2680                (
2681                    cand,
2682                    RoutingPayload::JumpInBuffer {
2683                        buffer_id,
2684                        line: *line,
2685                        col: *col,
2686                    },
2687                )
2688            })
2689            .collect();
2690        Ok(PickerInitResult::Inline(pairs))
2691    }
2692
2693    fn accept(
2694        &self,
2695        _ctx: &PickerContext<'_>,
2696        routing: &RoutingPayload,
2697    ) -> SourceResult<PickerAcceptOutcome> {
2698        match routing {
2699            RoutingPayload::JumpInBuffer {
2700                buffer_id,
2701                line,
2702                col,
2703            } => Ok(PickerAcceptOutcome::JumpInBuffer {
2704                buffer_id: *buffer_id,
2705                line: *line,
2706                col: *col,
2707            }),
2708            other => Err(format!("outline: unexpected routing payload {other:?}")),
2709        }
2710    }
2711}
2712
2713/// Hard cap on the file-picker walker's emitted entry count.
2714/// At this scale the host's fuzzy matcher stays well inside the
2715/// per-keystroke frame budget; larger trees fall back to ripgrep-
2716/// style live filtering via `:grep` (P.10) or `:Filetree`'s
2717/// per-directory lazy walk.
2718pub const FILE_PICKER_MAX_ENTRIES: usize = 5000;
2719
2720/// Walk `root` recursively (BFS) and return the absolute paths
2721/// of every regular file (with its metadata), capped at
2722/// [`FILE_PICKER_MAX_ENTRIES`].
2723///
2724/// Uses the parallel, gitignore-aware `ignore` walker (the one
2725/// `lattice-multibuffer::search` uses): dotfiles are skipped (`hidden`),
2726/// .gitignore / .ignore / global + parent ignores are honoured, and the
2727/// build / VCS directories (`.git`, `target`, `node_modules`, `dist`,
2728/// `.cache`) are pruned even outside a git checkout. Symlinks aren't followed.
2729/// Each file's metadata is gathered during the walk (reusing the walk's own
2730/// stat) so a source's `init` need not run a second sequential stat pass —
2731/// that pass was the bulk of the first-open latency on a large tree.
2732///
2733/// Errors are silently absorbed (unreadable directories show up
2734/// as gaps in the listing); the picker UX prefers "some results"
2735/// over a hard failure when the workspace has a permission
2736/// pocket somewhere.
2737pub fn walk_files_for_picker(root: &std::path::Path) -> Vec<WalkedFile> {
2738    use ignore::{WalkBuilder, WalkState};
2739    use std::sync::Mutex;
2740    use std::sync::atomic::{AtomicUsize, Ordering};
2741
2742    // Non-git safety net: prune the heavy build / VCS directories even when the
2743    // tree is not a git checkout (where `ignore`'s .gitignore handling would not
2744    // catch them). In a real repo these are almost always gitignored too, so
2745    // this only matters in a bare directory.
2746    const PRUNE_DIRS: &[&str] = &[".git", "target", "node_modules", "dist", ".cache"];
2747
2748    let out: std::sync::Arc<Mutex<Vec<WalkedFile>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
2749    let count = std::sync::Arc::new(AtomicUsize::new(0));
2750
2751    // `WalkBuilder`'s defaults already skip dotfiles (`hidden`) and honour
2752    // .gitignore / .ignore / global + parent ignores — the same walker
2753    // `lattice-multibuffer::search` uses. `build_parallel` fans the walk across
2754    // cores (the first-open win over the old single-threaded recursion), and
2755    // each file's `metadata()` is gathered HERE, reusing the walk's own stat,
2756    // rather than in a second sequential ≤5000-stat pass in the source's `init`.
2757    let mut builder = WalkBuilder::new(root);
2758    builder.filter_entry(|entry| {
2759        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
2760        let pruned = entry
2761            .file_name()
2762            .to_str()
2763            .map(|name| PRUNE_DIRS.contains(&name))
2764            .unwrap_or(false);
2765        !(is_dir && pruned)
2766    });
2767
2768    builder.build_parallel().run(|| {
2769        let out = std::sync::Arc::clone(&out);
2770        let count = std::sync::Arc::clone(&count);
2771        Box::new(move |result| {
2772            let Ok(entry) = result else {
2773                return WalkState::Continue;
2774            };
2775            let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
2776            if !is_file {
2777                return WalkState::Continue;
2778            }
2779            // A few threads may pass the cap before all observe the Quit; the
2780            // trailing `truncate` trims the overshoot.
2781            if count.fetch_add(1, Ordering::Relaxed) >= FILE_PICKER_MAX_ENTRIES {
2782                return WalkState::Quit;
2783            }
2784            let meta = entry.metadata().ok();
2785            if let Ok(mut guard) = out.lock() {
2786                guard.push((entry.into_path(), meta));
2787            }
2788            WalkState::Continue
2789        })
2790    });
2791
2792    let mut out = std::sync::Arc::try_unwrap(out)
2793        .ok()
2794        .and_then(|m| m.into_inner().ok())
2795        .unwrap_or_default();
2796    // Deterministic order: the parallel walk yields entries nondeterministically
2797    // and the fuzzy matcher reorders anyway, but a stable list keeps the
2798    // pre-filter view and the tests predictable.
2799    out.sort_by(|a, b| a.0.cmp(&b.0));
2800    out.truncate(FILE_PICKER_MAX_ENTRIES);
2801    out
2802}
2803
2804/// One walked file: its absolute path plus the metadata gathered during the
2805/// walk (`None` when the entry could not be stat'd). Returned by
2806/// [`walk_files_for_picker`] so a source's `init` builds candidates + their
2807/// marginalia without a second stat pass.
2808pub type WalkedFile = (std::path::PathBuf, Option<std::fs::Metadata>);
2809
2810/// A cache hit older than this is served immediately AND refreshed in the
2811/// background, so the next `:files` reflects on-disk changes without the open
2812/// re-walking. Short enough that a stale entry never lasts more than a beat;
2813/// long enough that rapid re-opens don't spawn a walk each time.
2814const FILE_WALK_REFRESH_TTL: std::time::Duration = std::time::Duration::from_secs(2);
2815
2816/// Process-global, in-memory session cache for the `:files` walk (Slice C:
2817/// background warm-up). Keyed by canonical root. **Never persisted** — the
2818/// picker must not show a stale tree across launches, so this lives and dies
2819/// with the process; the only staleness it can carry is bounded by
2820/// [`FILE_WALK_REFRESH_TTL`] within a session.
2821#[allow(clippy::type_complexity)]
2822fn file_walk_cache() -> &'static std::sync::Mutex<
2823    std::collections::HashMap<std::path::PathBuf, (Vec<WalkedFile>, std::time::Instant)>,
2824> {
2825    static CACHE: std::sync::OnceLock<
2826        std::sync::Mutex<
2827            std::collections::HashMap<std::path::PathBuf, (Vec<WalkedFile>, std::time::Instant)>,
2828        >,
2829    > = std::sync::OnceLock::new();
2830    CACHE.get_or_init(Default::default)
2831}
2832
2833/// Pre-walk `root` into the session cache so the first `:files` open is
2834/// instant. The host calls this from a background thread at boot (and on
2835/// project change); `FilesSource::init` also calls it to refresh a warm entry.
2836/// Runs the same [`walk_files_for_picker`] — off whatever thread the caller
2837/// spawns it on, never the UI thread.
2838pub fn warm_files_cache(root: &std::path::Path) {
2839    let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
2840    let walked = walk_files_for_picker(&canonical);
2841    if let Ok(mut cache) = file_walk_cache().lock() {
2842        cache.insert(canonical, (walked, std::time::Instant::now()));
2843    }
2844}
2845
2846/// The cached walk for `root` if it was warmed this session, cloned for the
2847/// caller. Returns `(entries, stale)` where `stale` marks an entry past
2848/// [`FILE_WALK_REFRESH_TTL`] — the caller serves it but should kick a refresh.
2849/// `None` on a cold cache.
2850fn cached_files(root: &std::path::Path) -> Option<(Vec<WalkedFile>, bool)> {
2851    let canonical = std::fs::canonicalize(root).ok()?;
2852    let cache = file_walk_cache().lock().ok()?;
2853    let (entries, walked_at) = cache.get(&canonical)?;
2854    Some((
2855        entries.clone(),
2856        walked_at.elapsed() >= FILE_WALK_REFRESH_TTL,
2857    ))
2858}
2859
2860/// Convenience: build the first-party source generators as
2861/// `Arc<dyn PickerSourceGenerator>` ready to register against
2862/// a `PickerRegistry`. Used by `App::new` (and a future host-
2863/// owned `Editor::boot`) to boot the registry. Sources that
2864/// need App-wide state captured at construction (e.g.
2865/// `CommandsSource` -> `CommandRegistry`, `GrepSource` ->
2866/// `ConfigRegistry`) take the relevant `Arc` here so the trait
2867/// surface stays state-handle-free.
2868pub fn first_party_generators(
2869    command_registry: CommandRegistryHandle,
2870    config: Arc<ConfigRegistry>,
2871    keybinding_reverse: Arc<dyn KeymapReverseLookup>,
2872    grep_highlighter: Option<Arc<dyn GrepPreviewHighlighter>>,
2873) -> Vec<Arc<dyn PickerSourceGenerator>> {
2874    vec![
2875        Arc::new(FilesSource::new()),
2876        Arc::new(FilePickSource::new()),
2877        Arc::new(DirPickSource::new()),
2878        Arc::new(YankRingSource::new()),
2879        Arc::new(RecentFilesSource::new()),
2880        Arc::new(BuffersSource::new()),
2881        Arc::new(LinesSource::new()),
2882        Arc::new(JumpsSource::new()),
2883        Arc::new(CommandsSource::new(command_registry, keybinding_reverse)),
2884        Arc::new(CommandHistorySource::new()),
2885        Arc::new(SearchHistorySource::new()),
2886        Arc::new(PaneBufferHistorySource::new()),
2887        Arc::new(RegistersSource::new()),
2888        Arc::new(MarksSource::new()),
2889        Arc::new(GrepSource::new(config, grep_highlighter)),
2890        Arc::new(OutlineSource::new()),
2891    ]
2892}
2893
2894#[cfg(test)]
2895mod tests {
2896    //! Unit tests for the pure private helpers (formatters,
2897    //! grep-line parser). The integration tests that need
2898    //! `app_with(...)` to build a real `PickerContext` snapshot
2899    //! stay in `lattice-ui-tui::picker_sources` -- they couple
2900    //! to the TUI's test-helper App constructor, not to the
2901    //! sources themselves. Slice 5.7.B.0 split the test layers
2902    //! so the renderer-neutral substrate's tests build without
2903    //! pulling ui-tui.
2904
2905    #![allow(clippy::unwrap_used, clippy::panic)]
2906
2907    use super::*;
2908
2909    /// `:picker files ~/notes` used to walk a directory literally named `~`.
2910    /// It found nothing and said "no files under ~/notes", which blames the
2911    /// directory for being empty rather than the path for never resolving.
2912    #[test]
2913    fn an_explicit_tilde_root_expands() {
2914        let home = lattice_core::home::expand_tilde("~");
2915        if !std::path::Path::new(&home).is_dir() {
2916            eprintln!("SKIP: no home directory to expand against");
2917            return;
2918        }
2919        let got =
2920            super::explicit_root_or(&["~/notes".to_string()], std::path::Path::new("/workspace"));
2921        assert_eq!(got, std::path::Path::new(&home).join("notes"));
2922    }
2923
2924    /// No argument means the workspace root — the whole point of `rooted`.
2925    #[test]
2926    fn no_argument_falls_back_to_the_workspace_root() {
2927        let ws = std::path::Path::new("/workspace");
2928        assert_eq!(super::explicit_root_or(&[], ws), ws);
2929        assert_eq!(super::explicit_root_or(&[String::new()], ws), ws);
2930    }
2931
2932    /// An absolute argument wins outright: the user saying "not that project,
2933    /// this one".
2934    #[test]
2935    fn an_absolute_root_is_taken_as_given() {
2936        assert_eq!(
2937            super::explicit_root_or(
2938                &["/elsewhere".to_string()],
2939                std::path::Path::new("/workspace")
2940            ),
2941            std::path::Path::new("/elsewhere")
2942        );
2943    }
2944
2945    /// Marginalia helpers: `format_size` matches the
2946    /// `ls -h` convention (bytes / K / M / G with one-decimal
2947    /// precision under 10 of each unit).
2948    #[test]
2949    fn format_size_humanizes_byte_counts() {
2950        assert_eq!(format_size(0), "0");
2951        assert_eq!(format_size(512), "512");
2952        assert_eq!(format_size(1024), "1.0K");
2953        assert_eq!(format_size(1024 * 9), "9.0K");
2954        assert_eq!(format_size(1024 * 10), "10K");
2955        assert_eq!(format_size(1024 * 70), "70K");
2956        assert_eq!(format_size(1024 * 1024), "1.0M");
2957        assert_eq!(format_size(1024 * 1024 * 12), "12M");
2958        assert_eq!(
2959            format_size(1024_u64.pow(3) * 4 + 1024_u64.pow(3) / 5),
2960            "4.2G"
2961        );
2962    }
2963
2964    /// `format_mtime_relative` produces stable English-y
2965    /// relative phrases. We don't test the boundary
2966    /// transitions exactly (they depend on wall-clock); we
2967    /// test category dispatch through synthesised deltas.
2968    #[test]
2969    fn format_mtime_relative_categorises_durations() {
2970        use std::time::{Duration, SystemTime};
2971
2972        let now = SystemTime::now();
2973        // 30 seconds ago -> "just now"
2974        let recent = now - Duration::from_secs(30);
2975        assert_eq!(format_mtime_relative(recent), "just now");
2976        // 3 minutes ago
2977        let mins = now - Duration::from_secs(3 * 60);
2978        assert_eq!(format_mtime_relative(mins), "3 minutes ago");
2979        // 1 minute ago (singular)
2980        let one_min = now - Duration::from_secs(70);
2981        assert_eq!(format_mtime_relative(one_min), "1 minute ago");
2982        // 28 hours ago (the user's example)
2983        let hours = now - Duration::from_secs(28 * 60 * 60);
2984        assert_eq!(format_mtime_relative(hours), "28 hours ago");
2985        // 5 days ago
2986        let days = now - Duration::from_secs(5 * 24 * 60 * 60);
2987        assert_eq!(format_mtime_relative(days), "5 days ago");
2988    }
2989
2990    /// MR.3: `perm_segments` yields one segment per bit class, each
2991    /// tagged with its theme slot, in `ls -l` shape. Bits map to the
2992    /// eza-convention slots; setuid/setgid/sticky fold into the exec
2993    /// positions as s/S/t/T against `perm.special`.
2994    #[cfg(unix)]
2995    #[test]
2996    fn perm_segments_map_bits_to_slots() {
2997        use std::os::unix::fs::PermissionsExt;
2998
2999        let tmp = std::env::temp_dir().join(format!(
3000            "lattice-perms-{}-{:?}",
3001            std::process::id(),
3002            std::thread::current().id()
3003        ));
3004        std::fs::write(&tmp, b"x").unwrap();
3005        // 0o755: rwx r-x r-x on a regular file.
3006        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).unwrap();
3007        let meta = std::fs::metadata(&tmp).unwrap();
3008        let segs = perm_segments(&meta);
3009        let text: String = segs.iter().map(|s| s.text.as_ref()).collect();
3010        assert_eq!(text, "-rwxr-xr-x", "ls -l shape");
3011        assert_eq!(segs.len(), 10);
3012        // Spot-check slot assignment for the user triad.
3013        assert_eq!(segs[0].slot.as_ref(), SLOT_PERM_TYPE); // '-'
3014        assert_eq!(segs[1].slot.as_ref(), SLOT_PERM_READ); // 'r'
3015        assert_eq!(segs[2].slot.as_ref(), SLOT_PERM_WRITE); // 'w'
3016        assert_eq!(segs[3].slot.as_ref(), SLOT_PERM_EXEC); // 'x'
3017        // Group write bit is absent → '-' on the `none` slot.
3018        assert_eq!(segs[5].text.as_ref(), "-");
3019        assert_eq!(segs[5].slot.as_ref(), SLOT_PERM_NONE);
3020
3021        // setuid + sticky: user-exec becomes 's', other-exec 't', both
3022        // on the special slot.
3023        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o4751)).unwrap();
3024        let meta = std::fs::metadata(&tmp).unwrap();
3025        let segs = perm_segments(&meta);
3026        let text: String = segs.iter().map(|s| s.text.as_ref()).collect();
3027        assert_eq!(text, "-rwsr-x--x", "setuid shows 's' in user-exec");
3028        assert_eq!(segs[3].text.as_ref(), "s");
3029        assert_eq!(segs[3].slot.as_ref(), SLOT_PERM_SPECIAL);
3030
3031        let _ = std::fs::remove_file(&tmp);
3032    }
3033
3034    /// MR.3: a stattable entry yields exactly the perm / size / mtime
3035    /// columns (in that order), each a `Styled` cell. `mtime` is present
3036    /// because temp files always carry a modified time.
3037    #[test]
3038    fn metadata_annotations_yields_perm_size_mtime() {
3039        use lattice_completion::Annotation;
3040        let tmp = std::env::temp_dir().join(format!(
3041            "lattice-meta-{}-{:?}",
3042            std::process::id(),
3043            std::thread::current().id()
3044        ));
3045        std::fs::write(&tmp, b"hello").unwrap();
3046        let meta = std::fs::metadata(&tmp).unwrap();
3047        let anns = metadata_annotations(&meta);
3048        let cats: Vec<&str> = anns.iter().map(|a| a.category()).collect();
3049        assert_eq!(cats, vec!["perm", "size", "mtime"]);
3050        // Every metadata annotation is a Styled cell.
3051        assert!(anns.iter().all(|a| matches!(a, Annotation::Styled { .. })));
3052        // The size cell carries the formatted size on the size slot.
3053        if let Annotation::Styled { segments, .. } = &anns[1] {
3054            assert_eq!(segments.len(), 1);
3055            assert_eq!(segments[0].text.as_ref(), "5");
3056            assert_eq!(segments[0].slot.as_ref(), SLOT_SIZE);
3057        } else {
3058            panic!("size annotation should be Styled");
3059        }
3060        let _ = std::fs::remove_file(&tmp);
3061    }
3062
3063    /// A directory renders with the `d` type char on `perm.type`.
3064    #[cfg(unix)]
3065    #[test]
3066    fn perm_segments_directory_type_char() {
3067        let dir = std::env::temp_dir().join(format!(
3068            "lattice-permdir-{}-{:?}",
3069            std::process::id(),
3070            std::thread::current().id()
3071        ));
3072        let _ = std::fs::create_dir(&dir);
3073        let meta = std::fs::metadata(&dir).unwrap();
3074        let segs = perm_segments(&meta);
3075        assert_eq!(segs[0].text.as_ref(), "d");
3076        assert_eq!(segs[0].slot.as_ref(), SLOT_PERM_TYPE);
3077        let _ = std::fs::remove_dir(&dir);
3078    }
3079
3080    /// MP.1: `location_segments` colors `path:line:col` — dim path, accent
3081    /// line, dim column — with `:` separators on the dim slots.
3082    #[test]
3083    fn location_segments_full_path_line_col() {
3084        let segs = location_segments(Some("src/main.rs"), 42, Some(7));
3085        let text: String = segs.iter().map(|s| s.text.as_ref()).collect();
3086        assert_eq!(text, "src/main.rs:42:7");
3087        assert_eq!(segs[0].slot.as_ref(), SLOT_LOC_PATH); // path
3088        assert_eq!(segs[1].slot.as_ref(), SLOT_LOC_PATH); // ":" sep
3089        assert_eq!(segs[2].slot.as_ref(), SLOT_LOC_LINE); // line
3090        assert_eq!(segs[3].slot.as_ref(), SLOT_LOC_COL); // ":" sep
3091        assert_eq!(segs[4].slot.as_ref(), SLOT_LOC_COL); // col
3092    }
3093
3094    /// MP.1: line-only location (no path, no col) for lines/outline pickers.
3095    #[test]
3096    fn location_segments_line_only() {
3097        let segs = location_segments(None, 12, None);
3098        assert_eq!(segs.len(), 1);
3099        assert_eq!(segs[0].text.as_ref(), "12");
3100        assert_eq!(segs[0].slot.as_ref(), SLOT_LOC_LINE);
3101    }
3102
3103    /// MP.1: status markers — active `•` then dirty `+`, each its own slot;
3104    /// empty when neither applies.
3105    #[test]
3106    fn status_segments_active_and_dirty() {
3107        assert!(status_segments(false, false).is_empty());
3108        let active = status_segments(false, true);
3109        assert_eq!(active.len(), 1);
3110        assert_eq!(active[0].slot.as_ref(), SLOT_STATUS_ACTIVE);
3111        let both = status_segments(true, true);
3112        assert_eq!(both.len(), 2);
3113        assert_eq!(both[0].slot.as_ref(), SLOT_STATUS_ACTIVE);
3114        assert_eq!(both[1].slot.as_ref(), SLOT_STATUS_DIRTY);
3115    }
3116
3117    /// MP.1: each latency class maps to its own slot.
3118    #[test]
3119    fn latency_segment_maps_class_to_slot() {
3120        assert_eq!(
3121            latency_segment(LatencyClass::Reflex).slot.as_ref(),
3122            SLOT_LATENCY_REFLEX
3123        );
3124        assert_eq!(
3125            latency_segment(LatencyClass::Display).slot.as_ref(),
3126            SLOT_LATENCY_DISPLAY
3127        );
3128        assert_eq!(
3129            latency_segment(LatencyClass::Background).slot.as_ref(),
3130            SLOT_LATENCY_BACKGROUND
3131        );
3132    }
3133
3134    /// MP.4: grep hits map to preview-as-display + a path:line:col
3135    /// `location` marginalia cell (1-based), routing to the file location.
3136    #[test]
3137    fn hits_to_pairs_emits_preview_and_location() {
3138        let pairs = hits_to_pairs(
3139            vec![GrepHit {
3140                path: std::path::PathBuf::from("src/main.rs"),
3141                line: 41,
3142                col: 6,
3143                preview: "    let x = 1;".to_string(),
3144            }],
3145            None,
3146        );
3147        assert_eq!(pairs.len(), 1);
3148        let cand = &pairs[0].0;
3149        // Preview (trimmed) is the matchable display.
3150        assert_eq!(cand.display, "let x = 1;");
3151        let loc = cand
3152            .annotations
3153            .iter()
3154            .find(|a| a.category() == "location")
3155            .expect("location cell");
3156        assert_eq!(loc.display_text(), "src/main.rs:42:7");
3157        assert!(matches!(
3158            &pairs[0].1,
3159            RoutingPayload::LspLocation {
3160                line: 41,
3161                col: 6,
3162                ..
3163            }
3164        ));
3165    }
3166
3167    /// PH.3: when a highlighter is wired, grep previews carry its spans
3168    /// as `display_spans` (display-relative, since `display` is the
3169    /// trimmed preview); without one, previews stay plain.
3170    #[test]
3171    fn hits_to_pairs_attaches_highlighter_spans() {
3172        struct Stub;
3173        impl GrepPreviewHighlighter for Stub {
3174            fn highlight_line(
3175                &self,
3176                _path: &std::path::Path,
3177                line: &str,
3178            ) -> Vec<lattice_completion::DisplaySpan> {
3179                vec![lattice_completion::DisplaySpan {
3180                    range: 0..line.len(),
3181                    style: lattice_cells::style::Style::Keyword,
3182                }]
3183            }
3184        }
3185        let mk = || GrepHit {
3186            path: std::path::PathBuf::from("src/main.rs"),
3187            line: 0,
3188            col: 0,
3189            preview: "  let x = 1;".to_string(),
3190        };
3191        // With a highlighter: spans attached, aligned to the trimmed display.
3192        let stub = Stub;
3193        let pairs = hits_to_pairs(vec![mk()], Some(&stub));
3194        let cand = &pairs[0].0;
3195        assert_eq!(cand.display, "let x = 1;");
3196        assert_eq!(cand.display_spans.len(), 1);
3197        assert_eq!(cand.display_spans[0].range, 0..cand.display.len());
3198        // Without one: plain preview.
3199        let plain = hits_to_pairs(vec![mk()], None);
3200        assert!(plain[0].0.display_spans.is_empty());
3201    }
3202
3203    /// Helper smoke: `format_args_hint` matches the
3204    /// emacs-style `<arg>` / `[<arg>]` convention.
3205    #[test]
3206    fn format_args_hint_renders_required_vs_optional() {
3207        use lattice_grammar::args::{ArgDefault, ArgKind, ArgSpec};
3208        let required = ArgSpec {
3209            name: "path".into(),
3210            kind: ArgKind::String,
3211            doc: "".into(),
3212            prompt: "".into(),
3213            default: ArgDefault::Required,
3214            completion: None,
3215            picker: None,
3216        };
3217        let optional = ArgSpec {
3218            default: ArgDefault::None,
3219            ..required.clone()
3220        };
3221        assert_eq!(format_args_hint(std::slice::from_ref(&required)), "<path>");
3222        assert_eq!(
3223            format_args_hint(std::slice::from_ref(&optional)),
3224            "[<path>]"
3225        );
3226        assert_eq!(format_args_hint(&[required, optional]), "<path> [<path>]");
3227        assert_eq!(format_args_hint(&[]), "");
3228    }
3229
3230    /// `parse_grep_line` decodes rg / ag format
3231    /// (`path:line:col:text`). Paths with colons (Windows
3232    /// drive letters, files with `:` in names) still parse
3233    /// because we key off the first numeric line segment, not
3234    /// blind colon-split.
3235    #[test]
3236    fn parse_grep_line_rg_format() {
3237        let hit = parse_grep_line("rg", "src/main.rs:42:7:    let x = foo();").unwrap();
3238        assert_eq!(hit.path, std::path::PathBuf::from("src/main.rs"));
3239        assert_eq!(hit.line, 41);
3240        assert_eq!(hit.col, 6);
3241        assert_eq!(hit.preview, "    let x = foo();");
3242    }
3243
3244    /// `parse_grep_line` decodes plain `grep -rn` format
3245    /// (`path:line:text`, no column).
3246    #[test]
3247    fn parse_grep_line_grep_format() {
3248        let hit = parse_grep_line("grep", "src/main.rs:42:    let x = foo();").unwrap();
3249        assert_eq!(hit.path, std::path::PathBuf::from("src/main.rs"));
3250        assert_eq!(hit.line, 41);
3251        assert_eq!(hit.col, 0);
3252        assert_eq!(hit.preview, "    let x = foo();");
3253    }
3254
3255    /// `walk_files_for_picker` walks a temp tree, honouring
3256    /// the dotfile + ignore-dir filters. Co-located with the
3257    /// walker so the sibling test in ui-tui's app/picker.rs
3258    /// (which referenced `super::walk_files_for_picker`) can
3259    /// retire post-move.
3260    #[test]
3261    fn walk_files_for_picker_honours_dotfile_and_ignore_filters() {
3262        let tmp = std::env::temp_dir().join(format!("lattice-walk-{}", std::process::id()));
3263        let _ = std::fs::remove_dir_all(&tmp);
3264        std::fs::create_dir_all(&tmp).unwrap();
3265        std::fs::write(tmp.join("a.rs"), "").unwrap();
3266        std::fs::write(tmp.join("b.rs"), "").unwrap();
3267        std::fs::create_dir(tmp.join("sub")).unwrap();
3268        std::fs::write(tmp.join("sub").join("c.rs"), "").unwrap();
3269        // Ignored: dotfile and ignore-dir.
3270        std::fs::write(tmp.join(".secret"), "").unwrap();
3271        std::fs::create_dir(tmp.join("target")).unwrap();
3272        std::fs::write(tmp.join("target").join("d.rs"), "").unwrap();
3273        let entries = walk_files_for_picker(&tmp);
3274        let names: Vec<String> = entries
3275            .iter()
3276            .map(|(p, _)| p.file_name().unwrap().to_string_lossy().into_owned())
3277            .collect();
3278        assert!(names.iter().any(|n| n == "a.rs"));
3279        assert!(names.iter().any(|n| n == "b.rs"));
3280        assert!(names.iter().any(|n| n == "c.rs"));
3281        assert!(!names.iter().any(|n| n == ".secret"));
3282        assert!(!names.iter().any(|n| n == "d.rs"));
3283        let _ = std::fs::remove_dir_all(&tmp);
3284    }
3285
3286    /// The parallel walk honours a repo `.gitignore` (the old hand-rolled walk
3287    /// only knew a hardcoded dir list), and the ≤`FILE_PICKER_MAX_ENTRIES` cap
3288    /// holds even though threads race past it before all observe the quit.
3289    #[test]
3290    fn walk_files_for_picker_respects_gitignore_and_caps() {
3291        let tmp = std::env::temp_dir().join(format!(
3292            "lattice-walk-gi-{}-{}",
3293            std::process::id(),
3294            std::time::SystemTime::now()
3295                .duration_since(std::time::UNIX_EPOCH)
3296                .map(|d| d.as_nanos())
3297                .unwrap_or(0)
3298        ));
3299        let _ = std::fs::remove_dir_all(&tmp);
3300        std::fs::create_dir_all(&tmp).unwrap();
3301        // A real git repo so `ignore` activates .gitignore handling.
3302        std::fs::create_dir_all(tmp.join(".git")).unwrap();
3303        std::fs::write(tmp.join(".gitignore"), "ignored.rs\nbuildout/\n").unwrap();
3304        std::fs::write(tmp.join("kept.rs"), "").unwrap();
3305        std::fs::write(tmp.join("ignored.rs"), "").unwrap();
3306        std::fs::create_dir(tmp.join("buildout")).unwrap();
3307        std::fs::write(tmp.join("buildout").join("gen.rs"), "").unwrap();
3308
3309        let names: Vec<String> = walk_files_for_picker(&tmp)
3310            .iter()
3311            .map(|(p, _)| p.file_name().unwrap().to_string_lossy().into_owned())
3312            .collect();
3313        assert!(names.iter().any(|n| n == "kept.rs"), "tracked file kept");
3314        assert!(
3315            !names.iter().any(|n| n == "ignored.rs"),
3316            "gitignored file excluded (the hand-rolled walk could not do this)"
3317        );
3318        assert!(
3319            !names.iter().any(|n| n == "gen.rs"),
3320            "file under a gitignored dir excluded"
3321        );
3322
3323        // Cap: many files, walked in parallel, must not exceed the ceiling.
3324        let big = tmp.join("many");
3325        std::fs::create_dir(&big).unwrap();
3326        for i in 0..(FILE_PICKER_MAX_ENTRIES + 200) {
3327            std::fs::write(big.join(format!("f{i}.rs")), "").unwrap();
3328        }
3329        assert!(
3330            walk_files_for_picker(&tmp).len() <= FILE_PICKER_MAX_ENTRIES,
3331            "the parallel walk must honour the entry cap"
3332        );
3333        let _ = std::fs::remove_dir_all(&tmp);
3334    }
3335
3336    /// Slice C: warming the session cache (what the host does on a background
3337    /// thread at boot) makes a subsequent read a hit — the first `:files` open
3338    /// is then served instantly instead of re-walking. A never-warmed root is a
3339    /// cold miss.
3340    #[test]
3341    fn warm_files_cache_serves_a_subsequent_read() {
3342        let tmp = std::env::temp_dir().join(format!(
3343            "lattice-walk-cache-{}-{}",
3344            std::process::id(),
3345            std::time::SystemTime::now()
3346                .duration_since(std::time::UNIX_EPOCH)
3347                .map(|d| d.as_nanos())
3348                .unwrap_or(0)
3349        ));
3350        let _ = std::fs::remove_dir_all(&tmp);
3351        std::fs::create_dir_all(&tmp).unwrap();
3352        std::fs::write(tmp.join("one.rs"), "").unwrap();
3353        std::fs::write(tmp.join("two.rs"), "").unwrap();
3354
3355        // Cold: nothing cached for this fresh dir.
3356        assert!(cached_files(&tmp).is_none(), "cold cache is a miss");
3357
3358        // Warm it, then the read is a hit — served from the session cache and,
3359        // being freshly walked, not stale.
3360        warm_files_cache(&tmp);
3361        let (entries, stale) = cached_files(&tmp).expect("warmed cache is a hit");
3362        assert!(!stale, "a just-warmed entry is fresh");
3363        let names: Vec<String> = entries
3364            .iter()
3365            .map(|(p, _)| p.file_name().unwrap().to_string_lossy().into_owned())
3366            .collect();
3367        assert!(names.iter().any(|n| n == "one.rs"));
3368        assert!(names.iter().any(|n| n == "two.rs"));
3369        let _ = std::fs::remove_dir_all(&tmp);
3370    }
3371}
3372
3373/// PC.9 — `dir-pick`'s pure halves: what it lists, and where it starts.
3374///
3375/// The hooks that need a real [`PickerContext`] (`descend` through a
3376/// keystroke, `init` through a seated picker) are exercised in
3377/// `lattice-ui-tui::picker_sources`, which is where this module's own doc
3378/// comment says context-needing tests live.
3379#[cfg(test)]
3380mod dir_pick_tests {
3381    #![allow(clippy::unwrap_used, clippy::panic)]
3382
3383    use super::*;
3384
3385    /// A tree with two subdirectories and a file, so "directories only" is
3386    /// falsifiable rather than vacuous.
3387    fn tree() -> tempfile::TempDir {
3388        let dir = tempfile::TempDir::new().unwrap();
3389        std::fs::create_dir_all(dir.path().join("alpha")).unwrap();
3390        std::fs::create_dir_all(dir.path().join("beta")).unwrap();
3391        std::fs::write(dir.path().join("gamma.txt"), "not a directory\n").unwrap();
3392        dir
3393    }
3394
3395    fn texts(rows: &[(RawCandidate, RoutingPayload)]) -> Vec<String> {
3396        rows.iter().map(|(c, _)| c.text.clone()).collect()
3397    }
3398
3399    /// The empty query lists the start directory — and the rows carry their
3400    /// full path, not bare names. That is what makes the first `<C-l>` behave
3401    /// like every later one.
3402    ///
3403    /// PP.1 put `../` in front of them. It is first because going up is the
3404    /// one destination that is never in the listing, so a row for it that
3405    /// sorted among the children would be lost in a long one.
3406    #[test]
3407    fn an_empty_query_lists_the_start_directory_with_full_paths() {
3408        let dir = tree();
3409        let start = dir.path().to_string_lossy().to_string();
3410        let parent = std::path::Path::new(&start)
3411            .parent()
3412            .unwrap()
3413            .to_string_lossy()
3414            .to_string();
3415        let rows = DirPickSource::rows(&DirPickSource::prefix_for(&start, ""));
3416
3417        assert_eq!(
3418            texts(&rows),
3419            vec![
3420                format!("{parent}/"),
3421                format!("{start}/alpha/"),
3422                format!("{start}/beta/")
3423            ],
3424            "`../` first, then both subdirectories, each spelled from the start \
3425             directory"
3426        );
3427        assert_eq!(
3428            rows[0].0.display, "../",
3429            "and it READS as `../` — the path it resolves to is already in the \
3430             prompt, so what the row adds is the verb"
3431        );
3432    }
3433
3434    /// PP.1: `../` is an ORDINARY row. `<C-l>` descends into it because its
3435    /// text ends in `/`, and `<CR>` supplies the parent because that is what
3436    /// every other row does with its own path. Pinned, because a synthetic
3437    /// go-up row with its own accept semantics would be a second answer to a
3438    /// question `descend` already answers.
3439    #[test]
3440    fn the_parent_row_descends_and_supplies_like_any_other() {
3441        let dir = tree();
3442        let start = dir.path().to_string_lossy().to_string();
3443        let rows = DirPickSource::rows(&DirPickSource::prefix_for(&start, ""));
3444        let (cand, routing) = &rows[0];
3445
3446        assert!(
3447            cand.text.ends_with('/'),
3448            "`descend` takes any row whose text ends in `/`: {}",
3449            cand.text
3450        );
3451        let RoutingPayload::SuppliedValue { value } = routing else {
3452            panic!("`../` supplies a value like every other row: {routing:?}");
3453        };
3454        assert_eq!(
3455            std::path::Path::new(value),
3456            std::path::Path::new(&start).parent().unwrap(),
3457            "and the value is the parent directory itself"
3458        );
3459    }
3460
3461    /// **`../` and `<C-w>` must land in the same place.** They share
3462    /// `parent_of` for exactly this reason: two ways to go up that arrive
3463    /// somewhere different is an inconsistency nobody reports and everybody
3464    /// trips on.
3465    #[test]
3466    fn the_parent_row_and_the_ascend_key_agree() {
3467        let source = DirPickSource::new();
3468        for query in ["/tmp/", "~/src/", "~/"] {
3469            let row = DirPickSource::parent_row(query).map(|(c, _)| c.text);
3470            assert_eq!(
3471                row,
3472                source.ascend(query),
3473                "`../` and `<C-w>` disagree about the parent of {query}"
3474            );
3475        }
3476    }
3477
3478    /// ascend at `~/` used to CLEAR the query, which re-listed `~/` — a key
3479    /// that visibly did nothing. Home's parent is spelled absolutely because
3480    /// the tilde form cannot name it, which is the one case where the query's
3481    /// own text is not enough.
3482    #[test]
3483    fn home_has_a_parent_spelled_absolutely() {
3484        let home = lattice_core::home::expand_tilde("~");
3485        if !std::path::Path::new(&home).is_dir() {
3486            eprintln!("SKIP: no home directory to expand against");
3487            return;
3488        }
3489        let up = DirPickSource::parent_of("~/").expect("home has a parent");
3490        // `is_absolute`, not `starts_with('/')`: on Windows it is `C:\Users/`.
3491        assert!(
3492            std::path::Path::new(&up).is_absolute() && up.ends_with('/'),
3493            "absolute, and a listing prefix: {up}"
3494        );
3495        assert_eq!(
3496            std::path::Path::new(up.trim_end_matches('/')),
3497            std::path::Path::new(&home).parent().unwrap()
3498        );
3499    }
3500
3501    /// The root is where going up stops. A `../` row there would offer a
3502    /// destination that does not exist.
3503    #[test]
3504    fn the_root_offers_no_way_up() {
3505        assert_eq!(DirPickSource::parent_of("/"), None);
3506        assert!(
3507            !texts(&DirPickSource::rows("/")).iter().any(|t| t == "/"),
3508            "no row pointing `/` at itself"
3509        );
3510    }
3511
3512    /// PP.1: the picker opens ON the start directory, so the prompt says
3513    /// where you are from the first frame.
3514    ///
3515    /// The trailing `/` is the assertion that matters: without it the seeded
3516    /// query is a FILTER (`path_entries("/tmp")` lists `/`'s children whose
3517    /// names start with `tmp`) rather than a listing, which is exactly what
3518    /// `:picker dir-pick /tmp` used to do.
3519    #[test]
3520    fn the_query_opens_on_the_start_directory() {
3521        let source = DirPickSource::new();
3522        assert_eq!(
3523            source.initial_query(&["/tmp".to_string()]),
3524            Some("/tmp/".to_string()),
3525            "an argument without a trailing slash is normalised into a listing"
3526        );
3527        assert_eq!(
3528            source.initial_query(&["/tmp/".to_string()]),
3529            Some("/tmp/".to_string()),
3530            "and one with it is left alone"
3531        );
3532        assert_eq!(
3533            source.initial_query(&[]),
3534            Some("~/".to_string()),
3535            "no argument opens on home, which is where this source starts"
3536        );
3537    }
3538
3539    /// A basename filter is not a listing, and `../` must not survive it: it
3540    /// would be the one row in a filtered set that is not a match.
3541    #[test]
3542    fn a_filtered_listing_offers_no_parent_row() {
3543        let dir = tree();
3544        let start = dir.path().to_string_lossy().to_string();
3545        let rows = DirPickSource::rows(&format!("{start}/al"));
3546
3547        assert_eq!(texts(&rows), vec![format!("{start}/alpha/")]);
3548    }
3549
3550    /// Files are not directories. A source that listed them would hand back a
3551    /// path its caller cannot use as one.
3552    #[test]
3553    fn a_regular_file_is_never_a_row() {
3554        let dir = tree();
3555        let start = dir.path().to_string_lossy().to_string();
3556        let rows = DirPickSource::rows(&DirPickSource::prefix_for(&start, ""));
3557
3558        assert!(
3559            !texts(&rows).iter().any(|t| t.contains("gamma")),
3560            "`gamma.txt` exists in the tree and must not be offered: {:?}",
3561            texts(&rows)
3562        );
3563    }
3564
3565    /// The basename after the last `/` filters, which is what makes typing
3566    /// narrow rather than restart.
3567    #[test]
3568    fn a_partial_basename_filters_the_listing() {
3569        let dir = tree();
3570        let start = dir.path().to_string_lossy().to_string();
3571        let rows = DirPickSource::rows(&format!("{start}/al"));
3572
3573        assert_eq!(texts(&rows), vec![format!("{start}/alpha/")]);
3574    }
3575
3576    /// **An unreadable query is an empty list, not an error.** Half a typed
3577    /// path names nothing yet, and that is the state the user is in for most
3578    /// of the keystrokes — erroring on it would mean the picker spends its
3579    /// life reporting failure.
3580    #[test]
3581    fn a_query_naming_nothing_yields_an_empty_list() {
3582        let dir = tree();
3583        let start = dir.path().to_string_lossy().to_string();
3584
3585        assert!(DirPickSource::rows(&format!("{start}/no-such-dir/")).is_empty());
3586        assert!(DirPickSource::rows("/definitely/not/a/real/path/").is_empty());
3587    }
3588
3589    /// The row's TEXT keeps the query's spelling (what the user reads and
3590    /// descends from); the VALUE it supplies is the expanded absolute path
3591    /// (what a consumer resolves). A consumer should not have to know about
3592    /// `~`.
3593    #[test]
3594    fn the_supplied_value_is_the_expanded_path_even_when_the_text_is_not() {
3595        let home = lattice_core::home::expand_tilde("~");
3596        if !std::path::Path::new(&home).is_dir() {
3597            eprintln!("SKIP: no home directory to expand against");
3598            return;
3599        }
3600        let rows = DirPickSource::rows("~/");
3601        // Past `../`: that row is the deliberate exception to the spelling
3602        // rule, because the tilde form cannot name home's parent (PP.1).
3603        let Some((cand, routing)) = rows.iter().find(|(c, _)| c.display != "../") else {
3604            eprintln!("SKIP: the home directory has no subdirectories");
3605            return;
3606        };
3607        let RoutingPayload::SuppliedValue { value } = routing else {
3608            panic!("dir-pick supplies values: {routing:?}");
3609        };
3610
3611        assert!(
3612            cand.text.starts_with("~/"),
3613            "the row keeps the spelling the user typed: {}",
3614            cand.text
3615        );
3616        assert!(
3617            value.starts_with(&home) && !value.starts_with('~'),
3618            "the value is expanded: {value}"
3619        );
3620    }
3621
3622    /// Every directory row ends in `/`, which is both what `descend` keys off
3623    /// and the prefix that lists a directory's CONTENTS rather than its
3624    /// siblings.
3625    #[test]
3626    fn every_row_ends_in_a_slash_so_descending_lists_its_children() {
3627        let dir = tree();
3628        let start = dir.path().to_string_lossy().to_string();
3629        let rows = DirPickSource::rows(&DirPickSource::prefix_for(&start, ""));
3630
3631        assert!(
3632            !rows.is_empty(),
3633            "precondition: the tree has subdirectories"
3634        );
3635        for (cand, _) in &rows {
3636            assert!(
3637                cand.text.ends_with('/'),
3638                "row without a slash: {}",
3639                cand.text
3640            );
3641            // The round trip descend relies on: this row's own text, used as
3642            // the next query, lists what is inside it.
3643            //
3644            // `../` is excluded from the *inner* listing, not from the slash
3645            // rule above: it is the one row that points OUT, so the listing it
3646            // produces contains its own `../` pointing further out, which is
3647            // not under the query by construction. Excluding the whole row
3648            // instead would stop checking that `<C-l>` on `../` works at all.
3649            assert!(
3650                DirPickSource::rows(&cand.text)
3651                    .iter()
3652                    .filter(|(c, _)| c.display != "../")
3653                    .all(|(c, _)| c.text.starts_with(&cand.text)),
3654                "descending into {} must list its children",
3655                cand.text
3656            );
3657        }
3658    }
3659
3660    /// A start already ending in `/` must not become `//`.
3661    #[test]
3662    fn the_start_prefix_carries_exactly_one_slash() {
3663        assert_eq!(DirPickSource::prefix_for("/tmp", ""), "/tmp/");
3664        assert_eq!(DirPickSource::prefix_for("/tmp/", ""), "/tmp/");
3665        assert_eq!(DirPickSource::prefix_for("~", ""), "~/");
3666    }
3667
3668    /// A non-empty query IS the prefix — the start is only ever a seed.
3669    #[test]
3670    fn a_non_empty_query_replaces_the_start_entirely() {
3671        assert_eq!(DirPickSource::prefix_for("/tmp", "/etc/x"), "/etc/x");
3672    }
3673
3674    /// Home, not the workspace root — see the type's own doc for why the
3675    /// asymmetry with `file-pick` is deliberate. Pinned because "make it
3676    /// consistent with `file-pick`" is exactly the tidy-looking change that
3677    /// would break the motivating case.
3678    #[test]
3679    fn browsing_starts_at_home_unless_told_otherwise() {
3680        assert_eq!(DirPickSource::start_dir(&[]), "~");
3681        assert_eq!(DirPickSource::start_dir(&[String::new()]), "~");
3682        assert_eq!(DirPickSource::start_dir(&["/srv".to_string()]), "/srv");
3683    }
3684
3685    /// The source declares `live`, and it must: the query is a PATH, so the
3686    /// picker's fuzzy refilter would rank `~/src/dh` against bare child names
3687    /// instead of listing what is under `~/src/`. The two declarations are
3688    /// paired by the trait's contract.
3689    #[test]
3690    fn the_source_is_live_because_its_query_is_a_path() {
3691        assert!(DirPickSource::new().spec().live);
3692    }
3693}