fallow_config/config/
finding_ignore.rs1use std::path::Path;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use fallow_types::discover::StableFileKey;
6use globset::{GlobSet, GlobSetBuilder};
7
8#[derive(Debug, Clone, Default)]
14pub struct FindingIgnoreMatcher {
15 hidden: GlobSet,
16 reported: GlobSet,
17 usage: Option<Arc<PatternUsage>>,
18}
19
20#[derive(Debug)]
26struct PatternUsage {
27 patterns: Vec<String>,
28 hidden_origins: Vec<usize>,
30 reported_origins: Vec<usize>,
32 matched: Vec<AtomicBool>,
33 consulted: AtomicBool,
34}
35
36impl PatternUsage {
37 fn record(&self, origins: &[usize], set_indices: &[usize]) {
38 for &index in set_indices {
39 if let Some(&origin) = origins.get(index)
40 && let Some(flag) = self.matched.get(origin)
41 {
42 flag.store(true, Ordering::Relaxed);
43 }
44 }
45 }
46}
47
48struct CompiledSets {
50 hidden: GlobSet,
51 reported: GlobSet,
52 hidden_origins: Vec<usize>,
53 reported_origins: Vec<usize>,
54}
55
56impl FindingIgnoreMatcher {
57 #[expect(
58 clippy::expect_used,
59 reason = "ignoreFindings patterns are validated before config resolution"
60 )]
61 pub(crate) fn compile(patterns: &[String]) -> Self {
62 if patterns.is_empty() {
63 return Self::default();
64 }
65
66 let sets = Self::build_sets(patterns)
67 .expect("ignoreFindings pattern sets were validated before config resolution");
68
69 Self {
70 hidden: sets.hidden,
71 reported: sets.reported,
72 usage: Some(Arc::new(PatternUsage {
73 patterns: patterns.to_vec(),
74 hidden_origins: sets.hidden_origins,
75 reported_origins: sets.reported_origins,
76 matched: patterns.iter().map(|_| AtomicBool::new(false)).collect(),
77 consulted: AtomicBool::new(false),
78 })),
79 }
80 }
81
82 pub fn try_compile(patterns: &[String]) -> Result<Self, globset::Error> {
88 Self::build_sets(patterns)?;
89 Ok(Self::compile(patterns))
90 }
91
92 pub(super) fn validate_compilation(patterns: &[String]) -> Result<(), globset::Error> {
93 Self::build_sets(patterns).map(|_| ())
94 }
95
96 fn build_sets(patterns: &[String]) -> Result<CompiledSets, globset::Error> {
97 let mut hidden = GlobSetBuilder::new();
98 let mut reported = GlobSetBuilder::new();
99 let mut hidden_origins = Vec::new();
100 let mut reported_origins = Vec::new();
101
102 for (index, pattern) in patterns.iter().enumerate() {
103 let (builder, origins, pattern) = if let Some(pattern) = pattern.strip_prefix('!') {
104 (&mut reported, &mut reported_origins, pattern)
105 } else {
106 (&mut hidden, &mut hidden_origins, pattern.as_str())
107 };
108 let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
109 builder.add(super::compile_path_glob(pattern)?);
110 origins.push(index);
111 }
112
113 Ok(CompiledSets {
114 hidden: hidden.build()?,
115 reported: reported.build()?,
116 hidden_origins,
117 reported_origins,
118 })
119 }
120
121 #[must_use]
123 pub const fn is_empty(&self) -> bool {
124 self.usage.is_none()
125 }
126
127 #[must_use]
129 pub fn is_ignored(&self, path: &str) -> bool {
130 let Some(usage) = self.usage.as_deref() else {
131 return false;
132 };
133 usage.consulted.store(true, Ordering::Relaxed);
134
135 let mut indices = Vec::new();
138 self.hidden.matches_into(path, &mut indices);
139 let hidden_hit = !indices.is_empty();
140 usage.record(&usage.hidden_origins, &indices);
141
142 self.reported.matches_into(path, &mut indices);
143 let reported_hit = !indices.is_empty();
144 usage.record(&usage.reported_origins, &indices);
145
146 (self.hidden.is_empty() || hidden_hit) && !reported_hit
147 }
148
149 #[must_use]
153 pub fn is_ignored_path(&self, root: &Path, path: &Path) -> bool {
154 if self.is_empty() {
155 return false;
156 }
157 let key = if let Ok(relative) = path.strip_prefix(root) {
160 StableFileKey::from_relative(relative)
161 } else if path.is_absolute() {
162 return false;
163 } else {
164 StableFileKey::from_relative(path)
165 };
166 self.is_ignored(key.as_str())
167 }
168
169 pub fn reset_usage(&self) {
176 let Some(usage) = self.usage.as_deref() else {
177 return;
178 };
179 usage.consulted.store(false, Ordering::Relaxed);
180 for flag in &usage.matched {
181 flag.store(false, Ordering::Relaxed);
182 }
183 }
184
185 #[must_use]
192 pub fn unmatched_patterns(&self) -> Vec<&str> {
193 let Some(usage) = self.usage.as_deref() else {
194 return Vec::new();
195 };
196 if !usage.consulted.load(Ordering::Relaxed) {
197 return Vec::new();
198 }
199 usage
200 .patterns
201 .iter()
202 .zip(&usage.matched)
203 .filter(|(_, matched)| !matched.load(Ordering::Relaxed))
204 .map(|(pattern, _)| pattern.as_str())
205 .collect()
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 fn matcher(patterns: &[&str]) -> FindingIgnoreMatcher {
214 FindingIgnoreMatcher::compile(
215 &patterns
216 .iter()
217 .map(|pattern| (*pattern).to_string())
218 .collect::<Vec<_>>(),
219 )
220 }
221
222 #[test]
223 fn empty_matcher_ignores_nothing() {
224 let matcher = matcher(&[]);
225
226 assert!(matcher.is_empty());
227 assert!(!matcher.is_ignored("src/app.ts"));
228 }
229
230 #[test]
231 fn positive_patterns_hide_matching_paths() {
232 let matcher = matcher(&["**/*.test.ts"]);
233
234 assert!(matcher.is_ignored("src/app.test.ts"));
235 assert!(!matcher.is_ignored("src/app.ts"));
236 }
237
238 #[test]
239 fn path_lookup_accepts_absolute_and_relative_paths() {
240 let matcher = matcher(&["test/fixtures/**"]);
241 let base = std::env::temp_dir();
244 let root = base.join("project");
245 let root = root.as_path();
246
247 assert!(matcher.is_ignored_path(root, &root.join("test/fixtures/bad.js")));
248 assert!(matcher.is_ignored_path(root, Path::new("test/fixtures/bad.js")));
249 assert!(!matcher.is_ignored_path(root, &root.join("src/app.js")));
250 assert!(!matcher.is_ignored_path(root, &base.join("elsewhere/test/fixtures/bad.js")));
251 }
252
253 #[test]
254 fn path_lookup_strips_a_root_that_is_not_absolute() {
255 let matcher = matcher(&["test/fixtures/**"]);
256 let root = Path::new("project");
259
260 assert!(matcher.is_ignored_path(root, &root.join("test/fixtures/bad.js")));
261 assert!(!matcher.is_ignored_path(root, &root.join("src/app.js")));
262 }
263
264 #[test]
265 fn negated_patterns_keep_matching_paths_reported() {
266 let matcher = matcher(&["**/*.ts", "!src/public/**"]);
267
268 assert!(matcher.is_ignored("src/private/app.ts"));
269 assert!(!matcher.is_ignored("src/public/app.ts"));
270 }
271
272 #[test]
273 fn negated_only_patterns_report_only_matching_paths() {
274 let matcher = matcher(&["!src/public/**"]);
275
276 assert!(matcher.is_ignored("src/private/app.ts"));
277 assert!(!matcher.is_ignored("src/public/app.ts"));
278 }
279
280 #[test]
281 fn leading_dot_slash_is_normalized_after_negation() {
282 let matcher = matcher(&["!./src/public/**"]);
283
284 assert!(matcher.is_ignored("src/private/app.ts"));
285 assert!(!matcher.is_ignored("src/public/app.ts"));
286 }
287
288 #[test]
289 fn pattern_order_does_not_change_set_semantics() {
290 let first = matcher(&["**/*.ts", "!src/public/**"]);
291 let second = matcher(&["!src/public/**", "**/*.ts"]);
292
293 for path in ["src/private/app.ts", "src/public/app.ts", "README.md"] {
294 assert_eq!(first.is_ignored(path), second.is_ignored(path));
295 }
296 }
297
298 #[test]
299 fn empty_configuration_reports_no_unmatched_patterns() {
300 let matcher = matcher(&[]);
301
302 assert!(!matcher.is_ignored("src/app.ts"));
303 assert!(matcher.unmatched_patterns().is_empty());
304 }
305
306 #[test]
307 fn matching_pattern_is_not_reported_as_unmatched() {
308 let matcher = matcher(&["**/*.test.ts"]);
309
310 assert!(matcher.is_ignored("src/app.test.ts"));
311 assert!(matcher.unmatched_patterns().is_empty());
312 }
313
314 #[test]
315 fn pattern_matching_nothing_is_reported() {
316 let matcher = matcher(&["**/*.test.ts", "src/legcy/**"]);
317
318 assert!(matcher.is_ignored("src/app.test.ts"));
319 assert_eq!(matcher.unmatched_patterns(), vec!["src/legcy/**"]);
320 }
321
322 #[test]
323 fn unconsulted_matcher_reports_no_unmatched_patterns() {
324 let matcher = matcher(&["src/legcy/**"]);
325
326 assert!(matcher.unmatched_patterns().is_empty());
327 }
328
329 #[test]
330 fn negated_pattern_matching_nothing_is_reported() {
331 let matcher = matcher(&["**/*.ts", "!src/public/**", "!src/publik/**"]);
332
333 assert!(matcher.is_ignored("src/private/app.ts"));
334 assert!(!matcher.is_ignored("src/public/app.ts"));
335 assert_eq!(matcher.unmatched_patterns(), vec!["!src/publik/**"]);
336 }
337
338 #[test]
339 fn usage_state_is_shared_across_clones() {
340 let matcher = matcher(&["**/*.test.ts"]);
341 let clone = matcher.clone();
342
343 assert!(clone.is_ignored("src/app.test.ts"));
344 assert!(matcher.unmatched_patterns().is_empty());
345 }
346
347 #[test]
348 fn reset_usage_forgets_hits_of_an_earlier_pass() {
349 let matcher = matcher(&["src/legacy/**"]);
350 assert!(matcher.is_ignored("src/legacy/old.ts"));
351 assert!(matcher.unmatched_patterns().is_empty());
352
353 matcher.reset_usage();
354 assert!(
355 matcher.unmatched_patterns().is_empty(),
356 "a pass that consulted nothing reports nothing"
357 );
358 assert!(!matcher.is_ignored("src/app.ts"));
359 assert_eq!(matcher.unmatched_patterns(), vec!["src/legacy/**"]);
360 }
361
362 #[test]
363 fn matches_dotfiles_and_forward_slash_paths() {
364 let matcher = matcher(&["**/*.test.ts", ".storybook/**"]);
365
366 assert!(matcher.is_ignored("packages/ui/src/button.test.ts"));
367 assert!(matcher.is_ignored(".storybook/preview.ts"));
368 }
369}