pub struct Glob {
patterns: Vec<Pattern>,
fold: bool,
either: bool,
}
impl Glob {
pub fn new(text: &str) -> Option<Self> {
Self::parse(text, false, &[])
}
pub fn filter(text: &str) -> Option<Self> {
let mut plain = Vec::new();
let widened: Vec<String> = text
.split(';')
.map(|pattern| {
let trimmed = pattern.trim();
let (name, slash) = trimmed.strip_suffix('/').map_or((trimmed, ""), |name| (name, "/"));
if name.is_empty() {
return trimmed.to_string();
}
let wild = name.contains(['*', '?', '[']);
plain.push(!wild);
if wild { trimmed.to_string() } else { format!("*{name}*{slash}") }
})
.collect();
Self::parse(&widened.join(";"), true, &plain)
}
fn parse(text: &str, either: bool, plain: &[bool]) -> Option<Self> {
let fold = !text.chars().any(char::is_uppercase);
let patterns: Vec<Pattern> = text
.split(';')
.map(str::trim)
.filter(|pattern| !pattern.is_empty())
.map(|pattern| match pattern.strip_suffix('/') {
Some(directory) => (directory, true),
None => (pattern, false),
})
.filter(|(pattern, _)| !pattern.is_empty())
.enumerate()
.map(|(index, (pattern, for_dirs))| Pattern {
chars: pattern.chars().map(|c| if std::path::is_separator(c) { '/' } else { fold_if(c, fold) }).collect(),
for_dirs,
whole: pattern.contains(std::path::is_separator) || plain.get(index).copied().unwrap_or(false),
})
.collect();
if patterns.is_empty() { None } else { Some(Glob { patterns, fold, either }) }
}
pub fn matches(&self, name: &str, is_dir: bool) -> bool {
let path: Vec<char> = name.chars().map(|c| if std::path::is_separator(c) { '/' } else { fold_if(c, self.fold) }).collect();
let leaf = &path[path.iter().rposition(|&c| c == '/').map_or(0, |at| at + 1)..];
self.patterns
.iter()
.any(|pattern| (pattern.for_dirs == is_dir || (self.either && !pattern.for_dirs)) && matches(&pattern.chars, if pattern.whole { &path } else { leaf }))
}
}
struct Pattern {
chars: Vec<char>,
for_dirs: bool,
whole: bool,
}
fn fold_if(c: char, fold: bool) -> char {
if fold { crate::find::fold_char(c) } else { c }
}
fn matches(pattern: &[char], name: &[char]) -> bool {
let (mut p, mut n) = (0, 0);
let mut star: Option<(usize, usize)> = None;
while n < name.len() {
if p < pattern.len() {
match pattern[p] {
'*' => {
star = Some((p, n));
p += 1;
continue;
}
'?' => {
p += 1;
n += 1;
continue;
}
'[' => {
if let Some((hit, next)) = class(pattern, p, name[n]) {
if hit {
p = next;
n += 1;
continue;
}
} else if name[n] == '[' {
p += 1;
n += 1;
continue;
}
}
c if c == name[n] => {
p += 1;
n += 1;
continue;
}
_ => {}
}
}
match star {
Some((star_p, star_n)) => {
star = Some((star_p, star_n + 1));
p = star_p + 1;
n = star_n + 1;
}
None => return false,
}
}
pattern[p..].iter().all(|&c| c == '*')
}
fn class(pattern: &[char], open: usize, c: char) -> Option<(bool, usize)> {
let mut index = open + 1;
let negated = matches!(pattern.get(index), Some('!' | '^'));
if negated {
index += 1;
}
let mut hit = false;
let mut first = true;
loop {
let &start = pattern.get(index)?;
if start == ']' && !first {
return Some((hit != negated, index + 1));
}
first = false;
if pattern.get(index + 1) == Some(&'-') && pattern.get(index + 2).is_some_and(|&end| end != ']') {
hit |= (start..=pattern[index + 2]).contains(&c);
index += 3;
} else {
hit |= start == c;
index += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn file(pattern: &str, name: &str) -> bool {
Glob::new(pattern).unwrap().matches(name, false)
}
#[test]
fn stars_and_question_marks() {
assert!(file("*", "anything"));
assert!(file("*", ".hidden"));
assert!(file("*.rs", "main.rs"));
assert!(!file("*.rs", "main.rsx"));
assert!(file("a*b*c", "aXXbYYc"));
assert!(!file("a*b*c", "aXXbYY"));
assert!(file("?.txt", "a.txt"));
assert!(!file("?.txt", "ab.txt"));
assert!(file("**x", "x"));
}
#[test]
fn classes() {
assert!(file("[abc].txt", "b.txt"));
assert!(!file("[abc].txt", "d.txt"));
assert!(file("file[0-9]", "file7"));
assert!(!file("file[!0-9]", "file7"));
assert!(file("file[!0-9]", "fileX"));
assert!(file("[]]", "]"));
assert!(file("[a-]", "-"));
assert!(file("[ab", "[ab"));
}
#[test]
fn smart_case_as_find_does() {
assert!(file("*.jpg", "PHOTO.JPG"));
assert!(file("*.JPG", "PHOTO.JPG"));
assert!(!file("*.JPG", "photo.jpg"));
}
#[test]
fn several_patterns_and_directories_apart() {
let glob = Glob::new("*.jpg; *.png").unwrap();
assert!(glob.matches("a.png", false));
assert!(glob.matches("b.jpg", false));
assert!(!glob.matches("c.gif", false));
let files = Glob::new("*").unwrap();
assert!(!files.matches("src", true));
let dirs = Glob::new("*/").unwrap();
assert!(dirs.matches("src", true));
assert!(!dirs.matches("main.rs", false));
assert!(Glob::new("").is_none());
assert!(Glob::new(" ; /").is_none());
}
#[test]
fn the_filter_looks_anywhere_in_the_name_and_takes_directories_too() {
let plain = Glob::filter("rep").unwrap();
assert!(plain.matches("report.txt", false));
assert!(plain.matches("Prep", false));
assert!(plain.matches("repos", true));
assert!(!plain.matches("main.rs", false));
let wild = Glob::filter("*.rs").unwrap();
assert!(wild.matches("main.rs", false));
assert!(!wild.matches("main.rsx", false));
assert!(wild.matches("crate.rs", true));
let dirs = Glob::filter("src/").unwrap();
assert!(dirs.matches("src", true));
assert!(dirs.matches("old-src", true));
assert!(!dirs.matches("src", false));
let sep = std::path::MAIN_SEPARATOR;
let deep = format!("src{sep}tests{sep}test_one.rs");
assert!(Glob::filter("test_*").unwrap().matches(&deep, false));
assert!(Glob::new("test_*").unwrap().matches(&deep, false));
assert!(Glob::new("[s-u]*.rs").unwrap().matches(&deep, false));
assert!(!Glob::new("s*").unwrap().matches(&deep, false));
assert!(Glob::new("src/*/*.rs").unwrap().matches(&deep, false));
assert!(!Glob::new("tests/*").unwrap().matches(&deep, false));
assert!(Glob::filter("tests").unwrap().matches(&deep, false));
assert!(Glob::filter("tests/test_*").is_some_and(|glob| !glob.matches(&deep, false)));
assert!(Glob::filter("*tests/test_*").unwrap().matches(&deep, false));
let several = Glob::filter("toml; *.md").unwrap();
assert!(several.matches("Cargo.toml", false));
assert!(several.matches("README.md", false));
assert!(Glob::filter(" ; ").is_none());
}
#[test]
fn a_caret_negates_as_an_exclamation_mark_does() {
assert!(file("[^0-9]x", "ax"));
assert!(!file("[^0-9]x", "1x"));
}
#[test]
fn a_question_mark_is_one_character_not_one_byte() {
assert!(file("caf?", "café"));
assert!(file("??", "日本"));
assert!(!file("?", "日本"));
}
#[test]
fn many_stars_against_a_long_name_that_misses_still_ends() {
let name = "a".repeat(10_000);
let started = std::time::Instant::now();
assert!(!file("*a*a*a*a*a*b", &name));
assert!(started.elapsed() < std::time::Duration::from_secs(1), "{:?}", started.elapsed());
}
}