use globset::{GlobBuilder, GlobMatcher};
use crate::paths;
use crate::realpath::PathResolver;
pub const MAX_MATCHES: usize = 256;
pub const MAX_LISTINGS: usize = 1024;
pub const MAX_GLOBSTAR_DEPTH: usize = 8;
pub(crate) struct Expansion<'r> {
paths: &'r dyn PathResolver,
matches: usize,
listings: usize,
exhausted: bool,
dot_dirs: bool,
overflow: Option<String>,
}
impl<'r> Expansion<'r> {
pub(crate) fn new(paths: &'r dyn PathResolver) -> Self {
Self {
paths,
matches: 0,
listings: 0,
exhausted: false,
dot_dirs: true,
overflow: None,
}
}
pub(crate) fn paths(&mut self, pattern: &str, dot_dirs: bool) -> Vec<String> {
self.dot_dirs = dot_dirs;
let (root, rest) = paths::split_root(pattern);
let components: Vec<&str> = rest.split('/').filter(|c| !c.is_empty()).collect();
let mut out = Vec::new();
self.walk(root, &components, 0, &mut out);
if self.exhausted && self.overflow.is_none() {
self.overflow = Some(format!(
"glob `{pattern}` names more than can be checked \
({MAX_MATCHES} paths, {MAX_LISTINGS} directories, {MAX_GLOBSTAR_DEPTH} levels of `**`)"
));
}
out
}
pub(crate) fn overflow(self) -> Option<String> {
self.overflow
}
fn walk(&mut self, dir: String, components: &[&str], depth: usize, out: &mut Vec<String>) {
if self.exhausted {
return;
}
let Some((first, rest)) = components.split_first() else {
self.matches += 1;
self.exhausted = self.matches > MAX_MATCHES;
out.push(dir);
return;
};
if *first == "**" {
let mut children = self.list(&dir);
if depth == 0 || !children.is_empty() {
self.walk(dir.clone(), rest, depth, out);
}
children.retain(|name| !name.starts_with('.'));
if depth == MAX_GLOBSTAR_DEPTH && !children.is_empty() {
self.exhausted = true;
return;
}
for name in children {
self.walk(join(&dir, &name), components, depth + 1, out);
}
return;
}
if !first.contains(['*', '?', '[']) {
return self.walk(join(&dir, first), rest, depth, out);
}
let Some(glob) = component_glob(first) else {
self.exhausted = true;
self.overflow.get_or_insert_with(|| {
format!("glob component `{first}` cannot be matched the way the shell does")
});
return;
};
if first.starts_with('.') && (self.dot_dirs || !rest.is_empty()) {
let mut named: Vec<String> = Vec::new();
for special in [".", ".."].into_iter().filter(|s| glob.is_match(s)) {
let path = paths::collapse(&join(&dir, special));
if !named.contains(&path) {
named.push(path.clone());
self.walk(path, rest, depth, out);
}
}
}
for name in self.list(&dir) {
let visible = first.starts_with('.') || !name.starts_with('.');
if name == *first || (visible && glob.is_match(&name)) {
self.walk(join(&dir, &name), rest, depth, out);
}
}
}
fn list(&mut self, dir: &str) -> Vec<String> {
self.listings += 1;
if self.listings > MAX_LISTINGS {
self.exhausted = true;
return Vec::new();
}
self.paths.read_dir(dir)
}
}
fn component_glob(component: &str) -> Option<GlobMatcher> {
GlobBuilder::new(&widen_classes(&component.replace("[^", "[!")))
.literal_separator(true)
.backslash_escape(false)
.build()
.ok()
.map(|g| g.compile_matcher())
}
fn widen_classes(component: &str) -> String {
let chars: Vec<char> = component.chars().collect();
let mut out = String::with_capacity(component.len());
let mut i = 0;
while let Some(&c) = chars.get(i) {
if c == '['
&& let Some((end, special)) = bracket_end(&chars, i)
{
if special {
out.push('?');
} else {
out.extend(&chars[i..=end]);
}
i = end + 1;
continue;
}
out.push(c);
i += 1;
}
out
}
fn bracket_end(chars: &[char], start: usize) -> Option<(usize, bool)> {
let mut i = start + 1;
if chars.get(i) == Some(&'!') {
i += 1;
}
if chars.get(i) == Some(&']') {
i += 1;
}
let mut special = false;
while let Some(&c) = chars.get(i) {
match c {
']' => return Some((i, special)),
'[' if matches!(chars.get(i + 1), Some(':' | '=' | '.')) => {
let delim = chars.get(i + 1);
let close = (i + 2..chars.len())
.find(|&j| chars.get(j) == delim && chars.get(j + 1) == Some(&']'))?;
special = true;
i = close + 2;
}
_ => i += 1,
}
}
None
}
fn join(dir: &str, name: &str) -> String {
if dir.ends_with('/') {
format!("{dir}{name}")
} else {
format!("{dir}/{name}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::programs::NoResolver;
use crate::realpath::MapPathResolver;
fn files(paths: &[&str]) -> MapPathResolver {
MapPathResolver {
files: paths.iter().map(|p| (*p).to_owned()).collect(),
..MapPathResolver::default()
}
}
fn expand(resolver: &MapPathResolver, pattern: &str) -> Vec<String> {
let mut paths = Expansion::new(resolver).paths(pattern, true);
paths.sort();
paths
}
#[test]
fn patterns_name_what_the_shell_would_pass() {
let fs = files(&[
"/p/.env",
"/p/.envrc",
"/p/README.md",
"/p/src/main.rs",
"/p/src/lib.rs",
"/p/src/deep/mod.rs",
"/p/.git/config",
"/h/.ssh/id_rsa",
]);
for (pattern, expected) in [
("/p/.en?", &["/p/.env"][..]),
("/p/.e*", &["/p/.env", "/p/.envrc"]),
("/p/.[e]nv", &["/p/.env"]),
("/p/.[!x]nv", &["/p/.env"]),
("/p/.[^x]nv", &["/p/.env"]),
("/p/*", &["/p/README.md", "/p/src"]),
("/p/src/*.rs", &["/p/src/lib.rs", "/p/src/main.rs"]),
("/p/?rc/m*", &["/p/src/main.rs"]),
("/h/.ss?/id_rsa", &["/h/.ssh/id_rsa"]),
("/h/.ss?/authorized_keys", &["/h/.ssh/authorized_keys"]),
("/p/*.txt", &[]),
] {
assert_eq!(expand(&fs, pattern), expected, "{pattern}");
}
assert_eq!(
expand(&fs, "/p/**/*.rs"),
["/p/src/deep/mod.rs", "/p/src/lib.rs", "/p/src/main.rs"]
);
assert_eq!(
expand(&fs, "/p/**/.env"),
["/p/.env", "/p/src/.env", "/p/src/deep/.env"]
);
}
#[test]
fn a_leading_dot_pattern_names_the_directory_and_its_parent() {
let fs = files(&["/h/.ssh/keys/.old", "/h/.ssh/keys/a"]);
assert_eq!(
expand(&fs, "/h/.ssh/keys/.*"),
["/h/.ssh", "/h/.ssh/keys", "/h/.ssh/keys/.old"]
);
assert_eq!(expand(&fs, "/h/.ssh/keys/.?"), ["/h/.ssh"]);
assert_eq!(expand(&fs, "/.*"), ["/"]);
assert_eq!(expand(&fs, "/h/.ssh/keys/.o*"), ["/h/.ssh/keys/.old"]);
assert_eq!(expand(&fs, "/h/.ssh/keys/*"), ["/h/.ssh/keys/a"]);
let mut e = Expansion::new(&fs);
assert_eq!(e.paths("/h/.ssh/keys/.*", false), ["/h/.ssh/keys/.old"]);
let mut e = Expansion::new(&fs);
assert_eq!(e.paths("/h/.ssh/keys/.?/x", false), ["/h/.ssh/x"]);
}
#[test]
fn a_literal_name_under_a_glob_root_is_kept() {
let fs = files(&["/w/a[1]/x.rs", "/w/a1/y.rs"]);
assert_eq!(expand(&fs, "/w/a[1]/*.rs"), ["/w/a1/y.rs", "/w/a[1]/x.rs"]);
}
#[test]
fn a_component_that_does_not_compile_is_not_expanded() {
let fs = files(&["/p/.e[x", "/p/.env"]);
for pattern in ["/p/.e[*", "/p/.e[", "/p/[z-a]*"] {
let mut e = Expansion::new(&fs);
assert!(e.paths(pattern, true).is_empty(), "{pattern}");
assert!(
e.overflow()
.is_some_and(|r| r.contains("cannot be matched")),
"{pattern}"
);
}
}
#[test]
fn posix_classes_name_what_the_shell_opens() {
let fs = files(&["/p/.env", "/h/.ssh/id_rsa"]);
for pattern in [
"/p/.e[[:alpha:]]v",
"/p/.[[:lower:]]nv",
"/p/.en[![:digit:]]",
"/p/.e[[=n=]]v",
"/p/.e[[.n.]]v",
"/p/.e[x[:alnum:]]v",
] {
assert_eq!(expand(&fs, pattern), ["/p/.env"], "{pattern}");
}
assert_eq!(expand(&fs, "/h/.s[[:alpha:]]h/id_rsa"), ["/h/.ssh/id_rsa"]);
assert_eq!(widen_classes("a[]x]b[!c]"), "a[]x]b[!c]");
assert_eq!(widen_classes("a[[:alpha:]"), "a[[:alpha:]");
}
#[test]
fn nothing_listed_names_nothing() {
let mut e = Expansion::new(&NoResolver);
assert!(e.paths("/p/.en?", true).is_empty());
assert_eq!(e.overflow(), None);
}
#[test]
fn bounds_stop_the_expansion_and_say_so() {
let many: Vec<String> = (0..=MAX_MATCHES).map(|n| format!("/p/f{n}")).collect();
let fs = MapPathResolver {
files: many,
..MapPathResolver::default()
};
let mut e = Expansion::new(&fs);
assert_eq!(e.paths("/p/*", true).len(), MAX_MATCHES + 1);
assert!(e.overflow().is_some_and(|r| r.contains("`/p/*`")));
let deep = format!("/p/{}x", "d/".repeat(MAX_GLOBSTAR_DEPTH + 1));
let fs = files(&[&deep]);
let mut e = Expansion::new(&fs);
e.paths("/p/**/x", true);
assert!(e.overflow().is_some());
}
}