1use std::collections::HashSet;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::thread;
7
8use anyhow::{Result, bail};
9
10use crate::commands::CommandCatalog;
11use crate::config::{AcceptMode, Settings};
12use crate::protocol::{Candidate, CandidateKind, CandidateSource, CompletionResponse};
13use crate::store::Store;
14
15const MAX_BUFFER_BYTES: usize = 64 * 1024;
16const FUZZY_HISTORY_LIMIT: usize = 4096;
17const MAX_DIRECTORY_ENTRIES: usize = 1024;
18
19pub fn complete(
20 store: &Store,
21 commands: &CommandCatalog,
22 buffer: &str,
23 cursor_byte: usize,
24 cwd: &str,
25 requested_limit: Option<usize>,
26 settings: &Settings,
27) -> Result<CompletionResponse> {
28 if buffer.len() > MAX_BUFFER_BYTES {
29 bail!("completion buffer exceeds {MAX_BUFFER_BYTES} bytes");
30 }
31 if cursor_byte > buffer.len() || !buffer.is_char_boundary(cursor_byte) {
32 bail!("cursor is not a valid UTF-8 byte offset");
33 }
34
35 if cursor_byte != buffer.len() || buffer.is_empty() {
38 return Ok(CompletionResponse::empty(cursor_byte));
39 }
40
41 let limit = requested_limit
42 .unwrap_or(settings.completion.max_candidates)
43 .min(settings.completion.max_candidates);
44 let history =
45 store.history_candidates(buffer, cwd, limit, settings.history.successful_first)?;
46
47 let mut candidates: Vec<_> = history
48 .into_iter()
49 .filter_map(|history| {
50 let command = history.command;
51 let suffix = command.strip_prefix(buffer)?;
52 if suffix.is_empty() {
53 return None;
54 }
55 let insert_text = suffix.to_owned();
56 let accept_text = match settings.completion.accept {
57 AcceptMode::Segment => next_segment(suffix),
58 AcceptMode::Full => insert_text.clone(),
59 };
60 Some(Candidate {
61 display: sanitize_display(&command),
62 description: history_description(history.uses, history.same_cwd),
63 description_pending: false,
64 kind: CandidateKind::History,
65 insert_text,
66 accept_text,
67 source: CandidateSource::History,
68 })
69 })
70 .collect();
71
72 if candidates.len() < limit && valid_command_prefix(buffer) {
73 let remaining = limit - candidates.len();
74 let command_candidates: Vec<_> = commands
75 .matching(buffer, limit)
76 .into_iter()
77 .filter(|entry| {
78 !candidates
79 .iter()
80 .any(|candidate| candidate.display == entry.name)
81 })
82 .take(remaining)
83 .map(|entry| {
84 let suffix = entry.name.strip_prefix(buffer).unwrap_or_default();
85 let insertion = if suffix.is_empty() { " " } else { suffix };
86 Candidate {
87 display: entry.name.clone(),
88 description: entry.description.clone(),
89 description_pending: entry.description_pending,
90 kind: CandidateKind::Command,
91 insert_text: insertion.to_owned(),
92 accept_text: insertion.to_owned(),
93 source: CandidateSource::Command,
94 }
95 })
96 .collect();
97 candidates.extend(command_candidates);
98 }
99
100 let mut enrichment_pending = false;
101 if let Some((command, prefix)) = option_context(buffer) {
102 let options = commands.matching_options(command, prefix, limit);
103 enrichment_pending = options.pending;
104 let option_candidates: Vec<_> = options
105 .entries
106 .into_iter()
107 .filter_map(|option| {
108 let suffix = option.spelling.strip_prefix(prefix)?;
109 if suffix.is_empty() {
110 return None;
111 }
112 Some(Candidate {
113 display: format!("{buffer}{suffix}"),
114 description: option.description,
115 description_pending: false,
116 kind: CandidateKind::Option,
117 insert_text: suffix.to_owned(),
118 accept_text: suffix.to_owned(),
119 source: CandidateSource::Help,
120 })
121 })
122 .collect();
123 if !option_candidates.is_empty() {
124 let option_slots = option_candidates.len().min((limit / 2).max(1));
125 candidates.truncate(limit.saturating_sub(option_slots));
126 candidates.extend(option_candidates.into_iter().take(option_slots));
127 }
128 }
129
130 Ok(CompletionResponse {
131 replace_start_byte: cursor_byte,
132 replace_end_byte: cursor_byte,
133 candidates,
134 enrichment_pending,
135 })
136}
137
138pub fn fuzzy(
139 store: &Store,
140 commands: &CommandCatalog,
141 query: &str,
142 cwd: &str,
143 requested_limit: Option<usize>,
144 settings: &Settings,
145) -> Result<CompletionResponse> {
146 if query.len() > MAX_BUFFER_BYTES {
147 bail!("fuzzy query exceeds {MAX_BUFFER_BYTES} bytes");
148 }
149 let limit = requested_limit
150 .unwrap_or(settings.completion.max_candidates)
151 .min(settings.completion.max_candidates);
152 let mut seen = HashSet::new();
153 let mut pool = Vec::new();
154
155 for history in
156 store.history_inventory(cwd, FUZZY_HISTORY_LIMIT, settings.history.successful_first)?
157 {
158 if seen.insert(history.command.clone()) {
159 pool.push(Candidate {
160 display: sanitize_display(&history.command),
161 description: history_description(history.uses, history.same_cwd),
162 description_pending: false,
163 kind: CandidateKind::History,
164 insert_text: history.command.clone(),
165 accept_text: history.command,
166 source: CandidateSource::History,
167 });
168 }
169 }
170 for command in commands.inventory() {
171 if seen.insert(command.name.clone()) {
172 pool.push(Candidate {
173 display: command.name.clone(),
174 description: command.description,
175 description_pending: false,
176 kind: CandidateKind::Command,
177 insert_text: command.name.clone(),
178 accept_text: command.name,
179 source: CandidateSource::Command,
180 });
181 }
182 }
183
184 let indexes = fzf_indexes(&pool, query, limit)?;
185 let mut candidates = Vec::with_capacity(indexes.len());
186 for index in indexes {
187 let mut candidate = pool[index].clone();
188 if candidate.source == CandidateSource::Command
189 && let Some(command) = commands
190 .matching(&candidate.display, 1)
191 .into_iter()
192 .find(|command| command.name == candidate.display)
193 {
194 candidate.description = command.description;
195 candidate.description_pending = command.description_pending;
196 }
197 candidates.push(candidate);
198 }
199 Ok(CompletionResponse {
200 replace_start_byte: 0,
201 replace_end_byte: 0,
202 candidates,
203 enrichment_pending: false,
204 })
205}
206
207pub fn filesystem_candidates(
208 buffer: &str,
209 cursor_byte: usize,
210 cwd: &str,
211 limit: usize,
212) -> Result<Vec<Candidate>> {
213 if limit == 0 || cursor_byte != buffer.len() || !buffer.is_char_boundary(cursor_byte) {
214 return Ok(Vec::new());
215 }
216 let Some(argument_start) = current_argument_start(buffer) else {
217 return Ok(Vec::new());
218 };
219 let Some(token) = unescape_path_token(&buffer[argument_start..]) else {
220 return Ok(Vec::new());
221 };
222 if token.starts_with('-') {
223 return Ok(Vec::new());
224 }
225
226 let (directory_text, name_prefix) = token
227 .rfind('/')
228 .map_or(("", token.as_str()), |slash| token.split_at(slash + 1));
229 let directory = resolve_directory(directory_text, cwd);
230 let Ok(children) = fs::read_dir(&directory) else {
231 return Ok(Vec::new());
232 };
233 let show_hidden = name_prefix.starts_with('.');
234 let mut matches = Vec::new();
235 for child in children.take(MAX_DIRECTORY_ENTRIES).flatten() {
236 let Some(name) = child.file_name().to_str().map(str::to_owned) else {
237 continue;
238 };
239 if name.is_empty()
240 || (!show_hidden && name.starts_with('.'))
241 || !name.starts_with(name_prefix)
242 || name.chars().any(char::is_control)
243 {
244 continue;
245 }
246 let Ok(file_type) = child.file_type() else {
247 continue;
248 };
249 if !(file_type.is_dir() || file_type.is_file() || file_type.is_symlink()) {
250 continue;
251 }
252 let is_directory = file_type.is_dir() || (file_type.is_symlink() && child.path().is_dir());
253 let suffix = &name[name_prefix.len()..];
254 let mut insert_text = escape_path_suffix(suffix);
255 if token.is_empty() && name.starts_with('-') {
256 insert_text.insert_str(0, "./");
257 }
258 if is_directory {
259 insert_text.push('/');
260 }
261 let exact_file = !is_directory && insert_text.is_empty();
262 if exact_file {
263 insert_text.push(' ');
264 }
265 matches.push((is_directory, name, insert_text, exact_file));
266 }
267 matches.sort_unstable_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
268 Ok(matches
269 .into_iter()
270 .take(limit)
271 .map(|(is_directory, _, insert_text, exact_file)| Candidate {
272 display: if exact_file {
273 buffer.to_owned()
274 } else {
275 format!("{buffer}{insert_text}")
276 },
277 description: if is_directory { "Directory" } else { "File" }.to_owned(),
278 description_pending: false,
279 kind: if is_directory {
280 CandidateKind::Directory
281 } else {
282 CandidateKind::File
283 },
284 accept_text: next_segment(&insert_text),
285 insert_text,
286 source: CandidateSource::Filesystem,
287 })
288 .collect())
289}
290
291pub fn merge_filesystem_candidates(
292 response: &mut CompletionResponse,
293 mut paths: Vec<Candidate>,
294 limit: usize,
295) {
296 if paths.is_empty() || limit == 0 {
297 return;
298 }
299 let history_count = response
300 .candidates
301 .iter()
302 .take_while(|candidate| candidate.source == CandidateSource::History)
303 .count();
304 let path_slots = paths.len().min((limit / 2).max(1));
305 if paths.len() > path_slots
306 && let Some(first) = paths.first_mut()
307 {
308 first.description.push_str(" (more matches)");
309 }
310 let history_keep = history_count.min(limit.saturating_sub(path_slots));
311 let mut original = std::mem::take(&mut response.candidates);
312 let trailing = original.split_off(history_count);
313 let history = original;
314 let mut seen = HashSet::new();
315 for candidate in history.into_iter().take(history_keep) {
316 if response.candidates.len() >= limit {
317 break;
318 }
319 if seen.insert(candidate.display.clone()) {
320 response.candidates.push(candidate);
321 }
322 }
323 let path_limit = response.candidates.len().saturating_add(path_slots);
324 for path in paths {
325 if response.candidates.len() >= path_limit || response.candidates.len() >= limit {
326 break;
327 }
328 if seen.insert(path.display.clone()) {
329 response.candidates.push(path);
330 }
331 }
332 for candidate in trailing {
333 if response.candidates.len() >= limit {
334 break;
335 }
336 if seen.insert(candidate.display.clone()) {
337 response.candidates.push(candidate);
338 }
339 }
340}
341
342fn resolve_directory(directory_text: &str, cwd: &str) -> PathBuf {
343 if directory_text == "~/" {
344 return std::env::var_os("HOME")
345 .map(PathBuf::from)
346 .unwrap_or_else(|| PathBuf::from(cwd));
347 }
348 if let Some(relative) = directory_text.strip_prefix("~/") {
349 return std::env::var_os("HOME")
350 .map(PathBuf::from)
351 .unwrap_or_else(|| PathBuf::from(cwd))
352 .join(relative);
353 }
354 let directory = Path::new(directory_text);
355 if directory.is_absolute() {
356 directory.to_owned()
357 } else {
358 Path::new(cwd).join(directory)
359 }
360}
361
362fn unescape_path_token(value: &str) -> Option<String> {
363 let mut unescaped = String::new();
364 let mut escaped = false;
365 for character in value.chars() {
366 if escaped {
367 unescaped.push(character);
368 escaped = false;
369 } else if character == '\\' {
370 escaped = true;
371 } else if character.is_control() || "'\";&|><$`(){}[]!*?".contains(character) {
372 return None;
373 } else {
374 unescaped.push(character);
375 }
376 }
377 (!escaped).then_some(unescaped)
378}
379
380fn current_argument_start(buffer: &str) -> Option<usize> {
381 let mut start = None;
382 let mut escaped = false;
383 for (index, character) in buffer.char_indices() {
384 if escaped {
385 escaped = false;
386 } else if character == '\\' {
387 escaped = true;
388 } else if character.is_whitespace() {
389 start = Some(index + character.len_utf8());
390 }
391 }
392 start
393}
394
395fn escape_path_suffix(value: &str) -> String {
396 let mut escaped = String::new();
397 for character in value.chars() {
398 if character.is_ascii()
399 && !(character.is_ascii_alphanumeric() || "_-+.@%,".contains(character))
400 {
401 escaped.push('\\');
402 }
403 escaped.push(character);
404 }
405 escaped
406}
407
408fn option_context(buffer: &str) -> Option<(&str, &str)> {
409 if buffer
410 .chars()
411 .any(|character| character.is_control() || "'\"\\;&|><$`(){}[]!*?".contains(character))
412 {
413 return None;
414 }
415 let argument_start = buffer.rfind(char::is_whitespace)? + 1;
416 let prefix = &buffer[argument_start..];
417 if !prefix.starts_with('-') {
418 return None;
419 }
420 let mut words = buffer[..argument_start].split_ascii_whitespace();
421 let command = words.next()?;
422 if !valid_command_prefix(command) || words.any(|word| !word.starts_with('-') || word == "--") {
423 return None;
424 }
425 Some((command, prefix))
426}
427
428fn fzf_indexes(candidates: &[Candidate], query: &str, limit: usize) -> Result<Vec<usize>> {
429 if candidates.is_empty() || limit == 0 {
430 return Ok(Vec::new());
431 }
432 let mut input = Vec::new();
433 for (index, candidate) in candidates.iter().enumerate() {
434 write!(input, "{index}\t{}\0", candidate.display)?;
435 }
436
437 let mut child = Command::new("fzf")
438 .args([
439 "--read0",
440 "--print0",
441 "--no-multi",
442 "--delimiter=\\t",
443 "--nth=2..",
444 "--tiebreak=index",
445 "--filter",
446 query,
447 ])
448 .env_remove("FZF_DEFAULT_OPTS")
449 .env_remove("FZF_DEFAULT_OPTS_FILE")
450 .stdin(Stdio::piped())
451 .stdout(Stdio::piped())
452 .stderr(Stdio::null())
453 .spawn()?;
454 let mut stdin = child.stdin.take().expect("fzf stdin is piped");
455 let writer = thread::spawn(move || stdin.write_all(&input));
456 let output = child.wait_with_output()?;
457 writer.join().expect("fzf input writer panicked")?;
458 if output.status.code() == Some(1) {
459 return Ok(Vec::new());
460 }
461 if !output.status.success() {
462 bail!("fzf fuzzy filter failed with {}", output.status);
463 }
464
465 let mut indexes = Vec::new();
466 for record in output.stdout.split(|byte| *byte == 0) {
467 if record.is_empty() || indexes.len() >= limit {
468 continue;
469 }
470 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
471 continue;
472 };
473 let index = std::str::from_utf8(&record[..tab])?.parse::<usize>()?;
474 if index < candidates.len() {
475 indexes.push(index);
476 }
477 }
478 Ok(indexes)
479}
480
481fn valid_command_prefix(buffer: &str) -> bool {
482 !buffer.is_empty()
483 && !buffer.starts_with('.')
484 && buffer
485 .bytes()
486 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+'))
487}
488
489fn history_description(uses: usize, same_cwd: bool) -> String {
490 match (uses, same_cwd) {
491 (1, true) => "used here".to_owned(),
492 (1, false) => "used once".to_owned(),
493 (uses, true) => format!("used {uses}x, here"),
494 (uses, false) => format!("used {uses}x"),
495 }
496}
497
498fn sanitize_display(value: &str) -> String {
499 let mut display = String::with_capacity(value.len());
500 for character in value.chars() {
501 if character.is_control() {
502 display.extend(character.escape_default());
503 } else {
504 display.push(character);
505 }
506 }
507 display
508}
509
510pub fn next_segment(suffix: &str) -> String {
511 let mut saw_non_whitespace = false;
512 let mut escaped = false;
513 let mut quote = None;
514 let mut bracket_depth: usize = 0;
515 let mut characters = suffix.char_indices().peekable();
516 while let Some((index, character)) = characters.next() {
517 let end = index + character.len_utf8();
518 if escaped {
519 escaped = false;
520 saw_non_whitespace = true;
521 continue;
522 }
523 if character == '\\' && quote != Some('\'') {
524 escaped = true;
525 saw_non_whitespace = true;
526 continue;
527 }
528 if matches!(character, '\'' | '"') {
529 if quote == Some(character) {
530 quote = None;
531 } else if quote.is_none() {
532 quote = Some(character);
533 }
534 saw_non_whitespace = true;
535 continue;
536 }
537 if quote.is_some() {
538 saw_non_whitespace = true;
539 continue;
540 }
541 if character.is_whitespace() {
542 if saw_non_whitespace {
543 return suffix[..end].to_owned();
544 }
545 continue;
546 }
547 saw_non_whitespace = true;
548 match character {
549 '[' => bracket_depth += 1,
550 ']' => bracket_depth = bracket_depth.saturating_sub(1),
551 '@' | '=' | ',' if bracket_depth == 0 => return suffix[..end].to_owned(),
552 ':' if bracket_depth == 0 => {
553 let mut boundary = end;
554 while let Some((next_index, next)) = characters.peek().copied() {
555 if !matches!(next, ':' | '/') {
556 break;
557 }
558 characters.next();
559 boundary = next_index + next.len_utf8();
560 }
561 return suffix[..boundary].to_owned();
562 }
563 '/' if bracket_depth == 0 => {
564 let mut boundary = end;
565 while let Some((next_index, '/')) = characters.peek().copied() {
566 characters.next();
567 boundary = next_index + 1;
568 }
569 return suffix[..boundary].to_owned();
570 }
571 _ => {}
572 }
573 }
574 suffix.to_owned()
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use crate::commands::{CommandCatalog, CommandEntry, OptionMatch};
581 use crate::config::Settings;
582 use crate::store::Store;
583 use tempfile::tempdir;
584
585 #[test]
586 fn accepts_one_path_segment() {
587 assert_eq!(next_segment("ev/gitrepos/aster"), "ev/");
588 }
589
590 #[test]
591 fn accepts_one_shell_word() {
592 assert_eq!(next_segment(" checkout feature/topic"), " checkout ");
593 }
594
595 #[test]
596 fn accepts_remaining_text_without_boundary() {
597 assert_eq!(next_segment("status"), "status");
598 }
599
600 #[test]
601 fn accepts_ssh_destinations_in_semantic_segments() {
602 assert_eq!(next_segment("lice@example.com"), "lice@");
603 assert_eq!(next_segment("example.com:/srv/app/file"), "example.com:/");
604 assert_eq!(next_segment("srv/app/file"), "srv/");
605 }
606
607 #[test]
608 fn accepts_common_structured_values_in_semantic_segments() {
609 assert_eq!(next_segment("output=value"), "output=");
610 assert_eq!(next_segment("https://example.com/path"), "https://");
611 assert_eq!(next_segment("host::module/path"), "host::");
612 assert_eq!(next_segment("one,two"), "one,");
613 }
614
615 #[test]
616 fn preserves_quoted_escaped_and_ipv6_separators() {
617 assert_eq!(next_segment("'user@host'/path"), "'user@host'/");
618 assert_eq!(next_segment("user\\@host/path"), "user\\@host/");
619 assert_eq!(next_segment("user@[2001:db8::1]:/srv/app"), "user@");
620 assert_eq!(next_segment("[2001:db8::1]:/srv/app"), "[2001:db8::1]:/");
621 }
622
623 #[test]
624 fn escapes_control_characters_in_display_text() {
625 assert_eq!(sanitize_display("echo\t\u{1b}"), "echo\\t\\u{1b}");
626 }
627
628 #[test]
629 fn completes_filesystem_entries_at_argument_positions() {
630 let directory = tempdir().unwrap();
631 fs::create_dir(directory.path().join("alpha-dir")).unwrap();
632 fs::write(directory.path().join("alpha-file"), "file").unwrap();
633 fs::write(directory.path().join("alpha space"), "file").unwrap();
634 fs::write(directory.path().join(".hidden"), "file").unwrap();
635 fs::write(directory.path().join("-rf"), "file").unwrap();
636 fs::write(directory.path().join("=command"), "file").unwrap();
637 fs::create_dir(directory.path().join("space dir")).unwrap();
638 fs::write(directory.path().join("space dir/child"), "file").unwrap();
639
640 let buffer = "scp -r alpha";
641 let candidates =
642 filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
643 .unwrap();
644 assert_eq!(candidates[0].display, "scp -r alpha-dir/");
645 assert_eq!(candidates[0].kind, CandidateKind::Directory);
646 assert!(candidates.iter().any(|candidate| {
647 candidate.display == "scp -r alpha-file" && candidate.kind == CandidateKind::File
648 }));
649 assert!(
650 candidates
651 .iter()
652 .any(|candidate| candidate.display == "scp -r alpha\\ space")
653 );
654 assert!(
655 !candidates
656 .iter()
657 .any(|candidate| candidate.display.contains(".hidden"))
658 );
659
660 let nested = "scp -r space\\ dir/ch";
661 let candidates =
662 filesystem_candidates(nested, nested.len(), directory.path().to_str().unwrap(), 10)
663 .unwrap();
664 assert_eq!(candidates[0].display, "scp -r space\\ dir/child");
665
666 let exact = "scp -r alpha-file";
667 let candidates =
668 filesystem_candidates(exact, exact.len(), directory.path().to_str().unwrap(), 10)
669 .unwrap();
670 assert_eq!(candidates.len(), 1);
671 assert_eq!(candidates[0].display, exact);
672 assert_eq!(candidates[0].accept_text, " ");
673
674 let blank = "scp -r ";
675 let candidates =
676 filesystem_candidates(blank, blank.len(), directory.path().to_str().unwrap(), 20)
677 .unwrap();
678 assert!(
679 candidates
680 .iter()
681 .any(|candidate| candidate.display == "scp -r ./-rf")
682 );
683 assert!(
684 candidates
685 .iter()
686 .any(|candidate| candidate.display == "scp -r \\=command")
687 );
688 }
689
690 #[test]
691 fn lists_paths_after_an_empty_argument_and_hides_them_in_command_position() {
692 let directory = tempdir().unwrap();
693 fs::write(directory.path().join("visible"), "file").unwrap();
694
695 let buffer = "command ";
696 let candidates =
697 filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
698 .unwrap();
699 assert!(
700 candidates
701 .iter()
702 .any(|candidate| candidate.display == "command visible")
703 );
704 assert!(
705 filesystem_candidates("com", 3, directory.path().to_str().unwrap(), 10)
706 .unwrap()
707 .is_empty()
708 );
709 }
710
711 #[test]
712 fn history_stays_first_while_filesystem_candidates_reserve_capacity() {
713 let mut response = CompletionResponse {
714 replace_start_byte: 5,
715 replace_end_byte: 5,
716 candidates: (0..4)
717 .map(|index| Candidate {
718 display: format!("cmd history-{index}"),
719 description: String::new(),
720 description_pending: false,
721 kind: CandidateKind::History,
722 insert_text: index.to_string(),
723 accept_text: index.to_string(),
724 source: CandidateSource::History,
725 })
726 .collect(),
727 enrichment_pending: false,
728 };
729 let paths = (0..2)
730 .map(|index| Candidate {
731 display: format!("cmd path-{index}"),
732 description: "File".to_owned(),
733 description_pending: false,
734 kind: CandidateKind::File,
735 insert_text: index.to_string(),
736 accept_text: index.to_string(),
737 source: CandidateSource::Filesystem,
738 })
739 .collect();
740 merge_filesystem_candidates(&mut response, paths, 4);
741 assert_eq!(response.candidates.len(), 4);
742 assert_eq!(response.candidates[0].source, CandidateSource::History);
743 assert_eq!(
744 response
745 .candidates
746 .iter()
747 .filter(|candidate| candidate.source == CandidateSource::Filesystem)
748 .count(),
749 2
750 );
751
752 let mut response = CompletionResponse::empty(0);
753 let paths = (0..2)
754 .map(|index| Candidate {
755 display: format!("cmd path-{index}"),
756 description: "File".to_owned(),
757 description_pending: false,
758 kind: CandidateKind::File,
759 insert_text: index.to_string(),
760 accept_text: index.to_string(),
761 source: CandidateSource::Filesystem,
762 })
763 .collect();
764 merge_filesystem_candidates(&mut response, paths, 1);
765 assert_eq!(response.candidates[0].description, "File (more matches)");
766 }
767
768 #[test]
769 fn recognizes_only_safe_root_option_contexts() {
770 assert_eq!(option_context("git --ver"), Some(("git", "--ver")));
771 assert_eq!(option_context("git status --short"), None);
772 assert_eq!(
773 option_context("git --quiet --short"),
774 Some(("git", "--short"))
775 );
776 assert_eq!(option_context("git -- --literal"), None);
777 assert_eq!(option_context("git \"--ver"), None);
778 assert_eq!(option_context("--ver"), None);
779 }
780
781 #[test]
782 fn completes_cached_root_command_options() {
783 let store = Store::in_memory().unwrap();
784 let commands = CommandCatalog::from_options(
785 "tool",
786 vec![
787 OptionMatch {
788 spelling: "--verbose".to_owned(),
789 description: "Show verbose output".to_owned(),
790 },
791 OptionMatch {
792 spelling: "--version".to_owned(),
793 description: "Print version".to_owned(),
794 },
795 ],
796 );
797 let completion = complete(
798 &store,
799 &commands,
800 "tool --ver",
801 "tool --ver".len(),
802 "/repo",
803 None,
804 &Settings::default(),
805 )
806 .unwrap();
807 assert_eq!(completion.candidates.len(), 2);
808 assert_eq!(completion.candidates[0].display, "tool --verbose");
809 assert_eq!(completion.candidates[0].kind, CandidateKind::Option);
810 assert_eq!(completion.candidates[0].source, CandidateSource::Help);
811 assert_eq!(completion.candidates[1].display, "tool --version");
812 }
813
814 #[test]
815 fn describes_history_usage_and_directory() {
816 assert_eq!(history_description(1, true), "used here");
817 assert_eq!(history_description(3, true), "used 3x, here");
818 assert_eq!(history_description(2, false), "used 2x");
819 }
820
821 #[test]
822 fn completes_history_with_a_single_segment() {
823 let store = Store::in_memory().unwrap();
824 let mut settings = Settings::default();
825 settings.completion.accept = AcceptMode::Segment;
826 store
827 .record("cd ~/dev/gitrepos/aster", "/repo", 0, 100, "test", true)
828 .unwrap();
829
830 let completion = complete(
831 &store,
832 &CommandCatalog::default(),
833 "cd ~/d",
834 "cd ~/d".len(),
835 "/repo",
836 None,
837 &settings,
838 )
839 .unwrap();
840
841 assert_eq!(completion.candidates.len(), 1);
842 assert_eq!(completion.candidates[0].insert_text, "ev/gitrepos/aster");
843 assert_eq!(completion.candidates[0].accept_text, "ev/");
844 assert_eq!(completion.candidates[0].description, "used here");
845
846 let completion = complete(
847 &store,
848 &CommandCatalog::default(),
849 "cd ~/d",
850 "cd ~/d".len(),
851 "/repo",
852 None,
853 &Settings::default(),
854 )
855 .unwrap();
856 assert_eq!(completion.candidates[0].accept_text, "ev/gitrepos/aster");
857 }
858
859 #[test]
860 fn fuzzy_searches_history_without_a_prefix() {
861 if Command::new("fzf").arg("--version").output().is_err() {
862 return;
863 }
864 let store = Store::in_memory().unwrap();
865 store
866 .record("cargo test --all", "/repo", 0, 100, "test", true)
867 .unwrap();
868 let completion = fuzzy(
869 &store,
870 &CommandCatalog::default(),
871 "cgt",
872 "/repo",
873 None,
874 &Settings::default(),
875 )
876 .unwrap();
877 assert_eq!(completion.candidates[0].display, "cargo test --all");
878 }
879
880 #[test]
881 fn abstains_from_mid_line_completion() {
882 let store = Store::in_memory().unwrap();
883 let completion = complete(
884 &store,
885 &CommandCatalog::default(),
886 "git status",
887 3,
888 "/repo",
889 None,
890 &Settings::default(),
891 )
892 .unwrap();
893 assert!(completion.candidates.is_empty());
894 }
895
896 #[test]
897 fn discovers_commands_when_history_abstains() {
898 let store = Store::in_memory().unwrap();
899 let commands = CommandCatalog::from_entries([CommandEntry {
900 name: "atlas".to_owned(),
901 description: "CLI tool to manage MongoDB Atlas".to_owned(),
902 }]);
903
904 let completion = complete(
905 &store,
906 &commands,
907 "atl",
908 3,
909 "/repo",
910 None,
911 &Settings::default(),
912 )
913 .unwrap();
914
915 assert_eq!(completion.candidates[0].display, "atlas");
916 assert_eq!(completion.candidates[0].accept_text, "as");
917 assert_eq!(completion.candidates[0].kind, CandidateKind::Command);
918 }
919
920 #[test]
921 fn history_precedes_command_inventory() {
922 let store = Store::in_memory().unwrap();
923 store
924 .record("git status", "/repo", 0, 100, "test", true)
925 .unwrap();
926 let commands = CommandCatalog::from_entries([CommandEntry {
927 name: "git-town".to_owned(),
928 description: "Git workflow automation".to_owned(),
929 }]);
930
931 let completion = complete(
932 &store,
933 &commands,
934 "git",
935 3,
936 "/repo",
937 None,
938 &Settings::default(),
939 )
940 .unwrap();
941
942 assert_eq!(completion.candidates[0].source, CandidateSource::History);
943 assert_eq!(completion.candidates[1].source, CandidateSource::Command);
944 }
945
946 #[test]
947 fn history_deduplicates_command_inventory() {
948 let store = Store::in_memory().unwrap();
949 store
950 .record("atlas", "/repo", 0, 100, "test", true)
951 .unwrap();
952 let commands = CommandCatalog::from_entries([
953 CommandEntry {
954 name: "atlas".to_owned(),
955 description: "CLI tool to manage MongoDB Atlas".to_owned(),
956 },
957 CommandEntry {
958 name: "atlantis".to_owned(),
959 description: "Terraform pull request automation".to_owned(),
960 },
961 ]);
962
963 let completion = complete(
964 &store,
965 &commands,
966 "atl",
967 3,
968 "/repo",
969 None,
970 &Settings::default(),
971 )
972 .unwrap();
973
974 assert_eq!(completion.candidates.len(), 2);
975 assert_eq!(completion.candidates[0].display, "atlas");
976 assert_eq!(completion.candidates[0].source, CandidateSource::History);
977 assert_eq!(completion.candidates[1].display, "atlantis");
978 }
979}