fallow_config/config/
ignore_patterns.rs1use std::ffi::OsStr;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13
14use globset::{GlobSet, GlobSetBuilder};
15
16use super::compile_path_glob;
17use super::resolution::{DEFAULT_IGNORE_PATTERNS, normalize_user_glob_pattern};
18
19pub const UNLIFTABLE_IGNORE_SEGMENTS: &[&str] = &["node_modules", ".git"];
21
22const GLOB_META: &[char] = &['*', '?', '[', ']', '{', '}', '\\'];
24
25#[derive(Debug, Clone)]
28struct ExceptionScope {
29 prefix: PathBuf,
31 has_globstar: bool,
33 hidden_names: Vec<String>,
35}
36
37impl ExceptionScope {
38 fn parse(body: &str) -> Self {
39 let mut prefix = PathBuf::new();
40 let mut segments = body.split('/').filter(|segment| !segment.is_empty());
41 let mut rest: Vec<&str> = Vec::new();
42 for segment in segments.by_ref() {
43 if segment.contains(GLOB_META) {
44 rest.push(segment);
45 break;
46 }
47 prefix.push(segment);
48 }
49 rest.extend(segments);
50 Self {
51 prefix,
52 has_globstar: rest.iter().any(|segment| segment.contains("**")),
53 hidden_names: rest
54 .iter()
55 .filter(|segment| segment.starts_with('.') && !segment.contains(GLOB_META))
56 .map(|segment| (*segment).to_owned())
57 .collect(),
58 }
59 }
60
61 fn admits_hidden_dir(&self, dir: &Path) -> bool {
62 if self.prefix.starts_with(dir) {
63 return true;
64 }
65 if !dir.starts_with(&self.prefix) {
66 return false;
67 }
68 let named = dir
69 .file_name()
70 .and_then(OsStr::to_str)
71 .is_some_and(|name| self.hidden_names.iter().any(|hidden| hidden == name));
72 named || (self.has_globstar && !self.prefix.as_os_str().is_empty())
73 }
74}
75
76#[derive(Debug, Clone, Default)]
83pub struct IgnorePatternSet {
84 patterns: GlobSet,
85 exceptions: GlobSet,
86 exception_scopes: Vec<ExceptionScope>,
87 user: Option<Arc<UserPatternUsage>>,
88}
89
90#[derive(Debug)]
99struct UserPatternUsage {
100 patterns: Vec<String>,
101 effective: Vec<AtomicBool>,
102 walked: AtomicBool,
105}
106
107impl IgnorePatternSet {
108 #[must_use]
110 pub fn empty() -> Self {
111 Self::default()
112 }
113
114 #[must_use]
118 pub fn new(patterns: GlobSet, exception_bodies: &[&str]) -> Self {
119 let mut builder = GlobSetBuilder::new();
120 let mut exception_scopes = Vec::with_capacity(exception_bodies.len());
121 for body in exception_bodies {
122 if let Ok(glob) = super::compile_path_glob(body) {
123 builder.add(glob);
124 exception_scopes.push(ExceptionScope::parse(body));
125 }
126 }
127 Self {
128 patterns,
129 exceptions: builder.build().unwrap_or_default(),
130 exception_scopes,
131 user: None,
132 }
133 }
134
135 #[must_use]
138 pub fn with_user_patterns(mut self, patterns: Vec<String>) -> Self {
139 self.user = (!patterns.is_empty()).then(|| {
140 Arc::new(UserPatternUsage {
141 effective: patterns.iter().map(|_| AtomicBool::new(false)).collect(),
142 patterns,
143 walked: AtomicBool::new(false),
144 })
145 });
146 self
147 }
148
149 #[must_use]
151 pub fn user_pattern_count(&self) -> usize {
152 self.user.as_deref().map_or(0, |user| user.patterns.len())
153 }
154
155 pub fn credit_user_matches(&self, indices: &[usize]) {
159 let Some(user) = self.user.as_deref() else {
160 return;
161 };
162 if indices.iter().any(|&index| index >= user.patterns.len()) {
163 return;
164 }
165 for &index in indices {
166 if let Some(flag) = user.effective.get(index) {
167 flag.store(true, Ordering::Relaxed);
168 }
169 }
170 }
171
172 pub fn excludes(&self, path: impl AsRef<Path>) -> bool {
176 let path = path.as_ref();
177 let ignored = self.is_match(path);
178 if ignored && self.user.is_some() {
179 self.credit_user_matches(&self.patterns.matches(path));
180 }
181 ignored
182 }
183
184 pub fn mark_walked(&self) {
186 if let Some(user) = self.user.as_deref() {
187 user.walked.store(true, Ordering::Relaxed);
188 }
189 }
190
191 #[must_use]
199 pub fn redundant_user_patterns(&self) -> Vec<&str> {
200 let Some(user) = self.user.as_deref() else {
201 return Vec::new();
202 };
203 if !user.walked.load(Ordering::Relaxed) {
204 return Vec::new();
205 }
206 user.patterns
207 .iter()
208 .zip(&user.effective)
209 .filter(|(pattern, effective)| {
210 !effective.load(Ordering::Relaxed) && !names_hidden_directory(pattern)
211 })
212 .map(|(pattern, _)| pattern.as_str())
213 .collect()
214 }
215
216 pub fn is_match(&self, path: impl AsRef<Path>) -> bool {
219 let path = path.as_ref();
220 self.patterns.is_match(path) && !self.is_lifted(path)
221 }
222
223 pub fn excluded_matches_into(&self, path: impl AsRef<Path>, into: &mut Vec<usize>) {
227 self.patterns.matches_into(path.as_ref(), into);
228 self.credit_user_matches(into);
229 }
230
231 #[must_use]
234 pub fn matches(&self, path: impl AsRef<Path>) -> Vec<usize> {
235 self.patterns.matches(path.as_ref())
236 }
237
238 #[must_use]
240 pub fn len(&self) -> usize {
241 self.patterns.len()
242 }
243
244 #[must_use]
246 pub fn is_empty(&self) -> bool {
247 self.patterns.is_empty()
248 }
249
250 #[must_use]
252 pub fn has_exceptions(&self) -> bool {
253 !self.exceptions.is_empty()
254 }
255
256 pub fn is_lifted(&self, path: impl AsRef<Path>) -> bool {
259 let path = path.as_ref();
260 !self.exceptions.is_empty() && self.exceptions.is_match(path) && !is_unliftable(path)
261 }
262
263 #[must_use]
267 pub fn admits_hidden_dir(&self, dir: &Path) -> bool {
268 !is_unliftable(dir)
269 && self
270 .exception_scopes
271 .iter()
272 .any(|scope| scope.admits_hidden_dir(dir))
273 }
274}
275
276#[expect(
279 clippy::expect_used,
280 reason = "user glob patterns are validated before config resolution"
281)]
282pub(super) fn compile_ignore_patterns(ignore_patterns: &[String]) -> (IgnorePatternSet, usize) {
283 let mut ignore_builder = GlobSetBuilder::new();
284 let mut user_patterns = Vec::new();
285 let mut exceptions: Vec<&str> = Vec::new();
286 for pattern in ignore_patterns {
287 if let Some(body) = pattern.strip_prefix('!') {
288 exceptions.push(normalize_user_glob_pattern(body));
289 continue;
290 }
291 let normalized = normalize_user_glob_pattern(pattern);
292 ignore_builder.add(
293 compile_path_glob(normalized)
294 .expect("ignorePatterns entry was validated at config load time"),
295 );
296 user_patterns.push(pattern.clone());
297 }
298
299 for pattern in DEFAULT_IGNORE_PATTERNS {
300 ignore_builder.add(compile_path_glob(pattern).expect("default ignore pattern is valid"));
301 }
302
303 let user_pattern_count = user_patterns.len();
304 let set = IgnorePatternSet::new(ignore_builder.build().unwrap_or_default(), &exceptions)
305 .with_user_patterns(user_patterns);
306 (set, user_pattern_count)
307}
308
309impl From<GlobSet> for IgnorePatternSet {
310 fn from(patterns: GlobSet) -> Self {
311 Self::new(patterns, &[])
312 }
313}
314
315fn names_hidden_directory(pattern: &str) -> bool {
318 pattern
319 .trim_start_matches("./")
320 .split('/')
321 .any(|segment| segment.len() > 1 && segment.starts_with('.') && segment != "..")
322}
323
324fn is_unliftable(path: &Path) -> bool {
325 path.components().any(|component| {
326 UNLIFTABLE_IGNORE_SEGMENTS
327 .iter()
328 .any(|segment| component.as_os_str() == OsStr::new(segment))
329 })
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 fn set(patterns: &[&str], exceptions: &[&str]) -> IgnorePatternSet {
337 let mut builder = GlobSetBuilder::new();
338 for pattern in patterns {
339 builder.add(globset::Glob::new(pattern).expect("valid glob"));
340 }
341 IgnorePatternSet::new(builder.build().expect("valid set"), exceptions)
342 }
343
344 fn user_set(user: &[&str], built_in: &[&str]) -> IgnorePatternSet {
345 let mut builder = GlobSetBuilder::new();
346 for pattern in user.iter().chain(built_in) {
347 builder.add(globset::Glob::new(pattern).expect("valid glob"));
348 }
349 IgnorePatternSet::new(builder.build().expect("valid set"), &[])
350 .with_user_patterns(user.iter().map(|pattern| (*pattern).to_owned()).collect())
351 }
352
353 #[test]
354 fn redundant_user_patterns_need_a_walk() {
355 let ignores = user_set(&["gen/**"], &["**/dist/**"]);
356 assert!(ignores.redundant_user_patterns().is_empty());
357 ignores.mark_walked();
358 assert_eq!(ignores.redundant_user_patterns(), vec!["gen/**"]);
359 }
360
361 #[test]
362 fn a_user_glob_gets_credit_only_where_no_built_in_matches() {
363 let ignores = user_set(&["dist/**", "gen/**", "legacy/**"], &["**/dist/**"]);
364 ignores.mark_walked();
365 assert!(ignores.excludes("dist/a.ts"));
366 assert!(ignores.excludes("gen/a.ts"));
367 assert_eq!(
368 ignores.redundant_user_patterns(),
369 vec!["dist/**", "legacy/**"]
370 );
371 let clone = ignores.clone();
372 assert!(clone.excludes("legacy/a.ts"));
373 assert_eq!(ignores.redundant_user_patterns(), vec!["dist/**"]);
374 }
375
376 #[test]
377 fn a_glob_that_names_a_hidden_directory_is_never_redundant() {
378 let ignores = user_set(&[".cache/**", "**/.turbo/**", "*.min.ts", "./out/**"], &[]);
379 ignores.mark_walked();
380 assert_eq!(
381 ignores.redundant_user_patterns(),
382 vec!["*.min.ts", "./out/**"]
383 );
384 }
385
386 #[test]
387 fn an_exception_lifts_a_match() {
388 let ignores = set(&["**/coverage/**"], &["src/coverage/**"]);
389 assert!(!ignores.is_match("src/coverage/a.ts"));
390 assert!(ignores.is_match("coverage/a.ts"));
391 assert_eq!(ignores.matches("src/coverage/a.ts"), vec![0]);
392 }
393
394 #[test]
395 fn node_modules_and_git_are_never_lifted() {
396 let ignores = set(&["**/node_modules/**", "**/.git/**"], &["**"]);
397 assert!(ignores.is_match("node_modules/dep/index.ts"));
398 assert!(ignores.is_match(".git/hooks/pre-commit.js"));
399 assert!(!ignores.admits_hidden_dir(Path::new(".git")));
400 }
401
402 #[test]
403 fn hidden_directories_open_only_on_a_named_path() {
404 let exact = set(&[], &[".config/**"]);
405 assert!(exact.admits_hidden_dir(Path::new(".config")));
406 assert!(exact.admits_hidden_dir(Path::new(".config/.inner")));
407 assert!(!exact.admits_hidden_dir(Path::new(".cache")));
408 assert!(!exact.admits_hidden_dir(Path::new("packages/a/.config")));
409
410 let any_depth = set(&[], &["**/.config/**"]);
411 assert!(any_depth.admits_hidden_dir(Path::new("packages/a/.config")));
412 assert!(!any_depth.admits_hidden_dir(Path::new(".next")));
413
414 let broad = set(&[], &["**/*.ts"]);
415 assert!(!broad.admits_hidden_dir(Path::new(".next")));
416
417 let file = set(&[], &["a/.b/c.ts"]);
418 assert!(file.admits_hidden_dir(Path::new("a/.b")));
419 assert!(!file.admits_hidden_dir(Path::new("a/.c")));
420 }
421}