use std::env; use std::fs; use std::path::Path; use rustyline::completion::{Completer, Pair}; use rustyline::highlight::Highlighter; use rustyline::hint::{Hinter, HistoryHinter}; use rustyline::validate::Validator; use rustyline::Helper; use rustyline::Context; pub struct ShellHelper { hinter: HistoryHinter, } impl ShellHelper { pub fn new() -> Self { Self { hinter: HistoryHinter::new(), } } } impl Helper for ShellHelper {} impl Validator for ShellHelper {} impl Highlighter for ShellHelper { fn highlight_hint<'h>(&self, hint: &'h str) -> std::borrow::Cow<'h, str> { // Gray color for hints std::borrow::Cow::Owned(format!("\x1b[90m{}\x1b[0m", hint)) } } impl Hinter for ShellHelper { type Hint = String; fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option { self.hinter.hint(line, pos, ctx) } } impl Completer for ShellHelper { type Candidate = Pair; fn complete( &self, line: &str, pos: usize, _ctx: &Context<'_>, ) -> rustyline::Result<(usize, Vec)> { let (start, word) = extract_word(line, pos); // If first word → command completion // Otherwise → file path completion let is_first_word = line[..start].trim().is_empty(); let candidates = if is_first_word { let mut results = complete_commands(word); // Also include files in current dir for ./script style results.extend(complete_path(word)); results } else { complete_path(word) }; Ok((start, candidates)) } } /// Extract the current word being completed fn extract_word(line: &str, pos: usize) -> (usize, &str) { let bytes = &line.as_bytes()[..pos]; let start = bytes.iter().rposition(|&b| b == b' ').map(|i| i + 1).unwrap_or(0); (start, &line[start..pos]) } /// Complete commands from PATH fn complete_commands(prefix: &str) -> Vec { if prefix.is_empty() { return Vec::new(); } let path_var = match env::var("PATH") { Ok(p) => p, Err(_) => return Vec::new(), }; let mut seen = std::collections::HashSet::new(); let mut results = Vec::new(); for dir in path_var.split(':') { let entries = match fs::read_dir(dir) { Ok(e) => e, Err(_) => continue, }; for entry in entries.flatten() { let name = entry.file_name(); let name = name.to_string_lossy(); if name.starts_with(prefix) && !seen.contains(name.as_ref()) { seen.insert(name.to_string()); results.push(Pair { display: name.to_string(), replacement: name.to_string(), }); } } } results.sort_by(|a, b| a.display.cmp(&b.display)); results } /// Complete file/directory paths fn complete_path(prefix: &str) -> Vec { let (dir, file_prefix) = if prefix.contains('/') { let p = Path::new(prefix); let dir = p.parent().unwrap_or(Path::new(".")); let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); // Expand ~ let dir_str = dir.to_string_lossy(); let expanded = if dir_str.starts_with('~') { if let Ok(home) = env::var("HOME") { Path::new(&dir_str.replacen('~', &home, 1)).to_path_buf() } else { dir.to_path_buf() } } else { dir.to_path_buf() }; (expanded, file) } else { (std::env::current_dir().unwrap_or_default(), prefix.to_string()) }; let entries = match fs::read_dir(&dir) { Ok(e) => e, Err(_) => return Vec::new(), }; let mut results = Vec::new(); for entry in entries.flatten() { let name = entry.file_name(); let name = name.to_string_lossy().to_string(); if name.starts_with(&file_prefix) { let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); let replacement = if prefix.contains('/') { let parent = Path::new(prefix).parent().unwrap_or(Path::new("")); let mut r = format!("{}/{}", parent.display(), name); if is_dir { r.push('/'); } r } else { let mut r = name.clone(); if is_dir { r.push('/'); } r }; let display = if is_dir { format!("{}/", name) } else { name }; results.push(Pair { display, replacement }); } } results.sort_by(|a, b| a.display.cmp(&b.display)); results }