use glob::Pattern;
#[derive(Debug, Clone)]
pub struct PathPattern {
raw: String,
full: Option<Pattern>,
base: Option<Pattern>,
dir: Option<Pattern>,
}
impl PathPattern {
pub fn new(pattern: &str) -> Result<Self, glob::PatternError> {
let full = Pattern::new(pattern)?;
let mut core = pattern;
while let Some(rest) = core.strip_prefix("**/") {
core = rest;
}
let base = if core.contains('/') {
None
} else {
Pattern::new(core).ok()
};
let dir = pattern
.strip_suffix("/**")
.filter(|d| !d.is_empty())
.and_then(|d| Pattern::new(d).ok());
Ok(Self {
raw: pattern.to_string(),
full: Some(full),
base,
dir,
})
}
pub fn lenient(pattern: &str) -> Self {
Self::new(pattern).unwrap_or_else(|_| Self {
raw: pattern.to_string(),
full: None,
base: None,
dir: None,
})
}
pub fn matches(&self, path: &str) -> bool {
if self.raw.is_empty() {
return false;
}
let basename = path.rsplit('/').next().unwrap_or(path);
if path == self.raw || basename == self.raw {
return true;
}
if self.full.as_ref().is_some_and(|g| g.matches(path)) {
return true;
}
if self.base.as_ref().is_some_and(|g| g.matches(basename)) {
return true;
}
self.dir.as_ref().is_some_and(|g| g.matches(path))
}
}
#[derive(Debug, Clone, Default)]
pub struct PathMatcher {
patterns: Vec<PathPattern>,
}
impl PathMatcher {
pub fn new(patterns: &[String]) -> Self {
Self {
patterns: patterns.iter().map(|p| PathPattern::lenient(p)).collect(),
}
}
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
}
pub fn is_match(&self, path: &str) -> bool {
self.patterns.iter().any(|p| p.matches(path))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn m(pats: &[&str]) -> PathMatcher {
PathMatcher::new(&pats.iter().map(|s| (*s).to_string()).collect::<Vec<_>>())
}
#[test]
fn empty_matcher_matches_nothing() {
assert!(!m(&[]).is_match("src/main.rs"));
assert!(m(&[]).is_empty());
assert!(!m(&[""]).is_match("src/main.rs"));
}
#[test]
fn exact_and_basename() {
let x = m(&["src/main.ts", "Makefile"]);
assert!(x.is_match("src/main.ts"));
assert!(x.is_match("a/b/Makefile"));
assert!(!x.is_match("src/main.tsx"));
}
#[test]
fn double_star_variants() {
let x = m(&["**/*.test.ts"]);
assert!(x.is_match("a.test.ts"));
assert!(x.is_match("a/b/c.test.ts"));
assert!(!x.is_match("footest.ts"));
assert!(!x.is_match("a/c.test.js"));
let d = m(&["**/phaser/**"]);
assert!(d.is_match("phaser/a.ts"));
assert!(d.is_match("src/phaser/a/b.ts"));
assert!(d.is_match("a/phaser"));
assert!(!d.is_match("src/phaserHelper.ts"));
let s = m(&["**/something"]);
assert!(s.is_match("something"));
assert!(s.is_match("a/b/something"));
assert!(!s.is_match("a/b/something-else"));
}
#[test]
fn dir_prefix() {
let x = m(&["target/**", "src/engine/**"]);
assert!(x.is_match("target/debug/x.rs"));
assert!(x.is_match("src/engine/core/mod.rs"));
assert!(x.is_match("src/engine"));
assert!(!x.is_match("src/app/engine.rs"));
assert!(!x.is_match("crates/a/target/x.rs"));
}
#[test]
fn wildcards_and_edge_cases() {
assert!(m(&["vite.config.*"]).is_match("apps/web/vite.config.ts"));
assert!(!m(&["vite.config.*"]).is_match("vite.configx"));
assert!(m(&["src/*.rs"]).is_match("src/lib.rs"));
assert!(m(&["*.gen.*"]).is_match("a/b/x.gen.go"));
assert!(!m(&["*.config.ts"]).is_match("config.ts"));
}
#[test]
fn invalid_glob_degrades_to_literal() {
let x = m(&["a[b"]);
assert!(x.is_match("a[b"));
assert!(!x.is_match("ab"));
assert!(PathPattern::new("a[b").is_err());
}
}