1use globset::GlobBuilder;
29use globset::GlobSet;
30use globset::GlobSetBuilder;
31
32use crate::GlobSettings;
33use crate::error::DatabaseError;
34
35#[inline]
38#[must_use]
39pub fn contains_glob_metacharacters<T: AsRef<str>>(pattern: T) -> bool {
40 pattern.as_ref().chars().any(|c| matches!(c, '*' | '?' | '[' | '{'))
41}
42
43pub(crate) fn build_glob_set<I, S>(patterns: I, glob_settings: GlobSettings) -> Result<GlobSet, DatabaseError>
52where
53 I: IntoIterator<Item = S>,
54 S: AsRef<str>,
55{
56 let mut builder = GlobSetBuilder::new();
57 for pattern in patterns {
58 let glob = GlobBuilder::new(pattern.as_ref())
59 .case_insensitive(glob_settings.case_insensitive)
60 .literal_separator(glob_settings.literal_separator)
61 .backslash_escape(glob_settings.backslash_escape)
62 .empty_alternates(glob_settings.empty_alternates)
63 .build()?;
64
65 builder.add(glob);
66 }
67
68 Ok(builder.build()?)
69}
70
71#[derive(Debug, Clone)]
77pub struct ExclusionMatcher<S: Clone + AsRef<str>> {
78 globs: GlobSet,
79 prefixes: Vec<S>,
80}
81
82impl<S: Clone + AsRef<str>> ExclusionMatcher<S> {
83 #[inline]
90 pub fn compile<I>(patterns: I, glob_settings: GlobSettings) -> Result<Self, DatabaseError>
91 where
92 I: IntoIterator<Item = S>,
93 {
94 let mut globs = Vec::new();
95 let mut prefixes = Vec::new();
96
97 for pattern in patterns {
98 if contains_glob_metacharacters(&pattern) {
99 globs.push(pattern);
100 } else {
101 prefixes.push(pattern);
102 }
103 }
104
105 Ok(Self { globs: build_glob_set(&globs, glob_settings)?, prefixes })
106 }
107
108 #[inline]
110 #[must_use]
111 pub fn is_empty(&self) -> bool {
112 self.globs.is_empty() && self.prefixes.is_empty()
113 }
114
115 #[inline]
122 #[must_use]
123 pub fn is_match(&self, file: &str) -> bool {
124 if !self.globs.is_empty() && self.globs.is_match(file) {
125 return true;
126 }
127
128 self.prefixes.iter().any(|pattern| prefix_matches(file, pattern.as_ref()))
129 }
130}
131
132fn prefix_matches(file: &str, pattern: &str) -> bool {
138 if pattern.ends_with('/') {
139 return file.starts_with(pattern);
140 }
141
142 if file == pattern {
143 return true;
144 }
145
146 let rest = file.strip_prefix(pattern);
147 matches!(rest, Some(rest) if rest.starts_with('/'))
148}
149
150#[cfg(test)]
151#[allow(clippy::expect_used)]
152mod tests {
153 use super::*;
154
155 fn matcher<'pat>(patterns: &[&'pat str]) -> ExclusionMatcher<&'pat str> {
156 ExclusionMatcher::compile(patterns.iter().copied(), GlobSettings::default()).expect("compile")
157 }
158
159 #[test]
160 fn empty_matcher_matches_nothing() {
161 let m = matcher(&[]);
162 assert!(m.is_empty());
163 assert!(!m.is_match("any/path.php"));
164 }
165
166 #[test]
167 fn plain_directory_prefix_matches_descendants() {
168 let m = matcher(&["src/vendor"]);
169 assert!(m.is_match("src/vendor"));
170 assert!(m.is_match("src/vendor/foo.php"));
171 assert!(m.is_match("src/vendor/deep/nested.php"));
172 assert!(!m.is_match("src/vendored.php"));
173 assert!(!m.is_match("src/other.php"));
174 }
175
176 #[test]
177 fn plain_trailing_slash_prefix_is_respected() {
178 let m = matcher(&["src/tests/"]);
179 assert!(m.is_match("src/tests/foo.php"));
180 assert!(!m.is_match("src/tests"));
181 }
182
183 #[test]
184 fn plain_file_matches_exactly() {
185 let m = matcher(&["src/skip.php"]);
186 assert!(m.is_match("src/skip.php"));
187 assert!(!m.is_match("src/skipped.php"));
188 }
189
190 #[test]
191 fn glob_double_star_matches_nested() {
192 let m = matcher(&["src/**/*.php"]);
193 assert!(m.is_match("src/a.php"));
194 assert!(m.is_match("src/dir/a.php"));
195 assert!(m.is_match("src/a/b/c.php"));
196 assert!(!m.is_match("tests/a.php"));
197 }
198
199 #[test]
200 fn glob_star_matches_flat_and_nested() {
201 let m = matcher(&["tests/fixtures/*"]);
202 assert!(m.is_match("tests/fixtures/a.php"));
203 assert!(m.is_match("tests/fixtures/dir/a.php"));
204 }
205
206 #[test]
207 fn mixed_patterns_combine_correctly() {
208 let m = matcher(&["src/legacy", "tests/**/*Test.php"]);
209 assert!(m.is_match("src/legacy/foo.php"));
210 assert!(m.is_match("tests/Unit/FooTest.php"));
211 assert!(!m.is_match("src/modern/foo.php"));
212 assert!(!m.is_match("tests/Unit/Helper.php"));
213 }
214
215 #[test]
216 fn contains_glob_metacharacters_detects_patterns() {
217 assert!(contains_glob_metacharacters("src/**/*.php"));
218 assert!(contains_glob_metacharacters("a?.php"));
219 assert!(contains_glob_metacharacters("[abc]"));
220 assert!(contains_glob_metacharacters("{a,b}"));
221 assert!(!contains_glob_metacharacters("src"));
222 assert!(!contains_glob_metacharacters("src/foo.php"));
223 assert!(!contains_glob_metacharacters("vendor/"));
224 }
225}