Skip to main content

rab/tui/
autocomplete.rs

1use std::path::Path;
2use std::process::{Command, Stdio};
3use std::sync::Arc;
4
5use crate::tui::components::select_list::SelectItem;
6
7/// A suggestion item for autocomplete.
8#[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/// Suggestions returned by an autocomplete provider.
26#[derive(Debug, Clone)]
27pub struct AutocompleteSuggestions {
28    pub items: Vec<AutocompleteItem>,
29    /// The prefix that was matched (e.g., "/" or "src/").
30    pub prefix: String,
31}
32
33/// A slash command definition.
34#[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    /// Static argument completions (pi-compat: `getArgumentCompletions`).
41    /// When set, these are filtered by the typed prefix and shown.
42    /// When None and `get_argument_completions` is also None, file completion is used.
43    pub argument_completions: Option<Vec<AutocompleteItem>>,
44    /// Dynamic argument completions callback (pi-style `getArgumentCompletions`).
45    /// Called with the typed argument prefix, returns matching items.
46    /// Takes precedence over `argument_completions` when set.
47    pub get_argument_completions: Option<Arc<dyn Fn(&str) -> Vec<AutocompleteItem> + Send + Sync>>,
48}
49
50/// Provider that generates autocomplete suggestions.
51pub trait AutocompleteProvider {
52    /// Characters that should naturally trigger this provider at token boundaries.
53    fn trigger_characters(&self) -> &[char];
54
55    /// Get suggestions for the current text/cursor position.
56    /// Returns None if no suggestions available.
57    fn get_suggestions(
58        &self,
59        lines: &[String],
60        cursor_line: usize,
61        cursor_col: usize,
62        force: bool,
63    ) -> Option<AutocompleteSuggestions>;
64
65    /// Apply the selected completion item.
66    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    /// Whether to trigger file completion on Tab.
76    fn should_trigger_file_completion(
77        &self,
78        lines: &[String],
79        cursor_line: usize,
80        cursor_col: usize,
81    ) -> bool;
82}
83
84// ── fd helpers (pi-compat) ───────────────────────────────────────────
85
86/// Find the `fd` binary in PATH.
87fn 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
101/// Build the fd query from a user-typed path prefix (matches pi's buildFdPathQuery).
102fn 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
125/// Walk directory tree with `fd` (fast, respects .gitignore).
126/// Mirrors pi's walkDirectoryWithFd().
127fn 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
191/// Score an entry against the query (higher = better match).
192/// Directories get a bonus to appear first.
193fn 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
217// ── Quoted prefix helpers (pi-compat) ─────────────────────────────────
218
219const PATH_DELIMITERS: &[char] = &[' ', '\t', '"', '\'', '='];
220
221/// Find an unclosed `"` or `@"` start in the text before cursor.
222/// Returns the start index and the prefix slice (including @ if present).
223fn 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    // Check for @" prefix
238    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    // Check for plain " prefix (token boundary)
249    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
256/// Parse a prefix (possibly with @ or "@) into its components.
257/// Returns (stripped_query, is_at_prefix, is_quoted).
258fn 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
270/// Resolve a scoped fd query: split `src/au` into base_dir=`CWD/src/` and query=`au`.
271fn 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
293// =============================================================================
294// CombinedAutocompleteProvider - handles slash commands + file paths
295// =============================================================================
296
297/// Combined provider that handles slash commands and file path completion.
298pub 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    /// Fuzzy file search using `fd` (fast, respects .gitignore).
345    /// Matches pi's getFuzzyFileSuggestions().
346    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                // No scope - search from base_path with the full query
352                (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        // Sort by score descending, then take top 20
378        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        // Determine search directory and file prefix
415        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 = &current_line[..cursor_col.min(current_line.len())];
556
557        // ── Slash command completion ──
558        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            // No slash command match – fall through to file completion for absolute paths like /tmp
564        }
565
566        // ── Slash command argument completion ──
567        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                    // Check for dynamic argument completions callback (pi-style)
576                    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                    // Check for static argument completions (pi-compat)
586                    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                    // Fall back to file path completion
601                    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        // ── Quoted prefix (@""" or """ for paths with spaces, pi-style) ──
614        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            // Use fd for simple queries (no /) to find files anywhere
617            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        // ── @ and # file/attachment completion ──
629        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 doesn't contain / and fd is available, use fd for project-wide search
635                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        // ── ~ path completion (tilde expansion) ──
647        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        // ── Absolute path completion (/) – automatic (non-force) fallback for paths
657        //     that didn't match any slash command ──
658        if text_before.starts_with('/') && !text_before.contains(' ') && text_before.len() > 1 {
659            return self.get_file_suggestions(text_before);
660        }
661
662        // ── Forced completion (Tab) ──
663        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 = &current_line[..prefix_start];
689        let after = &current_line[cursor_col..];
690
691        // Determine if this is a slash command completion or a file path completion.
692        // Slash commands have item.value = "help" (no leading /, ~, or . path chars).
693        // File paths have item.value = "/tmp/", "~/.rab/agent/", or "src/main.rs".
694        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            // Slash command: insert with trailing space
701            (
702                format!("{}/{} {}", before, item.value, after),
703                before.len() + 1 + item.value.len() + 1,
704            )
705        } else {
706            // File path: use the item value directly (it's already built by the provider)
707            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                // Only block Tab completion for known slash commands on line 0.
732                // Absolute paths like /usr/share/ should still get file completion.
733                if text.starts_with('/') && !text.contains(' ') && cursor_line == 0 {
734                    let cmd_input = text[1..].trim();
735                    if cmd_input.is_empty() {
736                        // Just "/" — don't trigger file completion yet
737                        return false;
738                    }
739                    // If text matches a known slash command, don't trigger file completion
740                    if self
741                        .slash_commands
742                        .iter()
743                        .any(|c| c.name.starts_with(cmd_input))
744                    {
745                        return false;
746                    }
747                    // Otherwise it's an absolute path — allow file completion
748                }
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    // ── Tests for fixed bugs ──
919
920    #[test]
921    fn test_apply_completion_absolute_path_no_double_slash() {
922        // Bug 1: completing / → tmp/ should give /tmp/ not //tmp/
923        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
924        // Absolute path file completion (item.value starts with /)
925        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        // Should NOT produce //tmp/
933        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        // Slash commands should still produce /cmd (with one slash)
942        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        // Bug 1: get_suggestions for absolute paths like /tmp should work
957        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
958        let lines = vec!["/tmp".into()];
959        let result = provider.get_suggestions(&lines, 0, 4, false);
960        // /tmp is a directory, should show its contents
961        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        // When no slash command matches, absolute paths should get file completion
976        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        // /tmp doesn't match any slash command, should fall through to file completion
988        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        // Bug 2: ~ paths should trigger file completion (non-force)
998        let home = std::env::var("HOME").unwrap_or_default();
999        if home.is_empty() || !std::path::Path::new(&home).is_dir() {
1000            // Skip if HOME is not set or not a directory
1001            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        // Bug 2: when query starts with '.', hidden files should be shown
1012        let tmp = std::env::temp_dir();
1013        // Create a temp dir with a hidden file
1014        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        // Query with dot prefix should show hidden files
1026        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        // Query without dot prefix should NOT show hidden files
1041        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        // Existing slash command completion should not be broken
1061        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}