fallow_config/config/
ignore_patterns.rs1use std::ffi::OsStr;
10use std::path::{Path, PathBuf};
11
12use globset::{GlobSet, GlobSetBuilder};
13
14pub const UNLIFTABLE_IGNORE_SEGMENTS: &[&str] = &["node_modules", ".git"];
16
17const GLOB_META: &[char] = &['*', '?', '[', ']', '{', '}', '\\'];
19
20#[derive(Debug, Clone)]
23struct ExceptionScope {
24 prefix: PathBuf,
26 has_globstar: bool,
28 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#[derive(Debug, Clone, Default)]
78pub struct IgnorePatternSet {
79 patterns: GlobSet,
80 exceptions: GlobSet,
81 exception_scopes: Vec<ExceptionScope>,
82}
83
84impl IgnorePatternSet {
85 #[must_use]
87 pub fn empty() -> Self {
88 Self::default()
89 }
90
91 #[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 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 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 #[must_use]
127 pub fn matches(&self, path: impl AsRef<Path>) -> Vec<usize> {
128 self.patterns.matches(path.as_ref())
129 }
130
131 #[must_use]
133 pub fn len(&self) -> usize {
134 self.patterns.len()
135 }
136
137 #[must_use]
139 pub fn is_empty(&self) -> bool {
140 self.patterns.is_empty()
141 }
142
143 #[must_use]
145 pub fn has_exceptions(&self) -> bool {
146 !self.exceptions.is_empty()
147 }
148
149 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 #[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}