use std::fs;
use std::path::{Path, PathBuf};
use crate::config::{Args, COMMANDS, Command};
use super::line_input::LineInput;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CompletionKind {
CommandName,
Path,
}
pub struct CompletionState {
pub kind: CompletionKind,
pub prefix: String,
pub head_chars: usize,
pub matches: Vec<String>,
pub selected: usize,
}
pub struct CommandPrompt {
pub input: LineInput,
pub completion: Option<CompletionState>,
}
impl CommandPrompt {
pub(super) fn new() -> Self {
Self {
input: LineInput::new(),
completion: None,
}
}
pub(super) fn tab(&mut self, step: i32, root: &Path) {
if self.completion.is_none() {
let Some(state) = build_completion(self.input.as_str(), root) else {
return;
};
self.completion = Some(state);
} else if let Some(c) = self.completion.as_mut() {
let len = c.matches.len() as i32;
let next = (c.selected as i32 + step).rem_euclid(len);
c.selected = next as usize;
}
let Some(c) = &self.completion else {
return;
};
let head: String = self.input.as_str().chars().take(c.head_chars).collect();
let new = format!("{}{}", head, c.matches[c.selected]);
let descend = c.kind == CompletionKind::CommandName
&& c.head_chars == 0
&& c.matches.len() == 1
&& Command::find(&c.matches[0]).is_some_and(|cmd| !matches!(cmd.args, Args::None));
self.input = LineInput::new();
for ch in new.chars() {
self.input.insert(ch);
}
if descend {
self.input.insert(' ');
self.completion = None;
}
}
}
fn build_completion(input: &str, root: &Path) -> Option<CompletionState> {
match input.find(' ') {
None => {
let prefix = input.to_string();
let matches: Vec<String> = COMMANDS
.iter()
.flat_map(|c| c.all_names())
.filter(|n| n.starts_with(&prefix))
.map(|n| n.to_string())
.collect();
if matches.is_empty() {
return None;
}
Some(CompletionState {
kind: CompletionKind::CommandName,
prefix,
head_chars: 0,
matches,
selected: 0,
})
}
Some(sp_byte) => {
let cmd = &input[..sp_byte];
let c = Command::find(cmd)?;
let partial = &input[sp_byte + 1..];
if partial.contains(' ') {
return None;
}
match &c.args {
Args::None => None,
Args::Sub(subs) => {
let matches: Vec<String> = subs
.iter()
.flat_map(|s| std::iter::once(s.name).chain(s.aliases.iter().copied()))
.filter(|n| n.starts_with(partial))
.map(|n| n.to_string())
.collect();
if matches.is_empty() {
return None;
}
Some(CompletionState {
kind: CompletionKind::CommandName,
prefix: partial.to_string(),
head_chars: sp_byte + 1,
matches,
selected: 0,
})
}
Args::Path => {
let matches = path_candidates(partial, root);
if matches.is_empty() {
return None;
}
let head_chars = cmd.chars().count() + 1;
Some(CompletionState {
kind: CompletionKind::Path,
prefix: partial.to_string(),
head_chars,
matches,
selected: 0,
})
}
}
}
}
}
fn path_candidates(partial: &str, root: &Path) -> Vec<String> {
let (dir_str, base_prefix) = match partial.rfind('/') {
Some(i) => (&partial[..=i], &partial[i + 1..]),
None => ("", partial),
};
let listing_dir: PathBuf = if dir_str.is_empty() {
root.to_path_buf()
} else {
let p = Path::new(dir_str);
if p.is_absolute() {
p.to_path_buf()
} else {
root.join(p)
}
};
let Ok(rd) = fs::read_dir(&listing_dir) else {
return Vec::new();
};
let mut out: Vec<String> = Vec::new();
for entry in rd.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.starts_with('.') && !base_prefix.starts_with('.') {
continue;
}
if !name.starts_with(base_prefix) {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
let mut s = String::with_capacity(dir_str.len() + name.len() + 1);
s.push_str(dir_str);
s.push_str(name);
if is_dir {
s.push('/');
}
out.push(s);
}
out.sort_by(|a, b| {
let a_dir = a.ends_with('/');
let b_dir = b.ends_with('/');
b_dir.cmp(&a_dir).then_with(|| a.cmp(b))
});
out.truncate(200);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn typed(s: &str) -> CommandPrompt {
let mut cp = CommandPrompt::new();
for ch in s.chars() {
cp.input.insert(ch);
}
cp
}
#[test]
fn unique_subcommand_command_descends_with_space() {
let mut cp = typed("cop");
cp.tab(1, Path::new(""));
assert_eq!(cp.input.as_str(), "copilot ");
assert!(cp.completion.is_none());
cp.tab(1, Path::new(""));
assert_eq!(cp.input.as_str(), "copilot status");
}
#[test]
fn unique_path_command_descends_with_space() {
let mut cp = typed("edit");
cp.tab(1, Path::new(""));
assert_eq!(cp.input.as_str(), "edit ");
assert!(cp.completion.is_none());
}
#[test]
fn ambiguous_prefix_cycles_without_space() {
let mut cp = typed("e");
cp.tab(1, Path::new(""));
assert_eq!(cp.input.as_str(), "e");
assert!(cp.completion.is_some());
cp.tab(1, Path::new(""));
assert_eq!(cp.input.as_str(), "edit");
}
#[test]
fn argless_command_gets_no_space() {
let mut cp = typed("log");
cp.tab(1, Path::new(""));
assert_eq!(cp.input.as_str(), "log");
assert!(cp.completion.is_some());
}
}