use std::borrow::Cow;
use std::path::Path;
use regex::Regex;
pub fn compile_glob(pattern: &str) -> Regex {
Regex::new(&glob_to_regex(pattern))
.unwrap_or_else(|err| panic!("glob compiler emitted invalid regex: {err}"))
}
fn glob_to_regex(pattern: &str) -> String {
let mut out = String::from("^");
let mut chars = pattern.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'*' if chars.peek() == Some(&'*') => {
chars.next();
if chars.peek() == Some(&'/') {
chars.next();
out.push_str("(?:.*/)?");
} else {
out.push_str(".*");
}
}
'*' => out.push_str("[^/]*"),
'?' => out.push_str("[^/]"),
other => out.push_str(®ex::escape(&other.to_string())),
}
}
out.push('$');
out
}
#[derive(Clone, Debug, Default)]
pub struct FilePathFilter {
patterns: Vec<Regex>,
}
impl FilePathFilter {
pub fn compile(patterns: &[String]) -> anyhow::Result<Self> {
patterns
.iter()
.map(|pattern| compile_path_pattern(&normalize_path_pattern(pattern)?))
.collect::<anyhow::Result<Vec<_>>>()
.map(|patterns| patterns.into_iter().flatten().collect())
.map(|patterns| Self { patterns })
}
pub fn matches(&self, rel_path: impl AsRef<Path>) -> bool {
if self.patterns.is_empty() {
return true;
}
let lossy = rel_path.as_ref().to_string_lossy();
let rel = if lossy.contains('\\') {
Cow::Owned(lossy.replace('\\', "/"))
} else {
lossy
};
self.patterns.iter().any(|pattern| pattern.is_match(&rel))
}
}
fn compile_path_pattern(pattern: &str) -> anyhow::Result<Vec<Regex>> {
if pattern.contains(['*', '?']) {
return Ok(vec![compile_glob(pattern)]);
}
let escaped = regex::escape(pattern);
Ok(vec![
Regex::new(&format!("^{escaped}$"))?,
Regex::new(&format!("^{escaped}/.*$"))?,
])
}
fn normalize_path_pattern(pattern: &str) -> anyhow::Result<String> {
let trimmed = pattern.trim();
if trimmed.is_empty() {
anyhow::bail!("path glob must not be empty");
}
let normalized = trimmed.replace('\\', "/");
Ok(normalized
.trim_start_matches("./")
.trim_start_matches('/')
.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_filter_matches_everything() {
let filter = FilePathFilter::compile(&[]).unwrap();
assert!(filter.matches("any/path.rs"));
}
#[test]
fn relative_glob_scopes_to_subtree() {
let filter = FilePathFilter::compile(&["pkg/src/**".to_string()]).unwrap();
assert!(filter.matches("pkg/src/a.ts"));
assert!(filter.matches("pkg/src/nested/a.ts"));
assert!(!filter.matches("pkg/test/a.ts"));
}
#[test]
fn plain_directory_scopes_to_subtree() {
let filter = FilePathFilter::compile(&["pkg/src".to_string()]).unwrap();
assert!(filter.matches("pkg/src"));
assert!(filter.matches("pkg/src/a.ts"));
assert!(filter.matches("pkg/src/nested/a.ts"));
assert!(!filter.matches("pkg/src-other/a.ts"));
assert!(!filter.matches("pkg/test/a.ts"));
}
#[test]
fn single_star_stays_within_one_segment() {
let filter = FilePathFilter::compile(&["**/fixtures/*.rs".to_string()]).unwrap();
assert!(filter.matches("crates/core/tests/fixtures/accounts.rs"));
assert!(!filter.matches("crates/core/tests/fixtures/rs/accounts.rs"));
}
#[test]
fn double_star_slash_allows_empty_prefix() {
assert_eq!(glob_to_regex("**/x"), "^(?:.*/)?x$");
}
#[test]
fn metacharacters_are_escaped() {
let filter = FilePathFilter::compile(&["a.txt".to_string()]).unwrap();
assert!(filter.matches("a.txt"));
assert!(!filter.matches("axtxt"));
}
#[test]
fn matches_accepts_both_path_and_str() {
let filter = FilePathFilter::compile(&["pkg/**".to_string()]).unwrap();
assert!(filter.matches(Path::new("pkg/a.rs")));
assert!(filter.matches("pkg/a.rs"));
}
#[test]
fn leading_dot_slash_is_normalized() {
let filter = FilePathFilter::compile(&["./pkg/**".to_string()]).unwrap();
assert!(filter.matches("pkg/a.rs"));
}
#[test]
fn backslash_separators_normalize_before_prefix_strip() {
let filter = FilePathFilter::compile(&["\\pkg\\src\\**".to_string()]).unwrap();
assert!(filter.matches("pkg/src/a.rs"));
assert!(filter.matches("pkg/src/nested/a.rs"));
assert!(!filter.matches("pkg/test/a.rs"));
}
#[test]
fn leading_dot_backslash_is_normalized() {
let filter = FilePathFilter::compile(&[".\\pkg\\**".to_string()]).unwrap();
assert!(filter.matches("pkg/a.rs"));
}
}