use crate::cst::{Word, WordPart};
const MAX_ENTRIES: usize = 4096;
const MAX_MATCHES: usize = 256;
pub(super) fn expand(word: &Word) -> Option<Vec<String>> {
let [WordPart::Lit(pattern)] = word.0.as_slice() else {
return None;
};
if !pattern.contains(['*', '?', '[']) || pattern.contains(['$', '{', '\\', '~']) || pattern.starts_with('/') {
return None;
}
let cwd = crate::pathctx::cwd().filter(|c| c.starts_with('/') && c != crate::pathctx::UNRESOLVED_CWD)?;
let mut found = vec![String::new()];
for component in pattern.split('/') {
let mut next = Vec::new();
for prefix in &found {
if !component.contains(['*', '?', '[']) {
next.push(join(prefix, component));
continue;
}
let dir = if prefix.is_empty() { cwd.clone() } else { format!("{cwd}/{prefix}") };
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) if prefix.is_empty() => return None,
Err(_) => continue,
};
let mut names = Vec::new();
for entry in entries.take(MAX_ENTRIES + 1) {
names.push(entry.ok()?.file_name().into_string().ok()?);
}
if names.len() > MAX_ENTRIES {
return None;
}
for name in names {
if (!name.starts_with('.') || component.starts_with('.')) && matches(component.as_bytes(), name.as_bytes())? {
next.push(join(prefix, &name));
}
}
}
found = next;
if found.len() > MAX_MATCHES {
return None;
}
}
let mut found: Vec<String> = found.into_iter().filter(|m| std::fs::symlink_metadata(format!("{cwd}/{m}")).is_ok()).collect();
found.sort();
Some(if found.is_empty() { vec![pattern.clone()] } else { found })
}
fn join(prefix: &str, name: &str) -> String {
if prefix.is_empty() { name.to_string() } else { format!("{prefix}/{name}") }
}
fn matches(pattern: &[u8], name: &[u8]) -> Option<bool> {
if pattern.iter().filter(|&&b| b == b'*').count() > 8 {
return None;
}
let Some((&p, rest)) = pattern.split_first() else {
return Some(name.is_empty());
};
match p {
b'*' => {
for skip in 0..=name.len() {
if matches(rest, &name[skip..])? {
return Some(true);
}
}
Some(false)
}
b'?' => Some(!name.is_empty() && matches(rest, &name[1..])?),
b'[' => {
let close = rest.iter().skip(1).position(|&b| b == b']')? + 1;
let set = &rest[..close];
if set.contains(&b'[') {
return None;
}
let (negate, set) = match set.first() {
Some(b'!' | b'^') => (true, &set[1..]),
_ => (false, set),
};
let Some((&c, tail)) = name.split_first() else { return Some(false) };
let mut hit = false;
let mut i = 0;
while i < set.len() {
if i + 2 < set.len() && set[i + 1] == b'-' {
hit |= (set[i]..=set[i + 2]).contains(&c);
i += 3;
} else {
hit |= set[i] == c;
i += 1;
}
}
Some(hit != negate && matches(&rest[close + 1..], tail)?)
}
_ => Some(name.first() == Some(&p) && matches(rest, &name[1..])?),
}
}
#[cfg(test)]
mod tests {
use super::matches;
use proptest::prelude::*;
#[test]
fn matches_as_fnmatch_does() {
let cases: &[(&str, &str, Option<bool>)] = &[
("*.txt", "a.txt", Some(true)),
("*.txt", "a.txt.bak", Some(false)),
("?", "-", Some(true)),
("[ab]*", "b-x", Some(true)),
("[!a]*", "a", Some(false)),
("[a-c]", "b", Some(true)),
("[]]", "]", Some(true)),
("[[:alpha:]]", "a", None),
("[ab", "a", None),
("*", "", Some(true)),
];
for (p, n, want) in cases {
assert_eq!(matches(p.as_bytes(), n.as_bytes()), *want, "{p} vs {n}");
}
}
proptest! {
#[test]
fn literals_match_themselves(name in "[a-z.-]{0,12}", other in "[a-z.-]{0,12}") {
prop_assert_eq!(matches(name.as_bytes(), other.as_bytes()), Some(name == other));
prop_assert_eq!(matches(b"*", name.as_bytes()), Some(true));
let prefixed = format!("{name}*");
prop_assert_eq!(matches(prefixed.as_bytes(), format!("{name}{other}").as_bytes()), Some(true));
}
}
}