Skip to main content

hjkl_ex/
complete.rs

1use std::ops::Range;
2
3use crate::{ArgKind, HostRegistry, Registry};
4
5/// What kind of token is being completed. Phase 5a only emits `Command`;
6/// Phase 6 adds Path/Setting/Buffer/Register/Mark for arg completion.
7#[derive(Copy, Clone, Debug, Eq, PartialEq)]
8pub enum CompletionKind {
9    None,
10    Command,
11    Path,
12    Setting,
13    Buffer,
14    Register,
15    Mark,
16}
17
18/// Sources for arg completion. Caller fills the slots applicable to
19/// their context. None means "no candidates" — completer returns empty.
20#[derive(Default)]
21pub struct ArgSources<'a> {
22    /// cwd to scan for `:e <Tab>` style path completion. None disables.
23    pub cwd: Option<&'a std::path::Path>,
24    /// All known option names + aliases for `:set <Tab>`. Empty disables.
25    pub settings: &'a [String],
26    /// Open buffer names for `:b <Tab>`. Empty disables.
27    pub buffers: &'a [String],
28    /// Non-empty register selectors (e.g. `"a"`, `"+"`, `"0"`) for `:reg`/`:put`.
29    pub registers: &'a [String],
30    /// Live mark names for `:marks`/`:delmarks`. Empty disables.
31    pub marks: &'a [String],
32}
33
34/// Completion candidates for an input line at a given caret offset.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct Completions {
37    /// Byte range in the original input that should be replaced when a
38    /// candidate is accepted. For command completion this is the leading
39    /// command-name token range.
40    pub replace_range: Range<usize>,
41    /// Candidate strings sorted alphabetically. Empty when nothing matches.
42    pub candidates: Vec<String>,
43    /// Token kind. `None` when caret is outside any completable position.
44    pub kind: CompletionKind,
45}
46
47impl Completions {
48    /// Empty completion at caret position — kind=None, no candidates.
49    pub fn empty(caret: usize) -> Self {
50        Self {
51            replace_range: caret..caret,
52            candidates: Vec::new(),
53            kind: CompletionKind::None,
54        }
55    }
56}
57
58/// Compute the longest common prefix of a non-empty slice of strings.
59/// Returns "" when the slice is empty or the LCP is empty.
60pub fn longest_common_prefix(candidates: &[String]) -> String {
61    if candidates.is_empty() {
62        return String::new();
63    }
64    let first = &candidates[0];
65    let mut end = first.len();
66    for s in &candidates[1..] {
67        end = end.min(s.len());
68        end = first
69            .as_bytes()
70            .iter()
71            .zip(s.as_bytes().iter())
72            .take(end)
73            .take_while(|(a, b)| a == b)
74            .count();
75        if end == 0 {
76            return String::new();
77        }
78    }
79    first[..end].to_string()
80}
81
82/// Complete a partial command name at the given caret position from a flat
83/// list of candidate names. The line may contain a leading range prefix
84/// (`5,10`, `%`, etc.) — those are NOT consumed here; the caller must pass
85/// the substring after any range. Phase 6 generalizes this for arg completion.
86///
87/// Returns:
88/// - `kind: Command` when the caret sits inside a leading alpha-prefix token
89/// - `kind: None` otherwise (e.g. caret past the first whitespace, line empty)
90///
91/// Candidates: every name from `available` that has the typed prefix as its
92/// own prefix, sorted alphabetically. Includes both canonical names and
93/// aliases — caller decides what to merge in.
94pub fn complete_command_from_names(line: &str, caret: usize, available: &[String]) -> Completions {
95    let caret = caret.min(line.len());
96    if line.is_empty() {
97        return Completions {
98            replace_range: 0..0,
99            candidates: available.to_vec(),
100            kind: CompletionKind::Command,
101        };
102    }
103    // Identify the command-name token: leading run of ASCII alpha + optional
104    // trailing `!`, but only if caret is inside that span.
105    let alpha_end = line
106        .char_indices()
107        .find(|(_, c)| !c.is_ascii_alphabetic())
108        .map(|(i, _)| i)
109        .unwrap_or(line.len());
110    let token_end = if line.as_bytes().get(alpha_end) == Some(&b'!') {
111        alpha_end + 1
112    } else {
113        alpha_end
114    };
115    if caret > token_end {
116        return Completions::empty(caret);
117    }
118    let prefix = &line[..caret];
119    let mut candidates: Vec<String> = available
120        .iter()
121        .filter(|n| n.starts_with(prefix))
122        .cloned()
123        .collect();
124    candidates.sort();
125    candidates.dedup();
126    Completions {
127        replace_range: 0..token_end,
128        candidates,
129        kind: CompletionKind::Command,
130    }
131}
132
133/// Collect command-name candidates (canonical name + aliases) from a Registry.
134pub fn collect_registry_names<H: hjkl_engine::Host>(reg: &Registry<H>) -> Vec<String> {
135    let mut names: Vec<String> = Vec::new();
136    for cmd in reg.iter() {
137        names.push(cmd.name.to_string());
138        names.extend(cmd.aliases.iter().map(|a| a.to_string()));
139    }
140    names
141}
142
143/// Same for HostRegistry.
144pub fn collect_host_registry_names<Ctx>(reg: &HostRegistry<Ctx>) -> Vec<String> {
145    let mut names: Vec<String> = Vec::new();
146    for cmd in reg.iter() {
147        names.push(cmd.name().to_string());
148        names.extend(cmd.aliases().iter().map(|a| a.to_string()));
149    }
150    names
151}
152
153// ── Arg-position helpers ──────────────────────────────────────────────────────
154
155/// Returns `(end_byte_offset_of_command_token, did_find_space_after)`.
156///
157/// The command token is the leading run of ASCII alpha characters with an
158/// optional trailing `!`. We don't consume the space itself.
159pub fn first_word_end(line: &str) -> (usize, bool) {
160    let alpha_end = line
161        .char_indices()
162        .find(|(_, c)| !c.is_ascii_alphabetic())
163        .map(|(i, _)| i)
164        .unwrap_or(line.len());
165    let token_end = if line.as_bytes().get(alpha_end) == Some(&b'!') {
166        alpha_end + 1
167    } else {
168        alpha_end
169    };
170    let has_space = line.as_bytes().get(token_end) == Some(&b' ');
171    (token_end, has_space)
172}
173
174/// Scan `cwd` for entries whose names begin with `file_part` (respecting the
175/// `dir_part` prefix).  Appends `/` to directories.  Hidden entries (starting
176/// with `.`) are skipped unless `file_part` itself starts with `.`.
177fn complete_path_entries(prefix: &str, cwd: &std::path::Path) -> Vec<String> {
178    // Split prefix at the last '/' into (dir_part, file_part).
179    let (dir_part, file_part) = match prefix.rfind('/') {
180        Some(idx) => (&prefix[..=idx], &prefix[idx + 1..]),
181        None => ("", prefix),
182    };
183    let scan_dir = if dir_part.is_empty() {
184        cwd.to_path_buf()
185    } else if std::path::Path::new(dir_part).is_absolute() {
186        std::path::PathBuf::from(dir_part)
187    } else {
188        cwd.join(dir_part)
189    };
190    let rd = match std::fs::read_dir(&scan_dir) {
191        Ok(rd) => rd,
192        Err(_) => return Vec::new(),
193    };
194    let show_hidden = file_part.starts_with('.');
195    let mut results: Vec<String> = rd
196        .filter_map(|e| e.ok())
197        .filter_map(|e| {
198            let name = e.file_name();
199            let name_str = name.to_str()?.to_string();
200            // Skip hidden unless file_part starts with '.'
201            if !show_hidden && name_str.starts_with('.') {
202                return None;
203            }
204            if !name_str.starts_with(file_part) {
205                return None;
206            }
207            let suffix = if e.file_type().ok()?.is_dir() {
208                "/"
209            } else {
210                ""
211            };
212            Some(format!("{dir_part}{name_str}{suffix}"))
213        })
214        .collect();
215    results.sort();
216    results
217}
218
219/// Per-arg-kind completion. Caller resolves the command and passes its
220/// arg_kind. Returns empty Completions when caret isn't in arg position,
221/// or when no sources match.
222pub fn complete_arg(
223    line: &str,
224    caret: usize,
225    arg_kind: ArgKind,
226    sources: &ArgSources<'_>,
227) -> Completions {
228    let caret = caret.min(line.len());
229    // Find end of command token.
230    let (cmd_end, has_space) = first_word_end(line);
231    // Arg position starts at cmd_end + 1 (past the space).
232    let arg_start = if has_space { cmd_end + 1 } else { cmd_end };
233    if caret <= cmd_end || !has_space {
234        // Caret still in command-name territory.
235        return Completions::empty(caret);
236    }
237    // Find token under caret: walk back from caret to previous whitespace.
238    let slice = &line[arg_start..caret];
239    let token_offset = slice
240        .rfind(|c: char| c.is_whitespace())
241        .map(|i| i + 1)
242        .unwrap_or(0);
243    let token_start = arg_start + token_offset;
244    let prefix = &line[token_start..caret];
245
246    let (candidates, kind) = match arg_kind {
247        ArgKind::None | ArgKind::Raw => return Completions::empty(caret),
248        ArgKind::Path => {
249            let cwd = match sources.cwd {
250                Some(p) => p,
251                None => return Completions::empty(caret),
252            };
253            (complete_path_entries(prefix, cwd), CompletionKind::Path)
254        }
255        ArgKind::Setting => {
256            let mut c: Vec<String> = sources
257                .settings
258                .iter()
259                .filter(|s| s.starts_with(prefix))
260                .cloned()
261                .collect();
262            c.sort();
263            c.dedup();
264            (c, CompletionKind::Setting)
265        }
266        ArgKind::Buffer => {
267            let mut c: Vec<String> = sources
268                .buffers
269                .iter()
270                .filter(|s| s.starts_with(prefix))
271                .cloned()
272                .collect();
273            c.sort();
274            c.dedup();
275            (c, CompletionKind::Buffer)
276        }
277        ArgKind::Register => {
278            let mut c: Vec<String> = sources
279                .registers
280                .iter()
281                .filter(|s| s.starts_with(prefix))
282                .cloned()
283                .collect();
284            c.sort();
285            c.dedup();
286            (c, CompletionKind::Register)
287        }
288        ArgKind::Mark => {
289            let mut c: Vec<String> = sources
290                .marks
291                .iter()
292                .filter(|s| s.starts_with(prefix))
293                .cloned()
294                .collect();
295            c.sort();
296            c.dedup();
297            (c, CompletionKind::Mark)
298        }
299    };
300
301    Completions {
302        replace_range: token_start..caret,
303        candidates,
304        kind,
305    }
306}
307
308/// High-level orchestrator: resolve the command name in `line` against both
309/// registries, then dispatch to arg completer or command-name completer.
310///
311/// Falls back to Phase 5a's command completer when caret is in command-name
312/// position.
313pub fn complete<H, Ctx>(
314    line: &str,
315    caret: usize,
316    editor_reg: &Registry<H>,
317    host_reg: &HostRegistry<Ctx>,
318    sources: &ArgSources<'_>,
319) -> Completions
320where
321    H: hjkl_engine::Host,
322{
323    let (cmd_token_end, has_arg_space) = first_word_end(line);
324    let caret = caret.min(line.len());
325    if caret <= cmd_token_end {
326        // Command-name completion path.
327        let mut names = collect_host_registry_names(host_reg);
328        names.extend(collect_registry_names(editor_reg));
329        names.sort();
330        names.dedup();
331        return complete_command_from_names(line, caret, &names);
332    }
333    if !has_arg_space {
334        return Completions::empty(caret);
335    }
336    // Arg position — resolve command name to find arg_kind.
337    let cmd_name = &line[..cmd_token_end];
338    let arg_kind = host_reg
339        .resolve(cmd_name)
340        .map(|c| c.arg_kind())
341        .or_else(|| editor_reg.resolve(cmd_name).map(|c| c.arg_kind))
342        .unwrap_or(ArgKind::None);
343    complete_arg(line, caret, arg_kind, sources)
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn names(s: &[&str]) -> Vec<String> {
351        s.iter().map(|s| s.to_string()).collect()
352    }
353
354    #[test]
355    fn complete_empty_line_returns_all_names() {
356        let available = names(&["quit", "write"]);
357        let result = complete_command_from_names("", 0, &available);
358        assert_eq!(result.kind, CompletionKind::Command);
359        assert_eq!(result.replace_range, 0..0);
360        assert!(result.candidates.contains(&"quit".to_string()));
361        assert!(result.candidates.contains(&"write".to_string()));
362    }
363
364    #[test]
365    fn complete_q_returns_quit() {
366        let available = names(&["quit", "write"]);
367        let result = complete_command_from_names("q", 1, &available);
368        assert_eq!(result.kind, CompletionKind::Command);
369        assert_eq!(result.replace_range, 0..1);
370        assert_eq!(result.candidates, vec!["quit".to_string()]);
371    }
372
373    #[test]
374    fn complete_w_returns_two_names() {
375        let available = names(&["wall", "write"]);
376        let result = complete_command_from_names("w", 1, &available);
377        assert_eq!(result.kind, CompletionKind::Command);
378        assert_eq!(result.replace_range, 0..1);
379        assert_eq!(
380            result.candidates,
381            vec!["wall".to_string(), "write".to_string()]
382        );
383    }
384
385    #[test]
386    fn complete_caret_past_alpha_returns_none() {
387        let available = names(&["quit", "write"]);
388        let result = complete_command_from_names("q ", 2, &available);
389        assert_eq!(result.kind, CompletionKind::None);
390        assert!(result.candidates.is_empty());
391    }
392
393    #[test]
394    fn complete_dedup_aliases() {
395        let available = names(&["quit", "quit", "write"]);
396        let result = complete_command_from_names("q", 1, &available);
397        assert_eq!(result.candidates, vec!["quit".to_string()]);
398    }
399
400    #[test]
401    fn complete_with_bang() {
402        let available = names(&["quit", "quit!", "qall"]);
403        let result = complete_command_from_names("q", 1, &available);
404        assert_eq!(result.kind, CompletionKind::Command);
405        // All three start with "q"
406        assert!(result.candidates.contains(&"quit".to_string()));
407        assert!(result.candidates.contains(&"quit!".to_string()));
408        assert!(result.candidates.contains(&"qall".to_string()));
409    }
410
411    #[test]
412    fn lcp_empty() {
413        assert_eq!(longest_common_prefix(&[]), "");
414    }
415
416    #[test]
417    fn lcp_single() {
418        assert_eq!(longest_common_prefix(&["quit".to_string()]), "quit");
419    }
420
421    #[test]
422    fn lcp_common() {
423        let candidates = names(&["wall", "write", "wq"]);
424        assert_eq!(longest_common_prefix(&candidates), "w");
425    }
426
427    #[test]
428    fn lcp_no_common() {
429        let candidates = names(&["a", "b"]);
430        assert_eq!(longest_common_prefix(&candidates), "");
431    }
432
433    // ── Phase 6 tests ─────────────────────────────────────────────────────────
434
435    fn str_vec(s: &[&str]) -> Vec<String> {
436        s.iter().map(|s| s.to_string()).collect()
437    }
438
439    #[test]
440    fn arg_position_detection_with_cwd() {
441        let tmp = tempfile::tempdir().unwrap();
442        // Write a file so read_dir has at least one result.
443        std::fs::write(tmp.path().join("foo.txt"), b"x").unwrap();
444        let sources = ArgSources {
445            cwd: Some(tmp.path()),
446            ..Default::default()
447        };
448        // "e " caret=2 → arg position, path completion → non-empty
449        let result = complete_arg("e ", 2, ArgKind::Path, &sources);
450        assert_eq!(result.kind, CompletionKind::Path);
451        assert!(!result.candidates.is_empty());
452        assert!(result.candidates.iter().any(|c| c.contains("foo.txt")));
453    }
454
455    #[test]
456    fn complete_set_filters_settings() {
457        let settings = str_vec(&["number", "numberwidth", "nu", "noic", "relativenumber"]);
458        let sources = ArgSources {
459            settings: &settings,
460            ..Default::default()
461        };
462        let result = complete_arg("set ", 4, ArgKind::Setting, &sources);
463        assert_eq!(result.kind, CompletionKind::Setting);
464        // prefix "" → all settings
465        assert!(result.candidates.contains(&"number".to_string()));
466        assert!(result.candidates.contains(&"numberwidth".to_string()));
467        assert!(result.candidates.contains(&"nu".to_string()));
468
469        // Now filter with prefix "nu"
470        let result2 = complete_arg("set nu", 6, ArgKind::Setting, &sources);
471        assert_eq!(result2.kind, CompletionKind::Setting);
472        assert!(result2.candidates.contains(&"number".to_string()));
473        assert!(result2.candidates.contains(&"numberwidth".to_string()));
474        assert!(result2.candidates.contains(&"nu".to_string()));
475        assert!(!result2.candidates.contains(&"noic".to_string()));
476        assert!(!result2.candidates.contains(&"relativenumber".to_string()));
477    }
478
479    #[test]
480    fn complete_buffer_filters_buffers() {
481        let buffers = str_vec(&["src/main.rs", "src/lib.rs", "tests/foo.rs"]);
482        let sources = ArgSources {
483            buffers: &buffers,
484            ..Default::default()
485        };
486        let result = complete_arg("b ", 2, ArgKind::Buffer, &sources);
487        assert_eq!(result.kind, CompletionKind::Buffer);
488        assert!(result.candidates.contains(&"src/main.rs".to_string()));
489        assert!(result.candidates.contains(&"src/lib.rs".to_string()));
490        assert!(result.candidates.contains(&"tests/foo.rs".to_string()));
491
492        let result2 = complete_arg("b src", 5, ArgKind::Buffer, &sources);
493        assert_eq!(result2.kind, CompletionKind::Buffer);
494        assert!(result2.candidates.contains(&"src/main.rs".to_string()));
495        assert!(result2.candidates.contains(&"src/lib.rs".to_string()));
496        assert!(!result2.candidates.contains(&"tests/foo.rs".to_string()));
497    }
498
499    #[test]
500    fn complete_register_filters() {
501        let regs = str_vec(&["\"\"", "\"0", "\"a", "\"b"]);
502        let sources = ArgSources {
503            registers: &regs,
504            ..Default::default()
505        };
506        let result = complete_arg("reg ", 4, ArgKind::Register, &sources);
507        assert_eq!(result.kind, CompletionKind::Register);
508        assert!(result.candidates.contains(&"\"a".to_string()));
509
510        // prefix "\"a" → only "\"a"
511        let result2 = complete_arg("reg \"a", 6, ArgKind::Register, &sources);
512        assert!(result2.candidates.contains(&"\"a".to_string()));
513        assert!(!result2.candidates.contains(&"\"b".to_string()));
514    }
515
516    #[test]
517    fn complete_mark_filters() {
518        let marks = str_vec(&["a", "b", "c"]);
519        let sources = ArgSources {
520            marks: &marks,
521            ..Default::default()
522        };
523        // prefix "" → all marks
524        let result = complete_arg("marks ", 6, ArgKind::Mark, &sources);
525        assert_eq!(result.kind, CompletionKind::Mark);
526        assert_eq!(result.candidates.len(), 3);
527
528        // prefix "a" → only "a"
529        let result2 = complete_arg("marks a", 7, ArgKind::Mark, &sources);
530        assert_eq!(result2.candidates, vec!["a".to_string()]);
531    }
532
533    #[test]
534    fn complete_path_skips_hidden_unless_dot() {
535        let tmp = tempfile::tempdir().unwrap();
536        std::fs::write(tmp.path().join(".hidden"), b"x").unwrap();
537        std::fs::write(tmp.path().join("visible.txt"), b"x").unwrap();
538
539        let sources = ArgSources {
540            cwd: Some(tmp.path()),
541            ..Default::default()
542        };
543
544        // prefix "" → hidden skipped
545        let result = complete_arg("e ", 2, ArgKind::Path, &sources);
546        assert!(result.candidates.iter().all(|c| !c.starts_with(".hidden")));
547        assert!(result.candidates.iter().any(|c| c.contains("visible.txt")));
548
549        // prefix "." → hidden shown
550        let result2 = complete_arg("e .", 3, ArgKind::Path, &sources);
551        assert!(result2.candidates.iter().any(|c| c.contains(".hidden")));
552    }
553
554    #[test]
555    fn complete_in_command_position_falls_back_to_command() {
556        use crate::{ExCommand, Registry};
557        use hjkl_engine::DefaultHost;
558
559        fn noop(
560            _: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, DefaultHost>,
561            _: &str,
562            _: Option<crate::range::LineRange>,
563        ) -> Option<crate::effect::ExEffect> {
564            None
565        }
566
567        let mut reg = Registry::<DefaultHost>::new();
568        reg.add(ExCommand {
569            name: "edit",
570            aliases: &["e"],
571            arg_kind: ArgKind::Path,
572            min_prefix: 1,
573            run: noop,
574        });
575        let host_reg = HostRegistry::<()>::new();
576        let sources = ArgSources::default();
577
578        // caret=1, line="e" → command position
579        let result = complete("e", 1, &reg, &host_reg, &sources);
580        assert_eq!(result.kind, CompletionKind::Command);
581    }
582
583    #[test]
584    fn complete_unknown_command_returns_none_kind() {
585        use crate::Registry;
586        use hjkl_engine::DefaultHost;
587
588        let reg = Registry::<DefaultHost>::new();
589        let host_reg = HostRegistry::<()>::new();
590        let sources = ArgSources::default();
591
592        // "xxx " with unknown command → kind=None
593        let result = complete("xxx ", 4, &reg, &host_reg, &sources);
594        assert_eq!(result.kind, CompletionKind::None);
595        assert!(result.candidates.is_empty());
596    }
597}