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