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 if insert_text.is_empty() {
262 continue;
263 }
264 matches.push((is_directory, name, insert_text));
265 }
266 matches.sort_unstable_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
267 Ok(matches
268 .into_iter()
269 .take(limit)
270 .map(|(is_directory, _, insert_text)| Candidate {
271 display: format!("{buffer}{insert_text}"),
272 description: if is_directory { "Directory" } else { "File" }.to_owned(),
273 description_pending: false,
274 kind: if is_directory {
275 CandidateKind::Directory
276 } else {
277 CandidateKind::File
278 },
279 accept_text: next_segment(&insert_text),
280 insert_text,
281 source: CandidateSource::Filesystem,
282 })
283 .collect())
284}
285
286pub fn merge_filesystem_candidates(
287 response: &mut CompletionResponse,
288 mut paths: Vec<Candidate>,
289 limit: usize,
290) {
291 if paths.is_empty() || limit == 0 {
292 return;
293 }
294 let history_count = response
295 .candidates
296 .iter()
297 .take_while(|candidate| candidate.source == CandidateSource::History)
298 .count();
299 let path_slots = paths.len().min((limit / 2).max(1));
300 if paths.len() > path_slots
301 && let Some(first) = paths.first_mut()
302 {
303 first.description.push_str(" (more matches)");
304 }
305 let history_keep = history_count.min(limit.saturating_sub(path_slots));
306 let mut original = std::mem::take(&mut response.candidates);
307 let trailing = original.split_off(history_count);
308 let history = original;
309 let mut seen = HashSet::new();
310 for path in paths {
311 if response.candidates.len() >= path_slots {
312 break;
313 }
314 if seen.insert(path.display.clone()) {
315 response.candidates.push(path);
316 }
317 }
318 for candidate in history.into_iter().take(history_keep) {
319 if response.candidates.len() >= limit {
320 break;
321 }
322 if seen.insert(candidate.display.clone()) {
323 response.candidates.push(candidate);
324 }
325 }
326 for candidate in trailing {
327 if response.candidates.len() >= limit {
328 break;
329 }
330 if seen.insert(candidate.display.clone()) {
331 response.candidates.push(candidate);
332 }
333 }
334}
335
336fn resolve_directory(directory_text: &str, cwd: &str) -> PathBuf {
337 if directory_text == "~/" {
338 return std::env::var_os("HOME")
339 .map(PathBuf::from)
340 .unwrap_or_else(|| PathBuf::from(cwd));
341 }
342 if let Some(relative) = directory_text.strip_prefix("~/") {
343 return std::env::var_os("HOME")
344 .map(PathBuf::from)
345 .unwrap_or_else(|| PathBuf::from(cwd))
346 .join(relative);
347 }
348 let directory = Path::new(directory_text);
349 if directory.is_absolute() {
350 directory.to_owned()
351 } else {
352 Path::new(cwd).join(directory)
353 }
354}
355
356fn unescape_path_token(value: &str) -> Option<String> {
357 let mut unescaped = String::new();
358 let mut escaped = false;
359 for character in value.chars() {
360 if escaped {
361 unescaped.push(character);
362 escaped = false;
363 } else if character == '\\' {
364 escaped = true;
365 } else if character.is_control() || "'\";&|><$`(){}[]!*?".contains(character) {
366 return None;
367 } else {
368 unescaped.push(character);
369 }
370 }
371 (!escaped).then_some(unescaped)
372}
373
374fn current_argument_start(buffer: &str) -> Option<usize> {
375 let mut start = None;
376 let mut escaped = false;
377 for (index, character) in buffer.char_indices() {
378 if escaped {
379 escaped = false;
380 } else if character == '\\' {
381 escaped = true;
382 } else if character.is_whitespace() {
383 start = Some(index + character.len_utf8());
384 }
385 }
386 start
387}
388
389fn escape_path_suffix(value: &str) -> String {
390 let mut escaped = String::new();
391 for character in value.chars() {
392 if character.is_ascii()
393 && !(character.is_ascii_alphanumeric() || "_-+.@%,".contains(character))
394 {
395 escaped.push('\\');
396 }
397 escaped.push(character);
398 }
399 escaped
400}
401
402fn option_context(buffer: &str) -> Option<(&str, &str)> {
403 if buffer
404 .chars()
405 .any(|character| character.is_control() || "'\"\\;&|><$`(){}[]!*?".contains(character))
406 {
407 return None;
408 }
409 let argument_start = buffer.rfind(char::is_whitespace)? + 1;
410 let prefix = &buffer[argument_start..];
411 if !prefix.starts_with('-') {
412 return None;
413 }
414 let mut words = buffer[..argument_start].split_ascii_whitespace();
415 let command = words.next()?;
416 if !valid_command_prefix(command) || words.any(|word| !word.starts_with('-') || word == "--") {
417 return None;
418 }
419 Some((command, prefix))
420}
421
422fn fzf_indexes(candidates: &[Candidate], query: &str, limit: usize) -> Result<Vec<usize>> {
423 if candidates.is_empty() || limit == 0 {
424 return Ok(Vec::new());
425 }
426 let mut input = Vec::new();
427 for (index, candidate) in candidates.iter().enumerate() {
428 write!(input, "{index}\t{}\0", candidate.display)?;
429 }
430
431 let mut child = Command::new("fzf")
432 .args([
433 "--read0",
434 "--print0",
435 "--no-multi",
436 "--delimiter=\\t",
437 "--nth=2..",
438 "--tiebreak=index",
439 "--filter",
440 query,
441 ])
442 .env_remove("FZF_DEFAULT_OPTS")
443 .env_remove("FZF_DEFAULT_OPTS_FILE")
444 .stdin(Stdio::piped())
445 .stdout(Stdio::piped())
446 .stderr(Stdio::null())
447 .spawn()?;
448 let mut stdin = child.stdin.take().expect("fzf stdin is piped");
449 let writer = thread::spawn(move || stdin.write_all(&input));
450 let output = child.wait_with_output()?;
451 writer.join().expect("fzf input writer panicked")?;
452 if output.status.code() == Some(1) {
453 return Ok(Vec::new());
454 }
455 if !output.status.success() {
456 bail!("fzf fuzzy filter failed with {}", output.status);
457 }
458
459 let mut indexes = Vec::new();
460 for record in output.stdout.split(|byte| *byte == 0) {
461 if record.is_empty() || indexes.len() >= limit {
462 continue;
463 }
464 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
465 continue;
466 };
467 let index = std::str::from_utf8(&record[..tab])?.parse::<usize>()?;
468 if index < candidates.len() {
469 indexes.push(index);
470 }
471 }
472 Ok(indexes)
473}
474
475fn valid_command_prefix(buffer: &str) -> bool {
476 !buffer.is_empty()
477 && !buffer.starts_with('.')
478 && buffer
479 .bytes()
480 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+'))
481}
482
483fn history_description(uses: usize, same_cwd: bool) -> String {
484 match (uses, same_cwd) {
485 (1, true) => "used here".to_owned(),
486 (1, false) => "used once".to_owned(),
487 (uses, true) => format!("used {uses}x, here"),
488 (uses, false) => format!("used {uses}x"),
489 }
490}
491
492fn sanitize_display(value: &str) -> String {
493 let mut display = String::with_capacity(value.len());
494 for character in value.chars() {
495 if character.is_control() {
496 display.extend(character.escape_default());
497 } else {
498 display.push(character);
499 }
500 }
501 display
502}
503
504pub fn next_segment(suffix: &str) -> String {
505 let mut saw_non_whitespace = false;
506 let mut escaped = false;
507 let mut quote = None;
508 let mut bracket_depth: usize = 0;
509 let mut characters = suffix.char_indices().peekable();
510 while let Some((index, character)) = characters.next() {
511 let end = index + character.len_utf8();
512 if escaped {
513 escaped = false;
514 saw_non_whitespace = true;
515 continue;
516 }
517 if character == '\\' && quote != Some('\'') {
518 escaped = true;
519 saw_non_whitespace = true;
520 continue;
521 }
522 if matches!(character, '\'' | '"') {
523 if quote == Some(character) {
524 quote = None;
525 } else if quote.is_none() {
526 quote = Some(character);
527 }
528 saw_non_whitespace = true;
529 continue;
530 }
531 if quote.is_some() {
532 saw_non_whitespace = true;
533 continue;
534 }
535 if character.is_whitespace() {
536 if saw_non_whitespace {
537 return suffix[..end].to_owned();
538 }
539 continue;
540 }
541 saw_non_whitespace = true;
542 match character {
543 '[' => bracket_depth += 1,
544 ']' => bracket_depth = bracket_depth.saturating_sub(1),
545 '@' | '=' | ',' if bracket_depth == 0 => return suffix[..end].to_owned(),
546 ':' if bracket_depth == 0 => {
547 let mut boundary = end;
548 while let Some((next_index, next)) = characters.peek().copied() {
549 if !matches!(next, ':' | '/') {
550 break;
551 }
552 characters.next();
553 boundary = next_index + next.len_utf8();
554 }
555 return suffix[..boundary].to_owned();
556 }
557 '/' if bracket_depth == 0 => {
558 let mut boundary = end;
559 while let Some((next_index, '/')) = characters.peek().copied() {
560 characters.next();
561 boundary = next_index + 1;
562 }
563 return suffix[..boundary].to_owned();
564 }
565 _ => {}
566 }
567 }
568 suffix.to_owned()
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use crate::commands::{CommandCatalog, CommandEntry, OptionMatch};
575 use crate::config::Settings;
576 use crate::store::Store;
577 use tempfile::tempdir;
578
579 #[test]
580 fn accepts_one_path_segment() {
581 assert_eq!(next_segment("ev/gitrepos/aster"), "ev/");
582 }
583
584 #[test]
585 fn accepts_one_shell_word() {
586 assert_eq!(next_segment(" checkout feature/topic"), " checkout ");
587 }
588
589 #[test]
590 fn accepts_remaining_text_without_boundary() {
591 assert_eq!(next_segment("status"), "status");
592 }
593
594 #[test]
595 fn accepts_ssh_destinations_in_semantic_segments() {
596 assert_eq!(next_segment("lice@example.com"), "lice@");
597 assert_eq!(next_segment("example.com:/srv/app/file"), "example.com:/");
598 assert_eq!(next_segment("srv/app/file"), "srv/");
599 }
600
601 #[test]
602 fn accepts_common_structured_values_in_semantic_segments() {
603 assert_eq!(next_segment("output=value"), "output=");
604 assert_eq!(next_segment("https://example.com/path"), "https://");
605 assert_eq!(next_segment("host::module/path"), "host::");
606 assert_eq!(next_segment("one,two"), "one,");
607 }
608
609 #[test]
610 fn preserves_quoted_escaped_and_ipv6_separators() {
611 assert_eq!(next_segment("'user@host'/path"), "'user@host'/");
612 assert_eq!(next_segment("user\\@host/path"), "user\\@host/");
613 assert_eq!(next_segment("user@[2001:db8::1]:/srv/app"), "user@");
614 assert_eq!(next_segment("[2001:db8::1]:/srv/app"), "[2001:db8::1]:/");
615 }
616
617 #[test]
618 fn escapes_control_characters_in_display_text() {
619 assert_eq!(sanitize_display("echo\t\u{1b}"), "echo\\t\\u{1b}");
620 }
621
622 #[test]
623 fn completes_filesystem_entries_at_argument_positions() {
624 let directory = tempdir().unwrap();
625 fs::create_dir(directory.path().join("alpha-dir")).unwrap();
626 fs::write(directory.path().join("alpha-file"), "file").unwrap();
627 fs::write(directory.path().join("alpha space"), "file").unwrap();
628 fs::write(directory.path().join(".hidden"), "file").unwrap();
629 fs::write(directory.path().join("-rf"), "file").unwrap();
630 fs::write(directory.path().join("=command"), "file").unwrap();
631 fs::create_dir(directory.path().join("space dir")).unwrap();
632 fs::write(directory.path().join("space dir/child"), "file").unwrap();
633
634 let buffer = "scp -r alpha";
635 let candidates =
636 filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
637 .unwrap();
638 assert_eq!(candidates[0].display, "scp -r alpha-dir/");
639 assert_eq!(candidates[0].kind, CandidateKind::Directory);
640 assert!(candidates.iter().any(|candidate| {
641 candidate.display == "scp -r alpha-file" && candidate.kind == CandidateKind::File
642 }));
643 assert!(
644 candidates
645 .iter()
646 .any(|candidate| candidate.display == "scp -r alpha\\ space")
647 );
648 assert!(
649 !candidates
650 .iter()
651 .any(|candidate| candidate.display.contains(".hidden"))
652 );
653
654 let nested = "scp -r space\\ dir/ch";
655 let candidates =
656 filesystem_candidates(nested, nested.len(), directory.path().to_str().unwrap(), 10)
657 .unwrap();
658 assert_eq!(candidates[0].display, "scp -r space\\ dir/child");
659
660 let blank = "scp -r ";
661 let candidates =
662 filesystem_candidates(blank, blank.len(), directory.path().to_str().unwrap(), 20)
663 .unwrap();
664 assert!(
665 candidates
666 .iter()
667 .any(|candidate| candidate.display == "scp -r ./-rf")
668 );
669 assert!(
670 candidates
671 .iter()
672 .any(|candidate| candidate.display == "scp -r \\=command")
673 );
674 }
675
676 #[test]
677 fn lists_paths_after_an_empty_argument_and_hides_them_in_command_position() {
678 let directory = tempdir().unwrap();
679 fs::write(directory.path().join("visible"), "file").unwrap();
680
681 let buffer = "command ";
682 let candidates =
683 filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
684 .unwrap();
685 assert!(
686 candidates
687 .iter()
688 .any(|candidate| candidate.display == "command visible")
689 );
690 assert!(
691 filesystem_candidates("com", 3, directory.path().to_str().unwrap(), 10)
692 .unwrap()
693 .is_empty()
694 );
695 }
696
697 #[test]
698 fn filesystem_candidates_reserve_capacity_after_history() {
699 let mut response = CompletionResponse {
700 replace_start_byte: 5,
701 replace_end_byte: 5,
702 candidates: (0..4)
703 .map(|index| Candidate {
704 display: format!("cmd history-{index}"),
705 description: String::new(),
706 description_pending: false,
707 kind: CandidateKind::History,
708 insert_text: index.to_string(),
709 accept_text: index.to_string(),
710 source: CandidateSource::History,
711 })
712 .collect(),
713 enrichment_pending: false,
714 };
715 let paths = (0..2)
716 .map(|index| Candidate {
717 display: format!("cmd path-{index}"),
718 description: "File".to_owned(),
719 description_pending: false,
720 kind: CandidateKind::File,
721 insert_text: index.to_string(),
722 accept_text: index.to_string(),
723 source: CandidateSource::Filesystem,
724 })
725 .collect();
726 merge_filesystem_candidates(&mut response, paths, 4);
727 assert_eq!(response.candidates.len(), 4);
728 assert_eq!(response.candidates[0].source, CandidateSource::Filesystem);
729 assert_eq!(
730 response
731 .candidates
732 .iter()
733 .filter(|candidate| candidate.source == CandidateSource::Filesystem)
734 .count(),
735 2
736 );
737
738 let mut response = CompletionResponse::empty(0);
739 let paths = (0..2)
740 .map(|index| Candidate {
741 display: format!("cmd path-{index}"),
742 description: "File".to_owned(),
743 description_pending: false,
744 kind: CandidateKind::File,
745 insert_text: index.to_string(),
746 accept_text: index.to_string(),
747 source: CandidateSource::Filesystem,
748 })
749 .collect();
750 merge_filesystem_candidates(&mut response, paths, 1);
751 assert_eq!(response.candidates[0].description, "File (more matches)");
752 }
753
754 #[test]
755 fn recognizes_only_safe_root_option_contexts() {
756 assert_eq!(option_context("git --ver"), Some(("git", "--ver")));
757 assert_eq!(option_context("git status --short"), None);
758 assert_eq!(
759 option_context("git --quiet --short"),
760 Some(("git", "--short"))
761 );
762 assert_eq!(option_context("git -- --literal"), None);
763 assert_eq!(option_context("git \"--ver"), None);
764 assert_eq!(option_context("--ver"), None);
765 }
766
767 #[test]
768 fn completes_cached_root_command_options() {
769 let store = Store::in_memory().unwrap();
770 let commands = CommandCatalog::from_options(
771 "tool",
772 vec![
773 OptionMatch {
774 spelling: "--verbose".to_owned(),
775 description: "Show verbose output".to_owned(),
776 },
777 OptionMatch {
778 spelling: "--version".to_owned(),
779 description: "Print version".to_owned(),
780 },
781 ],
782 );
783 let completion = complete(
784 &store,
785 &commands,
786 "tool --ver",
787 "tool --ver".len(),
788 "/repo",
789 None,
790 &Settings::default(),
791 )
792 .unwrap();
793 assert_eq!(completion.candidates.len(), 2);
794 assert_eq!(completion.candidates[0].display, "tool --verbose");
795 assert_eq!(completion.candidates[0].kind, CandidateKind::Option);
796 assert_eq!(completion.candidates[0].source, CandidateSource::Help);
797 assert_eq!(completion.candidates[1].display, "tool --version");
798 }
799
800 #[test]
801 fn describes_history_usage_and_directory() {
802 assert_eq!(history_description(1, true), "used here");
803 assert_eq!(history_description(3, true), "used 3x, here");
804 assert_eq!(history_description(2, false), "used 2x");
805 }
806
807 #[test]
808 fn completes_history_with_a_single_segment() {
809 let store = Store::in_memory().unwrap();
810 let mut settings = Settings::default();
811 settings.completion.accept = AcceptMode::Segment;
812 store
813 .record("cd ~/dev/gitrepos/aster", "/repo", 0, 100, "test", true)
814 .unwrap();
815
816 let completion = complete(
817 &store,
818 &CommandCatalog::default(),
819 "cd ~/d",
820 "cd ~/d".len(),
821 "/repo",
822 None,
823 &settings,
824 )
825 .unwrap();
826
827 assert_eq!(completion.candidates.len(), 1);
828 assert_eq!(completion.candidates[0].insert_text, "ev/gitrepos/aster");
829 assert_eq!(completion.candidates[0].accept_text, "ev/");
830 assert_eq!(completion.candidates[0].description, "used here");
831
832 let completion = complete(
833 &store,
834 &CommandCatalog::default(),
835 "cd ~/d",
836 "cd ~/d".len(),
837 "/repo",
838 None,
839 &Settings::default(),
840 )
841 .unwrap();
842 assert_eq!(completion.candidates[0].accept_text, "ev/gitrepos/aster");
843 }
844
845 #[test]
846 fn fuzzy_searches_history_without_a_prefix() {
847 if Command::new("fzf").arg("--version").output().is_err() {
848 return;
849 }
850 let store = Store::in_memory().unwrap();
851 store
852 .record("cargo test --all", "/repo", 0, 100, "test", true)
853 .unwrap();
854 let completion = fuzzy(
855 &store,
856 &CommandCatalog::default(),
857 "cgt",
858 "/repo",
859 None,
860 &Settings::default(),
861 )
862 .unwrap();
863 assert_eq!(completion.candidates[0].display, "cargo test --all");
864 }
865
866 #[test]
867 fn abstains_from_mid_line_completion() {
868 let store = Store::in_memory().unwrap();
869 let completion = complete(
870 &store,
871 &CommandCatalog::default(),
872 "git status",
873 3,
874 "/repo",
875 None,
876 &Settings::default(),
877 )
878 .unwrap();
879 assert!(completion.candidates.is_empty());
880 }
881
882 #[test]
883 fn discovers_commands_when_history_abstains() {
884 let store = Store::in_memory().unwrap();
885 let commands = CommandCatalog::from_entries([CommandEntry {
886 name: "atlas".to_owned(),
887 description: "CLI tool to manage MongoDB Atlas".to_owned(),
888 }]);
889
890 let completion = complete(
891 &store,
892 &commands,
893 "atl",
894 3,
895 "/repo",
896 None,
897 &Settings::default(),
898 )
899 .unwrap();
900
901 assert_eq!(completion.candidates[0].display, "atlas");
902 assert_eq!(completion.candidates[0].accept_text, "as");
903 assert_eq!(completion.candidates[0].kind, CandidateKind::Command);
904 }
905
906 #[test]
907 fn history_precedes_command_inventory() {
908 let store = Store::in_memory().unwrap();
909 store
910 .record("git status", "/repo", 0, 100, "test", true)
911 .unwrap();
912 let commands = CommandCatalog::from_entries([CommandEntry {
913 name: "git-town".to_owned(),
914 description: "Git workflow automation".to_owned(),
915 }]);
916
917 let completion = complete(
918 &store,
919 &commands,
920 "git",
921 3,
922 "/repo",
923 None,
924 &Settings::default(),
925 )
926 .unwrap();
927
928 assert_eq!(completion.candidates[0].source, CandidateSource::History);
929 assert_eq!(completion.candidates[1].source, CandidateSource::Command);
930 }
931
932 #[test]
933 fn history_deduplicates_command_inventory() {
934 let store = Store::in_memory().unwrap();
935 store
936 .record("atlas", "/repo", 0, 100, "test", true)
937 .unwrap();
938 let commands = CommandCatalog::from_entries([
939 CommandEntry {
940 name: "atlas".to_owned(),
941 description: "CLI tool to manage MongoDB Atlas".to_owned(),
942 },
943 CommandEntry {
944 name: "atlantis".to_owned(),
945 description: "Terraform pull request automation".to_owned(),
946 },
947 ]);
948
949 let completion = complete(
950 &store,
951 &commands,
952 "atl",
953 3,
954 "/repo",
955 None,
956 &Settings::default(),
957 )
958 .unwrap();
959
960 assert_eq!(completion.candidates.len(), 2);
961 assert_eq!(completion.candidates[0].display, "atlas");
962 assert_eq!(completion.candidates[0].source, CandidateSource::History);
963 assert_eq!(completion.candidates[1].display, "atlantis");
964 }
965}