1
0
This commit is contained in:
2026-02-27 19:49:18 +09:00
commit 09326b134c
11 changed files with 937 additions and 0 deletions

170
src/completer.rs Normal file
View File

@@ -0,0 +1,170 @@
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
}