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, ®istry));
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, ®istry));
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, ®istry));
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, ®istry));
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, ®istry));
467 let k2 = g.cache_key(&ctx_for("ex:wri", &buffer, ®istry));
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, ®istry));
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, ®istry));
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, ®istry));
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, ®istry));
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, ®istry));
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, ®istry));
566 let k_b = g.cache_key(&ctx_for("/tmp/foo/", &buffer, ®istry));
567 let k_c = g.cache_key(&ctx_for("/var/", &buffer, ®istry));
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}