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