fallow_config/config/
finding_ignore.rs1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5
6#[derive(Debug, Clone, Default)]
12pub struct FindingIgnoreMatcher {
13 hidden: GlobSet,
14 reported: GlobSet,
15 usage: Option<Arc<PatternUsage>>,
16}
17
18#[derive(Debug)]
24struct PatternUsage {
25 patterns: Vec<String>,
26 hidden_origins: Vec<usize>,
28 reported_origins: Vec<usize>,
30 matched: Vec<AtomicBool>,
31 consulted: AtomicBool,
32}
33
34impl PatternUsage {
35 fn record(&self, origins: &[usize], set_indices: &[usize]) {
36 for &index in set_indices {
37 if let Some(&origin) = origins.get(index)
38 && let Some(flag) = self.matched.get(origin)
39 {
40 flag.store(true, Ordering::Relaxed);
41 }
42 }
43 }
44}
45
46struct CompiledSets {
48 hidden: GlobSet,
49 reported: GlobSet,
50 hidden_origins: Vec<usize>,
51 reported_origins: Vec<usize>,
52}
53
54impl FindingIgnoreMatcher {
55 #[expect(
56 clippy::expect_used,
57 reason = "ignoreFindings patterns are validated before config resolution"
58 )]
59 pub(crate) fn compile(patterns: &[String]) -> Self {
60 if patterns.is_empty() {
61 return Self::default();
62 }
63
64 let sets = Self::build_sets(patterns)
65 .expect("ignoreFindings pattern sets were validated before config resolution");
66
67 Self {
68 hidden: sets.hidden,
69 reported: sets.reported,
70 usage: Some(Arc::new(PatternUsage {
71 patterns: patterns.to_vec(),
72 hidden_origins: sets.hidden_origins,
73 reported_origins: sets.reported_origins,
74 matched: patterns.iter().map(|_| AtomicBool::new(false)).collect(),
75 consulted: AtomicBool::new(false),
76 })),
77 }
78 }
79
80 pub(super) fn validate_compilation(patterns: &[String]) -> Result<(), globset::Error> {
81 Self::build_sets(patterns).map(|_| ())
82 }
83
84 fn build_sets(patterns: &[String]) -> Result<CompiledSets, globset::Error> {
85 let mut hidden = GlobSetBuilder::new();
86 let mut reported = GlobSetBuilder::new();
87 let mut hidden_origins = Vec::new();
88 let mut reported_origins = Vec::new();
89
90 for (index, pattern) in patterns.iter().enumerate() {
91 let (builder, origins, pattern) = if let Some(pattern) = pattern.strip_prefix('!') {
92 (&mut reported, &mut reported_origins, pattern)
93 } else {
94 (&mut hidden, &mut hidden_origins, pattern.as_str())
95 };
96 let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
97 builder.add(Glob::new(pattern)?);
98 origins.push(index);
99 }
100
101 Ok(CompiledSets {
102 hidden: hidden.build()?,
103 reported: reported.build()?,
104 hidden_origins,
105 reported_origins,
106 })
107 }
108
109 #[must_use]
111 pub const fn is_empty(&self) -> bool {
112 self.usage.is_none()
113 }
114
115 #[must_use]
117 pub fn is_ignored(&self, path: &str) -> bool {
118 let Some(usage) = self.usage.as_deref() else {
119 return false;
120 };
121 usage.consulted.store(true, Ordering::Relaxed);
122
123 let mut indices = Vec::new();
126 self.hidden.matches_into(path, &mut indices);
127 let hidden_hit = !indices.is_empty();
128 usage.record(&usage.hidden_origins, &indices);
129
130 self.reported.matches_into(path, &mut indices);
131 let reported_hit = !indices.is_empty();
132 usage.record(&usage.reported_origins, &indices);
133
134 (self.hidden.is_empty() || hidden_hit) && !reported_hit
135 }
136
137 pub fn reset_usage(&self) {
144 let Some(usage) = self.usage.as_deref() else {
145 return;
146 };
147 usage.consulted.store(false, Ordering::Relaxed);
148 for flag in &usage.matched {
149 flag.store(false, Ordering::Relaxed);
150 }
151 }
152
153 #[must_use]
160 pub fn unmatched_patterns(&self) -> Vec<&str> {
161 let Some(usage) = self.usage.as_deref() else {
162 return Vec::new();
163 };
164 if !usage.consulted.load(Ordering::Relaxed) {
165 return Vec::new();
166 }
167 usage
168 .patterns
169 .iter()
170 .zip(&usage.matched)
171 .filter(|(_, matched)| !matched.load(Ordering::Relaxed))
172 .map(|(pattern, _)| pattern.as_str())
173 .collect()
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn matcher(patterns: &[&str]) -> FindingIgnoreMatcher {
182 FindingIgnoreMatcher::compile(
183 &patterns
184 .iter()
185 .map(|pattern| (*pattern).to_string())
186 .collect::<Vec<_>>(),
187 )
188 }
189
190 #[test]
191 fn empty_matcher_ignores_nothing() {
192 let matcher = matcher(&[]);
193
194 assert!(matcher.is_empty());
195 assert!(!matcher.is_ignored("src/app.ts"));
196 }
197
198 #[test]
199 fn positive_patterns_hide_matching_paths() {
200 let matcher = matcher(&["**/*.test.ts"]);
201
202 assert!(matcher.is_ignored("src/app.test.ts"));
203 assert!(!matcher.is_ignored("src/app.ts"));
204 }
205
206 #[test]
207 fn negated_patterns_keep_matching_paths_reported() {
208 let matcher = matcher(&["**/*.ts", "!src/public/**"]);
209
210 assert!(matcher.is_ignored("src/private/app.ts"));
211 assert!(!matcher.is_ignored("src/public/app.ts"));
212 }
213
214 #[test]
215 fn negated_only_patterns_report_only_matching_paths() {
216 let matcher = matcher(&["!src/public/**"]);
217
218 assert!(matcher.is_ignored("src/private/app.ts"));
219 assert!(!matcher.is_ignored("src/public/app.ts"));
220 }
221
222 #[test]
223 fn leading_dot_slash_is_normalized_after_negation() {
224 let matcher = matcher(&["!./src/public/**"]);
225
226 assert!(matcher.is_ignored("src/private/app.ts"));
227 assert!(!matcher.is_ignored("src/public/app.ts"));
228 }
229
230 #[test]
231 fn pattern_order_does_not_change_set_semantics() {
232 let first = matcher(&["**/*.ts", "!src/public/**"]);
233 let second = matcher(&["!src/public/**", "**/*.ts"]);
234
235 for path in ["src/private/app.ts", "src/public/app.ts", "README.md"] {
236 assert_eq!(first.is_ignored(path), second.is_ignored(path));
237 }
238 }
239
240 #[test]
241 fn empty_configuration_reports_no_unmatched_patterns() {
242 let matcher = matcher(&[]);
243
244 assert!(!matcher.is_ignored("src/app.ts"));
245 assert!(matcher.unmatched_patterns().is_empty());
246 }
247
248 #[test]
249 fn matching_pattern_is_not_reported_as_unmatched() {
250 let matcher = matcher(&["**/*.test.ts"]);
251
252 assert!(matcher.is_ignored("src/app.test.ts"));
253 assert!(matcher.unmatched_patterns().is_empty());
254 }
255
256 #[test]
257 fn pattern_matching_nothing_is_reported() {
258 let matcher = matcher(&["**/*.test.ts", "src/legcy/**"]);
259
260 assert!(matcher.is_ignored("src/app.test.ts"));
261 assert_eq!(matcher.unmatched_patterns(), vec!["src/legcy/**"]);
262 }
263
264 #[test]
265 fn unconsulted_matcher_reports_no_unmatched_patterns() {
266 let matcher = matcher(&["src/legcy/**"]);
267
268 assert!(matcher.unmatched_patterns().is_empty());
269 }
270
271 #[test]
272 fn negated_pattern_matching_nothing_is_reported() {
273 let matcher = matcher(&["**/*.ts", "!src/public/**", "!src/publik/**"]);
274
275 assert!(matcher.is_ignored("src/private/app.ts"));
276 assert!(!matcher.is_ignored("src/public/app.ts"));
277 assert_eq!(matcher.unmatched_patterns(), vec!["!src/publik/**"]);
278 }
279
280 #[test]
281 fn usage_state_is_shared_across_clones() {
282 let matcher = matcher(&["**/*.test.ts"]);
283 let clone = matcher.clone();
284
285 assert!(clone.is_ignored("src/app.test.ts"));
286 assert!(matcher.unmatched_patterns().is_empty());
287 }
288
289 #[test]
290 fn reset_usage_forgets_hits_of_an_earlier_pass() {
291 let matcher = matcher(&["src/legacy/**"]);
292 assert!(matcher.is_ignored("src/legacy/old.ts"));
293 assert!(matcher.unmatched_patterns().is_empty());
294
295 matcher.reset_usage();
296 assert!(
297 matcher.unmatched_patterns().is_empty(),
298 "a pass that consulted nothing reports nothing"
299 );
300 assert!(!matcher.is_ignored("src/app.ts"));
301 assert_eq!(matcher.unmatched_patterns(), vec!["src/legacy/**"]);
302 }
303
304 #[test]
305 fn matches_dotfiles_and_forward_slash_paths() {
306 let matcher = matcher(&["**/*.test.ts", ".storybook/**"]);
307
308 assert!(matcher.is_ignored("packages/ui/src/button.test.ts"));
309 assert!(matcher.is_ignored(".storybook/preview.ts"));
310 }
311}