1use std::path::Path;
2use std::process::{Command, Stdio};
3use std::sync::Arc;
4
5use crate::tui::components::select_list::SelectItem;
6
7#[derive(Debug, Clone)]
9pub struct AutocompleteItem {
10 pub value: String,
11 pub label: String,
12 pub description: Option<String>,
13}
14
15impl From<AutocompleteItem> for SelectItem {
16 fn from(item: AutocompleteItem) -> Self {
17 let mut si = SelectItem::new(item.value, item.label);
18 if let Some(desc) = item.description {
19 si = si.with_description(desc);
20 }
21 si
22 }
23}
24
25#[derive(Debug, Clone)]
27pub struct AutocompleteSuggestions {
28 pub items: Vec<AutocompleteItem>,
29 pub prefix: String,
31}
32
33#[derive(Clone)]
35#[allow(clippy::type_complexity)]
36pub struct SlashCommand {
37 pub name: String,
38 pub description: Option<String>,
39 pub argument_hint: Option<String>,
40 pub argument_completions: Option<Vec<AutocompleteItem>>,
44 pub get_argument_completions: Option<Arc<dyn Fn(&str) -> Vec<AutocompleteItem> + Send + Sync>>,
48}
49
50pub trait AutocompleteProvider {
52 fn trigger_characters(&self) -> &[char];
54
55 fn get_suggestions(
58 &self,
59 lines: &[String],
60 cursor_line: usize,
61 cursor_col: usize,
62 force: bool,
63 ) -> Option<AutocompleteSuggestions>;
64
65 fn apply_completion(
67 &self,
68 lines: &[String],
69 cursor_line: usize,
70 cursor_col: usize,
71 item: &AutocompleteItem,
72 prefix: &str,
73 ) -> (Vec<String>, usize, usize);
74
75 fn should_trigger_file_completion(
77 &self,
78 lines: &[String],
79 cursor_line: usize,
80 cursor_col: usize,
81 ) -> bool;
82}
83
84fn find_fd() -> Option<String> {
88 std::env::var("PATH").ok().and_then(|path| {
89 for dir in path.split(':') {
90 for name in &["fd", "fdfind"] {
91 let p = format!("{}/{}", dir, name);
92 if std::path::Path::new(&p).is_file() {
93 return Some(p);
94 }
95 }
96 }
97 None
98 })
99}
100
101fn build_fd_path_query(query: &str) -> String {
103 let normalized = query.replace('\\', "/");
104 if !normalized.contains('/') {
105 return normalized;
106 }
107 let has_trailing = normalized.ends_with('/');
108 let trimmed = normalized.trim_matches('/');
109 if trimmed.is_empty() {
110 return normalized;
111 }
112 let sep = "[\\\\/]";
113 let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
114 let mut pattern = segments
115 .iter()
116 .map(|s| regex::escape(s))
117 .collect::<Vec<_>>()
118 .join(sep);
119 if has_trailing {
120 pattern.push_str(sep);
121 }
122 pattern
123}
124
125fn walk_directory_with_fd(
128 fd_path: &str,
129 base_dir: &str,
130 query: &str,
131 max_results: usize,
132) -> Vec<(String, bool)> {
133 let mr = max_results.to_string();
134 let mut cmd = Command::new(fd_path);
135 cmd.arg("--base-directory")
136 .arg(base_dir)
137 .arg("--max-results")
138 .arg(&mr)
139 .arg("--type")
140 .arg("f")
141 .arg("--type")
142 .arg("d")
143 .arg("--follow")
144 .arg("--hidden")
145 .arg("--exclude")
146 .arg(".git")
147 .arg("--exclude")
148 .arg(".git/*")
149 .arg("--exclude")
150 .arg(".git/**");
151
152 if query.contains('/') {
153 cmd.arg("--full-path");
154 }
155
156 if !query.is_empty() {
157 cmd.arg(build_fd_path_query(query));
158 }
159
160 cmd.stdout(Stdio::piped()).stderr(Stdio::null());
161
162 let output = match cmd.output() {
163 Ok(o) => o,
164 Err(_) => return Vec::new(),
165 };
166
167 if !output.status.success() {
168 return Vec::new();
169 }
170
171 let stdout = String::from_utf8_lossy(&output.stdout);
172 stdout
173 .lines()
174 .filter(|line| !line.is_empty())
175 .filter_map(|line| {
176 let display = line.replace('\\', "/");
177 if display == ".git" || display.starts_with(".git/") || display.contains("/.git/") {
178 return None;
179 }
180 let has_trailing = display.ends_with('/');
181 let normalized = if has_trailing {
182 &display[..display.len() - 1]
183 } else {
184 &display
185 };
186 Some((normalized.to_string(), has_trailing))
187 })
188 .collect()
189}
190
191fn score_entry(file_path: &str, query: &str, is_directory: bool) -> usize {
194 let file_name = Path::new(file_path)
195 .file_name()
196 .map(|f| f.to_string_lossy().to_string())
197 .unwrap_or_default();
198 let lower_name = file_name.to_lowercase();
199 let lower_query = query.to_lowercase();
200
201 let mut score: usize = 0;
202 if lower_name == lower_query {
203 score = 100;
204 } else if lower_name.starts_with(&lower_query) {
205 score = 80;
206 } else if lower_name.contains(&lower_query) {
207 score = 50;
208 } else if file_path.to_lowercase().contains(&lower_query) {
209 score = 30;
210 }
211 if is_directory && score > 0 {
212 score += 10;
213 }
214 score
215}
216
217const PATH_DELIMITERS: &[char] = &[' ', '\t', '"', '\'', '='];
220
221fn find_unclosed_quote_prefix(text: &str) -> Option<(usize, &str)> {
224 let mut in_quotes = false;
225 let mut quote_start = 0;
226 for (i, c) in text.char_indices() {
227 if c == '"' {
228 in_quotes = !in_quotes;
229 if in_quotes {
230 quote_start = i;
231 }
232 }
233 }
234 if !in_quotes {
235 return None;
236 }
237 if quote_start > 0 && text.as_bytes().get(quote_start - 1) == Some(&b'@') {
239 let before_at = if quote_start > 1 {
240 &text[..quote_start - 1]
241 } else {
242 ""
243 };
244 if before_at.is_empty() || before_at.ends_with(PATH_DELIMITERS) {
245 return Some((quote_start - 1, &text[quote_start - 1..]));
246 }
247 }
248 let before = &text[..quote_start];
250 if before.is_empty() || before.ends_with(PATH_DELIMITERS) {
251 return Some((quote_start, &text[quote_start..]));
252 }
253 None
254}
255
256fn parse_completion_prefix(prefix: &str) -> (&str, bool, bool) {
259 if let Some(stripped) = prefix.strip_prefix("@\"") {
260 (stripped, true, true)
261 } else if let Some(stripped) = prefix.strip_prefix('"') {
262 (stripped, false, true)
263 } else if let Some(stripped) = prefix.strip_prefix('@') {
264 (stripped, true, false)
265 } else {
266 (prefix, false, false)
267 }
268}
269
270fn resolve_scoped_fd_query(raw_query: &str, base_path: &str) -> Option<(String, String, String)> {
272 let normalized = raw_query.replace('\\', "/");
273 let slash_index = normalized.rfind('/')?;
274 let display_base = normalized[..=slash_index].to_string();
275 let query = normalized[slash_index + 1..].to_string();
276
277 let base_dir = if let Some(stripped) = display_base.strip_prefix("~/") {
278 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
279 format!("{}/{}", home, stripped)
280 } else if display_base.starts_with('/') {
281 display_base.clone()
282 } else {
283 format!("{}/{}", base_path, display_base)
284 };
285
286 if !Path::new(&base_dir).is_dir() {
287 return None;
288 }
289
290 Some((base_dir, query, display_base))
291}
292
293pub struct CombinedAutocompleteProvider {
299 slash_commands: Vec<SlashCommand>,
300 base_path: String,
301 fd_path: Option<String>,
302}
303
304impl CombinedAutocompleteProvider {
305 pub fn new(slash_commands: Vec<SlashCommand>, base_path: String) -> Self {
306 let fd_path = find_fd();
307 Self {
308 slash_commands,
309 base_path,
310 fd_path,
311 }
312 }
313
314 fn get_slash_suggestions(&self, prefix: &str) -> Option<AutocompleteSuggestions> {
315 let lower_prefix = prefix.to_lowercase();
316 let matching: Vec<AutocompleteItem> = self
317 .slash_commands
318 .iter()
319 .filter(|cmd| cmd.name.to_lowercase().starts_with(&lower_prefix))
320 .map(|cmd| {
321 let desc = match (&cmd.description, &cmd.argument_hint) {
322 (Some(d), Some(h)) => Some(format!("{} - {}", h, d)),
323 (Some(d), None) => Some(d.clone()),
324 (None, Some(h)) => Some(h.clone()),
325 (None, None) => None,
326 };
327 AutocompleteItem {
328 value: cmd.name.clone(),
329 label: format!("/{}", cmd.name),
330 description: desc,
331 }
332 })
333 .collect();
334
335 if matching.is_empty() {
336 return None;
337 }
338 Some(AutocompleteSuggestions {
339 items: matching,
340 prefix: format!("/{}", prefix),
341 })
342 }
343
344 fn get_fuzzy_file_suggestions(&self, query: &str) -> Option<AutocompleteSuggestions> {
347 let fd_path = self.fd_path.as_ref()?;
348
349 let (fd_base_dir, fd_query, display_base) = resolve_scoped_fd_query(query, &self.base_path)
350 .unwrap_or_else(|| {
351 (self.base_path.clone(), query.to_string(), String::new())
353 });
354
355 let entries = walk_directory_with_fd(fd_path, &fd_base_dir, &fd_query, 100);
356 if entries.is_empty() {
357 return None;
358 }
359
360 let scored: Vec<(String, bool, usize)> = entries
361 .into_iter()
362 .map(|(path, is_dir)| {
363 let score = if fd_query.is_empty() {
364 1
365 } else {
366 score_entry(&path, &fd_query, is_dir)
367 };
368 (path, is_dir, score)
369 })
370 .filter(|(_, _, score)| *score > 0)
371 .collect();
372
373 if scored.is_empty() {
374 return None;
375 }
376
377 let mut scored = scored;
379 scored.sort_by_key(|b| std::cmp::Reverse(b.2));
380 scored.truncate(20);
381
382 let items: Vec<AutocompleteItem> = scored
383 .into_iter()
384 .map(|(entry_path, is_dir, _score)| {
385 let entry_name = Path::new(&entry_path)
386 .file_name()
387 .map(|f| f.to_string_lossy().to_string())
388 .unwrap_or_default();
389 let display_path = if display_base.is_empty() {
390 entry_path.clone()
391 } else {
392 format!("{}{}", display_base, entry_path)
393 };
394 let completion_path = if is_dir {
395 format!("{}/", display_path)
396 } else {
397 display_path.clone()
398 };
399 AutocompleteItem {
400 value: completion_path,
401 label: format!("{}/", entry_name),
402 description: Some(display_path),
403 }
404 })
405 .collect();
406
407 Some(AutocompleteSuggestions {
408 items,
409 prefix: query.to_string(),
410 })
411 }
412
413 fn get_file_suggestions(&self, prefix: &str) -> Option<AutocompleteSuggestions> {
414 let expanded = if let Some(stripped) = prefix.strip_prefix("~/") {
416 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
417 format!("{}/{}", home, stripped)
418 } else if prefix == "~" {
419 std::env::var("HOME").unwrap_or_else(|_| "/tmp".into())
420 } else if prefix.starts_with('/') {
421 prefix.to_string()
422 } else {
423 format!("{}/{}", self.base_path, prefix)
424 };
425
426 let expanded_clone = expanded.clone();
427 let (dir, file_prefix) = if expanded.ends_with('/') {
428 (expanded_clone, String::new())
429 } else {
430 let p = Path::new(&expanded);
431 let parent = p
432 .parent()
433 .map(|p| p.to_string_lossy().to_string())
434 .unwrap_or("/".into());
435 let file = p
436 .file_name()
437 .map(|f| f.to_string_lossy().to_string())
438 .unwrap_or_default();
439 (
440 if parent.is_empty() {
441 "/".into()
442 } else {
443 parent
444 },
445 file,
446 )
447 };
448
449 let dir_path = Path::new(&dir);
450 if !dir_path.exists() || !dir_path.is_dir() {
451 return None;
452 }
453
454 let lower_prefix = file_prefix.to_lowercase();
455 let mut items: Vec<AutocompleteItem> = Vec::new();
456
457 if let Ok(entries) = std::fs::read_dir(dir_path) {
458 for entry in entries.flatten() {
459 let name = entry.file_name().to_string_lossy().to_string();
460 if name == ".git" || (name.starts_with('.') && !file_prefix.starts_with('.')) {
461 continue;
462 }
463 if !name.to_lowercase().starts_with(&lower_prefix) {
464 continue;
465 }
466 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
467 let suffix = if is_dir { "/" } else { "" };
468
469 let display = if prefix.starts_with('/') {
470 let base_dir = dir.clone();
471 if base_dir.ends_with('/') {
472 format!("{}{}{}", base_dir, name, suffix)
473 } else {
474 format!("{}/{}{}", base_dir, name, suffix)
475 }
476 } else if let Some(rel_part) = prefix.strip_prefix("~/") {
477 let parent_path = Path::new(rel_part)
478 .parent()
479 .map(|p| p.to_string_lossy().to_string())
480 .unwrap_or_default();
481 let base =
482 if rel_part.is_empty() || parent_path.is_empty() || parent_path == "." {
483 "~/".to_string()
484 } else {
485 format!("~/{}/", parent_path)
486 };
487 format!("{}{}{}", base, name, suffix)
488 } else if prefix == "~" {
489 format!("~/{}{}", name, suffix)
490 } else if prefix.ends_with('/') {
491 format!("{}{}{}", prefix, name, suffix)
492 } else if prefix.contains('/') {
493 let p = Path::new(prefix);
494 let parent = p
495 .parent()
496 .map(|p| p.to_string_lossy().to_string())
497 .unwrap_or_default();
498 let base = if parent.is_empty() || parent == "." {
499 String::new()
500 } else {
501 format!("{}/", parent)
502 };
503 if prefix.starts_with("./") && !base.starts_with("./") {
504 format!("./{}{}{}", base, name, suffix)
505 } else {
506 format!("{}{}{}", base, name, suffix)
507 }
508 } else {
509 format!("{}{}", name, suffix)
510 };
511
512 items.push(AutocompleteItem {
513 value: display,
514 label: format!("{}{}", name, suffix),
515 description: None,
516 });
517 }
518 }
519
520 items.sort_by(|a, b| {
521 let a_is_dir = a.value.ends_with('/');
522 let b_is_dir = b.value.ends_with('/');
523 if a_is_dir && !b_is_dir {
524 std::cmp::Ordering::Less
525 } else if !a_is_dir && b_is_dir {
526 std::cmp::Ordering::Greater
527 } else {
528 a.label.to_lowercase().cmp(&b.label.to_lowercase())
529 }
530 });
531
532 if items.is_empty() {
533 return None;
534 }
535 Some(AutocompleteSuggestions {
536 items,
537 prefix: prefix.to_string(),
538 })
539 }
540}
541
542impl AutocompleteProvider for CombinedAutocompleteProvider {
543 fn trigger_characters(&self) -> &[char] {
544 &['/', '@', '#']
545 }
546
547 fn get_suggestions(
548 &self,
549 lines: &[String],
550 cursor_line: usize,
551 cursor_col: usize,
552 force: bool,
553 ) -> Option<AutocompleteSuggestions> {
554 let current_line = lines.get(cursor_line)?;
555 let text_before = ¤t_line[..cursor_col.min(current_line.len())];
556
557 if text_before.starts_with('/') && !text_before.contains(' ') {
559 let cmd = &text_before[1..];
560 if let Some(suggestions) = self.get_slash_suggestions(cmd) {
561 return Some(suggestions);
562 }
563 }
565
566 if let Some(space_pos) = text_before.find(' ') {
568 if space_pos == 0 {
569 return None;
570 }
571 let cmd_name = &text_before[1..space_pos];
572 let arg_text = &text_before[space_pos + 1..];
573 for cmd in &self.slash_commands {
574 if cmd.name == cmd_name {
575 if let Some(ref get_completions) = cmd.get_argument_completions {
577 let items = get_completions(arg_text);
578 if !items.is_empty() {
579 return Some(AutocompleteSuggestions {
580 items,
581 prefix: arg_text.to_string(),
582 });
583 }
584 }
585 if let Some(ref completions) = cmd.argument_completions {
587 let lower = arg_text.to_lowercase();
588 let filtered: Vec<AutocompleteItem> = completions
589 .iter()
590 .filter(|c| c.value.to_lowercase().starts_with(&lower))
591 .cloned()
592 .collect();
593 if !filtered.is_empty() {
594 return Some(AutocompleteSuggestions {
595 items: filtered,
596 prefix: arg_text.to_string(),
597 });
598 }
599 }
600 if force
602 || arg_text.contains('/')
603 || arg_text.contains('.')
604 || arg_text.is_empty()
605 {
606 return self.get_file_suggestions(arg_text);
607 }
608 return None;
609 }
610 }
611 }
612
613 if let Some((_start, full_prefix)) = find_unclosed_quote_prefix(text_before) {
615 let (query, _is_at, _is_quoted) = parse_completion_prefix(full_prefix);
616 if !query.contains('/')
618 && !query.contains('.')
619 && self.fd_path.is_some()
620 && !query.is_empty()
621 && let Some(suggestions) = self.get_fuzzy_file_suggestions(query)
622 {
623 return Some(suggestions);
624 }
625 return self.get_file_suggestions(query);
626 }
627
628 if let Some(pos) = text_before.rfind(['@', '#']) {
630 let is_token_start =
631 pos == 0 || text_before[..pos].ends_with(' ') || text_before[..pos].ends_with('\t');
632 if is_token_start {
633 let path = &text_before[pos + 1..];
634 if !path.contains('/')
636 && self.fd_path.is_some()
637 && !path.is_empty()
638 && let Some(suggestions) = self.get_fuzzy_file_suggestions(path)
639 {
640 return Some(suggestions);
641 }
642 return self.get_file_suggestions(path);
643 }
644 }
645
646 if let Some(pos) = text_before.rfind('~') {
648 let is_token_start =
649 pos == 0 || text_before[..pos].ends_with(' ') || text_before[..pos].ends_with('\t');
650 if is_token_start {
651 let path = &text_before[pos..];
652 return self.get_file_suggestions(path);
653 }
654 }
655
656 if text_before.starts_with('/') && !text_before.contains(' ') && text_before.len() > 1 {
659 return self.get_file_suggestions(text_before);
660 }
661
662 if force && self.should_trigger_file_completion(lines, cursor_line, cursor_col) {
664 let last_space = text_before.rfind(|c: char| c.is_whitespace());
665 let token = if let Some(pos) = last_space {
666 &text_before[pos + 1..]
667 } else {
668 text_before
669 };
670 if !token.is_empty() {
671 return self.get_file_suggestions(token);
672 }
673 }
674
675 None
676 }
677
678 fn apply_completion(
679 &self,
680 lines: &[String],
681 cursor_line: usize,
682 cursor_col: usize,
683 item: &AutocompleteItem,
684 prefix: &str,
685 ) -> (Vec<String>, usize, usize) {
686 let current_line = lines[cursor_line].clone();
687 let prefix_start = cursor_col.saturating_sub(prefix.len());
688 let before = ¤t_line[..prefix_start];
689 let after = ¤t_line[cursor_col..];
690
691 let is_slash_command = prefix.starts_with('/')
695 && !item.value.starts_with('/')
696 && !item.value.starts_with('~')
697 && !item.value.starts_with('.');
698
699 let (new_line, new_col) = if is_slash_command {
700 (
702 format!("{}/{} {}", before, item.value, after),
703 before.len() + 1 + item.value.len() + 1,
704 )
705 } else {
706 let item_val = &item.value;
708 let suffix = if item_val.ends_with('/') { "" } else { " " };
709 (
710 format!("{}{}{}{}", before, item_val, suffix, after),
711 before.len() + item_val.len() + suffix.len(),
712 )
713 };
714
715 let mut new_lines = lines.to_vec();
716 new_lines[cursor_line] = new_line;
717 (new_lines, cursor_line, new_col)
718 }
719
720 fn should_trigger_file_completion(
721 &self,
722 lines: &[String],
723 cursor_line: usize,
724 cursor_col: usize,
725 ) -> bool {
726 let current_line = lines
727 .get(cursor_line)
728 .map(|l| &l[..cursor_col.min(l.len())]);
729 match current_line {
730 Some(text) => {
731 if text.starts_with('/') && !text.contains(' ') && cursor_line == 0 {
734 let cmd_input = text[1..].trim();
735 if cmd_input.is_empty() {
736 return false;
738 }
739 if self
741 .slash_commands
742 .iter()
743 .any(|c| c.name.starts_with(cmd_input))
744 {
745 return false;
746 }
747 }
749 true
750 }
751 None => false,
752 }
753 }
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759
760 fn build_completion_value(
761 path: &str,
762 is_directory: bool,
763 is_at_prefix: bool,
764 is_quoted_prefix: bool,
765 ) -> String {
766 let needs_quotes = is_quoted_prefix || path.contains(' ');
767 let at = if is_at_prefix { "@" } else { "" };
768 let suffix = if is_directory { "/" } else { "" };
769 if needs_quotes {
770 format!("{}\"{}{}\"", at, path, suffix)
771 } else {
772 format!("{}{}{}", at, path, suffix)
773 }
774 }
775
776 #[test]
777 fn test_slash_suggestions() {
778 let provider = CombinedAutocompleteProvider::new(
779 vec![
780 SlashCommand {
781 name: "help".into(),
782 description: Some("Show help".into()),
783 argument_hint: None,
784 argument_completions: None,
785 get_argument_completions: None,
786 },
787 SlashCommand {
788 name: "history".into(),
789 description: Some("Show history".into()),
790 argument_hint: None,
791 argument_completions: None,
792 get_argument_completions: None,
793 },
794 ],
795 "/tmp".into(),
796 );
797
798 let lines = vec!["/he".into()];
799 let result = provider.get_suggestions(&lines, 0, 3, false);
800 assert!(result.is_some());
801 let suggestions = result.unwrap();
802 assert_eq!(suggestions.items.len(), 1);
803 assert_eq!(suggestions.items[0].value, "help");
804 }
805
806 #[test]
807 fn test_no_slash_matches() {
808 let provider = CombinedAutocompleteProvider::new(
809 vec![SlashCommand {
810 name: "help".into(),
811 description: None,
812 argument_hint: None,
813 argument_completions: None,
814 get_argument_completions: None,
815 }],
816 "/tmp".into(),
817 );
818
819 let lines = vec!["/unknown".into()];
820 let result = provider.get_suggestions(&lines, 0, 8, false);
821 assert!(result.is_none());
822 }
823
824 #[test]
825 fn test_trigger_characters() {
826 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
827 assert_eq!(provider.trigger_characters(), &['/', '@', '#']);
828 }
829
830 #[test]
831 fn test_apply_completion_slash() {
832 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
833 let item = AutocompleteItem {
834 value: "help".into(),
835 label: "/help".into(),
836 description: None,
837 };
838 let lines = vec!["/".into()];
839 let (new_lines, new_line, new_col) = provider.apply_completion(&lines, 0, 1, &item, "/");
840 assert_eq!(new_lines[0], "/help ");
841 assert_eq!(new_line, 0);
842 assert_eq!(new_col, 6);
843 }
844
845 #[test]
846 fn test_find_unclosed_quote_prefix_basic() {
847 assert!(find_unclosed_quote_prefix("hello \"world").is_some());
848 assert!(find_unclosed_quote_prefix("hello \"world\"").is_none());
849 assert!(find_unclosed_quote_prefix("no quotes").is_none());
850 }
851
852 #[test]
853 fn test_find_unclosed_quote_prefix_at() {
854 let result = find_unclosed_quote_prefix("hello @\"path");
855 assert!(result.is_some());
856 let (_start, prefix) = result.unwrap();
857 assert_eq!(&prefix[..1], "@");
858 }
859
860 #[test]
861 fn test_parse_completion_prefix() {
862 let (q, at, quoted) = parse_completion_prefix("@\"path");
863 assert_eq!(q, "path");
864 assert!(at);
865 assert!(quoted);
866
867 let (q, at, quoted) = parse_completion_prefix("\"path");
868 assert_eq!(q, "path");
869 assert!(!at);
870 assert!(quoted);
871
872 let (q, at, quoted) = parse_completion_prefix("@path");
873 assert_eq!(q, "path");
874 assert!(at);
875 assert!(!quoted);
876
877 let (q, at, quoted) = parse_completion_prefix("path");
878 assert_eq!(q, "path");
879 assert!(!at);
880 assert!(!quoted);
881 }
882
883 #[test]
884 fn test_build_completion_value() {
885 let v = build_completion_value("foo.rs", false, true, false);
886 assert_eq!(v, "@foo.rs");
887
888 let v = build_completion_value("foo.rs", false, false, false);
889 assert_eq!(v, "foo.rs");
890
891 let v = build_completion_value("my dir/file.rs", false, true, false);
892 assert_eq!(v, "@\"my dir/file.rs\"");
893 }
894
895 #[test]
896 fn test_is_empty_items_on_empty_dir() {
897 let tmp = std::env::temp_dir();
898 let provider = CombinedAutocompleteProvider::new(vec![], tmp.to_string_lossy().to_string());
899 let result = provider.get_file_suggestions("");
900 assert!(result.is_some(), "Should find files in temp dir");
901 }
902
903 #[test]
904 fn test_build_fd_path_query() {
905 assert_eq!(build_fd_path_query("hello"), "hello");
906 assert_eq!(build_fd_path_query("src/main.rs"), "src[\\\\/]main\\.rs");
907 assert!(build_fd_path_query("src/").ends_with("[\\\\/]"));
908 }
909
910 #[test]
911 fn test_score_entry() {
912 let s = score_entry("src/main.rs", "main", false);
913 assert!(s > 0, "Should score positive for matching name");
914 let s = score_entry("src/main.rs", "nomatch", false);
915 assert_eq!(s, 0, "Should score zero for no match");
916 }
917
918 #[test]
921 fn test_apply_completion_absolute_path_no_double_slash() {
922 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
924 let item = AutocompleteItem {
926 value: "/tmp/".into(),
927 label: "tmp/".into(),
928 description: None,
929 };
930 let lines = vec!["/".into()];
931 let (new_lines, _new_line, _new_col) = provider.apply_completion(&lines, 0, 1, &item, "/");
932 assert_eq!(
934 new_lines[0], "/tmp/",
935 "Absolute path completion must not add extra slash"
936 );
937 }
938
939 #[test]
940 fn test_apply_completion_slash_command_still_works() {
941 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
943 let item = AutocompleteItem {
944 value: "help".into(),
945 label: "/help".into(),
946 description: None,
947 };
948 let lines = vec!["/".into()];
949 let (new_lines, _new_line, new_col) = provider.apply_completion(&lines, 0, 1, &item, "/");
950 assert_eq!(new_lines[0], "/help ");
951 assert_eq!(new_col, 6);
952 }
953
954 #[test]
955 fn test_get_file_suggestions_absolute_path() {
956 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
958 let lines = vec!["/tmp".into()];
959 let result = provider.get_suggestions(&lines, 0, 4, false);
960 assert!(
962 result.is_some(),
963 "Absolute path /tmp should produce suggestions"
964 );
965 let suggestions = result.unwrap();
966 assert!(
967 !suggestions.items.is_empty(),
968 "Should have entries from /tmp"
969 );
970 assert_eq!(suggestions.prefix, "/tmp");
971 }
972
973 #[test]
974 fn test_get_suggestions_slash_falls_through_to_file_completion() {
975 let provider = CombinedAutocompleteProvider::new(
977 vec![SlashCommand {
978 name: "help".into(),
979 description: None,
980 argument_hint: None,
981 argument_completions: None,
982 get_argument_completions: None,
983 }],
984 "/tmp".into(),
985 );
986 let lines = vec!["/tmp".into()];
987 let result = provider.get_suggestions(&lines, 0, 4, false);
989 assert!(
990 result.is_some(),
991 "/tmp should fall through to file completion"
992 );
993 }
994
995 #[test]
996 fn test_get_suggestions_tilde_path() {
997 let home = std::env::var("HOME").unwrap_or_default();
999 if home.is_empty() || !std::path::Path::new(&home).is_dir() {
1000 return;
1002 }
1003 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
1004 let lines = vec!["~/".into()];
1005 let result = provider.get_suggestions(&lines, 0, 2, false);
1006 assert!(result.is_some(), "~ path should produce file suggestions");
1007 }
1008
1009 #[test]
1010 fn test_hidden_file_filter_with_dot_prefix() {
1011 let tmp = std::env::temp_dir();
1013 let dir = tmp.join("autocomplete_test_dot");
1015 let _ = std::fs::remove_dir_all(&dir);
1016 std::fs::create_dir_all(&dir).unwrap();
1017 std::fs::write(dir.join(".hidden_file"), "").unwrap();
1018 std::fs::write(dir.join("visible_file"), "").unwrap();
1019 std::fs::create_dir(dir.join(".hidden_dir")).unwrap();
1020 std::fs::create_dir(dir.join("visible_dir")).unwrap();
1021
1022 let provider = CombinedAutocompleteProvider::new(vec![], dir.to_string_lossy().to_string());
1023 let dir_str = dir.to_string_lossy();
1024
1025 let result = provider.get_file_suggestions(&format!("{}/.h", dir_str));
1027 assert!(
1028 result.is_some(),
1029 "Dot prefix query should find hidden files"
1030 );
1031 if let Some(suggestions) = result {
1032 let values: Vec<&str> = suggestions.items.iter().map(|i| i.value.as_str()).collect();
1033 assert!(
1034 values.iter().any(|v| v.contains(".hidden")),
1035 "Should find .hidden_file or .hidden_dir, got: {:?}",
1036 values
1037 );
1038 }
1039
1040 let result2 = provider.get_file_suggestions(&format!("{}/v", dir_str));
1042 assert!(result2.is_some(), "Non-dot prefix query should find files");
1043 if let Some(suggestions) = result2 {
1044 let values: Vec<&str> = suggestions.items.iter().map(|i| i.value.as_str()).collect();
1045 assert!(
1046 values.iter().any(|v| v.contains("visible")),
1047 "Should find visible_file or visible_dir"
1048 );
1049 assert!(
1050 !values.iter().any(|v| v.contains(".hidden")),
1051 "Should NOT find hidden files with non-dot prefix"
1052 );
1053 }
1054
1055 let _ = std::fs::remove_dir_all(&dir);
1056 }
1057
1058 #[test]
1059 fn test_get_suggestions_slash_command_still_works() {
1060 let provider = CombinedAutocompleteProvider::new(
1062 vec![SlashCommand {
1063 name: "help".into(),
1064 description: Some("Show help".into()),
1065 argument_hint: None,
1066 argument_completions: None,
1067 get_argument_completions: None,
1068 }],
1069 "/tmp".into(),
1070 );
1071
1072 let lines = vec!["/he".into()];
1073 let result = provider.get_suggestions(&lines, 0, 3, false);
1074 assert!(result.is_some());
1075 let suggestions = result.unwrap();
1076 assert_eq!(suggestions.items.len(), 1);
1077 assert_eq!(suggestions.items[0].value, "help");
1078 }
1079}