Skip to main content

lattice_completion/builtins/
generators.rs

1//! Built-in candidate generators (DESIGN.md §5.11.3).
2//!
3//! v1 ships:
4//! - [`CommandsGenerator`] -- every `CommandSpec` in the registry.
5//!   Caches the full set per registry-version (commands don't
6//!   change at runtime in v1, so the cache key is fixed and the
7//!   cache effectively never expires).
8//! - [`FilesGenerator`] -- filesystem entries for a path-shaped
9//!   prefix. Caches per-directory with a 1-second soft TTL.
10//! - [`DirectoriesGenerator`] -- like [`FilesGenerator`] but only
11//!   emits directory entries. Intended for `:cd` and similar
12//!   directory-only commands.
13//!
14//! Other host-state generators (chords, registers, marks, buffers)
15//! live in `lattice-ui-tui` because they need App-level state.
16//! Plugins register their own through the same trait.
17
18use std::path::PathBuf;
19use std::time::Duration;
20
21use crate::candidate::{CacheKey, CandidateData, CandidateKind, RawCandidate};
22use crate::traits::{CandidateGenerator, GenerateContext};
23
24/// `gen:commands`. Walks the `CommandRegistry` and emits one
25/// `RawCandidate` per registered `CommandSpec`. Filtering is
26/// deferred to the matcher; the generator returns the full set
27/// every time the cache misses.
28///
29/// Delimiter-form commands (`ex:substitute`, `ex:global` --
30/// `SurfaceForm::Delimiter`) are excluded: the user types those
31/// via `:s/.../.../`, `:g/.../.../`, `:v/.../.../`. Surfacing them
32/// as completion candidates is misleading because typing
33/// `:ex:global` would error with "use the delimiter form" -- the
34/// keyword form is intentionally a hard-error redirect (DESIGN.md
35/// §B.2). They remain reachable through `:describe-command` /
36/// `:apropos` for introspection.
37pub struct CommandsGenerator;
38
39impl CandidateGenerator for CommandsGenerator {
40    fn generate(&self, ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
41        let mut out: Vec<RawCandidate> = ctx
42            .registry
43            .names()
44            .filter_map(|name| {
45                let id = ctx.registry.id_by_name(name)?;
46                let spec = ctx.registry.lookup(id)?;
47                // UD.2: include ex-commands AND motions. Both are
48                // invocable from the `:` line — `:w`, `:motion:line-down`
49                // — and now behave identically to a keystroke (the host
50                // dispatch is unified; see typed-motion-dispatch.md).
51                //
52                // OM.14: and ACTIONS, which the ex-command parser used to
53                // refuse. The exclusion here was true when it was written and
54                // stopped being true when `parse_naked_action` landed; leaving
55                // it would have made every action invocable and none of them
56                // discoverable, which is the worse half of the original bug —
57                // you can only type a name you already know.
58                //
59                // Operators and text-objects stay filtered: an operator needs
60                // a target, so it is not actionable standalone.
61                match spec.kind {
62                    lattice_grammar::CommandKind::ExCommand => {
63                        // Delimiter-only commands (`:s/.../`, `:g/.../`)
64                        // have no useful keyword-form completion target.
65                        let ex = ctx.registry.ex_command_spec(id)?;
66                        if matches!(
67                            ex.surface_form,
68                            lattice_grammar::SurfaceForm::Delimiter { .. }
69                        ) {
70                            return None;
71                        }
72                    }
73                    lattice_grammar::CommandKind::Motion | lattice_grammar::CommandKind::Action => {
74                    }
75                    _ => return None,
76                }
77                // User-facing name: strip the `ex:` namespace prefix so
78                // ex-commands show `oil` not `ex:oil`. Motions keep their
79                // `motion:` prefix — that IS what the user types
80                // (`:motion:line-down`).
81                let display_name = spec
82                    .name
83                    .strip_prefix("ex:")
84                    .unwrap_or(&spec.name)
85                    .to_string();
86                Some(RawCandidate {
87                    insert_text: None,
88                    text: display_name.clone(),
89                    display: display_name.clone(),
90                    kind: CandidateKind::Command,
91                    data: CandidateData::Command {
92                        name: spec.name.clone(),
93                        doc: spec.doc.clone(),
94                        kind_label: spec.kind.label().to_string(),
95                        source: spec.source.clone(),
96                    },
97                    source: None,
98                    accept_action: None,
99                    annotations: Vec::new(),
100                    display_spans: Vec::new(),
101                })
102            })
103            .collect();
104        out.sort_by(|a, b| a.text.cmp(&b.text));
105        out
106    }
107
108    fn cache_key(&self, ctx: &GenerateContext<'_>) -> Option<CacheKey> {
109        // Dynamic registration (WASM plugins) mutates the command set at
110        // runtime, so a fixed key would serve a stale candidate list forever
111        // (the cache lives on the CompletionRegistry, which a command-registry
112        // RCU never touches). The registry bumps `generation()` on every
113        // register / unregister — including a plugin's RCU'd drain and its
114        // unload — so keying on it makes a plugin's freshly-registered commands
115        // (and its auto `:<mode>` toggles) appear in `<Tab>` completion
116        // immediately, and unloaded ones vanish, with no manual cache flush.
117        Some(CacheKey::new(format!(
118            "gen:commands:v1:{}",
119            ctx.registry.generation()
120        )))
121    }
122}
123
124/// `gen:directories`. Resolves the prefix into a directory + basename
125/// pattern, then lists *only directories* of that directory matching
126/// the basename prefix. Like [`FilesGenerator`] but omits regular files.
127pub struct DirectoriesGenerator;
128
129/// `gen:files`. Resolves the prefix into a directory + basename
130/// pattern, then lists entries of that directory matching the
131/// basename prefix. Returns directories with a trailing `/` so the
132/// user can keep tab-completing into nested paths.
133pub struct FilesGenerator;
134
135impl CandidateGenerator for DirectoriesGenerator {
136    fn generate(&self, ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
137        fs_entries(ctx, false)
138    }
139
140    fn cache_key(&self, ctx: &GenerateContext<'_>) -> Option<CacheKey> {
141        let dir_str = match ctx.prefix.rfind('/') {
142            Some(i) => &ctx.prefix[..=i],
143            None => "",
144        };
145        let dir = expand_tilde(dir_str);
146        Some(CacheKey::new(format!(
147            "gen:directories:{}",
148            dir.to_string_lossy()
149        )))
150    }
151
152    fn cache_ttl(&self) -> Duration {
153        Duration::from_secs(1)
154    }
155}
156
157impl CandidateGenerator for FilesGenerator {
158    fn generate(&self, ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
159        fs_entries(ctx, true)
160    }
161
162    fn cache_key(&self, ctx: &GenerateContext<'_>) -> Option<CacheKey> {
163        let dir_str = match ctx.prefix.rfind('/') {
164            Some(i) => &ctx.prefix[..=i],
165            None => "",
166        };
167        let dir = expand_tilde(dir_str);
168        Some(CacheKey::new(format!(
169            "gen:files:{}",
170            dir.to_string_lossy()
171        )))
172    }
173
174    fn cache_ttl(&self) -> Duration {
175        Duration::from_secs(1)
176    }
177}
178
179/// Shared helper: list filesystem entries matching the prefix.
180/// When `include_files` is false, only directories are emitted.
181fn fs_entries(ctx: &GenerateContext<'_>, include_files: bool) -> Vec<RawCandidate> {
182    path_entries(ctx.prefix, ctx.case_sensitive, include_files)
183}
184
185/// PC.9: [`fs_entries`] without the completion engine's context.
186///
187/// The `prefix` split, the `~` expansion, the case rule, the trailing `/` on a
188/// directory and the sort are all one behaviour, and the picker's `dir-pick`
189/// source needs exactly it — a *picker* source cannot build a
190/// [`GenerateContext`], which is shaped for the completion engine (buffer,
191/// command registry, cursor). Extracted rather than copied so the two surfaces
192/// cannot drift on what "list this path's children" means: a `dir-pick` that
193/// expanded `~` differently from `<Tab>` on the `:` line would be a bug nobody
194/// would think to look for.
195///
196/// An unreadable directory yields an empty list rather than an error. Half a
197/// typed path names nothing yet, and that is the state the caller is in for
198/// most of the keystrokes.
199pub fn path_entries(prefix: &str, case_sensitive: bool, include_files: bool) -> Vec<RawCandidate> {
200    let (dir_str, basename) = match prefix.rfind('/') {
201        Some(i) => (&prefix[..=i], &prefix[i + 1..]),
202        None => ("", prefix),
203    };
204    let dir_path = expand_tilde(dir_str);
205
206    let read_dir = match std::fs::read_dir(&dir_path) {
207        Ok(rd) => rd,
208        Err(_) => return Vec::new(),
209    };
210
211    let basename_lower = basename.to_ascii_lowercase();
212    let mut out: Vec<RawCandidate> = Vec::new();
213    for entry in read_dir.flatten() {
214        let name_os = entry.file_name();
215        let name = name_os.to_string_lossy();
216        if !case_sensitive && !name.to_ascii_lowercase().starts_with(&basename_lower) {
217            continue;
218        }
219        if case_sensitive && !name.starts_with(basename) {
220            continue;
221        }
222        // `file_type()` before `metadata()`, and `metadata()` only when the
223        // answer is actually needed. `read_dir` already carries the type for
224        // most entries (`d_type` on Linux/macOS), so `file_type()` is free
225        // where `metadata()` is a `stat` syscall each — 5000 entries cost
226        // ~15ms of pure syscall that way, and this runs SYNCHRONOUSLY on the
227        // actor thread from `on_query_changed`, i.e. inside a keystroke.
228        //
229        // A symlink is the one case `file_type()` cannot answer: it reports
230        // the LINK, never its target, so a symlinked directory would stop
231        // being listed. Those fall back to `metadata()`, which follows — the
232        // stat is paid only for the entries that need it, and `~/src -> …`
233        // is common enough that losing them would be a real regression.
234        let file_type = entry.file_type();
235        let is_symlink = file_type.as_ref().map(|t| t.is_symlink()).unwrap_or(true);
236        let is_dir = if is_symlink {
237            entry.metadata().map(|m| m.is_dir()).unwrap_or(false)
238        } else {
239            file_type.as_ref().map(|t| t.is_dir()).unwrap_or(false)
240        };
241        if !include_files && !is_dir {
242            continue;
243        }
244        // Only files carry a size, and only a file listing shows one — so a
245        // directory listing never pays for it at all.
246        let size = if include_files && !is_dir {
247            entry.metadata().ok().map(|m| m.len())
248        } else {
249            None
250        };
251        let mut text = String::with_capacity(dir_str.len() + name.len() + 1);
252        text.push_str(dir_str);
253        text.push_str(&name);
254        if is_dir {
255            text.push('/');
256        }
257        let display = if is_dir {
258            format!("{name}/")
259        } else {
260            name.to_string()
261        };
262        out.push(RawCandidate {
263            insert_text: None,
264            text,
265            display,
266            kind: if is_dir {
267                CandidateKind::Directory
268            } else {
269                CandidateKind::File
270            },
271            data: CandidateData::File {
272                path: entry.path(),
273                is_dir,
274                size,
275            },
276            source: None,
277            accept_action: None,
278            annotations: Vec::new(),
279            display_spans: Vec::new(),
280        });
281    }
282    out.sort_by(|a, b| a.text.cmp(&b.text));
283    out
284}
285
286/// `~` expansion, plus this consumer's own empty-means-here rule.
287///
288/// The tilde half delegates to [`lattice_core::home::expand_tilde`] so a
289/// Windows host resolves `%USERPROFILE%`; the empty case stays local because it
290/// is a *completion* rule (an empty prefix completes the current directory),
291/// not something a path helper should decide for everyone.
292fn expand_tilde(p: &str) -> PathBuf {
293    if p.is_empty() {
294        return PathBuf::from(".");
295    }
296    PathBuf::from(for_the_filesystem(
297        lattice_core::home::expand_tilde(p),
298        cfg!(windows),
299    ))
300}
301
302/// Spell `path` the way the OS will accept it.
303///
304/// A prefix is split on `/` everywhere in this module, so a listing's
305/// directory always arrives ending in one. On Windows that is fine for an
306/// ordinary path — `C:\Users\me/` opens — but not for a VERBATIM one,
307/// `\\?\C:\Users\me/`, which is what `canonicalize` returns: a verbatim path
308/// is handed to the filesystem untouched, `/` is not a separator in it, and
309/// the directory does not exist. A picker rooted at a canonical path listed
310/// nothing at all.
311///
312/// So a verbatim path has its `/` turned into `\`. Only that form, and only on
313/// Windows: elsewhere a backslash is a legal character in a file name.
314fn for_the_filesystem(path: String, windows: bool) -> String {
315    if windows && path.starts_with(r"\\?\") {
316        path.replace('/', "\\")
317    } else {
318        path
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    #![allow(clippy::unwrap_used, clippy::panic)]
325    use super::*;
326    use lattice_core::{Buffer, Document};
327    use lattice_grammar::CommandRegistry;
328
329    fn ctx_for<'a>(
330        prefix: &'a str,
331        buffer: &'a Buffer,
332        registry: &'a CommandRegistry,
333    ) -> GenerateContext<'a> {
334        GenerateContext {
335            prefix,
336            buffer,
337            registry,
338            case_sensitive: false,
339        }
340    }
341
342    // ---- for_the_filesystem ----
343
344    /// The Windows half, pinned on every platform because the function is
345    /// pure: a verbatim path gets real separators, and nothing else is
346    /// touched — not an ordinary Windows path, and never a path off Windows,
347    /// where `\` may be part of a name.
348    #[test]
349    fn a_verbatim_windows_path_gets_real_separators_and_nothing_else_does() {
350        assert_eq!(
351            for_the_filesystem(r"\\?\C:\Users\me/src/".to_string(), true),
352            r"\\?\C:\Users\me\src\"
353        );
354        assert_eq!(
355            for_the_filesystem(r"C:\Users\me/src/".to_string(), true),
356            r"C:\Users\me/src/"
357        );
358        assert_eq!(
359            for_the_filesystem(r"\\?\odd/name/".to_string(), false),
360            r"\\?\odd/name/"
361        );
362    }
363
364    // ---- CommandsGenerator ----
365
366    #[test]
367    fn commands_generator_returns_one_candidate_per_registered_command() {
368        let mut registry = CommandRegistry::new();
369        let _ = lattice_grammar::builtins::populate(&mut registry);
370        let _ = lattice_grammar::ex_commands::populate(&mut registry);
371        let document = Document::empty();
372        let buffer = document.buffer().clone();
373        let g = CommandsGenerator;
374        let candidates = g.generate(&ctx_for("", &buffer, &registry));
375        assert!(candidates.len() > 10, "expected multiple ex-commands");
376        // Spot-check: write should appear (it's an ex-command).
377        assert!(candidates.iter().any(|c| c.text == "write"));
378        // UD.2: motions ARE invocable via `:` (unified dispatch), so they
379        // appear with their `motion:` prefix.
380        assert!(
381            candidates.iter().any(|c| c.text == "motion:line-down"),
382            "motions are actionable via `:` and must be completable",
383        );
384        // Operators are NOT actionable standalone (they need a target) and
385        // stay filtered — the boundary the filter draws.
386        assert!(
387            !candidates.iter().any(|c| c.text.starts_with("operator:")),
388            "operators are not standalone-actionable and must be filtered out",
389        );
390    }
391
392    #[test]
393    fn commands_generator_filters_delimiter_only_commands() {
394        // ex:substitute and ex:global have SurfaceForm::Delimiter --
395        // they're typed via :s/.../.../  and :g/.../.../  not by
396        // name. The completion list must hide them so the user
397        // doesn't see (and can't pick) a candidate that would error
398        // when accepted.
399        let mut registry = CommandRegistry::new();
400        let _ = lattice_grammar::builtins::populate(&mut registry);
401        let _ = lattice_grammar::ex_commands::populate(&mut registry);
402        let document = Document::empty();
403        let buffer = document.buffer().clone();
404        let candidates = CommandsGenerator.generate(&ctx_for("", &buffer, &registry));
405        assert!(
406            !candidates.iter().any(|c| c.text == "ex:substitute"),
407            "ex:substitute is delimiter-form-only and must not appear",
408        );
409        assert!(
410            !candidates.iter().any(|c| c.text == "ex:global"),
411            "ex:global is delimiter-form-only and must not appear",
412        );
413        // Other ex-commands stay present.
414        assert!(candidates.iter().any(|c| c.text == "write"));
415    }
416
417    #[test]
418    fn commands_generator_emits_doc_and_kind_label_in_data() {
419        let mut registry = CommandRegistry::new();
420        let _ = lattice_grammar::builtins::populate(&mut registry);
421        let _ = lattice_grammar::ex_commands::populate(&mut registry);
422        let document = Document::empty();
423        let buffer = document.buffer().clone();
424        let candidates = CommandsGenerator.generate(&ctx_for("", &buffer, &registry));
425        let write = candidates.iter().find(|c| c.text == "write").unwrap();
426        match &write.data {
427            CandidateData::Command {
428                doc, kind_label, ..
429            } => {
430                assert!(!doc.is_empty(), "ex:write should have a doc");
431                assert_eq!(kind_label, "ex-command");
432            }
433            other => panic!("expected Command data, got {other:?}"),
434        }
435    }
436
437    #[test]
438    fn commands_generator_cache_key_embeds_registry_generation() {
439        // The key embeds the registry generation so a runtime command
440        // registration / unload (a plugin drain, which RCUs a registry with a
441        // bumped `generation()`) invalidates the cached candidate list —
442        // plugin commands then appear in `<Tab>` completion without a flush.
443        let registry = CommandRegistry::new();
444        let document = Document::empty();
445        let buffer = document.buffer().clone();
446        let g = CommandsGenerator;
447        let key = g.cache_key(&ctx_for("anything", &buffer, &registry));
448        assert_eq!(
449            key,
450            Some(CacheKey::new(format!(
451                "gen:commands:v1:{}",
452                registry.generation()
453            )))
454        );
455    }
456
457    #[test]
458    fn commands_generator_cache_key_independent_of_prefix() {
459        // Verify cache key is the same regardless of prefix --
460        // matcher does the filtering against the cached set, so
461        // typing more chars shouldn't invalidate.
462        let registry = CommandRegistry::new();
463        let document = Document::empty();
464        let buffer = document.buffer().clone();
465        let g = CommandsGenerator;
466        let k1 = g.cache_key(&ctx_for("", &buffer, &registry));
467        let k2 = g.cache_key(&ctx_for("ex:wri", &buffer, &registry));
468        assert_eq!(k1, k2);
469    }
470
471    // ---- FilesGenerator ----
472
473    #[test]
474    fn files_generator_lists_current_directory_when_prefix_empty() {
475        let registry = CommandRegistry::new();
476        let document = Document::empty();
477        let buffer = document.buffer().clone();
478        let g = FilesGenerator;
479        // Run from the workspace root; the test harness's cwd is
480        // the crate dir.
481        let candidates = g.generate(&ctx_for("", &buffer, &registry));
482        // Should at least produce some entries (Cargo.toml, src/, etc.).
483        assert!(!candidates.is_empty(), "expected non-empty cwd listing");
484    }
485
486    #[test]
487    fn files_generator_filters_by_basename_prefix() {
488        let registry = CommandRegistry::new();
489        let document = Document::empty();
490        let buffer = document.buffer().clone();
491        let g = FilesGenerator;
492        let candidates = g.generate(&ctx_for("Carg", &buffer, &registry));
493        assert!(candidates.iter().any(|c| c.text.starts_with("Carg")));
494    }
495
496    #[test]
497    fn files_generator_marks_directories_with_trailing_slash() {
498        let registry = CommandRegistry::new();
499        let document = Document::empty();
500        let buffer = document.buffer().clone();
501        let g = FilesGenerator;
502        let candidates = g.generate(&ctx_for("", &buffer, &registry));
503        let src_entry = candidates.iter().find(|c| c.display.starts_with("src"));
504        if let Some(entry) = src_entry {
505            // src is a directory; should have trailing /.
506            assert_eq!(entry.kind, CandidateKind::Directory);
507            assert!(entry.display.ends_with('/'));
508            assert!(entry.text.ends_with('/'));
509        }
510    }
511
512    #[test]
513    fn files_generator_completes_nested_path_with_slash() {
514        // Regression guard: FilesGenerator must walk the
515        // directory referenced by everything before the LAST `/`
516        // in the prefix and filter remaining entries by the
517        // basename suffix. Without this, `:e crates/latt<Tab>`
518        // would fail to surface any candidates.
519        let tmp = std::env::temp_dir().join(format!("lattice-files-nested-{}", std::process::id()));
520        let _ = std::fs::remove_dir_all(&tmp);
521        let sub = tmp.join("sub");
522        std::fs::create_dir_all(&sub).unwrap();
523        for n in ["alpha", "beta", "zeta"] {
524            std::fs::write(sub.join(n), "").unwrap();
525        }
526        // An absolute prefix, NOT `set_current_dir` + a relative one. The
527        // working directory is process-global and tests run in parallel, so
528        // changing it — even briefly — made the tests above, which list the
529        // current directory, see this temp dir instead of the crate's.
530        let prefix = format!("{}/sub/al", tmp.display());
531        let expected = format!("{}/sub/alpha", tmp.display());
532
533        let registry = CommandRegistry::new();
534        let document = Document::empty();
535        let buffer = document.buffer().clone();
536        let g = FilesGenerator;
537        let candidates = g.generate(&ctx_for(&prefix, &buffer, &registry));
538
539        let _ = std::fs::remove_dir_all(&tmp);
540
541        assert!(
542            candidates.iter().any(|c| c.text == expected),
543            "expected `{expected}` candidate, got {} candidates: {:?}",
544            candidates.len(),
545            candidates.iter().map(|c| &c.text).collect::<Vec<_>>(),
546        );
547    }
548
549    #[test]
550    fn files_generator_returns_empty_for_nonexistent_directory() {
551        let registry = CommandRegistry::new();
552        let document = Document::empty();
553        let buffer = document.buffer().clone();
554        let g = FilesGenerator;
555        let candidates = g.generate(&ctx_for("/this/path/should/not/exist/", &buffer, &registry));
556        assert!(candidates.is_empty());
557    }
558
559    #[test]
560    fn files_generator_cache_key_is_per_directory() {
561        let registry = CommandRegistry::new();
562        let document = Document::empty();
563        let buffer = document.buffer().clone();
564        let g = FilesGenerator;
565        let k_a = g.cache_key(&ctx_for("/tmp/foo", &buffer, &registry));
566        let k_b = g.cache_key(&ctx_for("/tmp/foo/", &buffer, &registry));
567        let k_c = g.cache_key(&ctx_for("/var/", &buffer, &registry));
568        // /tmp/foo (no slash) keys to "" because no slash before.
569        // We deliberately bucket by directory; basename doesn't
570        // change the key.
571        assert_ne!(k_a, k_b);
572        assert_ne!(k_b, k_c);
573    }
574
575    #[test]
576    fn files_generator_uses_short_ttl() {
577        let g = FilesGenerator;
578        assert_eq!(g.cache_ttl(), Duration::from_secs(1));
579    }
580}