Skip to main content

fallow_config/config/
ignore_patterns.rs

1//! The compiled discovery ignore set: the built-in defaults, the project's own
2//! `ignorePatterns`, and the `!` exceptions from `ignorePatterns`.
3//!
4//! The order is the gitignore order. A path is ignored when a built-in default
5//! or a project pattern matches it, unless a `!` exception also matches it
6//! (issue #2940). A `!` exception can also add a hidden directory to traversal
7//! (issue #2452). Paths under `node_modules` or `.git` are never lifted.
8
9use 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
19/// Path components that a `!` exception can never lift.
20pub const UNLIFTABLE_IGNORE_SEGMENTS: &[&str] = &["node_modules", ".git"];
21
22/// Characters that make a pattern segment a glob instead of a literal name.
23const GLOB_META: &[char] = &['*', '?', '[', ']', '{', '}', '\\'];
24
25/// What one `!` exception can reach, read from its literal segments. Used to
26/// decide which hidden directories the walk must open.
27#[derive(Debug, Clone)]
28struct ExceptionScope {
29    /// The leading segments of the pattern that have no glob syntax.
30    prefix: PathBuf,
31    /// The segments after `prefix` contain `**`.
32    has_globstar: bool,
33    /// Literal hidden directory names in the segments after `prefix`.
34    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/// The discovery ignore set with its `!` exceptions.
77///
78/// `matches_into` and `len` read only the ignore globs, in the index layout
79/// that [`crate::DEFAULT_IGNORE_PATTERNS`] documents. `is_match` applies the
80/// exceptions too, so every caller that asks "is this path ignored" gets the
81/// same answer.
82#[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/// Which of the project's own `ignorePatterns` globs excluded a path that no
91/// built-in ignore excludes (issue #3824). The globs are the front of
92/// `patterns`, in config order.
93///
94/// Shared across clones of the resolved config, so every stage that excludes
95/// paths credits the same globs. A credit is never taken back: a long-lived
96/// process (the LSP, watch mode) can only report fewer globs as having no
97/// effect, never more.
98#[derive(Debug)]
99struct UserPatternUsage {
100    patterns: Vec<String>,
101    effective: Vec<AtomicBool>,
102    /// Set when source discovery ran, so a glob without a credit is a real
103    /// signal and not only a skipped walk.
104    walked: AtomicBool,
105}
106
107impl IgnorePatternSet {
108    /// A set that ignores nothing.
109    #[must_use]
110    pub fn empty() -> Self {
111        Self::default()
112    }
113
114    /// Build a set from ignore globs and `!` exception bodies (without the
115    /// `!`). Exception bodies that do not compile are dropped, because config
116    /// load validates them first.
117    #[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    /// Name the project's own globs, which are the first `patterns.len()`
136    /// globs of the set, as written in `ignorePatterns`.
137    #[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    /// Number of the project's own globs at the front of the set.
150    #[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    /// Credit the project's own globs among `indices` (from
156    /// [`Self::excluded_matches_into`] for one excluded path), unless a
157    /// built-in glob is among them too: the built-in excludes the path anyway.
158    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    /// [`Self::is_match`], and when the path is ignored, credit the
173    /// project's own globs that match it, as [`Self::credit_user_matches`]
174    /// does.
175    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    /// Record that source discovery ran with this set.
185    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    /// The project's own globs that excluded no path that the run would
192    /// otherwise keep, in config order. Empty until source discovery ran.
193    ///
194    /// A glob that names a hidden directory (`.cache/**`) is never in the
195    /// list. The walk does not open most hidden directories, so it cannot
196    /// credit such a glob, but the glob still silences the
197    /// skipped-source-dotdir advisory for that directory.
198    #[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    /// True when the path is ignored: an ignore glob matches it and no `!`
217    /// exception lifts it.
218    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    /// Indices of the ignore globs that match the path, without the
224    /// exceptions. Callers use it only for a path that `is_match` rejected,
225    /// so it also credits the project's own globs among the indices.
226    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    /// Indices of the ignore globs that match the path, without the
232    /// exceptions.
233    #[must_use]
234    pub fn matches(&self, path: impl AsRef<Path>) -> Vec<usize> {
235        self.patterns.matches(path.as_ref())
236    }
237
238    /// Number of ignore globs, without the exceptions.
239    #[must_use]
240    pub fn len(&self) -> usize {
241        self.patterns.len()
242    }
243
244    /// True when the set has no ignore globs.
245    #[must_use]
246    pub fn is_empty(&self) -> bool {
247        self.patterns.is_empty()
248    }
249
250    /// True when the project wrote at least one `!` exception.
251    #[must_use]
252    pub fn has_exceptions(&self) -> bool {
253        !self.exceptions.is_empty()
254    }
255
256    /// True when a `!` exception matches the path and the path is not under
257    /// `node_modules` or `.git`.
258    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    /// True when the walk must open the hidden directory `dir` (a path
264    /// relative to the project root) because a `!` exception can match a file
265    /// inside it. The walk keeps only the files that an exception matches.
266    #[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/// Compile `ignorePatterns` and the built-in defaults into one set, and return
277/// the number of the project's own globs at the front of the set.
278#[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
315/// Whether a glob has a literal path segment that starts with a dot, such as
316/// `.cache` in `.cache/**` or `**/.turbo/**`.
317fn 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}