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 let help_candidates = if let Some((command, option, prefix)) = value_context(buffer) {
102 let values = commands.matching_values(command, option, prefix, limit);
103 enrichment_pending = values.pending;
104 values
105 .entries
106 .into_iter()
107 .filter_map(|value| {
108 let suffix = value.value.strip_prefix(prefix)?;
109 let insertion = if suffix.is_empty() { " " } else { suffix };
110 Some(Candidate {
111 display: if suffix.is_empty() {
112 buffer.to_owned()
113 } else {
114 format!("{buffer}{suffix}")
115 },
116 description: if value.description.is_empty() {
117 format!("Value for {option}")
118 } else {
119 value.description
120 },
121 description_pending: false,
122 kind: CandidateKind::Value,
123 insert_text: insertion.to_owned(),
124 accept_text: insertion.to_owned(),
125 source: CandidateSource::Help,
126 })
127 })
128 .collect()
129 } else if let Some((command, prefix)) = option_context(buffer) {
130 let options = commands.matching_options(command, prefix, limit);
131 enrichment_pending = options.pending;
132 options
133 .entries
134 .into_iter()
135 .filter_map(|option| {
136 let suffix = option.spelling.strip_prefix(prefix)?;
137 if suffix.is_empty() {
138 return None;
139 }
140 Some(Candidate {
141 display: format!("{buffer}{suffix}"),
142 description: option.description,
143 description_pending: false,
144 kind: CandidateKind::Option,
145 insert_text: suffix.to_owned(),
146 accept_text: suffix.to_owned(),
147 source: CandidateSource::Help,
148 })
149 })
150 .collect()
151 } else if let Some((command, prefix)) = subcommand_context(buffer) {
152 let subcommands = commands.matching_subcommands(command, prefix, limit);
153 enrichment_pending = subcommands.pending;
154 subcommands
155 .entries
156 .into_iter()
157 .filter_map(|subcommand| {
158 let suffix = subcommand.name.strip_prefix(prefix)?;
159 let insertion = if suffix.is_empty() { " " } else { suffix };
160 Some(Candidate {
161 display: if suffix.is_empty() {
162 buffer.to_owned()
163 } else {
164 format!("{buffer}{suffix}")
165 },
166 description: subcommand.description,
167 description_pending: false,
168 kind: CandidateKind::Subcommand,
169 insert_text: insertion.to_owned(),
170 accept_text: insertion.to_owned(),
171 source: CandidateSource::Help,
172 })
173 })
174 .collect()
175 } else {
176 Vec::new()
177 };
178 if !help_candidates.is_empty() {
179 let mut help_candidates: Vec<_> = help_candidates
180 .into_iter()
181 .filter(|entry| {
182 !candidates
183 .iter()
184 .any(|candidate| candidate.display == entry.display)
185 })
186 .collect();
187 let help_slots = help_candidates.len().min((limit / 2).max(1));
188 candidates.truncate(limit.saturating_sub(help_slots));
189 candidates.extend(help_candidates.drain(..help_slots));
190 }
191
192 Ok(CompletionResponse {
193 replace_start_byte: cursor_byte,
194 replace_end_byte: cursor_byte,
195 candidates,
196 enrichment_pending,
197 })
198}
199
200pub fn fuzzy(
201 store: &Store,
202 commands: &CommandCatalog,
203 query: &str,
204 cwd: &str,
205 requested_limit: Option<usize>,
206 settings: &Settings,
207) -> Result<CompletionResponse> {
208 if query.len() > MAX_BUFFER_BYTES {
209 bail!("fuzzy query exceeds {MAX_BUFFER_BYTES} bytes");
210 }
211 let limit = requested_limit
212 .unwrap_or(settings.completion.max_candidates)
213 .min(settings.completion.max_candidates);
214 let mut seen = HashSet::new();
215 let mut pool = Vec::new();
216
217 for history in
218 store.history_inventory(cwd, FUZZY_HISTORY_LIMIT, settings.history.successful_first)?
219 {
220 if seen.insert(history.command.clone()) {
221 pool.push(Candidate {
222 display: sanitize_display(&history.command),
223 description: history_description(history.uses, history.same_cwd),
224 description_pending: false,
225 kind: CandidateKind::History,
226 insert_text: history.command.clone(),
227 accept_text: history.command,
228 source: CandidateSource::History,
229 });
230 }
231 }
232 for command in commands.inventory() {
233 if seen.insert(command.name.clone()) {
234 pool.push(Candidate {
235 display: command.name.clone(),
236 description: command.description,
237 description_pending: false,
238 kind: CandidateKind::Command,
239 insert_text: command.name.clone(),
240 accept_text: command.name,
241 source: CandidateSource::Command,
242 });
243 }
244 }
245
246 let indexes = fzf_indexes(&pool, query, limit)?;
247 let mut candidates = Vec::with_capacity(indexes.len());
248 for index in indexes {
249 let mut candidate = pool[index].clone();
250 if candidate.source == CandidateSource::Command
251 && let Some(command) = commands
252 .matching(&candidate.display, 1)
253 .into_iter()
254 .find(|command| command.name == candidate.display)
255 {
256 candidate.description = command.description;
257 candidate.description_pending = command.description_pending;
258 }
259 candidates.push(candidate);
260 }
261 Ok(CompletionResponse {
262 replace_start_byte: 0,
263 replace_end_byte: 0,
264 candidates,
265 enrichment_pending: false,
266 })
267}
268
269pub fn filesystem_candidates(
270 buffer: &str,
271 cursor_byte: usize,
272 cwd: &str,
273 limit: usize,
274) -> Result<Vec<Candidate>> {
275 if limit == 0 || cursor_byte != buffer.len() || !buffer.is_char_boundary(cursor_byte) {
276 return Ok(Vec::new());
277 }
278 let Some(argument_start) = current_argument_start(buffer) else {
279 return Ok(Vec::new());
280 };
281 let Some(token) = unescape_path_token(&buffer[argument_start..]) else {
282 return Ok(Vec::new());
283 };
284 if token.starts_with('-') {
285 return Ok(Vec::new());
286 }
287
288 let (directory_text, name_prefix) = token
289 .rfind('/')
290 .map_or(("", token.as_str()), |slash| token.split_at(slash + 1));
291 let directory = resolve_directory(directory_text, cwd);
292 let Ok(children) = fs::read_dir(&directory) else {
293 return Ok(Vec::new());
294 };
295 let show_hidden = name_prefix.starts_with('.');
296 let mut matches = Vec::new();
297 for child in children.take(MAX_DIRECTORY_ENTRIES).flatten() {
298 let Some(name) = child.file_name().to_str().map(str::to_owned) else {
299 continue;
300 };
301 if name.is_empty()
302 || (!show_hidden && name.starts_with('.'))
303 || !name.starts_with(name_prefix)
304 || name.chars().any(char::is_control)
305 {
306 continue;
307 }
308 let Ok(file_type) = child.file_type() else {
309 continue;
310 };
311 if !(file_type.is_dir() || file_type.is_file() || file_type.is_symlink()) {
312 continue;
313 }
314 let is_directory = file_type.is_dir() || (file_type.is_symlink() && child.path().is_dir());
315 let suffix = &name[name_prefix.len()..];
316 let mut insert_text = escape_path_suffix(suffix);
317 if token.is_empty() && name.starts_with('-') {
318 insert_text.insert_str(0, "./");
319 }
320 if is_directory {
321 insert_text.push('/');
322 }
323 let exact_file = !is_directory && insert_text.is_empty();
324 if exact_file {
325 insert_text.push(' ');
326 }
327 matches.push((is_directory, name, insert_text, exact_file));
328 }
329 matches.sort_unstable_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
330 Ok(matches
331 .into_iter()
332 .take(limit)
333 .map(|(is_directory, _, insert_text, exact_file)| Candidate {
334 display: if exact_file {
335 buffer.to_owned()
336 } else {
337 format!("{buffer}{insert_text}")
338 },
339 description: if is_directory { "Directory" } else { "File" }.to_owned(),
340 description_pending: false,
341 kind: if is_directory {
342 CandidateKind::Directory
343 } else {
344 CandidateKind::File
345 },
346 accept_text: next_segment(&insert_text),
347 insert_text,
348 source: CandidateSource::Filesystem,
349 })
350 .collect())
351}
352
353pub fn merge_filesystem_candidates(
354 response: &mut CompletionResponse,
355 mut paths: Vec<Candidate>,
356 limit: usize,
357) {
358 if paths.is_empty() || limit == 0 {
359 return;
360 }
361 let history_count = response
362 .candidates
363 .iter()
364 .take_while(|candidate| candidate.source == CandidateSource::History)
365 .count();
366 let path_slots = paths.len().min((limit / 2).max(1));
367 if paths.len() > path_slots
368 && let Some(first) = paths.first_mut()
369 {
370 first.description.push_str(" (more matches)");
371 }
372 let history_keep = history_count.min(limit.saturating_sub(path_slots));
373 let mut original = std::mem::take(&mut response.candidates);
374 let trailing = original.split_off(history_count);
375 let history = original;
376 let mut seen = HashSet::new();
377 for candidate in history.into_iter().take(history_keep) {
378 if response.candidates.len() >= limit {
379 break;
380 }
381 if seen.insert(candidate.display.clone()) {
382 response.candidates.push(candidate);
383 }
384 }
385 let path_limit = response.candidates.len().saturating_add(path_slots);
386 for path in paths {
387 if response.candidates.len() >= path_limit || response.candidates.len() >= limit {
388 break;
389 }
390 if seen.insert(path.display.clone()) {
391 response.candidates.push(path);
392 }
393 }
394 for candidate in trailing {
395 if response.candidates.len() >= limit {
396 break;
397 }
398 if seen.insert(candidate.display.clone()) {
399 response.candidates.push(candidate);
400 }
401 }
402}
403
404fn resolve_directory(directory_text: &str, cwd: &str) -> PathBuf {
405 if directory_text == "~/" {
406 return std::env::var_os("HOME")
407 .map(PathBuf::from)
408 .unwrap_or_else(|| PathBuf::from(cwd));
409 }
410 if let Some(relative) = directory_text.strip_prefix("~/") {
411 return std::env::var_os("HOME")
412 .map(PathBuf::from)
413 .unwrap_or_else(|| PathBuf::from(cwd))
414 .join(relative);
415 }
416 let directory = Path::new(directory_text);
417 if directory.is_absolute() {
418 directory.to_owned()
419 } else {
420 Path::new(cwd).join(directory)
421 }
422}
423
424fn unescape_path_token(value: &str) -> Option<String> {
425 let mut unescaped = String::new();
426 let mut escaped = false;
427 for character in value.chars() {
428 if escaped {
429 unescaped.push(character);
430 escaped = false;
431 } else if character == '\\' {
432 escaped = true;
433 } else if character.is_control() || "'\";&|><$`(){}[]!*?".contains(character) {
434 return None;
435 } else {
436 unescaped.push(character);
437 }
438 }
439 (!escaped).then_some(unescaped)
440}
441
442fn current_argument_start(buffer: &str) -> Option<usize> {
443 let mut start = None;
444 let mut escaped = false;
445 for (index, character) in buffer.char_indices() {
446 if escaped {
447 escaped = false;
448 } else if character == '\\' {
449 escaped = true;
450 } else if character.is_whitespace() {
451 start = Some(index + character.len_utf8());
452 }
453 }
454 start
455}
456
457fn escape_path_suffix(value: &str) -> String {
458 let mut escaped = String::new();
459 for character in value.chars() {
460 if character.is_ascii()
461 && !(character.is_ascii_alphanumeric() || "_-+.@%,".contains(character))
462 {
463 escaped.push('\\');
464 }
465 escaped.push(character);
466 }
467 escaped
468}
469
470fn option_context(buffer: &str) -> Option<(&str, &str)> {
471 if !safe_structured_buffer(buffer) {
472 return None;
473 }
474 let argument_start = buffer.rfind(char::is_whitespace)? + 1;
475 let prefix = &buffer[argument_start..];
476 if !prefix.starts_with('-') {
477 return None;
478 }
479 let mut words = buffer[..argument_start].split_ascii_whitespace();
480 let command = words.next()?;
481 if !valid_command_prefix(command) || words.any(|word| !word.starts_with('-') || word == "--") {
482 return None;
483 }
484 Some((command, prefix))
485}
486
487fn subcommand_context(buffer: &str) -> Option<(&str, &str)> {
488 if !safe_structured_buffer(buffer) {
489 return None;
490 }
491 let argument_start = buffer.rfind(char::is_whitespace)? + 1;
492 let prefix = &buffer[argument_start..];
493 if prefix.starts_with('-') || !valid_structured_prefix(prefix) {
494 return None;
495 }
496 let mut words = buffer[..argument_start].split_ascii_whitespace();
497 let command = words.next()?;
498 if !valid_command_prefix(command) || words.next().is_some() {
499 return None;
500 }
501 Some((command, prefix))
502}
503
504fn value_context(buffer: &str) -> Option<(&str, &str, &str)> {
505 if !safe_structured_buffer(buffer) {
506 return None;
507 }
508 let argument_start = buffer.rfind(char::is_whitespace)? + 1;
509 let token = &buffer[argument_start..];
510 let mut words = buffer[..argument_start].split_ascii_whitespace();
511 let command = words.next()?;
512 if !valid_command_prefix(command) {
513 return None;
514 }
515
516 if let Some((option, prefix)) = token.split_once('=') {
517 if words.next().is_none() && valid_option_context_spelling(option) {
518 return Some((command, option, prefix));
519 }
520 return None;
521 }
522
523 let option = words.next()?;
524 if words.next().is_some() || token.starts_with('-') || !valid_option_context_spelling(option) {
525 return None;
526 }
527 Some((command, option, token))
528}
529
530fn safe_structured_buffer(buffer: &str) -> bool {
531 buffer.is_ascii()
532 && !buffer
533 .chars()
534 .any(|character| character.is_control() || "'\"\\;&|><$`(){}[]!*?".contains(character))
535}
536
537fn valid_structured_prefix(prefix: &str) -> bool {
538 prefix.is_empty()
539 || (prefix.as_bytes()[0].is_ascii_alphanumeric()
540 && prefix.bytes().all(|byte| {
541 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+')
542 }))
543}
544
545fn valid_option_context_spelling(option: &str) -> bool {
546 option != "--"
547 && option.starts_with('-')
548 && option
549 .bytes()
550 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
551}
552
553fn fzf_indexes(candidates: &[Candidate], query: &str, limit: usize) -> Result<Vec<usize>> {
554 if candidates.is_empty() || limit == 0 {
555 return Ok(Vec::new());
556 }
557 let mut input = Vec::new();
558 for (index, candidate) in candidates.iter().enumerate() {
559 write!(input, "{index}\t{}\0", candidate.display)?;
560 }
561
562 let mut child = Command::new("fzf")
563 .args([
564 "--read0",
565 "--print0",
566 "--no-multi",
567 "--delimiter=\\t",
568 "--nth=2..",
569 "--tiebreak=index",
570 "--filter",
571 query,
572 ])
573 .env_remove("FZF_DEFAULT_OPTS")
574 .env_remove("FZF_DEFAULT_OPTS_FILE")
575 .stdin(Stdio::piped())
576 .stdout(Stdio::piped())
577 .stderr(Stdio::null())
578 .spawn()?;
579 let mut stdin = child.stdin.take().expect("fzf stdin is piped");
580 let writer = thread::spawn(move || stdin.write_all(&input));
581 let output = child.wait_with_output()?;
582 writer.join().expect("fzf input writer panicked")?;
583 if output.status.code() == Some(1) {
584 return Ok(Vec::new());
585 }
586 if !output.status.success() {
587 bail!("fzf fuzzy filter failed with {}", output.status);
588 }
589
590 let mut indexes = Vec::new();
591 for record in output.stdout.split(|byte| *byte == 0) {
592 if record.is_empty() || indexes.len() >= limit {
593 continue;
594 }
595 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
596 continue;
597 };
598 let index = std::str::from_utf8(&record[..tab])?.parse::<usize>()?;
599 if index < candidates.len() {
600 indexes.push(index);
601 }
602 }
603 Ok(indexes)
604}
605
606fn valid_command_prefix(buffer: &str) -> bool {
607 !buffer.is_empty()
608 && !buffer.starts_with('.')
609 && buffer
610 .bytes()
611 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+'))
612}
613
614fn history_description(uses: usize, same_cwd: bool) -> String {
615 match (uses, same_cwd) {
616 (1, true) => "used here".to_owned(),
617 (1, false) => "used once".to_owned(),
618 (uses, true) => format!("used {uses}x, here"),
619 (uses, false) => format!("used {uses}x"),
620 }
621}
622
623fn sanitize_display(value: &str) -> String {
624 let mut display = String::with_capacity(value.len());
625 for character in value.chars() {
626 if character.is_control() {
627 display.extend(character.escape_default());
628 } else {
629 display.push(character);
630 }
631 }
632 display
633}
634
635pub fn next_segment(suffix: &str) -> String {
636 let mut saw_non_whitespace = false;
637 let mut escaped = false;
638 let mut quote = None;
639 let mut bracket_depth: usize = 0;
640 let mut characters = suffix.char_indices().peekable();
641 while let Some((index, character)) = characters.next() {
642 let end = index + character.len_utf8();
643 if escaped {
644 escaped = false;
645 saw_non_whitespace = true;
646 continue;
647 }
648 if character == '\\' && quote != Some('\'') {
649 escaped = true;
650 saw_non_whitespace = true;
651 continue;
652 }
653 if matches!(character, '\'' | '"') {
654 if quote == Some(character) {
655 quote = None;
656 } else if quote.is_none() {
657 quote = Some(character);
658 }
659 saw_non_whitespace = true;
660 continue;
661 }
662 if quote.is_some() {
663 saw_non_whitespace = true;
664 continue;
665 }
666 if character.is_whitespace() {
667 if saw_non_whitespace {
668 return suffix[..end].to_owned();
669 }
670 continue;
671 }
672 saw_non_whitespace = true;
673 match character {
674 '[' => bracket_depth += 1,
675 ']' => bracket_depth = bracket_depth.saturating_sub(1),
676 '@' | '=' | ',' if bracket_depth == 0 => return suffix[..end].to_owned(),
677 ':' if bracket_depth == 0 => {
678 let mut boundary = end;
679 while let Some((next_index, next)) = characters.peek().copied() {
680 if !matches!(next, ':' | '/') {
681 break;
682 }
683 characters.next();
684 boundary = next_index + next.len_utf8();
685 }
686 return suffix[..boundary].to_owned();
687 }
688 '/' if bracket_depth == 0 => {
689 let mut boundary = end;
690 while let Some((next_index, '/')) = characters.peek().copied() {
691 characters.next();
692 boundary = next_index + 1;
693 }
694 return suffix[..boundary].to_owned();
695 }
696 _ => {}
697 }
698 }
699 suffix.to_owned()
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705 use crate::commands::{CommandCatalog, CommandEntry, OptionMatch, SubcommandMatch, ValueMatch};
706 use crate::config::Settings;
707 use crate::store::Store;
708 use tempfile::tempdir;
709
710 #[test]
711 fn accepts_one_path_segment() {
712 assert_eq!(next_segment("ev/gitrepos/aster"), "ev/");
713 }
714
715 #[test]
716 fn accepts_one_shell_word() {
717 assert_eq!(next_segment(" checkout feature/topic"), " checkout ");
718 }
719
720 #[test]
721 fn accepts_remaining_text_without_boundary() {
722 assert_eq!(next_segment("status"), "status");
723 }
724
725 #[test]
726 fn accepts_ssh_destinations_in_semantic_segments() {
727 assert_eq!(next_segment("lice@example.com"), "lice@");
728 assert_eq!(next_segment("example.com:/srv/app/file"), "example.com:/");
729 assert_eq!(next_segment("srv/app/file"), "srv/");
730 }
731
732 #[test]
733 fn accepts_common_structured_values_in_semantic_segments() {
734 assert_eq!(next_segment("output=value"), "output=");
735 assert_eq!(next_segment("https://example.com/path"), "https://");
736 assert_eq!(next_segment("host::module/path"), "host::");
737 assert_eq!(next_segment("one,two"), "one,");
738 }
739
740 #[test]
741 fn preserves_quoted_escaped_and_ipv6_separators() {
742 assert_eq!(next_segment("'user@host'/path"), "'user@host'/");
743 assert_eq!(next_segment("user\\@host/path"), "user\\@host/");
744 assert_eq!(next_segment("user@[2001:db8::1]:/srv/app"), "user@");
745 assert_eq!(next_segment("[2001:db8::1]:/srv/app"), "[2001:db8::1]:/");
746 }
747
748 #[test]
749 fn escapes_control_characters_in_display_text() {
750 assert_eq!(sanitize_display("echo\t\u{1b}"), "echo\\t\\u{1b}");
751 }
752
753 #[test]
754 fn completes_filesystem_entries_at_argument_positions() {
755 let directory = tempdir().unwrap();
756 fs::create_dir(directory.path().join("alpha-dir")).unwrap();
757 fs::write(directory.path().join("alpha-file"), "file").unwrap();
758 fs::write(directory.path().join("alpha space"), "file").unwrap();
759 fs::write(directory.path().join(".hidden"), "file").unwrap();
760 fs::write(directory.path().join("-rf"), "file").unwrap();
761 fs::write(directory.path().join("=command"), "file").unwrap();
762 fs::create_dir(directory.path().join("space dir")).unwrap();
763 fs::write(directory.path().join("space dir/child"), "file").unwrap();
764
765 let buffer = "scp -r alpha";
766 let candidates =
767 filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
768 .unwrap();
769 assert_eq!(candidates[0].display, "scp -r alpha-dir/");
770 assert_eq!(candidates[0].kind, CandidateKind::Directory);
771 assert!(candidates.iter().any(|candidate| {
772 candidate.display == "scp -r alpha-file" && candidate.kind == CandidateKind::File
773 }));
774 assert!(
775 candidates
776 .iter()
777 .any(|candidate| candidate.display == "scp -r alpha\\ space")
778 );
779 assert!(
780 !candidates
781 .iter()
782 .any(|candidate| candidate.display.contains(".hidden"))
783 );
784
785 let nested = "scp -r space\\ dir/ch";
786 let candidates =
787 filesystem_candidates(nested, nested.len(), directory.path().to_str().unwrap(), 10)
788 .unwrap();
789 assert_eq!(candidates[0].display, "scp -r space\\ dir/child");
790
791 let exact = "scp -r alpha-file";
792 let candidates =
793 filesystem_candidates(exact, exact.len(), directory.path().to_str().unwrap(), 10)
794 .unwrap();
795 assert_eq!(candidates.len(), 1);
796 assert_eq!(candidates[0].display, exact);
797 assert_eq!(candidates[0].accept_text, " ");
798
799 let blank = "scp -r ";
800 let candidates =
801 filesystem_candidates(blank, blank.len(), directory.path().to_str().unwrap(), 20)
802 .unwrap();
803 assert!(
804 candidates
805 .iter()
806 .any(|candidate| candidate.display == "scp -r ./-rf")
807 );
808 assert!(
809 candidates
810 .iter()
811 .any(|candidate| candidate.display == "scp -r \\=command")
812 );
813 }
814
815 #[test]
816 fn lists_paths_after_an_empty_argument_and_hides_them_in_command_position() {
817 let directory = tempdir().unwrap();
818 fs::write(directory.path().join("visible"), "file").unwrap();
819
820 let buffer = "command ";
821 let candidates =
822 filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
823 .unwrap();
824 assert!(
825 candidates
826 .iter()
827 .any(|candidate| candidate.display == "command visible")
828 );
829 assert!(
830 filesystem_candidates("com", 3, directory.path().to_str().unwrap(), 10)
831 .unwrap()
832 .is_empty()
833 );
834 }
835
836 #[test]
837 fn history_stays_first_while_filesystem_candidates_reserve_capacity() {
838 let mut response = CompletionResponse {
839 replace_start_byte: 5,
840 replace_end_byte: 5,
841 candidates: (0..4)
842 .map(|index| Candidate {
843 display: format!("cmd history-{index}"),
844 description: String::new(),
845 description_pending: false,
846 kind: CandidateKind::History,
847 insert_text: index.to_string(),
848 accept_text: index.to_string(),
849 source: CandidateSource::History,
850 })
851 .collect(),
852 enrichment_pending: false,
853 };
854 let paths = (0..2)
855 .map(|index| Candidate {
856 display: format!("cmd path-{index}"),
857 description: "File".to_owned(),
858 description_pending: false,
859 kind: CandidateKind::File,
860 insert_text: index.to_string(),
861 accept_text: index.to_string(),
862 source: CandidateSource::Filesystem,
863 })
864 .collect();
865 merge_filesystem_candidates(&mut response, paths, 4);
866 assert_eq!(response.candidates.len(), 4);
867 assert_eq!(response.candidates[0].source, CandidateSource::History);
868 assert_eq!(
869 response
870 .candidates
871 .iter()
872 .filter(|candidate| candidate.source == CandidateSource::Filesystem)
873 .count(),
874 2
875 );
876
877 let mut response = CompletionResponse::empty(0);
878 let paths = (0..2)
879 .map(|index| Candidate {
880 display: format!("cmd path-{index}"),
881 description: "File".to_owned(),
882 description_pending: false,
883 kind: CandidateKind::File,
884 insert_text: index.to_string(),
885 accept_text: index.to_string(),
886 source: CandidateSource::Filesystem,
887 })
888 .collect();
889 merge_filesystem_candidates(&mut response, paths, 1);
890 assert_eq!(response.candidates[0].description, "File (more matches)");
891 }
892
893 #[test]
894 fn recognizes_only_safe_root_option_contexts() {
895 assert_eq!(option_context("git --ver"), Some(("git", "--ver")));
896 assert_eq!(option_context("git status --short"), None);
897 assert_eq!(
898 option_context("git --quiet --short"),
899 Some(("git", "--short"))
900 );
901 assert_eq!(option_context("git -- --literal"), None);
902 assert_eq!(option_context("git \"--ver"), None);
903 assert_eq!(option_context("--ver"), None);
904 }
905
906 #[test]
907 fn completes_cached_root_command_options() {
908 let store = Store::in_memory().unwrap();
909 let commands = CommandCatalog::from_options(
910 "tool",
911 vec![
912 OptionMatch {
913 spelling: "--verbose".to_owned(),
914 description: "Show verbose output".to_owned(),
915 },
916 OptionMatch {
917 spelling: "--version".to_owned(),
918 description: "Print version".to_owned(),
919 },
920 ],
921 );
922 let completion = complete(
923 &store,
924 &commands,
925 "tool --ver",
926 "tool --ver".len(),
927 "/repo",
928 None,
929 &Settings::default(),
930 )
931 .unwrap();
932 assert_eq!(completion.candidates.len(), 2);
933 assert_eq!(completion.candidates[0].display, "tool --verbose");
934 assert_eq!(completion.candidates[0].kind, CandidateKind::Option);
935 assert_eq!(completion.candidates[0].source, CandidateSource::Help);
936 assert_eq!(completion.candidates[1].display, "tool --version");
937 }
938
939 #[test]
940 fn completes_cached_subcommands_and_documented_values() {
941 let store = Store::in_memory().unwrap();
942 let commands = CommandCatalog::from_structured(
943 "tool",
944 Vec::new(),
945 vec![
946 SubcommandMatch {
947 name: "build".to_owned(),
948 description: "Build the project".to_owned(),
949 },
950 SubcommandMatch {
951 name: "inspect".to_owned(),
952 description: "Inspect project state".to_owned(),
953 },
954 ],
955 vec![(
956 "--color".to_owned(),
957 vec![
958 ValueMatch {
959 value: "auto".to_owned(),
960 description: String::new(),
961 },
962 ValueMatch {
963 value: "always".to_owned(),
964 description: "Always use color".to_owned(),
965 },
966 ],
967 )],
968 );
969
970 let completion = complete(
971 &store,
972 &commands,
973 "tool bu",
974 "tool bu".len(),
975 "/repo",
976 None,
977 &Settings::default(),
978 )
979 .unwrap();
980 assert_eq!(completion.candidates.len(), 1);
981 assert_eq!(completion.candidates[0].display, "tool build");
982 assert_eq!(completion.candidates[0].insert_text, "ild");
983 assert_eq!(completion.candidates[0].kind, CandidateKind::Subcommand);
984
985 let completion = complete(
986 &store,
987 &commands,
988 "tool --color=a",
989 "tool --color=a".len(),
990 "/repo",
991 None,
992 &Settings::default(),
993 )
994 .unwrap();
995 assert_eq!(completion.candidates.len(), 2);
996 assert_eq!(completion.candidates[0].display, "tool --color=auto");
997 assert_eq!(completion.candidates[0].description, "Value for --color");
998 assert_eq!(completion.candidates[0].kind, CandidateKind::Value);
999 assert_eq!(completion.candidates[1].display, "tool --color=always");
1000 assert_eq!(completion.candidates[1].description, "Always use color");
1001
1002 let completion = complete(
1003 &store,
1004 &commands,
1005 "tool --color auto",
1006 "tool --color auto".len(),
1007 "/repo",
1008 None,
1009 &Settings::default(),
1010 )
1011 .unwrap();
1012 assert_eq!(completion.candidates[0].display, "tool --color auto");
1013 assert_eq!(completion.candidates[0].insert_text, " ");
1014 }
1015
1016 #[test]
1017 fn recognizes_only_unambiguous_structured_contexts() {
1018 assert_eq!(subcommand_context("tool bu"), Some(("tool", "bu")));
1019 assert_eq!(subcommand_context("tool "), Some(("tool", "")));
1020 assert_eq!(subcommand_context("tool other bu"), None);
1021 assert_eq!(subcommand_context("tool --flag "), None);
1022 assert_eq!(
1023 value_context("tool --color=a"),
1024 Some(("tool", "--color", "a"))
1025 );
1026 assert_eq!(
1027 value_context("tool --color a"),
1028 Some(("tool", "--color", "a"))
1029 );
1030 assert_eq!(value_context("tool other --color a"), None);
1031 }
1032
1033 #[test]
1034 fn describes_history_usage_and_directory() {
1035 assert_eq!(history_description(1, true), "used here");
1036 assert_eq!(history_description(3, true), "used 3x, here");
1037 assert_eq!(history_description(2, false), "used 2x");
1038 }
1039
1040 #[test]
1041 fn completes_history_with_a_single_segment() {
1042 let store = Store::in_memory().unwrap();
1043 let mut settings = Settings::default();
1044 settings.completion.accept = AcceptMode::Segment;
1045 store
1046 .record("cd ~/dev/gitrepos/aster", "/repo", 0, 100, "test", true)
1047 .unwrap();
1048
1049 let completion = complete(
1050 &store,
1051 &CommandCatalog::default(),
1052 "cd ~/d",
1053 "cd ~/d".len(),
1054 "/repo",
1055 None,
1056 &settings,
1057 )
1058 .unwrap();
1059
1060 assert_eq!(completion.candidates.len(), 1);
1061 assert_eq!(completion.candidates[0].insert_text, "ev/gitrepos/aster");
1062 assert_eq!(completion.candidates[0].accept_text, "ev/");
1063 assert_eq!(completion.candidates[0].description, "used here");
1064
1065 let completion = complete(
1066 &store,
1067 &CommandCatalog::default(),
1068 "cd ~/d",
1069 "cd ~/d".len(),
1070 "/repo",
1071 None,
1072 &Settings::default(),
1073 )
1074 .unwrap();
1075 assert_eq!(completion.candidates[0].accept_text, "ev/gitrepos/aster");
1076 }
1077
1078 #[test]
1079 fn fuzzy_searches_history_without_a_prefix() {
1080 if Command::new("fzf").arg("--version").output().is_err() {
1081 return;
1082 }
1083 let store = Store::in_memory().unwrap();
1084 store
1085 .record("cargo test --all", "/repo", 0, 100, "test", true)
1086 .unwrap();
1087 let completion = fuzzy(
1088 &store,
1089 &CommandCatalog::default(),
1090 "cgt",
1091 "/repo",
1092 None,
1093 &Settings::default(),
1094 )
1095 .unwrap();
1096 assert_eq!(completion.candidates[0].display, "cargo test --all");
1097 }
1098
1099 #[test]
1100 fn abstains_from_mid_line_completion() {
1101 let store = Store::in_memory().unwrap();
1102 let completion = complete(
1103 &store,
1104 &CommandCatalog::default(),
1105 "git status",
1106 3,
1107 "/repo",
1108 None,
1109 &Settings::default(),
1110 )
1111 .unwrap();
1112 assert!(completion.candidates.is_empty());
1113 }
1114
1115 #[test]
1116 fn discovers_commands_when_history_abstains() {
1117 let store = Store::in_memory().unwrap();
1118 let commands = CommandCatalog::from_entries([CommandEntry {
1119 name: "atlas".to_owned(),
1120 description: "CLI tool to manage MongoDB Atlas".to_owned(),
1121 }]);
1122
1123 let completion = complete(
1124 &store,
1125 &commands,
1126 "atl",
1127 3,
1128 "/repo",
1129 None,
1130 &Settings::default(),
1131 )
1132 .unwrap();
1133
1134 assert_eq!(completion.candidates[0].display, "atlas");
1135 assert_eq!(completion.candidates[0].accept_text, "as");
1136 assert_eq!(completion.candidates[0].kind, CandidateKind::Command);
1137 }
1138
1139 #[test]
1140 fn history_precedes_command_inventory() {
1141 let store = Store::in_memory().unwrap();
1142 store
1143 .record("git status", "/repo", 0, 100, "test", true)
1144 .unwrap();
1145 let commands = CommandCatalog::from_entries([CommandEntry {
1146 name: "git-town".to_owned(),
1147 description: "Git workflow automation".to_owned(),
1148 }]);
1149
1150 let completion = complete(
1151 &store,
1152 &commands,
1153 "git",
1154 3,
1155 "/repo",
1156 None,
1157 &Settings::default(),
1158 )
1159 .unwrap();
1160
1161 assert_eq!(completion.candidates[0].source, CandidateSource::History);
1162 assert_eq!(completion.candidates[1].source, CandidateSource::Command);
1163 }
1164
1165 #[test]
1166 fn history_deduplicates_command_inventory() {
1167 let store = Store::in_memory().unwrap();
1168 store
1169 .record("atlas", "/repo", 0, 100, "test", true)
1170 .unwrap();
1171 let commands = CommandCatalog::from_entries([
1172 CommandEntry {
1173 name: "atlas".to_owned(),
1174 description: "CLI tool to manage MongoDB Atlas".to_owned(),
1175 },
1176 CommandEntry {
1177 name: "atlantis".to_owned(),
1178 description: "Terraform pull request automation".to_owned(),
1179 },
1180 ]);
1181
1182 let completion = complete(
1183 &store,
1184 &commands,
1185 "atl",
1186 3,
1187 "/repo",
1188 None,
1189 &Settings::default(),
1190 )
1191 .unwrap();
1192
1193 assert_eq!(completion.candidates.len(), 2);
1194 assert_eq!(completion.candidates[0].display, "atlas");
1195 assert_eq!(completion.candidates[0].source, CandidateSource::History);
1196 assert_eq!(completion.candidates[1].display, "atlantis");
1197 }
1198}