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 #[must_use]
144 pub fn unmatched_patterns(&self) -> Vec<&str> {
145 let Some(usage) = self.usage.as_deref() else {
146 return Vec::new();
147 };
148 if !usage.consulted.load(Ordering::Relaxed) {
149 return Vec::new();
150 }
151 usage
152 .patterns
153 .iter()
154 .zip(&usage.matched)
155 .filter(|(_, matched)| !matched.load(Ordering::Relaxed))
156 .map(|(pattern, _)| pattern.as_str())
157 .collect()
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 fn matcher(patterns: &[&str]) -> FindingIgnoreMatcher {
166 FindingIgnoreMatcher::compile(
167 &patterns
168 .iter()
169 .map(|pattern| (*pattern).to_string())
170 .collect::<Vec<_>>(),
171 )
172 }
173
174 #[test]
175 fn empty_matcher_ignores_nothing() {
176 let matcher = matcher(&[]);
177
178 assert!(matcher.is_empty());
179 assert!(!matcher.is_ignored("src/app.ts"));
180 }
181
182 #[test]
183 fn positive_patterns_hide_matching_paths() {
184 let matcher = matcher(&["**/*.test.ts"]);
185
186 assert!(matcher.is_ignored("src/app.test.ts"));
187 assert!(!matcher.is_ignored("src/app.ts"));
188 }
189
190 #[test]
191 fn negated_patterns_keep_matching_paths_reported() {
192 let matcher = matcher(&["**/*.ts", "!src/public/**"]);
193
194 assert!(matcher.is_ignored("src/private/app.ts"));
195 assert!(!matcher.is_ignored("src/public/app.ts"));
196 }
197
198 #[test]
199 fn negated_only_patterns_report_only_matching_paths() {
200 let matcher = matcher(&["!src/public/**"]);
201
202 assert!(matcher.is_ignored("src/private/app.ts"));
203 assert!(!matcher.is_ignored("src/public/app.ts"));
204 }
205
206 #[test]
207 fn leading_dot_slash_is_normalized_after_negation() {
208 let matcher = matcher(&["!./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 pattern_order_does_not_change_set_semantics() {
216 let first = matcher(&["**/*.ts", "!src/public/**"]);
217 let second = matcher(&["!src/public/**", "**/*.ts"]);
218
219 for path in ["src/private/app.ts", "src/public/app.ts", "README.md"] {
220 assert_eq!(first.is_ignored(path), second.is_ignored(path));
221 }
222 }
223
224 #[test]
225 fn empty_configuration_reports_no_unmatched_patterns() {
226 let matcher = matcher(&[]);
227
228 assert!(!matcher.is_ignored("src/app.ts"));
229 assert!(matcher.unmatched_patterns().is_empty());
230 }
231
232 #[test]
233 fn matching_pattern_is_not_reported_as_unmatched() {
234 let matcher = matcher(&["**/*.test.ts"]);
235
236 assert!(matcher.is_ignored("src/app.test.ts"));
237 assert!(matcher.unmatched_patterns().is_empty());
238 }
239
240 #[test]
241 fn pattern_matching_nothing_is_reported() {
242 let matcher = matcher(&["**/*.test.ts", "src/legcy/**"]);
243
244 assert!(matcher.is_ignored("src/app.test.ts"));
245 assert_eq!(matcher.unmatched_patterns(), vec!["src/legcy/**"]);
246 }
247
248 #[test]
249 fn unconsulted_matcher_reports_no_unmatched_patterns() {
250 let matcher = matcher(&["src/legcy/**"]);
251
252 assert!(matcher.unmatched_patterns().is_empty());
253 }
254
255 #[test]
256 fn negated_pattern_matching_nothing_is_reported() {
257 let matcher = matcher(&["**/*.ts", "!src/public/**", "!src/publik/**"]);
258
259 assert!(matcher.is_ignored("src/private/app.ts"));
260 assert!(!matcher.is_ignored("src/public/app.ts"));
261 assert_eq!(matcher.unmatched_patterns(), vec!["!src/publik/**"]);
262 }
263
264 #[test]
265 fn usage_state_is_shared_across_clones() {
266 let matcher = matcher(&["**/*.test.ts"]);
267 let clone = matcher.clone();
268
269 assert!(clone.is_ignored("src/app.test.ts"));
270 assert!(matcher.unmatched_patterns().is_empty());
271 }
272
273 #[test]
274 fn matches_dotfiles_and_forward_slash_paths() {
275 let matcher = matcher(&["**/*.test.ts", ".storybook/**"]);
276
277 assert!(matcher.is_ignored("packages/ui/src/button.test.ts"));
278 assert!(matcher.is_ignored(".storybook/preview.ts"));
279 }
280}