use glob::Pattern;
use std::path::Path;
pub struct PatternMatcher {
excludes: Vec<CompiledPattern>,
includes: Vec<CompiledPattern>,
}
struct CompiledPattern {
pattern: Pattern,
anchored: bool,
is_dir_pattern: bool,
}
fn normalize_separators(s: &str) -> String {
s.replace('\\', "/")
}
fn matches_any_prefix(path: &Path, pattern: &Pattern) -> bool {
let normalized = normalize_separators(&path.to_string_lossy());
let parts: Vec<&str> = normalized.split('/').collect();
for i in 1..parts.len() {
let prefix = parts[..i].join("/");
if pattern.matches(&prefix) {
return true;
}
}
false
}
impl CompiledPattern {
fn matches_path(&self, path: &Path, path_str: &str) -> bool {
if self.anchored {
if !self.is_dir_pattern && self.pattern.matches(path_str) {
return true;
}
if matches_any_prefix(path, &self.pattern) {
return true;
}
} else {
if !self.is_dir_pattern && self.pattern.matches(path_str) {
return true;
}
if !self.is_dir_pattern {
if let Some(filename) = path.file_name() {
let filename_str = normalize_separators(&filename.to_string_lossy());
if self.pattern.matches(&filename_str) {
return true;
}
}
}
let components: Vec<_> = path.components().collect();
for (i, component) in components.iter().enumerate() {
let is_last = i == components.len() - 1;
if self.is_dir_pattern && is_last {
continue;
}
let component_str = component.as_os_str().to_string_lossy();
if self.pattern.matches(&component_str) {
return true;
}
}
if matches_any_prefix(path, &self.pattern) {
return true;
}
}
false
}
}
impl PatternMatcher {
pub fn new(exclude_patterns: &[String], include_patterns: &[String]) -> Self {
let excludes = exclude_patterns
.iter()
.filter_map(|p| compile_pattern(p))
.collect();
let includes = include_patterns
.iter()
.filter_map(|p| compile_pattern(p))
.collect();
Self { excludes, includes }
}
pub fn is_excluded(&self, path: &Path) -> bool {
let path_str = normalize_separators(&path.to_string_lossy());
self.excludes
.iter()
.any(|p| p.matches_path(path, &path_str))
}
pub fn is_included(&self, path: &Path) -> bool {
if self.includes.is_empty() {
return true;
}
let path_str = normalize_separators(&path.to_string_lossy());
self.includes
.iter()
.any(|p| p.matches_path(path, &path_str))
}
}
fn compile_pattern(pattern: &str) -> Option<CompiledPattern> {
let trimmed = pattern.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
if trimmed.starts_with('!') {
eprintln!(
"Warning: negation patterns are not supported, ignoring: {}",
trimmed
);
return None;
}
let normalized = normalize_separators(trimmed);
let cleaned = if let Some(stripped) = normalized.strip_prefix("./") {
eprintln!(
"Warning: '{}' looks like a file path, not a pattern. \
Stripping leading \"./\". Consider using a positional argument instead: \
pctx {}",
trimmed, stripped
);
stripped
} else {
normalized.as_str()
};
let is_dir_pattern = cleaned.ends_with('/');
let clean = cleaned.trim_end_matches('/');
let (anchored, pattern_body) = if let Some(stripped) = clean.strip_prefix('/') {
(true, stripped)
} else {
(false, clean)
};
if pattern_body.is_empty() {
return None;
}
let glob_pattern = if anchored {
pattern_body.to_string()
} else if pattern_body.contains('/') && !pattern_body.starts_with("**") {
format!("**/{}", pattern_body)
} else {
pattern_body.to_string()
};
Pattern::new(&glob_pattern).ok().map(|p| CompiledPattern {
pattern: p,
anchored,
is_dir_pattern,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_simple_exclude() {
let matcher = PatternMatcher::new(&["*.log".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("app.log")));
assert!(matcher.is_excluded(&PathBuf::from("logs/app.log")));
assert!(!matcher.is_excluded(&PathBuf::from("app.txt")));
}
#[test]
fn test_directory_exclude() {
let matcher = PatternMatcher::new(&["node_modules".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("node_modules/package/index.js")));
assert!(matcher.is_excluded(&PathBuf::from("src/node_modules/foo.js")));
}
#[test]
fn test_anchored_pattern() {
let matcher = PatternMatcher::new(&["/src/test".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("src/test")));
assert!(!matcher.is_excluded(&PathBuf::from("foo/src/test")));
}
#[test]
fn test_include_patterns() {
let matcher = PatternMatcher::new(&[], &["*.rs".to_string()]);
assert!(matcher.is_included(&PathBuf::from("main.rs")));
assert!(matcher.is_included(&PathBuf::from("src/lib.rs")));
assert!(!matcher.is_included(&PathBuf::from("main.py")));
}
#[test]
fn test_empty_include_allows_all() {
let matcher = PatternMatcher::new(&[], &[]);
assert!(matcher.is_included(&PathBuf::from("anything.txt")));
}
#[test]
fn test_comment_pattern_ignored() {
let result = compile_pattern("# this is a comment");
assert!(result.is_none());
}
#[test]
fn test_empty_pattern_ignored() {
let result = compile_pattern(" ");
assert!(result.is_none());
}
#[test]
fn test_double_star_pattern() {
let matcher = PatternMatcher::new(&["**/test/**".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("src/test/file.rs")));
assert!(matcher.is_excluded(&PathBuf::from("test/file.rs")));
}
#[test]
fn test_extension_with_path() {
let matcher = PatternMatcher::new(&["**/*.test.ts".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("src/app.test.ts")));
assert!(matcher.is_excluded(&PathBuf::from("app.test.ts")));
assert!(!matcher.is_excluded(&PathBuf::from("app.ts")));
}
#[test]
fn test_multi_component_directory_exclude() {
let matcher = PatternMatcher::new(&["src/config".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("src/config")));
assert!(matcher.is_excluded(&PathBuf::from("src/config/mod.rs")));
assert!(matcher.is_excluded(&PathBuf::from("src/config/defaults.rs")));
assert!(matcher.is_excluded(&PathBuf::from("src/config/file.rs")));
assert!(!matcher.is_excluded(&PathBuf::from("src/main.rs")));
assert!(!matcher.is_excluded(&PathBuf::from("src/content/mod.rs")));
assert!(!matcher.is_excluded(&PathBuf::from("README.md")));
}
#[test]
fn test_multi_component_directory_exclude_with_trailing_slash() {
let matcher = PatternMatcher::new(&["src/output/".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("src/output/formatter.rs")));
assert!(matcher.is_excluded(&PathBuf::from("src/output/json_types.rs")));
assert!(!matcher.is_excluded(&PathBuf::from("src/scanner/mod.rs")));
}
#[test]
fn test_multi_component_anchored_directory_exclude() {
let matcher = PatternMatcher::new(&["/src/filter".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("src/filter/binary.rs")));
assert!(matcher.is_excluded(&PathBuf::from("src/filter/patterns.rs")));
assert!(!matcher.is_excluded(&PathBuf::from("other/src/filter/binary.rs")));
}
#[test]
fn test_multi_component_include() {
let matcher = PatternMatcher::new(&[], &["src/output".to_string()]);
assert!(matcher.is_included(&PathBuf::from("src/output/formatter.rs")));
assert!(matcher.is_included(&PathBuf::from("src/output/tree.rs")));
assert!(!matcher.is_included(&PathBuf::from("tests/integration_test.rs")));
}
#[test]
fn test_backslash_separator_excluded() {
let matcher = PatternMatcher::new(&["src/config".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("src\\config\\defaults.rs")));
}
#[test]
fn test_trailing_slash_semantics() {
let matcher = PatternMatcher::new(&["output/".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("output/file.txt")));
assert!(matcher.is_excluded(&PathBuf::from("src/output/file.txt")));
assert!(!matcher.is_excluded(&PathBuf::from("output")));
assert!(!matcher.is_excluded(&PathBuf::from("src/output")));
let matcher2 = PatternMatcher::new(&["output".to_string()], &[]);
assert!(matcher2.is_excluded(&PathBuf::from("output/file.txt")));
assert!(matcher2.is_excluded(&PathBuf::from("output")));
}
#[test]
fn test_trailing_backslash_detected_as_dir_pattern() {
let matcher = PatternMatcher::new(&[r"output\".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("output/file.txt")));
assert!(matcher.is_excluded(&PathBuf::from("src/output/file.txt")));
assert!(!matcher.is_excluded(&PathBuf::from("output")));
}
#[test]
fn test_backslash_multi_component_include() {
let matcher = PatternMatcher::new(&[], &[r"src\content".to_string()]);
assert!(matcher.is_included(&PathBuf::from("src/content/reader.rs")));
assert!(matcher.is_included(&PathBuf::from("src/content/truncator.rs")));
assert!(!matcher.is_included(&PathBuf::from("src/filter/binary.rs")));
}
#[test]
fn test_backslash_multi_component_dir_include() {
let matcher = PatternMatcher::new(&[], &[r".\src\scanner\".to_string()]);
assert!(matcher.is_included(&PathBuf::from("src/scanner/git.rs")));
assert!(matcher.is_included(&PathBuf::from("src/scanner/walker.rs")));
assert!(!matcher.is_included(&PathBuf::from("src/filter/binary.rs")));
}
#[test]
fn test_dot_slash_prefix_stripped_include() {
let matcher = PatternMatcher::new(&[], &["./src/config".to_string()]);
assert!(matcher.is_included(&PathBuf::from("src/config/mod.rs")));
assert!(!matcher.is_included(&PathBuf::from("tests/integration_test.rs")));
}
#[test]
fn test_dot_slash_prefix_stripped_exclude() {
let matcher = PatternMatcher::new(&["./node_modules".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("node_modules/pkg/index.js")));
}
#[test]
fn test_dot_backslash_prefix_stripped() {
let matcher = PatternMatcher::new(&[r".\node_modules".to_string()], &[]);
assert!(matcher.is_excluded(&PathBuf::from("node_modules/pkg/index.js")));
}
#[test]
fn test_bare_dot_pattern_kept() {
assert!(compile_pattern(".").is_some());
}
#[test]
fn test_dot_slash_alone_ignored() {
assert!(compile_pattern("./").is_none());
}
#[test]
fn test_dot_backslash_alone_ignored() {
assert!(compile_pattern(r".\").is_none());
}
#[test]
fn test_dot_slash_does_not_strip_from_middle() {
let compiled = compile_pattern("src/./config");
assert!(compiled.is_some());
}
}