rush_sh/
completion.rs

1use rustyline::completion::{Candidate, Completer};
2use rustyline::highlight::Highlighter;
3use rustyline::hint::Hinter;
4use rustyline::validate::Validator;
5use rustyline::{Context, Helper};
6use std::env;
7use std::fs;
8use std::path::Path;
9use std::sync::Mutex;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12#[derive(Debug, Clone)]
13struct CompletionContext {
14    word: String,
15    pos: usize,
16    timestamp: u64,
17    attempt_count: u32,
18}
19
20// Global state for tracking completion context
21lazy_static::lazy_static! {
22    static ref COMPLETION_STATE: Mutex<Option<CompletionContext>> = Mutex::new(None);
23}
24
25pub struct RushCompleter {}
26
27impl Default for RushCompleter {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl RushCompleter {
34    pub fn new() -> Self {
35        Self {}
36    }
37
38    fn get_builtin_commands() -> Vec<String> {
39        crate::builtins::get_builtin_commands()
40    }
41
42    fn get_path_executables() -> Vec<String> {
43        let mut executables = Vec::new();
44
45        if let Ok(path_var) = env::var("PATH") {
46            for dir in env::split_paths(&path_var) {
47                if let Ok(entries) = fs::read_dir(&dir) {
48                    for entry in entries.flatten() {
49                        if let Ok(file_type) = entry.file_type()
50                            && file_type.is_file()
51                            && let Some(name) = entry.file_name().to_str() {
52                                // Check if executable (on Unix-like systems)
53                                use std::os::unix::fs::PermissionsExt;
54                                if let Ok(metadata) = entry.metadata() {
55                                    let permissions = metadata.permissions();
56                                    if permissions.mode() & 0o111 != 0 {
57                                        executables.push(name.to_string());
58                                    }
59                                }
60                            }
61                    }
62                }
63            }
64        }
65
66        executables.sort();
67        executables.dedup();
68        executables
69    }
70
71    fn is_first_word(line: &str, pos: usize) -> bool {
72        let before_cursor = &line[..pos];
73        let words_before: Vec<&str> = before_cursor.split_whitespace().collect();
74        words_before.is_empty() || (words_before.len() == 1 && !before_cursor.ends_with(' '))
75    }
76
77    fn looks_like_file_path(word: &str) -> bool {
78        word.starts_with("./")
79            || word.starts_with("/")
80            || word.starts_with("~/")
81            || word.contains("/")
82    }
83
84    fn get_command_candidates(prefix: &str) -> Vec<RushCandidate> {
85        let mut candidates = Vec::new();
86
87        // Add built-ins
88        for builtin in Self::get_builtin_commands() {
89            if builtin.starts_with(prefix) {
90                candidates.push(RushCandidate::new(builtin.clone(), builtin));
91            }
92        }
93
94        // Add PATH executables
95        for executable in Self::get_path_executables() {
96            if executable.starts_with(prefix) {
97                candidates.push(RushCandidate::new(executable.clone(), executable));
98            }
99        }
100
101        candidates.sort_by(|a, b| a.display.cmp(&b.display));
102        candidates.dedup_by(|a, b| a.display == b.display);
103        candidates
104    }
105
106    fn is_repeated_completion(word: &str, pos: usize) -> bool {
107        if let Ok(context) = COMPLETION_STATE.lock()
108            && let Some(ref ctx) = *context {
109                // Check if this is the same word and position (within a reasonable time window)
110                if ctx.word == word && ctx.pos == pos {
111                    let current_time = SystemTime::now()
112                        .duration_since(UNIX_EPOCH)
113                        .unwrap_or_default()
114                        .as_secs();
115                    // Consider it a repeated attempt if within 2 seconds
116                    if current_time - ctx.timestamp <= 2 {
117                        return true;
118                    }
119                }
120            }
121        false
122    }
123
124    fn update_completion_context(word: String, pos: usize, is_repeated: bool) {
125        let current_time = SystemTime::now()
126            .duration_since(UNIX_EPOCH)
127            .unwrap_or_default()
128            .as_secs();
129
130        if let Ok(mut context) = COMPLETION_STATE.lock() {
131            if is_repeated {
132                if let Some(ref mut ctx) = *context {
133                    ctx.attempt_count += 1;
134                    ctx.timestamp = current_time;
135                }
136            } else {
137                *context = Some(CompletionContext {
138                    word,
139                    pos,
140                    timestamp: current_time,
141                    attempt_count: 1,
142                });
143            }
144        }
145    }
146
147    fn get_current_attempt_count(&self) -> u32 {
148        if let Ok(context) = COMPLETION_STATE.lock()
149            && let Some(ref ctx) = *context {
150                return ctx.attempt_count;
151            }
152        1
153    }
154
155    fn get_next_completion_candidate(candidates: &[RushCandidate], attempt_count: u32) -> Option<(usize, Vec<RushCandidate>)> {
156        if candidates.len() <= 1 {
157            return None;
158        }
159
160        // Cycle through candidates based on attempt count
161        let index = ((attempt_count - 1) % candidates.len() as u32) as usize;
162        let candidate = &candidates[index];
163
164        // Return single candidate for cycling behavior
165        Some((0, vec![RushCandidate::new(
166            candidate.display.clone(),
167            candidate.replacement.clone(),
168        )]))
169    }
170
171    fn get_file_candidates(line: &str, pos: usize) -> Vec<RushCandidate> {
172        let before_cursor = &line[..pos];
173        let words: Vec<&str> = before_cursor.split_whitespace().collect();
174
175        if words.is_empty() {
176            return vec![];
177        }
178
179        // Find the current word being completed
180        let mut current_word = String::new();
181        let mut start_pos = 0;
182
183        for &word in words.iter() {
184            let word_start = line[start_pos..].find(word).unwrap_or(0) + start_pos;
185            let word_end = word_start + word.len();
186
187            if pos >= word_start && pos <= word_end {
188                current_word = word.to_string();
189                break;
190            }
191            start_pos = word_end;
192        }
193
194        // If we're at the end and there's a space, we're starting a new word
195        if before_cursor.ends_with(' ') {
196            current_word = "".to_string();
197        }
198
199        // Parse the current word to separate directory path from filename prefix
200        let (base_dir, prefix) = Self::parse_path_for_completion(&current_word);
201
202        let mut candidates = Vec::new();
203
204        // Try to read the target directory
205        if let Ok(entries) = fs::read_dir(&base_dir) {
206            for entry in entries.flatten() {
207                if let Some(name) = entry.file_name().to_str()
208                    && name.starts_with(&prefix) {
209                        // Determine the replacement string
210                        let replacement = if current_word.is_empty() || current_word.ends_with('/')
211                        {
212                            // If completing from a directory, just append the name
213                            format!("{}{}", current_word, name)
214                        } else if let Some(last_slash) = current_word.rfind('/') {
215                            // If completing a partial name in a subdirectory
216                            format!("{}{}", &current_word[..=last_slash], name)
217                        } else {
218                            // Completing in current directory
219                            name.to_string()
220                        };
221
222                        // Add trailing slash for directories
223                        let display_name = if let Ok(file_type) = entry.file_type() {
224                            if file_type.is_dir() {
225                                format!("{}/", name)
226                            } else {
227                                name.to_string()
228                            }
229                        } else {
230                            name.to_string()
231                        };
232
233                        candidates.push(RushCandidate::new(display_name, replacement));
234                    }
235            }
236        }
237
238        candidates.sort_by(|a, b| a.display.cmp(&b.display));
239        candidates
240    }
241
242    fn parse_path_for_completion(word: &str) -> (std::path::PathBuf, String) {
243        if word.is_empty() {
244            return (
245                env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf()),
246                String::new(),
247            );
248        }
249
250        let path = Path::new(word);
251
252        // Handle absolute paths
253        if path.is_absolute() {
254            // Check if the path ends with '/' - if so, we're completing from that directory
255            if word.ends_with('/') {
256                return (path.to_path_buf(), String::new());
257            }
258
259            if let Some(parent) = path.parent() {
260                let prefix = path
261                    .file_name()
262                    .and_then(|n| n.to_str())
263                    .unwrap_or("")
264                    .to_string();
265                return (parent.to_path_buf(), prefix);
266            } else {
267                // Root directory
268                return (Path::new("/").to_path_buf(), String::new());
269            }
270        }
271
272        // Handle home directory expansion
273        if (word.starts_with("~/") || word == "~")
274            && let Ok(home_dir) = env::var("HOME") {
275                let home_path = Path::new(&home_dir);
276                let relative_path = if word == "~" {
277                    Path::new("")
278                } else {
279                    Path::new(&word[2..]) // Remove "~/"
280                };
281
282                // Check if the path ends with '/' - if so, we're completing from that directory
283                if word.ends_with('/') || word == "~" {
284                    return (home_path.join(relative_path), String::new());
285                }
286
287                if let Some(parent) = relative_path.parent() {
288                    let full_parent = home_path.join(parent);
289                    let prefix = relative_path
290                        .file_name()
291                        .and_then(|n| n.to_str())
292                        .unwrap_or("")
293                        .to_string();
294                    return (full_parent, prefix);
295                } else {
296                    return (home_path.to_path_buf(), String::new());
297                }
298            }
299
300        // Handle relative paths
301        if word.ends_with('/') {
302            // Completing from a directory
303            return (Path::new(word).to_path_buf(), String::new());
304        }
305
306        if let Some(last_slash) = word.rfind('/') {
307            let dir_part = &word[..last_slash];
308            let file_part = &word[last_slash + 1..];
309
310            let base_dir = if dir_part.is_empty() {
311                env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf())
312            } else {
313                Path::new(dir_part).to_path_buf()
314            };
315
316            (base_dir, file_part.to_string())
317        } else {
318            // No directory separator, complete from current directory
319            (
320                env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf()),
321                word.to_string(),
322            )
323        }
324    }
325}
326
327impl Completer for RushCompleter {
328    type Candidate = RushCandidate;
329
330    fn complete(
331        &self,
332        line: &str,
333        pos: usize,
334        _ctx: &Context<'_>,
335    ) -> rustyline::Result<(usize, Vec<RushCandidate>)> {
336        let prefix = &line[..pos];
337        let last_space = prefix.rfind(' ').unwrap_or(0);
338        let start = if last_space > 0 { last_space + 1 } else { 0 };
339        let current_word = &line[start..pos];
340
341        let is_first = Self::is_first_word(line, pos);
342        let is_file_path = Self::looks_like_file_path(current_word);
343
344        let candidates = if is_first && !is_file_path {
345            // For first word that doesn't look like a file path, check if there are file matches
346            // If there are, prefer file completion over command completion
347            let file_candidates = Self::get_file_candidates(line, pos);
348            if file_candidates.is_empty() {
349                Self::get_command_candidates(current_word)
350            } else {
351                file_candidates
352            }
353        } else {
354            Self::get_file_candidates(line, pos)
355        };
356
357        // Check if this is a repeated completion attempt
358        let is_repeated = Self::is_repeated_completion(current_word, pos);
359
360        // If this is a repeated attempt with multiple matches, cycle through candidates
361        if is_repeated && candidates.len() > 1
362            && let Some(completion_result) = Self::get_next_completion_candidate(&candidates, self.get_current_attempt_count()) {
363                Self::update_completion_context(current_word.to_string(), pos, true);
364                return Ok(completion_result);
365            }
366
367        // Update completion context for next attempt
368        Self::update_completion_context(current_word.to_string(), pos, is_repeated);
369
370        Ok((start, candidates))
371    }
372}
373
374impl Validator for RushCompleter {}
375
376impl Highlighter for RushCompleter {}
377
378impl Hinter for RushCompleter {
379    type Hint = String;
380}
381
382impl Helper for RushCompleter {}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use std::sync::Mutex;
388
389    // Import the mutex from main.rs tests module
390    // We need to use a separate mutex for completion tests to avoid cross-module issues
391    static COMPLETION_DIR_LOCK: Mutex<()> = Mutex::new(());
392
393    #[test]
394    fn test_builtin_commands() {
395        let commands = RushCompleter::get_builtin_commands();
396        assert!(commands.contains(&"cd".to_string()));
397        assert!(commands.contains(&"pwd".to_string()));
398        assert!(commands.contains(&"exit".to_string()));
399        assert!(commands.contains(&"help".to_string()));
400        assert!(commands.contains(&"source".to_string()));
401    }
402
403    #[test]
404    fn test_get_command_candidates() {
405        let candidates = RushCompleter::get_command_candidates("e");
406        // Should include env, exit
407        let displays: Vec<String> = candidates.iter().map(|c| c.display.clone()).collect();
408        assert!(displays.contains(&"env".to_string()));
409        assert!(displays.contains(&"exit".to_string()));
410    }
411
412    #[test]
413    fn test_get_command_candidates_exact() {
414        let candidates = RushCompleter::get_command_candidates("cd");
415        let displays: Vec<String> = candidates.iter().map(|c| c.display.clone()).collect();
416        assert!(displays.contains(&"cd".to_string()));
417    }
418
419    #[test]
420    fn test_is_first_word() {
421        assert!(RushCompleter::is_first_word("", 0));
422        assert!(RushCompleter::is_first_word("c", 1));
423        assert!(RushCompleter::is_first_word("cd", 2));
424        assert!(!RushCompleter::is_first_word("cd ", 3));
425        assert!(!RushCompleter::is_first_word("cd /", 4));
426    }
427
428    #[test]
429    fn test_rush_candidate_display() {
430        let candidate = RushCandidate::new("test".to_string(), "replacement".to_string());
431        assert_eq!(candidate.display(), "test");
432        assert_eq!(candidate.replacement(), "replacement");
433    }
434
435    #[test]
436    fn test_parse_path_for_completion_current_dir() {
437        let (_base_dir, prefix) = RushCompleter::parse_path_for_completion("");
438        assert_eq!(prefix, "");
439        // Should be current directory
440
441        let (_base_dir, prefix) = RushCompleter::parse_path_for_completion("file");
442        assert_eq!(prefix, "file");
443        // Should be current directory
444    }
445
446    #[test]
447    fn test_parse_path_for_completion_with_directory() {
448        let (base_dir, prefix) = RushCompleter::parse_path_for_completion("src/");
449        assert_eq!(prefix, "");
450        assert_eq!(base_dir, Path::new("src"));
451
452        let (base_dir, prefix) = RushCompleter::parse_path_for_completion("src/main");
453        assert_eq!(prefix, "main");
454        assert_eq!(base_dir, Path::new("src"));
455    }
456
457    #[test]
458    fn test_parse_path_for_completion_absolute() {
459        let (_base_dir, prefix) = RushCompleter::parse_path_for_completion("/usr/");
460        assert_eq!(prefix, "");
461
462        let (_base_dir, prefix) = RushCompleter::parse_path_for_completion("/usr/bin/l");
463        assert_eq!(prefix, "l");
464    }
465
466    #[test]
467    fn test_parse_path_for_completion_home() {
468        // This test assumes HOME environment variable is set
469        if env::var("HOME").is_ok() {
470            let (base_dir, prefix) = RushCompleter::parse_path_for_completion("~/");
471            assert_eq!(prefix, "");
472            assert_eq!(base_dir, Path::new(&env::var("HOME").unwrap()));
473
474            let (base_dir, prefix) = RushCompleter::parse_path_for_completion("~/doc");
475            assert_eq!(prefix, "doc");
476            assert_eq!(base_dir, Path::new(&env::var("HOME").unwrap()));
477        }
478    }
479
480    #[test]
481    fn test_get_file_candidates_basic() {
482        // Test completion from current directory
483        let candidates = RushCompleter::get_file_candidates("ls ", 3);
484        // Should return candidates from current directory
485        // (exact results depend on the test environment)
486        assert!(candidates.is_empty() || !candidates.is_empty()); // Just check it doesn't panic
487    }
488
489    #[test]
490    fn test_get_file_candidates_with_directory() {
491        // Test completion with directory path
492        let candidates = RushCompleter::get_file_candidates("ls src/", 7);
493        // Should return candidates from src directory if it exists
494        assert!(candidates.is_empty() || !candidates.is_empty()); // Just check it doesn't panic
495    }
496
497    #[test]
498    fn test_directory_completion_formatting() {
499        // Lock to prevent parallel tests from interfering with directory changes
500        let _lock = COMPLETION_DIR_LOCK.lock().unwrap();
501        
502        // Create a temporary directory for testing
503        let temp_dir = env::temp_dir().join("rush_completion_test");
504        let _ = fs::create_dir_all(&temp_dir);
505        let _ = fs::create_dir_all(temp_dir.join("testdir"));
506        let _ = fs::write(temp_dir.join("testfile"), "content");
507
508        // Ensure we're in a safe directory first, then change to temp directory
509        let _ = env::set_current_dir(env::temp_dir());
510        let _ = env::set_current_dir(&temp_dir);
511
512        // Test directory completion
513        let candidates = RushCompleter::get_file_candidates("ls test", 7);
514        let has_testdir = candidates.iter().any(|c| c.display() == "testdir/");
515        let has_testfile = candidates.iter().any(|c| c.display() == "testfile");
516
517        // Change back to a safe directory before cleanup
518        let _ = env::set_current_dir(env::temp_dir());
519
520        // Clean up
521        let _ = fs::remove_dir_all(&temp_dir);
522
523        assert!(has_testdir || has_testfile); // At least one should be found
524    }
525
526    #[test]
527    fn test_first_word_file_completion_precedence() {
528        // Lock to prevent parallel tests from interfering with directory changes
529        let _lock = COMPLETION_DIR_LOCK.lock().unwrap();
530
531        // Create a temporary directory for testing
532        let temp_dir = env::temp_dir().join("rush_completion_test_first_word");
533        let _ = fs::create_dir_all(&temp_dir);
534        let _ = fs::create_dir_all(temp_dir.join("examples"));
535
536        // Ensure we're in a safe directory first, then change to temp directory
537        let _ = env::set_current_dir(env::temp_dir());
538        let _ = env::set_current_dir(&temp_dir);
539
540        // Test that "ex" completes to "examples/" when it's the first word
541        // This tests the fix for the issue where "ex" + Tab didn't complete
542        // but "./ex" + Tab did complete to "./examples"
543        let candidates = RushCompleter::get_file_candidates("ex", 2);
544        let has_examples = candidates.iter().any(|c| c.display() == "examples/");
545
546        // Change back to a safe directory before cleanup
547        let _ = env::set_current_dir(env::temp_dir());
548
549        // Clean up
550        let _ = fs::remove_dir_all(&temp_dir);
551
552        assert!(has_examples, "First word 'ex' should complete to 'examples/' when examples directory exists");
553    }
554
555    #[test]
556    fn test_multi_match_completion_cycling() {
557        // Test that multiple matches cycle correctly
558        let candidates = vec![
559            RushCandidate::new("file1".to_string(), "file1".to_string()),
560            RushCandidate::new("file2".to_string(), "file2".to_string()),
561            RushCandidate::new("file3".to_string(), "file3".to_string()),
562        ];
563
564        // First attempt should return first candidate
565        let result1 = RushCompleter::get_next_completion_candidate(&candidates, 1);
566        assert!(result1.is_some());
567        let (_, first_candidates) = result1.unwrap();
568        assert_eq!(first_candidates.len(), 1);
569        assert_eq!(first_candidates[0].display, "file1");
570
571        // Second attempt should return second candidate
572        let result2 = RushCompleter::get_next_completion_candidate(&candidates, 2);
573        assert!(result2.is_some());
574        let (_, second_candidates) = result2.unwrap();
575        assert_eq!(second_candidates.len(), 1);
576        assert_eq!(second_candidates[0].display, "file2");
577
578        // Third attempt should return third candidate
579        let result3 = RushCompleter::get_next_completion_candidate(&candidates, 3);
580        assert!(result3.is_some());
581        let (_, third_candidates) = result3.unwrap();
582        assert_eq!(third_candidates.len(), 1);
583        assert_eq!(third_candidates[0].display, "file3");
584
585        // Fourth attempt should cycle back to first candidate
586        let result4 = RushCompleter::get_next_completion_candidate(&candidates, 4);
587        assert!(result4.is_some());
588        let (_, fourth_candidates) = result4.unwrap();
589        assert_eq!(fourth_candidates.len(), 1);
590        assert_eq!(fourth_candidates[0].display, "file1");
591    }
592
593    #[test]
594    fn test_multi_match_completion_single_candidate() {
595        // Test that single candidate doesn't trigger cycling behavior
596        let candidates = vec![
597            RushCandidate::new("single_file".to_string(), "single_file".to_string()),
598        ];
599
600        let result = RushCompleter::get_next_completion_candidate(&candidates, 1);
601        assert!(result.is_none());
602    }
603
604    #[test]
605    fn test_multi_match_completion_empty_candidates() {
606        // Test that empty candidates doesn't trigger cycling behavior
607        let candidates: Vec<RushCandidate> = vec![];
608
609        let result = RushCompleter::get_next_completion_candidate(&candidates, 1);
610        assert!(result.is_none());
611    }
612
613    #[test]
614    fn test_repeated_completion_detection() {
615        // Clear any existing state first
616        if let Ok(mut context) = COMPLETION_STATE.lock() {
617            *context = None;
618        }
619
620        // Test that repeated completion attempts are detected correctly
621        let word = "test";
622        let pos = 4;
623
624        // First attempt should not be detected as repeated
625        assert!(!RushCompleter::is_repeated_completion(word, pos));
626
627        // Update context to simulate a completion attempt
628        RushCompleter::update_completion_context(word.to_string(), pos, false);
629
630        // Second attempt should be detected as repeated
631        assert!(RushCompleter::is_repeated_completion(word, pos));
632
633        // Different word should not be detected as repeated
634        assert!(!RushCompleter::is_repeated_completion("different", pos));
635
636        // Different position should not be detected as repeated
637        assert!(!RushCompleter::is_repeated_completion(word, pos + 1));
638    }
639
640    #[test]
641    fn test_completion_context_update() {
642        // Clear any existing state first
643        if let Ok(mut context) = COMPLETION_STATE.lock() {
644            *context = None;
645        }
646
647        let word = "test";
648        let pos = 4;
649
650        // Test initial context creation
651        RushCompleter::update_completion_context(word.to_string(), pos, false);
652
653        if let Ok(context) = COMPLETION_STATE.lock() {
654            assert!(context.is_some());
655            let ctx = context.as_ref().unwrap();
656            assert_eq!(ctx.word, word);
657            assert_eq!(ctx.pos, pos);
658            assert_eq!(ctx.attempt_count, 1);
659        }
660
661        // Test repeated attempt updates attempt count
662        RushCompleter::update_completion_context(word.to_string(), pos, true);
663
664        if let Ok(context) = COMPLETION_STATE.lock() {
665            assert!(context.is_some());
666            let ctx = context.as_ref().unwrap();
667            assert_eq!(ctx.attempt_count, 2);
668        }
669    }
670}
671
672#[derive(Debug, Clone)]
673pub struct RushCandidate {
674    pub display: String,
675    pub replacement: String,
676}
677
678impl RushCandidate {
679    pub fn new(display: String, replacement: String) -> Self {
680        Self {
681            display,
682            replacement,
683        }
684    }
685}
686
687impl Candidate for RushCandidate {
688    fn display(&self) -> &str {
689        &self.display
690    }
691
692    fn replacement(&self) -> &str {
693        &self.replacement
694    }
695}