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
20lazy_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 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 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 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 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 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 let index = ((attempt_count - 1) % candidates.len() as u32) as usize;
162 let candidate = &candidates[index];
163
164 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 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 before_cursor.ends_with(' ') {
196 current_word = "".to_string();
197 }
198
199 let (base_dir, prefix) = Self::parse_path_for_completion(¤t_word);
201
202 let mut candidates = Vec::new();
203
204 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 let replacement = if current_word.is_empty() || current_word.ends_with('/')
211 {
212 format!("{}{}", current_word, name)
214 } else if let Some(last_slash) = current_word.rfind('/') {
215 format!("{}{}", ¤t_word[..=last_slash], name)
217 } else {
218 name.to_string()
220 };
221
222 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 if path.is_absolute() {
254 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 return (Path::new("/").to_path_buf(), String::new());
269 }
270 }
271
272 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..]) };
281
282 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 if word.ends_with('/') {
302 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 (
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 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 let is_repeated = Self::is_repeated_completion(current_word, pos);
359
360 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 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 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 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 let (_base_dir, prefix) = RushCompleter::parse_path_for_completion("file");
442 assert_eq!(prefix, "file");
443 }
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 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 let candidates = RushCompleter::get_file_candidates("ls ", 3);
484 assert!(candidates.is_empty() || !candidates.is_empty()); }
488
489 #[test]
490 fn test_get_file_candidates_with_directory() {
491 let candidates = RushCompleter::get_file_candidates("ls src/", 7);
493 assert!(candidates.is_empty() || !candidates.is_empty()); }
496
497 #[test]
498 fn test_directory_completion_formatting() {
499 let _lock = COMPLETION_DIR_LOCK.lock().unwrap();
501
502 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 let _ = env::set_current_dir(env::temp_dir());
510 let _ = env::set_current_dir(&temp_dir);
511
512 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 let _ = env::set_current_dir(env::temp_dir());
519
520 let _ = fs::remove_dir_all(&temp_dir);
522
523 assert!(has_testdir || has_testfile); }
525
526 #[test]
527 fn test_first_word_file_completion_precedence() {
528 let _lock = COMPLETION_DIR_LOCK.lock().unwrap();
530
531 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 let _ = env::set_current_dir(env::temp_dir());
538 let _ = env::set_current_dir(&temp_dir);
539
540 let candidates = RushCompleter::get_file_candidates("ex", 2);
544 let has_examples = candidates.iter().any(|c| c.display() == "examples/");
545
546 let _ = env::set_current_dir(env::temp_dir());
548
549 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 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 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 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 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 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 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 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 if let Ok(mut context) = COMPLETION_STATE.lock() {
617 *context = None;
618 }
619
620 let word = "test";
622 let pos = 4;
623
624 assert!(!RushCompleter::is_repeated_completion(word, pos));
626
627 RushCompleter::update_completion_context(word.to_string(), pos, false);
629
630 assert!(RushCompleter::is_repeated_completion(word, pos));
632
633 assert!(!RushCompleter::is_repeated_completion("different", pos));
635
636 assert!(!RushCompleter::is_repeated_completion(word, pos + 1));
638 }
639
640 #[test]
641 fn test_completion_context_update() {
642 if let Ok(mut context) = COMPLETION_STATE.lock() {
644 *context = None;
645 }
646
647 let word = "test";
648 let pos = 4;
649
650 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 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}