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};
11
12use globset::{GlobSet, GlobSetBuilder};
13
14/// Path components that a `!` exception can never lift.
15pub const UNLIFTABLE_IGNORE_SEGMENTS: &[&str] = &["node_modules", ".git"];
16
17/// Characters that make a pattern segment a glob instead of a literal name.
18const GLOB_META: &[char] = &['*', '?', '[', ']', '{', '}', '\\'];
19
20/// What one `!` exception can reach, read from its literal segments. Used to
21/// decide which hidden directories the walk must open.
22#[derive(Debug, Clone)]
23struct ExceptionScope {
24    /// The leading segments of the pattern that have no glob syntax.
25    prefix: PathBuf,
26    /// The segments after `prefix` contain `**`.
27    has_globstar: bool,
28    /// Literal hidden directory names in the segments after `prefix`.
29    hidden_names: Vec<String>,
30}
31
32impl ExceptionScope {
33    fn parse(body: &str) -> Self {
34        let mut prefix = PathBuf::new();
35        let mut segments = body.split('/').filter(|segment| !segment.is_empty());
36        let mut rest: Vec<&str> = Vec::new();
37        for segment in segments.by_ref() {
38            if segment.contains(GLOB_META) {
39                rest.push(segment);
40                break;
41            }
42            prefix.push(segment);
43        }
44        rest.extend(segments);
45        Self {
46            prefix,
47            has_globstar: rest.iter().any(|segment| segment.contains("**")),
48            hidden_names: rest
49                .iter()
50                .filter(|segment| segment.starts_with('.') && !segment.contains(GLOB_META))
51                .map(|segment| (*segment).to_owned())
52                .collect(),
53        }
54    }
55
56    fn admits_hidden_dir(&self, dir: &Path) -> bool {
57        if self.prefix.starts_with(dir) {
58            return true;
59        }
60        if !dir.starts_with(&self.prefix) {
61            return false;
62        }
63        let named = dir
64            .file_name()
65            .and_then(OsStr::to_str)
66            .is_some_and(|name| self.hidden_names.iter().any(|hidden| hidden == name));
67        named || (self.has_globstar && !self.prefix.as_os_str().is_empty())
68    }
69}
70
71/// The discovery ignore set with its `!` exceptions.
72///
73/// `matches_into` and `len` read only the ignore globs, in the index layout
74/// that [`crate::DEFAULT_IGNORE_PATTERNS`] documents. `is_match` applies the
75/// exceptions too, so every caller that asks "is this path ignored" gets the
76/// same answer.
77#[derive(Debug, Clone, Default)]
78pub struct IgnorePatternSet {
79    patterns: GlobSet,
80    exceptions: GlobSet,
81    exception_scopes: Vec<ExceptionScope>,
82}
83
84impl IgnorePatternSet {
85    /// A set that ignores nothing.
86    #[must_use]
87    pub fn empty() -> Self {
88        Self::default()
89    }
90
91    /// Build a set from ignore globs and `!` exception bodies (without the
92    /// `!`). Exception bodies that do not compile are dropped, because config
93    /// load validates them first.
94    #[must_use]
95    pub fn new(patterns: GlobSet, exception_bodies: &[&str]) -> Self {
96        let mut builder = GlobSetBuilder::new();
97        let mut exception_scopes = Vec::with_capacity(exception_bodies.len());
98        for body in exception_bodies {
99            if let Ok(glob) = globset::Glob::new(body) {
100                builder.add(glob);
101                exception_scopes.push(ExceptionScope::parse(body));
102            }
103        }
104        Self {
105            patterns,
106            exceptions: builder.build().unwrap_or_default(),
107            exception_scopes,
108        }
109    }
110
111    /// True when the path is ignored: an ignore glob matches it and no `!`
112    /// exception lifts it.
113    pub fn is_match(&self, path: impl AsRef<Path>) -> bool {
114        let path = path.as_ref();
115        self.patterns.is_match(path) && !self.is_lifted(path)
116    }
117
118    /// Indices of the ignore globs that match the path, without the
119    /// exceptions. Callers use it only for a path that `is_match` rejected.
120    pub fn matches_into(&self, path: impl AsRef<Path>, into: &mut Vec<usize>) {
121        self.patterns.matches_into(path.as_ref(), into);
122    }
123
124    /// Indices of the ignore globs that match the path, without the
125    /// exceptions.
126    #[must_use]
127    pub fn matches(&self, path: impl AsRef<Path>) -> Vec<usize> {
128        self.patterns.matches(path.as_ref())
129    }
130
131    /// Number of ignore globs, without the exceptions.
132    #[must_use]
133    pub fn len(&self) -> usize {
134        self.patterns.len()
135    }
136
137    /// True when the set has no ignore globs.
138    #[must_use]
139    pub fn is_empty(&self) -> bool {
140        self.patterns.is_empty()
141    }
142
143    /// True when the project wrote at least one `!` exception.
144    #[must_use]
145    pub fn has_exceptions(&self) -> bool {
146        !self.exceptions.is_empty()
147    }
148
149    /// True when a `!` exception matches the path and the path is not under
150    /// `node_modules` or `.git`.
151    pub fn is_lifted(&self, path: impl AsRef<Path>) -> bool {
152        let path = path.as_ref();
153        !self.exceptions.is_empty() && self.exceptions.is_match(path) && !is_unliftable(path)
154    }
155
156    /// True when the walk must open the hidden directory `dir` (a path
157    /// relative to the project root) because a `!` exception can match a file
158    /// inside it. The walk keeps only the files that an exception matches.
159    #[must_use]
160    pub fn admits_hidden_dir(&self, dir: &Path) -> bool {
161        !is_unliftable(dir)
162            && self
163                .exception_scopes
164                .iter()
165                .any(|scope| scope.admits_hidden_dir(dir))
166    }
167}
168
169impl From<GlobSet> for IgnorePatternSet {
170    fn from(patterns: GlobSet) -> Self {
171        Self::new(patterns, &[])
172    }
173}
174
175fn is_unliftable(path: &Path) -> bool {
176    path.components().any(|component| {
177        UNLIFTABLE_IGNORE_SEGMENTS
178            .iter()
179            .any(|segment| component.as_os_str() == OsStr::new(segment))
180    })
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn set(patterns: &[&str], exceptions: &[&str]) -> IgnorePatternSet {
188        let mut builder = GlobSetBuilder::new();
189        for pattern in patterns {
190            builder.add(globset::Glob::new(pattern).expect("valid glob"));
191        }
192        IgnorePatternSet::new(builder.build().expect("valid set"), exceptions)
193    }
194
195    #[test]
196    fn an_exception_lifts_a_match() {
197        let ignores = set(&["**/coverage/**"], &["src/coverage/**"]);
198        assert!(!ignores.is_match("src/coverage/a.ts"));
199        assert!(ignores.is_match("coverage/a.ts"));
200        assert_eq!(ignores.matches("src/coverage/a.ts"), vec![0]);
201    }
202
203    #[test]
204    fn node_modules_and_git_are_never_lifted() {
205        let ignores = set(&["**/node_modules/**", "**/.git/**"], &["**"]);
206        assert!(ignores.is_match("node_modules/dep/index.ts"));
207        assert!(ignores.is_match(".git/hooks/pre-commit.js"));
208        assert!(!ignores.admits_hidden_dir(Path::new(".git")));
209    }
210
211    #[test]
212    fn hidden_directories_open_only_on_a_named_path() {
213        let exact = set(&[], &[".config/**"]);
214        assert!(exact.admits_hidden_dir(Path::new(".config")));
215        assert!(exact.admits_hidden_dir(Path::new(".config/.inner")));
216        assert!(!exact.admits_hidden_dir(Path::new(".cache")));
217        assert!(!exact.admits_hidden_dir(Path::new("packages/a/.config")));
218
219        let any_depth = set(&[], &["**/.config/**"]);
220        assert!(any_depth.admits_hidden_dir(Path::new("packages/a/.config")));
221        assert!(!any_depth.admits_hidden_dir(Path::new(".next")));
222
223        let broad = set(&[], &["**/*.ts"]);
224        assert!(!broad.admits_hidden_dir(Path::new(".next")));
225
226        let file = set(&[], &["a/.b/c.ts"]);
227        assert!(file.admits_hidden_dir(Path::new("a/.b")));
228        assert!(!file.admits_hidden_dir(Path::new("a/.c")));
229    }
230}