use std::path::PathBuf;
#[derive(Debug, Clone, Default)]
pub struct Completions {
start: usize,
candidates: Vec<String>,
}
impl Completions {
#[must_use]
pub fn new(start: usize) -> Self {
Completions {
start,
candidates: Vec::new(),
}
}
#[must_use]
pub fn start(&self) -> usize {
self.start
}
pub fn add(&mut self, candidate: impl Into<String>) {
self.candidates.push(candidate.into());
}
#[must_use]
pub fn candidates(&self) -> &[String] {
&self.candidates
}
#[must_use]
pub fn len(&self) -> usize {
self.candidates.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.candidates.is_empty()
}
pub fn sort_dedup(&mut self) {
self.candidates.sort();
self.candidates.dedup();
}
#[must_use]
pub fn common_prefix_len(&self) -> usize {
let Some((first, rest)) = self.candidates.split_first() else {
return 0;
};
let mut len = first.len();
for other in rest {
len = len.min(shared_prefix_len(first, other));
if len == 0 {
break;
}
}
len
}
}
fn shared_prefix_len(a: &str, b: &str) -> usize {
let mut len = 0;
for (x, y) in a.chars().zip(b.chars()) {
if x != y {
break;
}
len += x.len_utf8();
}
len
}
pub trait CompletionProvider {
fn complete(&self, line: &str, pos: usize) -> Completions;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoCompleter;
impl CompletionProvider for NoCompleter {
fn complete(&self, _line: &str, pos: usize) -> Completions {
Completions::new(pos)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct FileCompleter;
impl FileCompleter {
#[must_use]
pub fn new() -> Self {
FileCompleter
}
}
#[must_use]
pub fn word_start(line: &str, pos: usize) -> usize {
line[..pos]
.char_indices()
.rev()
.find(|(_, c)| c.is_whitespace())
.map_or(0, |(i, c)| i + c.len_utf8())
}
fn split_path_word(word: &str) -> (&str, &str) {
match word.rfind('/') {
Some(i) => word.split_at(i + 1),
None => ("", word),
}
}
fn expand_tilde(dir: &str) -> Option<PathBuf> {
let rest = dir.strip_prefix('~')?;
if !rest.is_empty() && !rest.starts_with('/') {
return None;
}
let home = crate::home_dir()?;
Some(home.join(rest.trim_start_matches('/')))
}
impl CompletionProvider for FileCompleter {
fn complete(&self, line: &str, pos: usize) -> Completions {
let start = word_start(line, pos);
let mut completions = Completions::new(start);
let word = &line[start..pos];
let (dir_part, prefix) = split_path_word(word);
let read_from: PathBuf = if dir_part.is_empty() {
PathBuf::from(".")
} else if let Some(expanded) = expand_tilde(dir_part) {
expanded
} else {
PathBuf::from(dir_part)
};
let Ok(entries) = std::fs::read_dir(&read_from) else {
return completions;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with(prefix) {
continue;
}
let mut candidate = format!("{dir_part}{name}");
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
candidate.push('/');
}
completions.add(candidate);
}
completions.sort_dedup();
completions
}
}
pub struct CommandCompleter<P: CompletionProvider> {
commands: Vec<String>,
rest: P,
}
impl<P: CompletionProvider> CommandCompleter<P> {
pub fn new(commands: impl IntoIterator<Item = impl Into<String>>, rest: P) -> Self {
let mut commands: Vec<String> = commands.into_iter().map(Into::into).collect();
commands.sort();
CommandCompleter { commands, rest }
}
}
impl<P: CompletionProvider> CompletionProvider for CommandCompleter<P> {
fn complete(&self, line: &str, pos: usize) -> Completions {
let start = word_start(line, pos);
if !line[..start].trim().is_empty() {
return self.rest.complete(line, pos);
}
let word = &line[start..pos];
let mut completions = Completions::new(start);
for command in &self.commands {
if command.starts_with(word) {
completions.add(command.clone());
}
}
completions
}
}
#[must_use]
pub fn format_columns(candidates: &[String], width: usize) -> Vec<String> {
if candidates.is_empty() {
return Vec::new();
}
let widest = candidates
.iter()
.map(|c| crate::unicode::display_width(c))
.max()
.unwrap_or(1);
let column = widest + 2;
let columns = (width / column).max(1);
let rows = candidates.len().div_ceil(columns);
let mut lines = Vec::with_capacity(rows);
for row in 0..rows {
let mut line = String::new();
for col in 0..columns {
let Some(candidate) = candidates.get(col * rows + row) else {
continue;
};
line.push_str(candidate);
if col + 1 < columns && col * rows + row + rows < candidates.len() {
let pad = column - crate::unicode::display_width(candidate);
line.extend(std::iter::repeat_n(' ', pad));
}
}
lines.push(line.trim_end().to_string());
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn word_start_stops_at_whitespace_only() {
assert_eq!(word_start("cat src/buf", 11), 4);
assert_eq!(word_start("cat", 3), 0);
assert_eq!(word_start("", 0), 0);
assert_eq!(word_start("a b", 4), 3);
assert_eq!(word_start("échó xy", 9), 7);
}
#[test]
fn split_path_word_keeps_the_directory() {
assert_eq!(split_path_word("src/buf"), ("src/", "buf"));
assert_eq!(split_path_word("buf"), ("", "buf"));
assert_eq!(split_path_word("/etc/pa"), ("/etc/", "pa"));
assert_eq!(split_path_word("src/"), ("src/", ""));
}
#[test]
fn common_prefix_is_a_char_boundary() {
let mut c = Completions::new(0);
c.add("日本.txt");
c.add("日月.txt");
let len = c.common_prefix_len();
assert_eq!(len, "æ—¥".len());
assert!(c.candidates()[0].is_char_boundary(len));
let mut c = Completions::new(0);
c.add("abc");
c.add("abd");
assert_eq!(c.common_prefix_len(), 2);
let mut c = Completions::new(0);
c.add("only");
assert_eq!(c.common_prefix_len(), 4);
let c = Completions::new(0);
assert_eq!(c.common_prefix_len(), 0);
let mut c = Completions::new(0);
c.add("abc");
c.add("xyz");
assert_eq!(c.common_prefix_len(), 0);
}
#[test]
fn file_completer_reads_the_right_directory() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/buffer.rs"), "").unwrap();
std::fs::write(dir.path().join("src/butter.rs"), "").unwrap();
std::fs::write(dir.path().join("decoy.rs"), "").unwrap();
let base = dir.path().to_str().unwrap();
let line = format!("cat {base}/src/buf");
let c = FileCompleter.complete(&line, line.len());
assert_eq!(c.candidates().len(), 1);
assert!(c.candidates()[0].ends_with("src/buffer.rs"));
assert_eq!(c.start(), 4);
assert_eq!(&line[c.start()..], format!("{base}/src/buf"));
}
#[test]
fn file_completer_marks_directories() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("bin")).unwrap();
let base = dir.path().to_str().unwrap();
let line = format!("cat {base}/bi");
let c = FileCompleter.complete(&line, line.len());
assert_eq!(c.candidates().len(), 1);
assert!(c.candidates()[0].ends_with("bin/"));
}
#[test]
fn file_completer_on_unreadable_directory_is_empty() {
let c = FileCompleter.complete("cat /nonexistent-xyz/f", 22);
assert!(c.is_empty());
}
#[test]
fn command_completer_only_fires_in_first_position() {
let completer = CommandCompleter::new(["help", "history", "exit"], NoCompleter);
let c = completer.complete("hi", 2);
assert_eq!(c.candidates(), ["history"]);
assert_eq!(c.start(), 0);
let c = completer.complete("h", 1);
assert_eq!(c.candidates(), ["help", "history"]);
let c = completer.complete("echo h", 6);
assert!(c.is_empty());
}
#[test]
fn columns_fit_the_width() {
let items: Vec<String> = (0..6).map(|i| format!("item{i}")).collect();
let lines = format_columns(&items, 40);
assert!(!lines.is_empty());
for line in &lines {
assert!(crate::unicode::display_width(line) <= 40);
}
let joined = lines.join(" ");
for item in &items {
assert_eq!(joined.matches(item.as_str()).count(), 1);
}
}
#[test]
fn columns_handle_a_narrow_terminal() {
let items = vec!["a-very-long-candidate-name".to_string(), "b".to_string()];
let lines = format_columns(&items, 10);
assert_eq!(lines.len(), 2);
}
}