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 base = if rel_part.ends_with('/') {
481 format!("~/{}", rel_part)
482 } else {
483 let parent_path = Path::new(rel_part)
484 .parent()
485 .map(|p| p.to_string_lossy().to_string())
486 .unwrap_or_default();
487 if rel_part.is_empty() || parent_path.is_empty() || parent_path == "." {
488 "~/".to_string()
489 } else {
490 format!("~/{}/", parent_path)
491 }
492 };
493 format!("{}{}{}", base, name, suffix)
494 } else if prefix == "~" {
495 format!("~/{}{}", name, suffix)
496 } else if prefix.ends_with('/') {
497 format!("{}{}{}", prefix, name, suffix)
498 } else if prefix.contains('/') {
499 let p = Path::new(prefix);
500 let parent = p
501 .parent()
502 .map(|p| p.to_string_lossy().to_string())
503 .unwrap_or_default();
504 let base = if parent.is_empty() || parent == "." {
505 String::new()
506 } else {
507 format!("{}/", parent)
508 };
509 if prefix.starts_with("./") && !base.starts_with("./") {
510 format!("./{}{}{}", base, name, suffix)
511 } else {
512 format!("{}{}{}", base, name, suffix)
513 }
514 } else {
515 format!("{}{}", name, suffix)
516 };
517
518 items.push(AutocompleteItem {
519 value: display,
520 label: format!("{}{}", name, suffix),
521 description: None,
522 });
523 }
524 }
525
526 items.sort_by(|a, b| {
527 let a_is_dir = a.value.ends_with('/');
528 let b_is_dir = b.value.ends_with('/');
529 if a_is_dir && !b_is_dir {
530 std::cmp::Ordering::Less
531 } else if !a_is_dir && b_is_dir {
532 std::cmp::Ordering::Greater
533 } else {
534 a.label.to_lowercase().cmp(&b.label.to_lowercase())
535 }
536 });
537
538 if items.is_empty() {
539 return None;
540 }
541 Some(AutocompleteSuggestions {
542 items,
543 prefix: prefix.to_string(),
544 })
545 }
546}
547
548impl AutocompleteProvider for CombinedAutocompleteProvider {
549 fn trigger_characters(&self) -> &[char] {
550 &['/', '@', '#']
551 }
552
553 fn get_suggestions(
554 &self,
555 lines: &[String],
556 cursor_line: usize,
557 cursor_col: usize,
558 force: bool,
559 ) -> Option<AutocompleteSuggestions> {
560 let current_line = lines.get(cursor_line)?;
561 let text_before = ¤t_line[..cursor_col.min(current_line.len())];
562
563 if text_before.starts_with('/') && !text_before.contains(' ') {
565 let cmd = &text_before[1..];
566 if let Some(suggestions) = self.get_slash_suggestions(cmd) {
567 return Some(suggestions);
568 }
569 }
571
572 if let Some(space_pos) = text_before.find(' ') {
574 if space_pos == 0 {
575 return None;
576 }
577 let cmd_name = &text_before[1..space_pos];
578 let arg_text = &text_before[space_pos + 1..];
579 for cmd in &self.slash_commands {
580 if cmd.name == cmd_name {
581 if let Some(ref get_completions) = cmd.get_argument_completions {
583 let items = get_completions(arg_text);
584 if !items.is_empty() {
585 return Some(AutocompleteSuggestions {
586 items,
587 prefix: arg_text.to_string(),
588 });
589 }
590 }
591 if let Some(ref completions) = cmd.argument_completions {
593 let lower = arg_text.to_lowercase();
594 let filtered: Vec<AutocompleteItem> = completions
595 .iter()
596 .filter(|c| c.value.to_lowercase().starts_with(&lower))
597 .cloned()
598 .collect();
599 if !filtered.is_empty() {
600 return Some(AutocompleteSuggestions {
601 items: filtered,
602 prefix: arg_text.to_string(),
603 });
604 }
605 }
606 if force
608 || arg_text.contains('/')
609 || arg_text.contains('.')
610 || arg_text.is_empty()
611 {
612 return self.get_file_suggestions(arg_text);
613 }
614 return None;
615 }
616 }
617 }
618
619 if let Some((_start, full_prefix)) = find_unclosed_quote_prefix(text_before) {
621 let (query, _is_at, _is_quoted) = parse_completion_prefix(full_prefix);
622 if !query.contains('/')
624 && !query.contains('.')
625 && self.fd_path.is_some()
626 && !query.is_empty()
627 && let Some(suggestions) = self.get_fuzzy_file_suggestions(query)
628 {
629 return Some(suggestions);
630 }
631 return self.get_file_suggestions(query);
632 }
633
634 if let Some(pos) = text_before.rfind(['@', '#']) {
636 let is_token_start =
637 pos == 0 || text_before[..pos].ends_with(' ') || text_before[..pos].ends_with('\t');
638 if is_token_start {
639 let path = &text_before[pos + 1..];
640 if !path.contains('/')
642 && self.fd_path.is_some()
643 && !path.is_empty()
644 && let Some(suggestions) = self.get_fuzzy_file_suggestions(path)
645 {
646 return Some(suggestions);
647 }
648 return self.get_file_suggestions(path);
649 }
650 }
651
652 if let Some(pos) = text_before.rfind('~') {
654 let is_token_start =
655 pos == 0 || text_before[..pos].ends_with(' ') || text_before[..pos].ends_with('\t');
656 if is_token_start {
657 let path = &text_before[pos..];
658 return self.get_file_suggestions(path);
659 }
660 }
661
662 if text_before.starts_with('/') && !text_before.contains(' ') && text_before.len() > 1 {
665 return self.get_file_suggestions(text_before);
666 }
667
668 if force && self.should_trigger_file_completion(lines, cursor_line, cursor_col) {
670 let last_space = text_before.rfind(|c: char| c.is_whitespace());
671 let token = if let Some(pos) = last_space {
672 &text_before[pos + 1..]
673 } else {
674 text_before
675 };
676 if !token.is_empty() {
677 return self.get_file_suggestions(token);
678 }
679 }
680
681 None
682 }
683
684 fn apply_completion(
685 &self,
686 lines: &[String],
687 cursor_line: usize,
688 cursor_col: usize,
689 item: &AutocompleteItem,
690 prefix: &str,
691 ) -> (Vec<String>, usize, usize) {
692 let current_line = lines[cursor_line].clone();
693 let prefix_start = cursor_col.saturating_sub(prefix.len());
694 let before = ¤t_line[..prefix_start];
695 let after = ¤t_line[cursor_col..];
696
697 let is_slash_command = prefix.starts_with('/')
701 && !item.value.starts_with('/')
702 && !item.value.starts_with('~')
703 && !item.value.starts_with('.');
704
705 let (new_line, new_col) = if is_slash_command {
706 (
708 format!("{}/{} {}", before, item.value, after),
709 before.len() + 1 + item.value.len() + 1,
710 )
711 } else {
712 let item_val = &item.value;
714 let suffix = if item_val.ends_with('/') { "" } else { " " };
715 (
716 format!("{}{}{}{}", before, item_val, suffix, after),
717 before.len() + item_val.len() + suffix.len(),
718 )
719 };
720
721 let mut new_lines = lines.to_vec();
722 new_lines[cursor_line] = new_line;
723 (new_lines, cursor_line, new_col)
724 }
725
726 fn should_trigger_file_completion(
727 &self,
728 lines: &[String],
729 cursor_line: usize,
730 cursor_col: usize,
731 ) -> bool {
732 let current_line = lines
733 .get(cursor_line)
734 .map(|l| &l[..cursor_col.min(l.len())]);
735 match current_line {
736 Some(text) => {
737 if text.starts_with('/') && !text.contains(' ') && cursor_line == 0 {
740 let cmd_input = text[1..].trim();
741 if cmd_input.is_empty() {
742 return false;
744 }
745 if self
747 .slash_commands
748 .iter()
749 .any(|c| c.name.starts_with(cmd_input))
750 {
751 return false;
752 }
753 }
755 true
756 }
757 None => false,
758 }
759 }
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 fn build_completion_value(
767 path: &str,
768 is_directory: bool,
769 is_at_prefix: bool,
770 is_quoted_prefix: bool,
771 ) -> String {
772 let needs_quotes = is_quoted_prefix || path.contains(' ');
773 let at = if is_at_prefix { "@" } else { "" };
774 let suffix = if is_directory { "/" } else { "" };
775 if needs_quotes {
776 format!("{}\"{}{}\"", at, path, suffix)
777 } else {
778 format!("{}{}{}", at, path, suffix)
779 }
780 }
781
782 #[test]
783 fn test_slash_suggestions() {
784 let provider = CombinedAutocompleteProvider::new(
785 vec![
786 SlashCommand {
787 name: "help".into(),
788 description: Some("Show help".into()),
789 argument_hint: None,
790 argument_completions: None,
791 get_argument_completions: None,
792 },
793 SlashCommand {
794 name: "history".into(),
795 description: Some("Show history".into()),
796 argument_hint: None,
797 argument_completions: None,
798 get_argument_completions: None,
799 },
800 ],
801 "/tmp".into(),
802 );
803
804 let lines = vec!["/he".into()];
805 let result = provider.get_suggestions(&lines, 0, 3, false);
806 assert!(result.is_some());
807 let suggestions = result.unwrap();
808 assert_eq!(suggestions.items.len(), 1);
809 assert_eq!(suggestions.items[0].value, "help");
810 }
811
812 #[test]
813 fn test_no_slash_matches() {
814 let provider = CombinedAutocompleteProvider::new(
815 vec![SlashCommand {
816 name: "help".into(),
817 description: None,
818 argument_hint: None,
819 argument_completions: None,
820 get_argument_completions: None,
821 }],
822 "/tmp".into(),
823 );
824
825 let lines = vec!["/unknown".into()];
826 let result = provider.get_suggestions(&lines, 0, 8, false);
827 assert!(result.is_none());
828 }
829
830 #[test]
831 fn test_trigger_characters() {
832 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
833 assert_eq!(provider.trigger_characters(), &['/', '@', '#']);
834 }
835
836 #[test]
837 fn test_apply_completion_slash() {
838 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
839 let item = AutocompleteItem {
840 value: "help".into(),
841 label: "/help".into(),
842 description: None,
843 };
844 let lines = vec!["/".into()];
845 let (new_lines, new_line, new_col) = provider.apply_completion(&lines, 0, 1, &item, "/");
846 assert_eq!(new_lines[0], "/help ");
847 assert_eq!(new_line, 0);
848 assert_eq!(new_col, 6);
849 }
850
851 #[test]
852 fn test_find_unclosed_quote_prefix_basic() {
853 assert!(find_unclosed_quote_prefix("hello \"world").is_some());
854 assert!(find_unclosed_quote_prefix("hello \"world\"").is_none());
855 assert!(find_unclosed_quote_prefix("no quotes").is_none());
856 }
857
858 #[test]
859 fn test_find_unclosed_quote_prefix_at() {
860 let result = find_unclosed_quote_prefix("hello @\"path");
861 assert!(result.is_some());
862 let (_start, prefix) = result.unwrap();
863 assert_eq!(&prefix[..1], "@");
864 }
865
866 #[test]
867 fn test_parse_completion_prefix() {
868 let (q, at, quoted) = parse_completion_prefix("@\"path");
869 assert_eq!(q, "path");
870 assert!(at);
871 assert!(quoted);
872
873 let (q, at, quoted) = parse_completion_prefix("\"path");
874 assert_eq!(q, "path");
875 assert!(!at);
876 assert!(quoted);
877
878 let (q, at, quoted) = parse_completion_prefix("@path");
879 assert_eq!(q, "path");
880 assert!(at);
881 assert!(!quoted);
882
883 let (q, at, quoted) = parse_completion_prefix("path");
884 assert_eq!(q, "path");
885 assert!(!at);
886 assert!(!quoted);
887 }
888
889 #[test]
890 fn test_build_completion_value() {
891 let v = build_completion_value("foo.rs", false, true, false);
892 assert_eq!(v, "@foo.rs");
893
894 let v = build_completion_value("foo.rs", false, false, false);
895 assert_eq!(v, "foo.rs");
896
897 let v = build_completion_value("my dir/file.rs", false, true, false);
898 assert_eq!(v, "@\"my dir/file.rs\"");
899 }
900
901 #[test]
902 fn test_is_empty_items_on_empty_dir() {
903 let tmp = std::env::temp_dir();
904 let provider = CombinedAutocompleteProvider::new(vec![], tmp.to_string_lossy().to_string());
905 let result = provider.get_file_suggestions("");
906 assert!(result.is_some(), "Should find files in temp dir");
907 }
908
909 #[test]
910 fn test_build_fd_path_query() {
911 assert_eq!(build_fd_path_query("hello"), "hello");
912 assert_eq!(build_fd_path_query("src/main.rs"), "src[\\\\/]main\\.rs");
913 assert!(build_fd_path_query("src/").ends_with("[\\\\/]"));
914 }
915
916 #[test]
917 fn test_score_entry() {
918 let s = score_entry("src/main.rs", "main", false);
919 assert!(s > 0, "Should score positive for matching name");
920 let s = score_entry("src/main.rs", "nomatch", false);
921 assert_eq!(s, 0, "Should score zero for no match");
922 }
923
924 #[test]
927 fn test_apply_completion_absolute_path_no_double_slash() {
928 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
930 let item = AutocompleteItem {
932 value: "/tmp/".into(),
933 label: "tmp/".into(),
934 description: None,
935 };
936 let lines = vec!["/".into()];
937 let (new_lines, _new_line, _new_col) = provider.apply_completion(&lines, 0, 1, &item, "/");
938 assert_eq!(
940 new_lines[0], "/tmp/",
941 "Absolute path completion must not add extra slash"
942 );
943 }
944
945 #[test]
946 fn test_apply_completion_slash_command_still_works() {
947 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
949 let item = AutocompleteItem {
950 value: "help".into(),
951 label: "/help".into(),
952 description: None,
953 };
954 let lines = vec!["/".into()];
955 let (new_lines, _new_line, new_col) = provider.apply_completion(&lines, 0, 1, &item, "/");
956 assert_eq!(new_lines[0], "/help ");
957 assert_eq!(new_col, 6);
958 }
959
960 #[test]
961 fn test_get_file_suggestions_absolute_path() {
962 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
964 let lines = vec!["/tmp".into()];
965 let result = provider.get_suggestions(&lines, 0, 4, false);
966 assert!(
968 result.is_some(),
969 "Absolute path /tmp should produce suggestions"
970 );
971 let suggestions = result.unwrap();
972 assert!(
973 !suggestions.items.is_empty(),
974 "Should have entries from /tmp"
975 );
976 assert_eq!(suggestions.prefix, "/tmp");
977 }
978
979 #[test]
980 fn test_get_suggestions_slash_falls_through_to_file_completion() {
981 let provider = CombinedAutocompleteProvider::new(
983 vec![SlashCommand {
984 name: "help".into(),
985 description: None,
986 argument_hint: None,
987 argument_completions: None,
988 get_argument_completions: None,
989 }],
990 "/tmp".into(),
991 );
992 let lines = vec!["/tmp".into()];
993 let result = provider.get_suggestions(&lines, 0, 4, false);
995 assert!(
996 result.is_some(),
997 "/tmp should fall through to file completion"
998 );
999 }
1000
1001 #[test]
1002 fn test_get_suggestions_tilde_path() {
1003 let home = std::env::var("HOME").unwrap_or_default();
1005 if home.is_empty() || !std::path::Path::new(&home).is_dir() {
1006 return;
1008 }
1009 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
1010 let lines = vec!["~/".into()];
1011 let result = provider.get_suggestions(&lines, 0, 2, false);
1012 assert!(result.is_some(), "~ path should produce file suggestions");
1013 }
1014
1015 #[test]
1016 fn test_hidden_file_filter_with_dot_prefix() {
1017 let tmp = std::env::temp_dir();
1019 let dir = tmp.join("autocomplete_test_dot");
1021 let _ = std::fs::remove_dir_all(&dir);
1022 std::fs::create_dir_all(&dir).unwrap();
1023 std::fs::write(dir.join(".hidden_file"), "").unwrap();
1024 std::fs::write(dir.join("visible_file"), "").unwrap();
1025 std::fs::create_dir(dir.join(".hidden_dir")).unwrap();
1026 std::fs::create_dir(dir.join("visible_dir")).unwrap();
1027
1028 let provider = CombinedAutocompleteProvider::new(vec![], dir.to_string_lossy().to_string());
1029 let dir_str = dir.to_string_lossy();
1030
1031 let result = provider.get_file_suggestions(&format!("{}/.h", dir_str));
1033 assert!(
1034 result.is_some(),
1035 "Dot prefix query should find hidden files"
1036 );
1037 if let Some(suggestions) = result {
1038 let values: Vec<&str> = suggestions.items.iter().map(|i| i.value.as_str()).collect();
1039 assert!(
1040 values.iter().any(|v| v.contains(".hidden")),
1041 "Should find .hidden_file or .hidden_dir, got: {:?}",
1042 values
1043 );
1044 }
1045
1046 let result2 = provider.get_file_suggestions(&format!("{}/v", dir_str));
1048 assert!(result2.is_some(), "Non-dot prefix query should find files");
1049 if let Some(suggestions) = result2 {
1050 let values: Vec<&str> = suggestions.items.iter().map(|i| i.value.as_str()).collect();
1051 assert!(
1052 values.iter().any(|v| v.contains("visible")),
1053 "Should find visible_file or visible_dir"
1054 );
1055 assert!(
1056 !values.iter().any(|v| v.contains(".hidden")),
1057 "Should NOT find hidden files with non-dot prefix"
1058 );
1059 }
1060
1061 let _ = std::fs::remove_dir_all(&dir);
1062 }
1063
1064 #[test]
1065 fn test_get_suggestions_slash_command_still_works() {
1066 let provider = CombinedAutocompleteProvider::new(
1068 vec![SlashCommand {
1069 name: "help".into(),
1070 description: Some("Show help".into()),
1071 argument_hint: None,
1072 argument_completions: None,
1073 get_argument_completions: None,
1074 }],
1075 "/tmp".into(),
1076 );
1077
1078 let lines = vec!["/he".into()];
1079 let result = provider.get_suggestions(&lines, 0, 3, false);
1080 assert!(result.is_some());
1081 let suggestions = result.unwrap();
1082 assert_eq!(suggestions.items.len(), 1);
1083 assert_eq!(suggestions.items[0].value, "help");
1084 }
1085
1086 fn setup_path_test_dir() -> (tempfile::TempDir, String) {
1098 let dir = tempfile::tempdir().expect("create temp dir");
1099 let root = dir.path().to_string_lossy().to_string();
1100
1101 std::fs::create_dir_all(format!("{}/src/autocomplete", root)).unwrap();
1103 std::fs::create_dir_all(format!("{}/src/components", root)).unwrap();
1104 std::fs::write(format!("{}/src/autocomplete/mod.rs", root), "").unwrap();
1105 std::fs::write(format!("{}/src/editor.rs", root), "").unwrap();
1106 std::fs::write(format!("{}/src/components/select_list.rs", root), "").unwrap();
1107
1108 (dir, root)
1109 }
1110
1111 #[test]
1112 fn test_get_file_suggestions_relative_path_with_folder() {
1113 let (_dir, root) = setup_path_test_dir();
1114 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1115
1116 let result = provider.get_file_suggestions("src/au");
1118 assert!(result.is_some(), "src/au should produce suggestions");
1119 let suggestions = result.unwrap();
1120 assert_eq!(
1121 suggestions.prefix, "src/au",
1122 "prefix should be the typed text"
1123 );
1124 assert!(
1125 !suggestions.items.is_empty(),
1126 "should have at least one item"
1127 );
1128
1129 let has_autocomplete = suggestions
1131 .items
1132 .iter()
1133 .any(|i| i.value == "src/autocomplete/");
1134 assert!(
1135 has_autocomplete,
1136 "should contain src/autocomplete/ as a completion candidate, got: {:?}",
1137 suggestions
1138 .items
1139 .iter()
1140 .map(|i| &i.value)
1141 .collect::<Vec<_>>()
1142 );
1143 }
1144
1145 #[test]
1146 fn test_get_file_suggestions_relative_path_trailing_slash() {
1147 let (_dir, root) = setup_path_test_dir();
1148 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1149
1150 let result = provider.get_file_suggestions("src/");
1152 assert!(result.is_some(), "src/ should produce suggestions");
1153 let suggestions = result.unwrap();
1154 assert_eq!(suggestions.prefix, "src/", "prefix should be src/");
1155
1156 let values: Vec<&str> = suggestions.items.iter().map(|i| i.value.as_str()).collect();
1158 assert!(
1159 values.contains(&"src/autocomplete/"),
1160 "should contain src/autocomplete/, got: {:?}",
1161 values
1162 );
1163 assert!(
1164 values.contains(&"src/editor.rs"),
1165 "should contain src/editor.rs, got: {:?}",
1166 values
1167 );
1168 assert!(
1169 values.contains(&"src/components/"),
1170 "should contain src/components/, got: {:?}",
1171 values
1172 );
1173 }
1174
1175 #[test]
1176 fn test_get_file_suggestions_deep_path() {
1177 let (_dir, root) = setup_path_test_dir();
1178 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1179
1180 let result = provider.get_file_suggestions("src/components/s");
1182 assert!(
1183 result.is_some(),
1184 "src/components/s should produce suggestions"
1185 );
1186 let suggestions = result.unwrap();
1187 assert_eq!(suggestions.prefix, "src/components/s");
1188
1189 let has_select_list = suggestions
1190 .items
1191 .iter()
1192 .any(|i| i.value == "src/components/select_list.rs");
1193 assert!(
1194 has_select_list,
1195 "should contain src/components/select_list.rs, got: {:?}",
1196 suggestions
1197 .items
1198 .iter()
1199 .map(|i| &i.value)
1200 .collect::<Vec<_>>()
1201 );
1202 }
1203
1204 #[test]
1205 fn test_get_suggestions_force_triggers_file_completion() {
1206 let (_dir, root) = setup_path_test_dir();
1207 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1208
1209 let lines = vec!["src/au".into()];
1211 let result = provider.get_suggestions(&lines, 0, 6, true);
1212 assert!(
1213 result.is_some(),
1214 "Force should trigger file completion for src/au"
1215 );
1216 let suggestions = result.unwrap();
1217 assert_eq!(suggestions.prefix, "src/au");
1218
1219 let has_autocomplete = suggestions
1220 .items
1221 .iter()
1222 .any(|i| i.value == "src/autocomplete/");
1223 assert!(
1224 has_autocomplete,
1225 "Should suggest src/autocomplete/, got: {:?}",
1226 suggestions
1227 .items
1228 .iter()
1229 .map(|i| &i.value)
1230 .collect::<Vec<_>>()
1231 );
1232 }
1233
1234 #[test]
1235 fn test_get_suggestions_at_prefix_file_completion() {
1236 let (_dir, root) = setup_path_test_dir();
1237 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1238
1239 let lines = vec!["@src/au".into()];
1241 let result = provider.get_suggestions(&lines, 0, 7, false);
1242 assert!(result.is_some(), "@src/au should produce suggestions");
1243 let suggestions = result.unwrap();
1244 assert_eq!(suggestions.prefix, "src/au", "prefix should not include @");
1246
1247 let has_autocomplete = suggestions
1248 .items
1249 .iter()
1250 .any(|i| i.value == "src/autocomplete/");
1251 assert!(
1252 has_autocomplete,
1253 "Should suggest src/autocomplete/, got: {:?}",
1254 suggestions
1255 .items
1256 .iter()
1257 .map(|i| &i.value)
1258 .collect::<Vec<_>>()
1259 );
1260 }
1261
1262 #[test]
1263 fn test_apply_completion_relative_path_with_folder() {
1264 let (_dir, root) = setup_path_test_dir();
1265 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1266
1267 let item = AutocompleteItem {
1269 value: "src/autocomplete/".into(),
1270 label: "autocomplete/".into(),
1271 description: None,
1272 };
1273 let lines = vec!["src/au".into()];
1274 let (new_lines, new_line, new_col) =
1275 provider.apply_completion(&lines, 0, 6, &item, "src/au");
1276
1277 assert_eq!(
1278 new_lines[0], "src/autocomplete/",
1279 "Should replace src/au with src/autocomplete/"
1280 );
1281 assert_eq!(new_line, 0);
1282 assert_eq!(new_col, 17); }
1284
1285 #[test]
1286 fn test_apply_completion_relative_path_trailing_slash() {
1287 let (_dir, root) = setup_path_test_dir();
1288 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1289
1290 let item = AutocompleteItem {
1292 value: "src/autocomplete/".into(),
1293 label: "autocomplete/".into(),
1294 description: None,
1295 };
1296 let lines = vec!["src/".into()];
1297 let (new_lines, new_line, new_col) = provider.apply_completion(&lines, 0, 4, &item, "src/");
1298
1299 assert_eq!(
1300 new_lines[0], "src/autocomplete/",
1301 "Should replace src/ with src/autocomplete/"
1302 );
1303 assert_eq!(new_line, 0);
1304 assert_eq!(new_col, 17);
1305 }
1306
1307 #[test]
1308 fn test_apply_completion_at_prefix() {
1309 let (_dir, root) = setup_path_test_dir();
1310 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1311
1312 let item = AutocompleteItem {
1314 value: "src/autocomplete/".into(),
1315 label: "autocomplete/".into(),
1316 description: None,
1317 };
1318 let lines = vec!["@src/au".into()];
1319 let (new_lines, new_line, new_col) =
1321 provider.apply_completion(&lines, 0, 7, &item, "src/au");
1322
1323 assert_eq!(
1324 new_lines[0], "@src/autocomplete/",
1325 "Should replace src/au with src/autocomplete/, keeping @ prefix"
1326 );
1327 assert_eq!(new_line, 0);
1328 assert_eq!(new_col, 18); }
1330
1331 #[test]
1332 fn test_apply_completion_deep_path() {
1333 let (_dir, root) = setup_path_test_dir();
1334 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1335
1336 let item = AutocompleteItem {
1338 value: "src/components/select_list.rs".into(),
1339 label: "select_list.rs".into(),
1340 description: None,
1341 };
1342 let lines = vec!["src/components/s".into()];
1343 let (new_lines, new_line, new_col) =
1344 provider.apply_completion(&lines, 0, 16, &item, "src/components/s");
1345
1346 assert_eq!(
1347 new_lines[0], "src/components/select_list.rs ",
1348 "Should complete deep path correctly"
1349 );
1350 assert_eq!(new_line, 0);
1351 assert_eq!(new_col, 30); }
1353
1354 #[test]
1355 fn test_apply_completion_at_prefix_deep_path() {
1356 let (_dir, root) = setup_path_test_dir();
1357 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1358
1359 let item = AutocompleteItem {
1361 value: "src/components/select_list.rs".into(),
1362 label: "select_list.rs".into(),
1363 description: None,
1364 };
1365 let lines = vec!["@src/components/s".into()];
1366 let (new_lines, new_line, new_col) =
1368 provider.apply_completion(&lines, 0, 17, &item, "src/components/s");
1369
1370 assert_eq!(
1371 new_lines[0], "@src/components/select_list.rs ",
1372 "Should complete deep @-path correctly"
1373 );
1374 assert_eq!(new_line, 0);
1375 assert_eq!(new_col, 31); }
1377
1378 #[test]
1379 fn test_apply_completion_after_folder_completion_then_deeper() {
1380 let (_dir, root) = setup_path_test_dir();
1382 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1383
1384 let item1 = AutocompleteItem {
1386 value: "src/autocomplete/".into(),
1387 label: "autocomplete/".into(),
1388 description: None,
1389 };
1390 let lines = vec!["src/".into()];
1391 let (new_lines, _, _) = provider.apply_completion(&lines, 0, 4, &item1, "src/");
1392 assert_eq!(new_lines[0], "src/autocomplete/");
1393
1394 let text = format!("{}m", new_lines[0]);
1396 let cursor_col = text.len(); let lines2 = vec![text];
1398 let result = provider.get_suggestions(&lines2, 0, cursor_col, true);
1400 assert!(
1401 result.is_some(),
1402 "src/autocomplete/m should produce suggestions"
1403 );
1404 let suggestions = result.unwrap();
1405 assert_eq!(suggestions.prefix, "src/autocomplete/m");
1406
1407 let has_mod = suggestions
1409 .items
1410 .iter()
1411 .any(|i| i.value == "src/autocomplete/mod.rs");
1412 assert!(
1413 has_mod,
1414 "Should suggest src/autocomplete/mod.rs, got: {:?}",
1415 suggestions
1416 .items
1417 .iter()
1418 .map(|i| &i.value)
1419 .collect::<Vec<_>>()
1420 );
1421
1422 let item2 = AutocompleteItem {
1424 value: "src/autocomplete/mod.rs".into(),
1425 label: "mod.rs".into(),
1426 description: None,
1427 };
1428 let (final_lines, _, _) =
1429 provider.apply_completion(&lines2, 0, cursor_col, &item2, "src/autocomplete/m");
1430 assert_eq!(
1431 final_lines[0], "src/autocomplete/mod.rs ",
1432 "After completing deeper, should keep the full path"
1433 );
1434 }
1435
1436 #[test]
1439 fn test_file_suggestions_roundtrip() {
1440 let (_dir, root) = setup_path_test_dir();
1441 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1442
1443 let result = provider.get_file_suggestions("src/au").unwrap();
1445 assert_eq!(result.prefix, "src/au");
1446
1447 for item in &result.items {
1449 let lines = vec!["src/au".into()];
1450 let (new_lines, _, _) = provider.apply_completion(&lines, 0, 6, item, "src/au");
1451 let _expected_len = "src/au".len() + item.value.len() - "src/au".len();
1452 assert!(
1455 new_lines[0].starts_with(item.value.trim_end_matches(' ')),
1456 "apply_completion({}, {:?}) should produce text starting with '{}', got '{}'",
1457 "src/au",
1458 item.value,
1459 item.value.trim_end_matches(' '),
1460 new_lines[0]
1461 );
1462 }
1463 }
1464
1465 #[test]
1466 fn test_at_suggestions_roundtrip() {
1467 let (_dir, root) = setup_path_test_dir();
1468 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1469
1470 let lines = vec!["@src/au".into()];
1472 let result = provider.get_suggestions(&lines, 0, 7, false).unwrap();
1473 assert_eq!(result.prefix, "src/au");
1474
1475 for item in &result.items {
1477 let lines = vec!["@src/au".into()];
1478 let (new_lines, _, _) = provider.apply_completion(&lines, 0, 7, item, "src/au");
1479
1480 assert!(
1482 new_lines[0].starts_with('@'),
1483 "apply_completion for @src/au should preserve @ prefix, got '{}'",
1484 new_lines[0]
1485 );
1486 let after_at = &new_lines[0][1..];
1488 let trimmed = after_at.trim_end_matches(' ');
1489 assert_eq!(
1490 trimmed, item.value,
1491 "Text after @ should match item.value, got '{}' vs '{}'",
1492 trimmed, item.value
1493 );
1494 }
1495 }
1496
1497 #[test]
1498 fn test_tilde_path_completion_does_not_drop_folder() {
1499 let (_dir, root) = setup_path_test_dir();
1501
1502 std::fs::create_dir_all(format!("{}/sub/deep/target", root)).unwrap();
1510 std::fs::write(format!("{}/sub/deep/target/file.txt", root), "").unwrap();
1511
1512 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1513
1514 let result = provider.get_file_suggestions("sub/deep/tar");
1516 assert!(result.is_some(), "sub/deep/tar should produce suggestions");
1517 let suggestions = result.unwrap();
1518 assert_eq!(suggestions.prefix, "sub/deep/tar");
1519
1520 let has_target = suggestions
1521 .items
1522 .iter()
1523 .any(|i| i.value == "sub/deep/target/");
1524 assert!(
1525 has_target,
1526 "Should suggest sub/deep/target/, not target/ alone. Got: {:?}",
1527 suggestions
1528 .items
1529 .iter()
1530 .map(|i| &i.value)
1531 .collect::<Vec<_>>()
1532 );
1533
1534 let item = AutocompleteItem {
1536 value: "sub/deep/target/".into(),
1537 label: "target/".into(),
1538 description: None,
1539 };
1540 let lines = vec!["sub/deep/tar".into()];
1541 let (new_lines, _, _) = provider.apply_completion(&lines, 0, 12, &item, "sub/deep/tar");
1542 assert_eq!(
1543 new_lines[0], "sub/deep/target/",
1544 "Must produce sub/deep/target/ not target/ alone"
1545 );
1546 }
1547
1548 #[test]
1549 fn test_nested_path_with_get_suggestions_force() {
1550 let (_dir, root) = setup_path_test_dir();
1551
1552 std::fs::create_dir_all(format!("{}/sub/deep/target", root)).unwrap();
1553 std::fs::write(format!("{}/sub/deep/target/file.txt", root), "").unwrap();
1554
1555 let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1556
1557 let lines = vec!["sub/deep/tar".into()];
1559 let result = provider.get_suggestions(&lines, 0, 13, true);
1560 assert!(
1561 result.is_some(),
1562 "Force should trigger file completion for sub/deep/tar"
1563 );
1564 let suggestions = result.unwrap();
1565 assert_eq!(suggestions.prefix, "sub/deep/tar");
1566
1567 let has_target = suggestions
1568 .items
1569 .iter()
1570 .any(|i| i.value == "sub/deep/target/");
1571 assert!(
1572 has_target,
1573 "Force should suggest sub/deep/target/. Got: {:?}",
1574 suggestions
1575 .items
1576 .iter()
1577 .map(|i| &i.value)
1578 .collect::<Vec<_>>()
1579 );
1580 }
1581
1582 #[test]
1583 fn test_nested_path_with_tilde_prefix() {
1584 let home = std::env::var("HOME").unwrap_or_default();
1586 if home.is_empty() {
1587 return;
1588 }
1589
1590 let test_dir = std::path::Path::new(&home).join(".rab_test_autocomplete");
1592 let _ = std::fs::remove_dir_all(&test_dir);
1593 std::fs::create_dir_all(test_dir.join("sub/deep/target")).unwrap();
1594 std::fs::write(test_dir.join("sub/deep/target/file.txt"), "").unwrap();
1595
1596 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
1598
1599 let tilde_path = format!("~/.rab_test_autocomplete/sub/deep/tar");
1600 let result = provider.get_file_suggestions(&tilde_path);
1601 assert!(result.is_some(), "~/ path should produce suggestions");
1602 let suggestions = result.unwrap();
1603 assert_eq!(suggestions.prefix, tilde_path);
1604
1605 let expected_value = format!("~/.rab_test_autocomplete/sub/deep/target/");
1606 let has_target = suggestions.items.iter().any(|i| i.value == expected_value);
1607 assert!(
1608 has_target,
1609 "Should suggest ~/.rab_test_autocomplete/sub/deep/target/, not target/ alone. Got: {:?}",
1610 suggestions
1611 .items
1612 .iter()
1613 .map(|i| &i.value)
1614 .collect::<Vec<_>>()
1615 );
1616
1617 let item = AutocompleteItem {
1619 value: expected_value.clone(),
1620 label: "target/".into(),
1621 description: None,
1622 };
1623 let lines = vec![tilde_path.clone()];
1624 let cursor_col = tilde_path.len();
1625 let (new_lines, _, _) =
1626 provider.apply_completion(&lines, 0, cursor_col, &item, &tilde_path);
1627 assert_eq!(
1628 new_lines[0], expected_value,
1629 "Must preserve full ~/ path, not drop folders"
1630 );
1631
1632 let _ = std::fs::remove_dir_all(&test_dir);
1634 }
1635
1636 #[test]
1637 fn test_tilde_path_with_trailing_slash_preserves_folder() {
1638 let home = std::env::var("HOME").unwrap_or_default();
1641 if home.is_empty() {
1642 return;
1643 }
1644
1645 let test_dir = std::path::Path::new(&home).join(".rab_test_trailing");
1646 let _ = std::fs::remove_dir_all(&test_dir);
1647 std::fs::create_dir_all(test_dir.join("sub/deep/target")).unwrap();
1649 std::fs::write(test_dir.join("sub/deep/target/file.txt"), "").unwrap();
1650
1651 let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
1652
1653 let tilde_path = format!("~/.rab_test_trailing/sub/deep/");
1655 let result = provider.get_file_suggestions(&tilde_path);
1656 assert!(
1657 result.is_some(),
1658 "~/ path with trailing slash should produce suggestions"
1659 );
1660 let suggestions = result.unwrap();
1661 assert_eq!(suggestions.prefix, tilde_path);
1662
1663 let expected_value = format!("~/.rab_test_trailing/sub/deep/target/");
1665 let has_target = suggestions.items.iter().any(|i| i.value == expected_value);
1666 assert!(
1667 has_target,
1668 "Must suggest full path ~/.rab_test_trailing/sub/deep/target/, not target/ alone. Got: {:?}",
1669 suggestions
1670 .items
1671 .iter()
1672 .map(|i| &i.value)
1673 .collect::<Vec<_>>()
1674 );
1675
1676 let item = AutocompleteItem {
1678 value: expected_value.clone(),
1679 label: "target/".into(),
1680 description: None,
1681 };
1682 let lines = vec![tilde_path.clone()];
1683 let cursor_col = tilde_path.len();
1684 let (new_lines, _, _) =
1685 provider.apply_completion(&lines, 0, cursor_col, &item, &tilde_path);
1686 assert_eq!(
1687 new_lines[0], expected_value,
1688 "Must produce full path, not drop the last folder"
1689 );
1690
1691 let _ = std::fs::remove_dir_all(&test_dir);
1693 }
1694}