use std::path::{Path, PathBuf};
pub(super) const MAX_HITS: usize = 6;
fn split(expanded: &str) -> Option<(PathBuf, String)> {
if expanded.ends_with('/') || expanded.ends_with(std::path::MAIN_SEPARATOR) {
return Some((PathBuf::from(expanded), String::new()));
}
let path = Path::new(expanded);
let parent = path.parent()?;
if parent.as_os_str().is_empty() {
return None;
}
let name = path.file_name()?.to_string_lossy().into_owned();
Some((parent.to_path_buf(), name))
}
fn children(parent: &Path, fragment: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(parent) else {
return Vec::new();
};
let wanted = fragment.to_lowercase();
let mut out: Vec<PathBuf> = entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') && !fragment.starts_with('.') {
return None;
}
name.to_lowercase()
.starts_with(&wanted)
.then(|| e.path().to_path_buf())
})
.collect();
out.sort();
out
}
pub(super) fn suggest(typed: &str, known: &[PathBuf]) -> Vec<PathBuf> {
let typed = typed.trim();
let expanded = crate::util::untildify(typed);
match split(&expanded) {
Some((parent, fragment)) => children(&parent, &fragment)
.into_iter()
.take(MAX_HITS)
.collect(),
None => {
let wanted = typed.to_lowercase();
known
.iter()
.filter(|dir| dir.to_string_lossy().to_lowercase().contains(&wanted))
.take(MAX_HITS)
.cloned()
.collect()
}
}
}
pub(super) fn complete(typed: &str, hits: &[PathBuf]) -> Option<String> {
let expanded = crate::util::untildify(typed.trim());
let filled = match hits {
[] => return None,
[one] => {
let mut s = crate::util::tildify(&one.to_string_lossy());
s.push('/');
s
}
many => {
let shared = shared_prefix(many);
if shared.chars().count() <= expanded.chars().count() {
return None;
}
crate::util::tildify(&shared)
}
};
let sep = std::path::MAIN_SEPARATOR;
let filled = match sep != '/' && typed.contains('/') && !typed.contains(sep) {
true => filled.replace(sep, "/"),
false => filled,
};
(filled != typed).then_some(filled)
}
fn shared_prefix(paths: &[PathBuf]) -> String {
let spellings: Vec<String> = paths.iter().map(|p| p.to_string_lossy().into()).collect();
let mut prefix = String::new();
let Some(first) = spellings.first() else {
return prefix;
};
for (i, c) in first.char_indices() {
let upto = i + c.len_utf8();
if spellings
.iter()
.all(|s| s.len() >= upto && s[..upto] == first[..upto])
{
prefix.push(c);
} else {
break;
}
}
prefix
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_typed_path_offers_the_directories_under_it() {
let root = tempfile::tempdir().expect("tempdir");
for name in ["alpha", "album", "beta", ".hidden"] {
std::fs::create_dir(root.path().join(name)).expect("mkdir");
}
std::fs::write(root.path().join("afile"), "").expect("write");
let base = root
.path()
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
let all = suggest(&format!("{base}/"), &[]);
let names: Vec<String> = all
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["album", "alpha", "beta"]);
let hidden = suggest(&format!("{base}/.h"), &[]);
assert_eq!(hidden.len(), 1, "asked for by its dot: {hidden:?}");
let hits = suggest(&format!("{base}/al"), &[]);
assert_eq!(hits.len(), 2);
assert_eq!(
complete(&format!("{base}/a"), &hits),
Some(format!("{base}/al"))
);
let one = suggest(&format!("{base}/be"), &[]);
assert_eq!(
complete(&format!("{base}/be"), &one),
Some(format!("{base}/beta/"))
);
assert_eq!(complete(&format!("{base}/al"), &hits), None);
}
#[test]
fn completing_keeps_the_separator_that_was_typed() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(root.path().join("beta")).expect("mkdir");
let base = root
.path()
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
let hits = suggest(&format!("{base}/be"), &[]);
assert_eq!(hits.len(), 1, "{hits:?}");
let filled = complete(&format!("{base}/be"), &hits).expect("something to add");
assert_eq!(filled, format!("{base}/beta/"));
assert_eq!(complete(&filled, &suggest(&filled, &[])), None);
}
#[test]
fn a_bare_word_matches_the_projects_already_known() {
let known = vec![
PathBuf::from("/home/x/cctop"),
PathBuf::from("/home/x/work/api"),
PathBuf::from("/srv/cctop-fork"),
];
assert_eq!(suggest("", &known), known);
assert_eq!(
suggest("cctop", &known),
vec![
PathBuf::from("/home/x/cctop"),
PathBuf::from("/srv/cctop-fork")
]
);
assert_eq!(suggest("API", &known).len(), 1);
assert!(suggest("nothing-like-it", &known).is_empty());
}
#[test]
fn a_bare_word_never_reads_the_filesystem() {
assert_eq!(split("cctop"), None);
assert!(suggest("src", &[]).is_empty());
}
}