use std::ops::Range;
use crate::{ArgKind, HostRegistry, Registry};
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum CompletionKind {
None,
Command,
Path,
Setting,
Buffer,
Register,
Mark,
}
#[derive(Default)]
pub struct ArgSources<'a> {
pub cwd: Option<&'a std::path::Path>,
pub settings: &'a [String],
pub buffers: &'a [String],
pub registers: &'a [String],
pub marks: &'a [String],
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Completions {
pub replace_range: Range<usize>,
pub candidates: Vec<String>,
pub kind: CompletionKind,
}
impl Completions {
pub fn empty(caret: usize) -> Self {
Self {
replace_range: caret..caret,
candidates: Vec::new(),
kind: CompletionKind::None,
}
}
}
pub fn longest_common_prefix(candidates: &[String]) -> String {
if candidates.is_empty() {
return String::new();
}
let first = &candidates[0];
let mut end = first.len();
for s in &candidates[1..] {
end = end.min(s.len());
end = first
.as_bytes()
.iter()
.zip(s.as_bytes().iter())
.take(end)
.take_while(|(a, b)| a == b)
.count();
if end == 0 {
return String::new();
}
}
first[..end].to_string()
}
pub fn complete_command_from_names(line: &str, caret: usize, available: &[String]) -> Completions {
let caret = caret.min(line.len());
if line.is_empty() {
return Completions {
replace_range: 0..0,
candidates: available.to_vec(),
kind: CompletionKind::Command,
};
}
let alpha_end = line
.char_indices()
.find(|(_, c)| !c.is_ascii_alphabetic())
.map(|(i, _)| i)
.unwrap_or(line.len());
let token_end = if line.as_bytes().get(alpha_end) == Some(&b'!') {
alpha_end + 1
} else {
alpha_end
};
if caret > token_end {
return Completions::empty(caret);
}
let prefix = &line[..caret];
let mut candidates: Vec<String> = available
.iter()
.filter(|n| n.starts_with(prefix))
.cloned()
.collect();
candidates.sort();
candidates.dedup();
Completions {
replace_range: 0..token_end,
candidates,
kind: CompletionKind::Command,
}
}
pub fn collect_registry_names<H: hjkl_engine::Host>(reg: &Registry<H>) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for cmd in reg.iter() {
names.push(cmd.name.to_string());
names.extend(cmd.aliases.iter().map(|a| a.to_string()));
}
names
}
pub fn collect_host_registry_names<Ctx>(reg: &HostRegistry<Ctx>) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for cmd in reg.iter() {
names.push(cmd.name().to_string());
names.extend(cmd.aliases().iter().map(|a| a.to_string()));
}
names
}
pub fn first_word_end(line: &str) -> (usize, bool) {
let alpha_end = line
.char_indices()
.find(|(_, c)| !c.is_ascii_alphabetic())
.map(|(i, _)| i)
.unwrap_or(line.len());
let token_end = if line.as_bytes().get(alpha_end) == Some(&b'!') {
alpha_end + 1
} else {
alpha_end
};
let has_space = line.as_bytes().get(token_end) == Some(&b' ');
(token_end, has_space)
}
fn complete_path_entries(prefix: &str, cwd: &std::path::Path) -> Vec<String> {
let (dir_part, file_part) = match prefix.rfind('/') {
Some(idx) => (&prefix[..=idx], &prefix[idx + 1..]),
None => ("", prefix),
};
let scan_dir = if dir_part.is_empty() {
cwd.to_path_buf()
} else if std::path::Path::new(dir_part).is_absolute() {
std::path::PathBuf::from(dir_part)
} else {
cwd.join(dir_part)
};
let rd = match std::fs::read_dir(&scan_dir) {
Ok(rd) => rd,
Err(_) => return Vec::new(),
};
let show_hidden = file_part.starts_with('.');
let mut results: Vec<String> = rd
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name();
let name_str = name.to_str()?.to_string();
if !show_hidden && name_str.starts_with('.') {
return None;
}
if !name_str.starts_with(file_part) {
return None;
}
let suffix = if e.file_type().ok()?.is_dir() {
"/"
} else {
""
};
Some(format!("{dir_part}{name_str}{suffix}"))
})
.collect();
results.sort();
results
}
pub fn complete_arg(
line: &str,
caret: usize,
arg_kind: ArgKind,
sources: &ArgSources<'_>,
) -> Completions {
let caret = caret.min(line.len());
let (cmd_end, has_space) = first_word_end(line);
let arg_start = if has_space { cmd_end + 1 } else { cmd_end };
if caret <= cmd_end || !has_space {
return Completions::empty(caret);
}
let slice = &line[arg_start..caret];
let token_offset = slice
.rfind(|c: char| c.is_whitespace())
.map(|i| i + 1)
.unwrap_or(0);
let token_start = arg_start + token_offset;
let prefix = &line[token_start..caret];
let (candidates, kind) = match arg_kind {
ArgKind::None | ArgKind::Raw => return Completions::empty(caret),
ArgKind::Path => {
let cwd = match sources.cwd {
Some(p) => p,
None => return Completions::empty(caret),
};
(complete_path_entries(prefix, cwd), CompletionKind::Path)
}
ArgKind::Setting => {
let mut c: Vec<String> = sources
.settings
.iter()
.filter(|s| s.starts_with(prefix))
.cloned()
.collect();
c.sort();
c.dedup();
(c, CompletionKind::Setting)
}
ArgKind::Buffer => {
let mut c: Vec<String> = sources
.buffers
.iter()
.filter(|s| s.starts_with(prefix))
.cloned()
.collect();
c.sort();
c.dedup();
(c, CompletionKind::Buffer)
}
ArgKind::Register => {
let mut c: Vec<String> = sources
.registers
.iter()
.filter(|s| s.starts_with(prefix))
.cloned()
.collect();
c.sort();
c.dedup();
(c, CompletionKind::Register)
}
ArgKind::Mark => {
let mut c: Vec<String> = sources
.marks
.iter()
.filter(|s| s.starts_with(prefix))
.cloned()
.collect();
c.sort();
c.dedup();
(c, CompletionKind::Mark)
}
};
Completions {
replace_range: token_start..caret,
candidates,
kind,
}
}
pub fn complete<H, Ctx>(
line: &str,
caret: usize,
editor_reg: &Registry<H>,
host_reg: &HostRegistry<Ctx>,
sources: &ArgSources<'_>,
) -> Completions
where
H: hjkl_engine::Host,
{
let (cmd_token_end, has_arg_space) = first_word_end(line);
let caret = caret.min(line.len());
if caret <= cmd_token_end {
let mut names = collect_host_registry_names(host_reg);
names.extend(collect_registry_names(editor_reg));
names.sort();
names.dedup();
return complete_command_from_names(line, caret, &names);
}
if !has_arg_space {
return Completions::empty(caret);
}
let cmd_name = &line[..cmd_token_end];
let arg_kind = host_reg
.resolve(cmd_name)
.map(|c| c.arg_kind())
.or_else(|| editor_reg.resolve(cmd_name).map(|c| c.arg_kind))
.unwrap_or(ArgKind::None);
complete_arg(line, caret, arg_kind, sources)
}
#[cfg(test)]
mod tests {
use super::*;
fn names(s: &[&str]) -> Vec<String> {
s.iter().map(|s| s.to_string()).collect()
}
#[test]
fn complete_empty_line_returns_all_names() {
let available = names(&["quit", "write"]);
let result = complete_command_from_names("", 0, &available);
assert_eq!(result.kind, CompletionKind::Command);
assert_eq!(result.replace_range, 0..0);
assert!(result.candidates.contains(&"quit".to_string()));
assert!(result.candidates.contains(&"write".to_string()));
}
#[test]
fn complete_q_returns_quit() {
let available = names(&["quit", "write"]);
let result = complete_command_from_names("q", 1, &available);
assert_eq!(result.kind, CompletionKind::Command);
assert_eq!(result.replace_range, 0..1);
assert_eq!(result.candidates, vec!["quit".to_string()]);
}
#[test]
fn complete_w_returns_two_names() {
let available = names(&["wall", "write"]);
let result = complete_command_from_names("w", 1, &available);
assert_eq!(result.kind, CompletionKind::Command);
assert_eq!(result.replace_range, 0..1);
assert_eq!(
result.candidates,
vec!["wall".to_string(), "write".to_string()]
);
}
#[test]
fn complete_caret_past_alpha_returns_none() {
let available = names(&["quit", "write"]);
let result = complete_command_from_names("q ", 2, &available);
assert_eq!(result.kind, CompletionKind::None);
assert!(result.candidates.is_empty());
}
#[test]
fn complete_dedup_aliases() {
let available = names(&["quit", "quit", "write"]);
let result = complete_command_from_names("q", 1, &available);
assert_eq!(result.candidates, vec!["quit".to_string()]);
}
#[test]
fn complete_with_bang() {
let available = names(&["quit", "quit!", "qall"]);
let result = complete_command_from_names("q", 1, &available);
assert_eq!(result.kind, CompletionKind::Command);
assert!(result.candidates.contains(&"quit".to_string()));
assert!(result.candidates.contains(&"quit!".to_string()));
assert!(result.candidates.contains(&"qall".to_string()));
}
#[test]
fn lcp_empty() {
assert_eq!(longest_common_prefix(&[]), "");
}
#[test]
fn lcp_single() {
assert_eq!(longest_common_prefix(&["quit".to_string()]), "quit");
}
#[test]
fn lcp_common() {
let candidates = names(&["wall", "write", "wq"]);
assert_eq!(longest_common_prefix(&candidates), "w");
}
#[test]
fn lcp_no_common() {
let candidates = names(&["a", "b"]);
assert_eq!(longest_common_prefix(&candidates), "");
}
fn str_vec(s: &[&str]) -> Vec<String> {
s.iter().map(|s| s.to_string()).collect()
}
#[test]
fn arg_position_detection_with_cwd() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("foo.txt"), b"x").unwrap();
let sources = ArgSources {
cwd: Some(tmp.path()),
..Default::default()
};
let result = complete_arg("e ", 2, ArgKind::Path, &sources);
assert_eq!(result.kind, CompletionKind::Path);
assert!(!result.candidates.is_empty());
assert!(result.candidates.iter().any(|c| c.contains("foo.txt")));
}
#[test]
fn complete_set_filters_settings() {
let settings = str_vec(&["number", "numberwidth", "nu", "noic", "relativenumber"]);
let sources = ArgSources {
settings: &settings,
..Default::default()
};
let result = complete_arg("set ", 4, ArgKind::Setting, &sources);
assert_eq!(result.kind, CompletionKind::Setting);
assert!(result.candidates.contains(&"number".to_string()));
assert!(result.candidates.contains(&"numberwidth".to_string()));
assert!(result.candidates.contains(&"nu".to_string()));
let result2 = complete_arg("set nu", 6, ArgKind::Setting, &sources);
assert_eq!(result2.kind, CompletionKind::Setting);
assert!(result2.candidates.contains(&"number".to_string()));
assert!(result2.candidates.contains(&"numberwidth".to_string()));
assert!(result2.candidates.contains(&"nu".to_string()));
assert!(!result2.candidates.contains(&"noic".to_string()));
assert!(!result2.candidates.contains(&"relativenumber".to_string()));
}
#[test]
fn complete_buffer_filters_buffers() {
let buffers = str_vec(&["src/main.rs", "src/lib.rs", "tests/foo.rs"]);
let sources = ArgSources {
buffers: &buffers,
..Default::default()
};
let result = complete_arg("b ", 2, ArgKind::Buffer, &sources);
assert_eq!(result.kind, CompletionKind::Buffer);
assert!(result.candidates.contains(&"src/main.rs".to_string()));
assert!(result.candidates.contains(&"src/lib.rs".to_string()));
assert!(result.candidates.contains(&"tests/foo.rs".to_string()));
let result2 = complete_arg("b src", 5, ArgKind::Buffer, &sources);
assert_eq!(result2.kind, CompletionKind::Buffer);
assert!(result2.candidates.contains(&"src/main.rs".to_string()));
assert!(result2.candidates.contains(&"src/lib.rs".to_string()));
assert!(!result2.candidates.contains(&"tests/foo.rs".to_string()));
}
#[test]
fn complete_register_filters() {
let regs = str_vec(&["\"\"", "\"0", "\"a", "\"b"]);
let sources = ArgSources {
registers: ®s,
..Default::default()
};
let result = complete_arg("reg ", 4, ArgKind::Register, &sources);
assert_eq!(result.kind, CompletionKind::Register);
assert!(result.candidates.contains(&"\"a".to_string()));
let result2 = complete_arg("reg \"a", 6, ArgKind::Register, &sources);
assert!(result2.candidates.contains(&"\"a".to_string()));
assert!(!result2.candidates.contains(&"\"b".to_string()));
}
#[test]
fn complete_mark_filters() {
let marks = str_vec(&["a", "b", "c"]);
let sources = ArgSources {
marks: &marks,
..Default::default()
};
let result = complete_arg("marks ", 6, ArgKind::Mark, &sources);
assert_eq!(result.kind, CompletionKind::Mark);
assert_eq!(result.candidates.len(), 3);
let result2 = complete_arg("marks a", 7, ArgKind::Mark, &sources);
assert_eq!(result2.candidates, vec!["a".to_string()]);
}
#[test]
fn complete_path_skips_hidden_unless_dot() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".hidden"), b"x").unwrap();
std::fs::write(tmp.path().join("visible.txt"), b"x").unwrap();
let sources = ArgSources {
cwd: Some(tmp.path()),
..Default::default()
};
let result = complete_arg("e ", 2, ArgKind::Path, &sources);
assert!(result.candidates.iter().all(|c| !c.starts_with(".hidden")));
assert!(result.candidates.iter().any(|c| c.contains("visible.txt")));
let result2 = complete_arg("e .", 3, ArgKind::Path, &sources);
assert!(result2.candidates.iter().any(|c| c.contains(".hidden")));
}
#[test]
fn complete_in_command_position_falls_back_to_command() {
use crate::{ExCommand, Registry};
use hjkl_engine::DefaultHost;
fn noop(
_: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, DefaultHost>,
_: &str,
_: Option<crate::range::LineRange>,
) -> Option<crate::effect::ExEffect> {
None
}
let mut reg = Registry::<DefaultHost>::new();
reg.add(ExCommand {
name: "edit",
aliases: &["e"],
arg_kind: ArgKind::Path,
min_prefix: 1,
run: noop,
});
let host_reg = HostRegistry::<()>::new();
let sources = ArgSources::default();
let result = complete("e", 1, ®, &host_reg, &sources);
assert_eq!(result.kind, CompletionKind::Command);
}
#[test]
fn complete_unknown_command_returns_none_kind() {
use crate::Registry;
use hjkl_engine::DefaultHost;
let reg = Registry::<DefaultHost>::new();
let host_reg = HostRegistry::<()>::new();
let sources = ArgSources::default();
let result = complete("xxx ", 4, ®, &host_reg, &sources);
assert_eq!(result.kind, CompletionKind::None);
assert!(result.candidates.is_empty());
}
}