use std::path::Path;
use thiserror::Error;
use crate::glob::glob_match;
#[derive(Debug, Clone, Error)]
pub enum PatternError {
#[error("empty pattern")]
Empty,
#[error("invalid pattern: {0}")]
Invalid(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum PathSegment {
Literal(String),
Pattern(String),
Globstar,
}
#[derive(Debug, Clone)]
pub struct GlobPath {
segments: Vec<PathSegment>,
anchored: bool,
}
impl GlobPath {
pub fn new(pattern: &str) -> Result<Self, PatternError> {
if pattern.is_empty() {
return Err(PatternError::Empty);
}
let (pattern, anchored) = if let Some(stripped) = pattern.strip_prefix('/') {
(stripped, true)
} else {
(pattern, false)
};
let mut segments = Vec::new();
for part in pattern.split('/') {
if part.is_empty() {
continue;
}
if part == "**" {
if !matches!(segments.last(), Some(PathSegment::Globstar)) {
segments.push(PathSegment::Globstar);
}
} else if Self::is_literal(part) {
segments.push(PathSegment::Literal(part.to_string()));
} else {
segments.push(PathSegment::Pattern(part.to_string()));
}
}
Ok(GlobPath { segments, anchored })
}
pub fn matches(&self, path: &Path) -> bool {
let components: Vec<&str> = path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
self.match_segments(&self.segments, &components, 0, 0)
}
pub fn static_prefix(&self) -> Option<std::path::PathBuf> {
let mut prefix = std::path::PathBuf::new();
for segment in &self.segments {
match segment {
PathSegment::Literal(s) => prefix.push(s),
_ => break,
}
}
if prefix.as_os_str().is_empty() {
None
} else {
Some(prefix)
}
}
pub fn split_static_dir(&self) -> (std::path::PathBuf, GlobPath) {
let leading_literals = self
.segments
.iter()
.take_while(|s| matches!(s, PathSegment::Literal(_)))
.count();
let prefix_len = leading_literals.min(self.segments.len().saturating_sub(1));
let mut prefix = std::path::PathBuf::new();
for segment in &self.segments[..prefix_len] {
if let PathSegment::Literal(s) = segment {
prefix.push(s);
}
}
let remaining = GlobPath {
segments: self.segments[prefix_len..].to_vec(),
anchored: false,
};
(prefix, remaining)
}
pub fn is_dir_only(&self) -> bool {
matches!(self.segments.last(), Some(PathSegment::Globstar))
}
pub fn is_anchored(&self) -> bool {
self.anchored
}
pub fn has_globstar(&self) -> bool {
self.segments.iter().any(|s| matches!(s, PathSegment::Globstar))
}
pub fn fixed_depth(&self) -> Option<usize> {
if self.has_globstar() {
None
} else {
Some(self.segments.len())
}
}
pub fn matches_walk(&self, path: &Path, dotglob: bool) -> bool {
let components: Vec<&str> = path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
self.walk_match(&components, 0, 0, dotglob, false)
}
pub fn could_descend(&self, dir: &Path, dotglob: bool) -> bool {
let components: Vec<&str> = dir
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
self.walk_match(&components, 0, 0, dotglob, true)
}
fn walk_match(
&self,
components: &[&str],
seg_idx: usize,
comp_idx: usize,
dotglob: bool,
prefix: bool,
) -> bool {
if comp_idx >= components.len() {
return if prefix {
seg_idx < self.segments.len()
} else {
self.segments[seg_idx..]
.iter()
.all(|s| matches!(s, PathSegment::Globstar))
};
}
if seg_idx >= self.segments.len() {
return false;
}
match &self.segments[seg_idx] {
PathSegment::Globstar => {
if self.walk_match(components, seg_idx + 1, comp_idx, dotglob, prefix) {
return true;
}
if dotglob || !components[comp_idx].starts_with('.') {
self.walk_match(components, seg_idx, comp_idx + 1, dotglob, prefix)
} else {
false
}
}
PathSegment::Literal(lit) => {
if components[comp_idx] == *lit {
self.walk_match(components, seg_idx + 1, comp_idx + 1, dotglob, prefix)
} else {
false
}
}
PathSegment::Pattern(pat) => {
let comp = components[comp_idx];
if comp.starts_with('.') && !dotglob && !pattern_leads_with_dot(pat) {
return false;
}
if self.matches_component(pat, comp) {
self.walk_match(components, seg_idx + 1, comp_idx + 1, dotglob, prefix)
} else {
false
}
}
}
}
fn is_literal(s: &str) -> bool {
!s.contains('*') && !s.contains('?') && !s.contains('[') && !s.contains('{')
}
fn match_segments(
&self,
segments: &[PathSegment],
components: &[&str],
seg_idx: usize,
comp_idx: usize,
) -> bool {
if seg_idx >= segments.len() && comp_idx >= components.len() {
return true;
}
if seg_idx >= segments.len() {
return false;
}
match &segments[seg_idx] {
PathSegment::Globstar => {
for skip in 0..=(components.len() - comp_idx) {
if self.match_segments(segments, components, seg_idx + 1, comp_idx + skip) {
return true;
}
}
false
}
PathSegment::Literal(lit) => {
if comp_idx >= components.len() {
return false;
}
if components[comp_idx] == lit {
self.match_segments(segments, components, seg_idx + 1, comp_idx + 1)
} else {
false
}
}
PathSegment::Pattern(pat) => {
if comp_idx >= components.len() {
return false;
}
if self.matches_component(pat, components[comp_idx]) {
self.match_segments(segments, components, seg_idx + 1, comp_idx + 1)
} else {
false
}
}
}
}
fn matches_component(&self, pattern: &str, component: &str) -> bool {
glob_match(pattern, component)
}
}
fn pattern_leads_with_dot(pattern: &str) -> bool {
crate::glob::expand_braces(pattern)
.iter()
.any(|alt| alt.starts_with('.'))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_literal_pattern() {
let pat = GlobPath::new("src/main.rs").unwrap();
assert!(pat.matches(Path::new("src/main.rs")));
assert!(!pat.matches(Path::new("src/lib.rs")));
assert!(!pat.matches(Path::new("main.rs")));
}
#[test]
fn test_simple_wildcard() {
let pat = GlobPath::new("*.rs").unwrap();
assert!(pat.matches(Path::new("main.rs")));
assert!(pat.matches(Path::new("lib.rs")));
assert!(!pat.matches(Path::new("main.go")));
assert!(!pat.matches(Path::new("src/main.rs"))); }
#[test]
fn test_globstar_prefix() {
let pat = GlobPath::new("**/*.rs").unwrap();
assert!(pat.matches(Path::new("main.rs")));
assert!(pat.matches(Path::new("src/main.rs")));
assert!(pat.matches(Path::new("src/lib/utils.rs")));
assert!(pat.matches(Path::new("a/b/c/d/e.rs")));
assert!(!pat.matches(Path::new("main.go")));
assert!(!pat.matches(Path::new("src/main.go")));
}
#[test]
fn test_globstar_suffix() {
let pat = GlobPath::new("src/**").unwrap();
assert!(pat.matches(Path::new("src")));
assert!(pat.matches(Path::new("src/main.rs")));
assert!(pat.matches(Path::new("src/lib/utils.rs")));
assert!(!pat.matches(Path::new("test/main.rs")));
}
#[test]
fn test_globstar_middle() {
let pat = GlobPath::new("a/**/z").unwrap();
assert!(pat.matches(Path::new("a/z")));
assert!(pat.matches(Path::new("a/b/z")));
assert!(pat.matches(Path::new("a/b/c/z")));
assert!(pat.matches(Path::new("a/b/c/d/e/z")));
assert!(!pat.matches(Path::new("b/c/z")));
assert!(!pat.matches(Path::new("a/z/extra")));
}
#[test]
fn test_consecutive_globstars() {
let pat = GlobPath::new("a/**/**/z").unwrap();
assert!(pat.matches(Path::new("a/z")));
assert!(pat.matches(Path::new("a/b/z")));
assert!(pat.matches(Path::new("a/b/c/z")));
}
#[test]
fn test_brace_expansion() {
let pat = GlobPath::new("*.{rs,go,py}").unwrap();
assert!(pat.matches(Path::new("main.rs")));
assert!(pat.matches(Path::new("server.go")));
assert!(pat.matches(Path::new("script.py")));
assert!(!pat.matches(Path::new("style.css")));
}
#[test]
fn test_brace_with_globstar() {
let pat = GlobPath::new("**/*.{rs,go}").unwrap();
assert!(pat.matches(Path::new("main.rs")));
assert!(pat.matches(Path::new("src/lib.go")));
assert!(pat.matches(Path::new("a/b/c/d.rs")));
assert!(!pat.matches(Path::new("src/main.py")));
}
#[test]
fn test_question_mark() {
let pat = GlobPath::new("file?.txt").unwrap();
assert!(pat.matches(Path::new("file1.txt")));
assert!(pat.matches(Path::new("fileA.txt")));
assert!(!pat.matches(Path::new("file12.txt")));
assert!(!pat.matches(Path::new("file.txt")));
}
#[test]
fn test_char_class() {
let pat = GlobPath::new("[abc].rs").unwrap();
assert!(pat.matches(Path::new("a.rs")));
assert!(pat.matches(Path::new("b.rs")));
assert!(pat.matches(Path::new("c.rs")));
assert!(!pat.matches(Path::new("d.rs")));
}
#[test]
fn test_static_prefix() {
assert_eq!(
GlobPath::new("src/lib/**/*.rs").unwrap().static_prefix(),
Some(std::path::PathBuf::from("src/lib"))
);
assert_eq!(
GlobPath::new("src/**").unwrap().static_prefix(),
Some(std::path::PathBuf::from("src"))
);
assert_eq!(GlobPath::new("**/*.rs").unwrap().static_prefix(), None);
assert_eq!(GlobPath::new("*.rs").unwrap().static_prefix(), None);
}
#[test]
fn test_anchored_pattern() {
let pat = GlobPath::new("/src/*.rs").unwrap();
assert!(pat.is_anchored());
assert!(pat.matches(Path::new("src/main.rs")));
}
#[test]
fn test_empty_pattern() {
assert!(matches!(GlobPath::new(""), Err(PatternError::Empty)));
}
#[test]
fn test_has_globstar() {
assert!(GlobPath::new("**/*.rs").unwrap().has_globstar());
assert!(GlobPath::new("src/**").unwrap().has_globstar());
assert!(GlobPath::new("a/**/z").unwrap().has_globstar());
assert!(!GlobPath::new("*.rs").unwrap().has_globstar());
assert!(!GlobPath::new("src/*.rs").unwrap().has_globstar());
assert!(!GlobPath::new("src/lib/main.rs").unwrap().has_globstar());
}
#[test]
fn test_fixed_depth() {
assert_eq!(GlobPath::new("*.rs").unwrap().fixed_depth(), Some(1));
assert_eq!(GlobPath::new("src/*.rs").unwrap().fixed_depth(), Some(2));
assert_eq!(GlobPath::new("a/b/c.txt").unwrap().fixed_depth(), Some(3));
assert_eq!(GlobPath::new("**/*.rs").unwrap().fixed_depth(), None);
assert_eq!(GlobPath::new("src/**").unwrap().fixed_depth(), None);
}
#[test]
fn test_hidden_files() {
let pat = GlobPath::new("**/*.rs").unwrap();
assert!(pat.matches(Path::new(".hidden.rs")));
assert!(pat.matches(Path::new(".config/settings.rs")));
}
#[test]
fn test_matches_walk_leading_dot_rule() {
let no = false;
assert!(!GlobPath::new("*").unwrap().matches_walk(Path::new(".env"), no));
assert!(GlobPath::new("*").unwrap().matches_walk(Path::new("visible"), no));
assert!(GlobPath::new(".*").unwrap().matches_walk(Path::new(".env"), no));
assert!(!GlobPath::new(".*").unwrap().matches_walk(Path::new("visible"), no));
assert!(GlobPath::new(".github/*").unwrap().matches_walk(Path::new(".github/ci.yml"), no));
assert!(!GlobPath::new(".github/*").unwrap().matches_walk(Path::new(".github/.secret"), no));
assert!(!GlobPath::new("**/*.rs").unwrap().matches_walk(Path::new(".hidden.rs"), no));
assert!(!GlobPath::new("**/*.rs").unwrap().matches_walk(Path::new(".git/config.rs"), no));
assert!(GlobPath::new("**/*.rs").unwrap().matches_walk(Path::new("src/main.rs"), no));
assert!(GlobPath::new("**/.env").unwrap().matches_walk(Path::new(".env"), no));
assert!(GlobPath::new("**/.env").unwrap().matches_walk(Path::new("sub/.env"), no));
assert!(!GlobPath::new("**/.env").unwrap().matches_walk(Path::new(".hidden/.env"), no));
assert!(GlobPath::new("**/.github/*.yml").unwrap()
.matches_walk(Path::new(".github/ci.yml"), no));
assert!(GlobPath::new("**/.github/*.yml").unwrap()
.matches_walk(Path::new("sub/.github/ci.yml"), no));
assert!(GlobPath::new("*").unwrap().matches_walk(Path::new(".env"), true));
assert!(GlobPath::new("**/*.rs").unwrap().matches_walk(Path::new(".git/config.rs"), true));
}
#[test]
fn test_could_descend_leading_dot_rule() {
let no = false;
assert!(GlobPath::new("**/.env").unwrap().could_descend(Path::new("sub"), no));
assert!(!GlobPath::new("**/.env").unwrap().could_descend(Path::new(".hidden"), no));
assert!(GlobPath::new(".github/*").unwrap().could_descend(Path::new(".github"), no));
assert!(GlobPath::new("**/.github/*.yml").unwrap()
.could_descend(Path::new(".github"), no));
assert!(!GlobPath::new("*").unwrap().could_descend(Path::new("sub"), no));
assert!(GlobPath::new("src/*.rs").unwrap().could_descend(Path::new("src"), no));
assert!(!GlobPath::new("src/*.rs").unwrap().could_descend(Path::new("other"), no));
assert!(GlobPath::new("**/*.rs").unwrap().could_descend(Path::new(".git"), true));
assert!(!GlobPath::new("**/*.rs").unwrap().could_descend(Path::new(".git"), no));
}
#[test]
fn test_complex_real_world() {
let pat = GlobPath::new("**/*_test.rs").unwrap();
assert!(pat.matches(Path::new("parser_test.rs")));
assert!(pat.matches(Path::new("src/lexer_test.rs")));
assert!(pat.matches(Path::new("crates/kernel/tests/eval_test.rs")));
assert!(!pat.matches(Path::new("parser.rs")));
let pat = GlobPath::new("src/**/*.{rs,go}").unwrap();
assert!(pat.matches(Path::new("src/main.rs")));
assert!(pat.matches(Path::new("src/api/handler.go")));
assert!(!pat.matches(Path::new("test/main.rs")));
}
}