1use std::ops::Range;
2
3use crate::{ArgKind, HostRegistry, Registry};
4
5#[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#[derive(Default)]
21pub struct ArgSources<'a> {
22 pub cwd: Option<&'a std::path::Path>,
24 pub settings: &'a [String],
26 pub buffers: &'a [String],
28 pub registers: &'a [String],
30 pub marks: &'a [String],
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct Completions {
37 pub replace_range: Range<usize>,
41 pub candidates: Vec<String>,
43 pub kind: CompletionKind,
45}
46
47impl Completions {
48 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
58pub 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
82pub 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 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
133pub 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
143pub 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
153pub 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
174fn complete_path_entries(prefix: &str, cwd: &std::path::Path) -> Vec<String> {
178 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 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
219pub 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 let (cmd_end, has_space) = first_word_end(line);
231 let arg_start = if has_space { cmd_end + 1 } else { cmd_end };
233 if caret <= cmd_end || !has_space {
234 return Completions::empty(caret);
236 }
237 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
308pub 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 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 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 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 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 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 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 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 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: ®s,
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 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 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 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 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 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 let result = complete("e", 1, ®, &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 let result = complete("xxx ", 4, ®, &host_reg, &sources);
594 assert_eq!(result.kind, CompletionKind::None);
595 assert!(result.candidates.is_empty());
596 }
597}