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 {
53 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 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 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 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 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 let index = ((attempt_count - 1) % candidates.len() as u32) as usize;
168 let candidate = &candidates[index];
169
170 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 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 before_cursor.ends_with(' ') {
205 current_word = "".to_string();
206 }
207
208 let (base_dir, prefix) = Self::parse_path_for_completion(¤t_word);
210
211 let mut candidates = Vec::new();
212
213 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 let replacement = if current_word.is_empty() || current_word.ends_with('/') {
221 format!("{}{}", current_word, name)
223 } else if let Some(last_slash) = current_word.rfind('/') {
224 format!("{}{}", ¤t_word[..=last_slash], name)
226 } else {
227 name.to_string()
229 };
230
231 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 if path.is_absolute() {
263 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 (Path::new("/").to_path_buf(), String::new())
278 };
279 }
280
281 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..]) };
291
292 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 if word.ends_with('/') {
312 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 (
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 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 let is_repeated = Self::is_repeated_completion(current_word, pos);
369
370 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 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 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 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 let (_base_dir, prefix) = RushCompleter::parse_path_for_completion("file");
455 assert_eq!(prefix, "file");
456 }
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 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 let candidates = RushCompleter::get_file_candidates("ls ", 3);
497 assert!(candidates.is_empty() || !candidates.is_empty()); }
501
502 #[test]
503 fn test_get_file_candidates_with_directory() {
504 let candidates = RushCompleter::get_file_candidates("ls src/", 7);
506 assert!(candidates.is_empty() || !candidates.is_empty()); }
509
510 #[test]
511 fn test_directory_completion_formatting() {
512 let _lock = COMPLETION_DIR_LOCK.lock().unwrap();
514
515 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 let _ = env::set_current_dir(env::temp_dir());
523 let _ = env::set_current_dir(&temp_dir);
524
525 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 let _ = env::set_current_dir(env::temp_dir());
532
533 let _ = fs::remove_dir_all(&temp_dir);
535
536 assert!(has_testdir || has_testfile); }
538
539 #[test]
540 fn test_first_word_file_completion_precedence() {
541 let _lock = COMPLETION_DIR_LOCK.lock().unwrap();
543
544 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 let _ = env::set_current_dir(env::temp_dir());
551 let _ = env::set_current_dir(&temp_dir);
552
553 let candidates = RushCompleter::get_file_candidates("ex", 2);
557 let has_examples = candidates.iter().any(|c| c.display() == "examples/");
558
559 let _ = env::set_current_dir(env::temp_dir());
561
562 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 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 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 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 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 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 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 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 if let Ok(mut context) = COMPLETION_STATE.lock() {
634 *context = None;
635 }
636
637 let word = "test";
639 let pos = 4;
640
641 assert!(!RushCompleter::is_repeated_completion(word, pos));
643
644 RushCompleter::update_completion_context(word.to_string(), pos, false);
646
647 assert!(RushCompleter::is_repeated_completion(word, pos));
649
650 assert!(!RushCompleter::is_repeated_completion("different", pos));
652
653 assert!(!RushCompleter::is_repeated_completion(word, pos + 1));
655 }
656
657 #[test]
658 fn test_completion_context_update() {
659 if let Ok(mut context) = COMPLETION_STATE.lock() {
661 *context = None;
662 }
663
664 let word = "test";
665 let pos = 4;
666
667 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 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}