use std::sync::OnceLock;
use globset::{Glob, GlobMatcher};
#[derive(Debug, Clone)]
pub(super) struct LazyGlob {
glob: Glob,
prefix: String,
case_insensitive: bool,
matcher: OnceLock<GlobMatcher>,
}
pub(super) fn literal_start(glob: &str) -> &str {
let end = glob
.find(|c: char| !c.is_ascii() || matches!(c, '*' | '?' | '[' | '{' | '\\'))
.unwrap_or(glob.len());
&glob[..end]
}
impl LazyGlob {
pub(super) fn new(glob: Glob, case_insensitive: bool) -> Self {
let prefix = literal_start(glob.glob()).to_owned();
Self {
glob,
prefix,
case_insensitive,
matcher: OnceLock::new(),
}
}
pub(super) fn is_match(&self, candidate: &str) -> bool {
self.may_match(candidate) && self.regex_match(candidate)
}
fn regex_match(&self, candidate: &str) -> bool {
self.matcher
.get_or_init(|| self.glob.compile_matcher())
.is_match(candidate)
}
fn may_match(&self, candidate: &str) -> bool {
let mut chars = candidate.chars();
for want in self.prefix.chars() {
let Some(c) = chars.next() else {
return false;
};
if !c.is_ascii() || c == '\\' {
return true;
}
if c != want && !(self.case_insensitive && c.eq_ignore_ascii_case(&want)) {
return false;
}
}
true
}
}
#[cfg(test)]
mod tests {
use globset::GlobBuilder;
use super::LazyGlob;
fn glob(pattern: &str, case_insensitive: bool) -> LazyGlob {
let built = GlobBuilder::new(pattern)
.literal_separator(true)
.case_insensitive(case_insensitive)
.build()
.unwrap();
LazyGlob::new(built, case_insensitive)
}
#[test]
fn the_prefix_check_agrees_with_the_regex() {
let patterns = [
"/Users/me/.ssh/**",
"/Users/me/.ssh",
"/Users/me/.hermes/{auth.json,mcp-tokens,mcp-tokens/**,profiles/*/auth.json}",
"/Users/me/.local/share/opencode/{auth.json,opencode*.db*}",
"/Users/me/.kube/**",
"/Users/me/.aws/credentials",
"**/.env",
"**/.env.*",
"*.pem",
"/Users/me/projects/[a-c]*/x",
"/Users/me/esc\\*aped",
"C:/Users/me/.ssh/**",
"/",
"",
];
let candidates = [
"/Users/me/.ssh/id_rsa",
"/users/ME/.SSH/id_rsa",
"/Users/me/.ssh",
"/Users/me/.ss",
"/Users/me",
"",
"/",
"/Users/me/.hermes/auth.json",
"/Users/me/.hermes/profiles/work/auth.json",
"/Users/me/.hermes/mcp-tokens",
"/Users/me/.local/share/opencode/opencode.db-wal",
"/Users/me/.\u{212A}ube/config",
"/U\u{17F}ers/me/.ssh/id_rsa",
"/Users/mé/.ssh/id_rsa",
"C:\\Users\\me\\.ssh\\id_rsa",
"C:/Users/me/.ssh/id_rsa",
"/p/.env",
"/p/.env.local",
"key.pem",
"/Users/me/projects/b1/x",
"/Users/me/esc*aped",
];
for pattern in patterns {
for case_insensitive in [false, true] {
let g = glob(pattern, case_insensitive);
for candidate in candidates {
assert_eq!(
g.is_match(candidate),
g.regex_match(candidate),
"{pattern:?} {candidate:?} case_insensitive={case_insensitive}"
);
}
}
}
}
#[test]
fn the_prefix_stops_at_the_first_glob_character() {
assert_eq!(glob("/a/b/{c,d}", false).prefix, "/a/b/");
assert_eq!(glob("/a/*.pem", false).prefix, "/a/");
assert_eq!(glob("**/.env", false).prefix, "");
assert_eq!(glob("/a/é/b", false).prefix, "/a/");
}
}