code_moniker_workspace/
glob.rs1use std::borrow::Cow;
2use std::path::Path;
3
4use regex::Regex;
5
6pub fn compile_glob(pattern: &str) -> Regex {
7 Regex::new(&glob_to_regex(pattern))
8 .unwrap_or_else(|err| panic!("glob compiler emitted invalid regex: {err}"))
9}
10
11fn glob_to_regex(pattern: &str) -> String {
12 let mut out = String::from("^");
13 let mut chars = pattern.chars().peekable();
14 while let Some(ch) = chars.next() {
15 match ch {
16 '*' if chars.peek() == Some(&'*') => {
17 chars.next();
18 if chars.peek() == Some(&'/') {
19 chars.next();
20 out.push_str("(?:.*/)?");
21 } else {
22 out.push_str(".*");
23 }
24 }
25 '*' => out.push_str("[^/]*"),
26 '?' => out.push_str("[^/]"),
27 other => out.push_str(®ex::escape(&other.to_string())),
28 }
29 }
30 out.push('$');
31 out
32}
33
34#[derive(Clone, Debug, Default)]
35pub struct FilePathFilter {
36 patterns: Vec<Regex>,
37}
38
39impl FilePathFilter {
40 pub fn compile(patterns: &[String]) -> anyhow::Result<Self> {
41 patterns
42 .iter()
43 .map(|pattern| compile_path_pattern(&normalize_path_pattern(pattern)?))
44 .collect::<anyhow::Result<Vec<_>>>()
45 .map(|patterns| patterns.into_iter().flatten().collect())
46 .map(|patterns| Self { patterns })
47 }
48
49 pub fn matches(&self, rel_path: impl AsRef<Path>) -> bool {
50 if self.patterns.is_empty() {
51 return true;
52 }
53 let lossy = rel_path.as_ref().to_string_lossy();
54 let rel = if lossy.contains('\\') {
55 Cow::Owned(lossy.replace('\\', "/"))
56 } else {
57 lossy
58 };
59 self.patterns.iter().any(|pattern| pattern.is_match(&rel))
60 }
61}
62
63fn compile_path_pattern(pattern: &str) -> anyhow::Result<Vec<Regex>> {
64 if pattern.contains(['*', '?']) {
65 return Ok(vec![compile_glob(pattern)]);
66 }
67 let escaped = regex::escape(pattern);
68 Ok(vec![
69 Regex::new(&format!("^{escaped}$"))?,
70 Regex::new(&format!("^{escaped}/.*$"))?,
71 ])
72}
73
74fn normalize_path_pattern(pattern: &str) -> anyhow::Result<String> {
75 let trimmed = pattern.trim();
76 if trimmed.is_empty() {
77 anyhow::bail!("path glob must not be empty");
78 }
79 let normalized = trimmed.replace('\\', "/");
80 Ok(normalized
81 .trim_start_matches("./")
82 .trim_start_matches('/')
83 .to_string())
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn empty_filter_matches_everything() {
92 let filter = FilePathFilter::compile(&[]).unwrap();
93 assert!(filter.matches("any/path.rs"));
94 }
95
96 #[test]
97 fn relative_glob_scopes_to_subtree() {
98 let filter = FilePathFilter::compile(&["pkg/src/**".to_string()]).unwrap();
99 assert!(filter.matches("pkg/src/a.ts"));
100 assert!(filter.matches("pkg/src/nested/a.ts"));
101 assert!(!filter.matches("pkg/test/a.ts"));
102 }
103
104 #[test]
105 fn plain_directory_scopes_to_subtree() {
106 let filter = FilePathFilter::compile(&["pkg/src".to_string()]).unwrap();
107 assert!(filter.matches("pkg/src"));
108 assert!(filter.matches("pkg/src/a.ts"));
109 assert!(filter.matches("pkg/src/nested/a.ts"));
110 assert!(!filter.matches("pkg/src-other/a.ts"));
111 assert!(!filter.matches("pkg/test/a.ts"));
112 }
113
114 #[test]
115 fn single_star_stays_within_one_segment() {
116 let filter = FilePathFilter::compile(&["**/fixtures/*.rs".to_string()]).unwrap();
117 assert!(filter.matches("crates/core/tests/fixtures/accounts.rs"));
118 assert!(!filter.matches("crates/core/tests/fixtures/rs/accounts.rs"));
119 }
120
121 #[test]
122 fn double_star_slash_allows_empty_prefix() {
123 assert_eq!(glob_to_regex("**/x"), "^(?:.*/)?x$");
124 }
125
126 #[test]
127 fn metacharacters_are_escaped() {
128 let filter = FilePathFilter::compile(&["a.txt".to_string()]).unwrap();
129 assert!(filter.matches("a.txt"));
130 assert!(!filter.matches("axtxt"));
131 }
132
133 #[test]
134 fn matches_accepts_both_path_and_str() {
135 let filter = FilePathFilter::compile(&["pkg/**".to_string()]).unwrap();
136 assert!(filter.matches(Path::new("pkg/a.rs")));
137 assert!(filter.matches("pkg/a.rs"));
138 }
139
140 #[test]
141 fn leading_dot_slash_is_normalized() {
142 let filter = FilePathFilter::compile(&["./pkg/**".to_string()]).unwrap();
143 assert!(filter.matches("pkg/a.rs"));
144 }
145
146 #[test]
147 fn backslash_separators_normalize_before_prefix_strip() {
148 let filter = FilePathFilter::compile(&["\\pkg\\src\\**".to_string()]).unwrap();
149 assert!(filter.matches("pkg/src/a.rs"));
150 assert!(filter.matches("pkg/src/nested/a.rs"));
151 assert!(!filter.matches("pkg/test/a.rs"));
152 }
153
154 #[test]
155 fn leading_dot_backslash_is_normalized() {
156 let filter = FilePathFilter::compile(&[".\\pkg\\**".to_string()]).unwrap();
157 assert!(filter.matches("pkg/a.rs"));
158 }
159}