171 lines
4.8 KiB
Rust
171 lines
4.8 KiB
Rust
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<String> {
|
|
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<Pair>)> {
|
|
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<Pair> {
|
|
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<Pair> {
|
|
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
|
|
}
|