use std::fs;
use std::sync::Arc;
use reedline::{Completer, Span, Suggestion};
use crate::engine::classifier::InputClassifier;
use crate::engine::expand;
const BUILTIN_COMMANDS: &[&str] = &["cd", "cwd", "exit", "export", "unset", "help", "history"];
const GIT_BRANCH_SUBCOMMANDS: &[&str] = &[
"checkout",
"switch",
"merge",
"rebase",
"branch",
"diff",
"log",
"cherry-pick",
"reset",
];
pub struct JarvishCompleter {
classifier: Arc<InputClassifier>,
}
impl JarvishCompleter {
pub fn new(classifier: Arc<InputClassifier>) -> Self {
Self { classifier }
}
fn complete_command(&self, partial: &str, span: Span) -> Vec<Suggestion> {
let path_commands = self.classifier.path_commands();
let mut matches: Vec<&str> = path_commands
.iter()
.map(|s| s.as_str())
.chain(BUILTIN_COMMANDS.iter().copied())
.filter(|cmd| cmd.starts_with(partial))
.collect();
matches.sort_unstable();
matches.dedup();
matches
.into_iter()
.map(|cmd| Suggestion {
value: cmd.to_string(),
description: None,
style: None,
extra: None,
span,
append_whitespace: true,
match_indices: None,
})
.collect()
}
fn complete_path(&self, partial: &str, span: Span, dirs_only: bool) -> Vec<Suggestion> {
let (search_dir, prefix, original_dir) = Self::split_path_prefix(partial);
let entries = match fs::read_dir(&search_dir) {
Ok(e) => e,
Err(_) => return vec![],
};
let mut suggestions: Vec<Suggestion> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
if !name.starts_with(&prefix) {
return None;
}
if name.starts_with('.') && !prefix.starts_with('.') {
return None;
}
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
if dirs_only && !is_dir {
return None;
}
let value = if !original_dir.is_empty() {
if is_dir {
format!("{original_dir}{name}/")
} else {
format!("{original_dir}{name}")
}
} else if is_dir {
format!("{name}/")
} else {
name
};
Some(Suggestion {
value,
description: None,
style: None,
extra: None,
span,
append_whitespace: !is_dir,
match_indices: None,
})
})
.collect();
suggestions.sort_by(|a, b| a.value.cmp(&b.value));
suggestions
}
fn complete_git_branch(&self, partial: &str, span: Span) -> Vec<Suggestion> {
let output = match std::process::Command::new("git")
.args(["branch", "--format=%(refname:short)"])
.stderr(std::process::Stdio::null())
.output()
{
Ok(o) if o.status.success() => o,
_ => return vec![],
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut branches: Vec<&str> = stdout.lines().filter(|b| b.starts_with(partial)).collect();
branches.sort_unstable();
branches.dedup();
branches
.into_iter()
.map(|branch| Suggestion {
value: branch.to_string(),
description: None,
style: None,
extra: None,
span,
append_whitespace: true,
match_indices: None,
})
.collect()
}
fn split_path_prefix(partial: &str) -> (String, String, String) {
let effective = if partial == "~" { "~/" } else { partial };
let expanded = expand::expand_token(effective);
if let Some(idx) = expanded.rfind('/') {
let search_dir = expanded[..=idx].to_string();
let file_part = expanded[idx + 1..].to_string();
let original_dir = if let Some(orig_idx) = partial.rfind('/') {
partial[..=orig_idx].to_string()
} else {
format!("{}/", partial)
};
(search_dir, file_part, original_dir)
} else {
(".".to_string(), partial.to_string(), String::new())
}
}
fn token_start(line: &str, pos: usize) -> usize {
let before = &line[..pos];
before.rfind(' ').map(|i| i + 1).unwrap_or(0)
}
fn is_first_token(line: &str, pos: usize) -> bool {
!line[..pos].contains(' ')
}
}
impl Completer for JarvishCompleter {
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
let start = Self::token_start(line, pos);
let partial = &line[start..pos];
let span = Span::new(start, pos);
if Self::is_first_token(line, pos) {
self.complete_command(partial, span)
} else {
let tokens: Vec<&str> = line[..pos].split_whitespace().collect();
let first_token = tokens.first().copied().unwrap_or("");
if first_token == "git"
&& tokens.len() >= 2
&& GIT_BRANCH_SUBCOMMANDS.contains(&tokens[1])
{
self.complete_git_branch(partial, span)
} else {
let dirs_only = first_token == "cd";
self.complete_path(partial, span, dirs_only)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use std::fs;
fn test_completer() -> JarvishCompleter {
JarvishCompleter::new(Arc::new(InputClassifier::new()))
}
fn create_test_tree() -> (tempfile::TempDir, String) {
let tmpdir = tempfile::tempdir().expect("failed to create tempdir");
let base = tmpdir.path();
fs::create_dir(base.join("Documents")).unwrap();
fs::create_dir(base.join("Desktop")).unwrap();
fs::create_dir(base.join("Downloads")).unwrap();
fs::create_dir(base.join(".hidden_dir")).unwrap();
fs::write(base.join("readme.txt"), "").unwrap();
fs::write(base.join(".dotfile"), "").unwrap();
let path = base.to_str().unwrap().to_string();
(tmpdir, path)
}
#[test]
fn split_relative_path() {
let (search_dir, prefix, original_dir) = JarvishCompleter::split_path_prefix("src/ma");
assert_eq!(search_dir, "src/");
assert_eq!(prefix, "ma");
assert_eq!(original_dir, "src/");
}
#[test]
fn split_bare_filename() {
let (search_dir, prefix, original_dir) = JarvishCompleter::split_path_prefix("file");
assert_eq!(search_dir, ".");
assert_eq!(prefix, "file");
assert_eq!(original_dir, "");
}
#[test]
#[serial]
fn split_tilde_with_slash() {
let home = env::var("HOME").unwrap();
let (search_dir, prefix, original_dir) = JarvishCompleter::split_path_prefix("~/Do");
assert_eq!(search_dir, format!("{home}/"));
assert_eq!(prefix, "Do");
assert_eq!(original_dir, "~/");
}
#[test]
#[serial]
fn split_tilde_alone() {
let home = env::var("HOME").unwrap();
let (search_dir, prefix, original_dir) = JarvishCompleter::split_path_prefix("~");
assert_eq!(search_dir, format!("{home}/"));
assert_eq!(prefix, "");
assert_eq!(original_dir, "~/");
}
#[test]
#[serial]
fn split_tilde_trailing_slash() {
let home = env::var("HOME").unwrap();
let (search_dir, prefix, original_dir) = JarvishCompleter::split_path_prefix("~/");
assert_eq!(search_dir, format!("{home}/"));
assert_eq!(prefix, "");
assert_eq!(original_dir, "~/");
}
#[test]
fn split_absolute_path() {
let (search_dir, prefix, original_dir) = JarvishCompleter::split_path_prefix("/tmp/te");
assert_eq!(search_dir, "/tmp/");
assert_eq!(prefix, "te");
assert_eq!(original_dir, "/tmp/");
}
#[test]
fn complete_path_absolute_with_trailing_slash() {
let (_tmpdir, path) = create_test_tree();
let completer = test_completer();
let partial = format!("{path}/");
let span = Span::new(3, 3 + partial.len());
let suggestions = completer.complete_path(&partial, span, false);
let values: Vec<&str> = suggestions.iter().map(|s| s.value.as_str()).collect();
assert!(values.contains(&format!("{path}/Documents/").as_str()));
assert!(values.contains(&format!("{path}/Desktop/").as_str()));
assert!(values.contains(&format!("{path}/readme.txt").as_str()));
assert!(!values.iter().any(|v| v.contains(".hidden_dir")));
assert!(!values.iter().any(|v| v.contains(".dotfile")));
}
#[test]
fn complete_path_absolute_with_prefix() {
let (_tmpdir, path) = create_test_tree();
let completer = test_completer();
let partial = format!("{path}/Do");
let span = Span::new(3, 3 + partial.len());
let suggestions = completer.complete_path(&partial, span, false);
let values: Vec<&str> = suggestions.iter().map(|s| s.value.as_str()).collect();
assert!(values.contains(&format!("{path}/Documents/").as_str()));
assert!(values.contains(&format!("{path}/Downloads/").as_str()));
assert!(!values.iter().any(|v| v.contains("Desktop")));
assert!(!values.iter().any(|v| v.contains("readme")));
}
#[test]
fn complete_path_dirs_only() {
let (_tmpdir, path) = create_test_tree();
let completer = test_completer();
let partial = format!("{path}/");
let span = Span::new(3, 3 + partial.len());
let suggestions = completer.complete_path(&partial, span, true);
let values: Vec<&str> = suggestions.iter().map(|s| s.value.as_str()).collect();
assert!(values.contains(&format!("{path}/Documents/").as_str()));
assert!(values.contains(&format!("{path}/Desktop/").as_str()));
assert!(!values.iter().any(|v| v.contains("readme.txt")));
}
#[test]
fn complete_path_dot_prefix_shows_hidden() {
let (_tmpdir, path) = create_test_tree();
let completer = test_completer();
let partial = format!("{path}/.");
let span = Span::new(3, 3 + partial.len());
let suggestions = completer.complete_path(&partial, span, false);
let values: Vec<&str> = suggestions.iter().map(|s| s.value.as_str()).collect();
assert!(values.contains(&format!("{path}/.hidden_dir/").as_str()));
assert!(values.contains(&format!("{path}/.dotfile").as_str()));
}
#[test]
fn complete_cd_dirs_only_via_trait() {
let (_tmpdir, path) = create_test_tree();
let mut completer = test_completer();
let line = format!("cd {path}/");
let pos = line.len();
let suggestions = completer.complete(&line, pos);
let values: Vec<&str> = suggestions.iter().map(|s| s.value.as_str()).collect();
assert!(values.contains(&format!("{path}/Documents/").as_str()));
assert!(!values.iter().any(|v| v.contains("readme.txt")));
}
#[test]
fn complete_ls_shows_files_and_dirs() {
let (_tmpdir, path) = create_test_tree();
let mut completer = test_completer();
let line = format!("ls {path}/");
let pos = line.len();
let suggestions = completer.complete(&line, pos);
let values: Vec<&str> = suggestions.iter().map(|s| s.value.as_str()).collect();
assert!(values.contains(&format!("{path}/Documents/").as_str()));
assert!(values.contains(&format!("{path}/readme.txt").as_str()));
}
#[test]
#[serial]
fn complete_tilde_alone_expands_home() {
let mut completer = test_completer();
let line = "cd ~";
let pos = line.len();
let suggestions = completer.complete(line, pos);
assert!(!suggestions.is_empty(), "cd ~ should produce suggestions");
for s in &suggestions {
assert!(
s.value.starts_with("~/"),
"suggestion '{}' should start with ~/",
s.value
);
}
}
#[test]
#[serial]
fn complete_tilde_slash_expands_home() {
let mut completer = test_completer();
let line = "cd ~/";
let pos = line.len();
let suggestions = completer.complete(line, pos);
assert!(!suggestions.is_empty(), "cd ~/ should produce suggestions");
for s in &suggestions {
assert!(
s.value.starts_with("~/"),
"suggestion '{}' should start with ~/",
s.value
);
}
}
#[test]
fn complete_nonexistent_dir_returns_empty() {
let completer = test_completer();
let partial = "/nonexistent_dir_12345/";
let span = Span::new(3, 3 + partial.len());
let suggestions = completer.complete_path(partial, span, false);
assert!(suggestions.is_empty());
}
#[test]
fn token_start_no_space() {
assert_eq!(JarvishCompleter::token_start("ls", 2), 0);
}
#[test]
fn token_start_after_command() {
assert_eq!(JarvishCompleter::token_start("cd /tmp", 7), 3);
}
#[test]
fn is_first_token_true() {
assert!(JarvishCompleter::is_first_token("ls", 2));
}
#[test]
fn is_first_token_false() {
assert!(!JarvishCompleter::is_first_token("cd /tmp", 7));
}
fn create_test_git_repo() -> tempfile::TempDir {
use std::process::Command;
let tmpdir = tempfile::tempdir().unwrap();
let dir = tmpdir.path();
Command::new("git")
.args(["init"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["branch", "test-feature"])
.current_dir(dir)
.output()
.unwrap();
tmpdir
}
#[test]
#[serial]
fn complete_git_branch_returns_candidates() {
let tmpdir = create_test_git_repo();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(tmpdir.path()).unwrap();
let completer = test_completer();
let span = Span::new(0, 0);
let suggestions = completer.complete_git_branch("", span);
env::set_current_dir(&original_dir).unwrap();
let values: Vec<&str> = suggestions.iter().map(|s| s.value.as_str()).collect();
assert!(
values.contains(&"test-feature"),
"test-feature branch should be in suggestions: {values:?}"
);
}
#[test]
#[serial]
fn complete_git_branch_filters_by_prefix() {
let tmpdir = create_test_git_repo();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(tmpdir.path()).unwrap();
let completer = test_completer();
let span = Span::new(0, 5);
let suggestions = completer.complete_git_branch("test-", span);
env::set_current_dir(&original_dir).unwrap();
let values: Vec<&str> = suggestions.iter().map(|s| s.value.as_str()).collect();
assert!(values.contains(&"test-feature"));
for v in &values {
assert!(v.starts_with("test-"), "'{v}' should start with 'test-'");
}
}
#[test]
fn complete_git_branch_nonexistent_prefix_returns_empty() {
let completer = test_completer();
let span = Span::new(0, 0);
let suggestions = completer.complete_git_branch("zzz_no_such_branch_", span);
assert!(suggestions.is_empty());
}
#[test]
#[serial]
fn complete_git_checkout_includes_branches() {
let tmpdir = create_test_git_repo();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(tmpdir.path()).unwrap();
let mut completer = test_completer();
let line = "git checkout test-";
let pos = line.len();
let suggestions = completer.complete(line, pos);
env::set_current_dir(&original_dir).unwrap();
let values: Vec<&str> = suggestions.iter().map(|s| s.value.as_str()).collect();
assert!(
values.contains(&"test-feature"),
"git checkout should suggest 'test-feature': {values:?}"
);
}
#[test]
fn complete_git_non_branch_subcommand_no_branches() {
let mut completer = test_completer();
let line = "git add zzz_no_such_";
let pos = line.len();
let suggestions = completer.complete(line, pos);
assert!(
suggestions.is_empty(),
"git add should not suggest anything for nonexistent prefix: {suggestions:?}"
);
}
}