fallow_config/config/
dependency_ignore.rs1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use rustc_hash::FxHashMap;
6
7const GLOB_METACHARACTERS: [char; 4] = ['*', '?', '[', '{'];
12
13#[must_use]
16pub fn is_dependency_glob(entry: &str) -> bool {
17 entry.contains(GLOB_METACHARACTERS)
18}
19
20#[derive(Debug, Clone, Default)]
26pub struct IgnoreDependencyMatcher {
27 exact: FxHashMap<String, Vec<usize>>,
29 globs: GlobSet,
30 glob_entries: Vec<usize>,
32 usage: Option<Arc<EntryUsage>>,
33}
34
35#[derive(Debug)]
37struct EntryUsage {
38 entries: Vec<String>,
39 matched: Vec<AtomicBool>,
41 suppressed: Vec<AtomicBool>,
44 declared_checked: AtomicBool,
48}
49
50impl IgnoreDependencyMatcher {
51 #[expect(
59 clippy::expect_used,
60 reason = "ignoreDependencies globs are validated before config resolution"
61 )]
62 #[must_use]
63 pub fn compile(entries: &[String]) -> Self {
64 let mut exact: FxHashMap<String, Vec<usize>> = FxHashMap::default();
65 let mut builder = GlobSetBuilder::new();
66 let mut glob_entries = Vec::new();
67 for (index, entry) in entries.iter().enumerate() {
68 if is_dependency_glob(entry) {
69 builder.add(
70 Glob::new(entry)
71 .expect("ignoreDependencies glob was validated at config load time"),
72 );
73 glob_entries.push(index);
74 } else {
75 exact.entry(entry.clone()).or_default().push(index);
76 }
77 }
78 let globs = builder
79 .build()
80 .expect("ignoreDependencies globs were validated at config load time");
81 let usage = (!entries.is_empty()).then(|| {
82 Arc::new(EntryUsage {
83 matched: entries.iter().map(|_| AtomicBool::new(false)).collect(),
84 suppressed: entries.iter().map(|_| AtomicBool::new(false)).collect(),
85 entries: entries.to_vec(),
86 declared_checked: AtomicBool::new(false),
87 })
88 });
89 Self {
90 exact,
91 globs,
92 glob_entries,
93 usage,
94 }
95 }
96
97 #[must_use]
99 pub fn is_empty(&self) -> bool {
100 self.usage.is_none()
101 }
102
103 fn matching_entries(&self, package_name: &str) -> Vec<usize> {
105 let mut indices = self.exact.get(package_name).cloned().unwrap_or_default();
106 if !self.glob_entries.is_empty() {
107 indices.extend(
108 self.globs
109 .matches(package_name)
110 .into_iter()
111 .filter_map(|glob_index| self.glob_entries.get(glob_index).copied()),
112 );
113 }
114 indices
115 }
116
117 #[must_use]
119 pub fn is_ignored(&self, package_name: &str) -> bool {
120 if self.exact.contains_key(package_name) {
121 return true;
122 }
123 if self.glob_entries.is_empty() {
124 return false;
125 }
126 let indices = self.globs.matches(package_name);
127 if let Some(usage) = self.usage.as_deref() {
128 for &glob_index in &indices {
129 if let Some(flag) = self
130 .glob_entries
131 .get(glob_index)
132 .and_then(|&index| usage.matched.get(index))
133 {
134 flag.store(true, Ordering::Relaxed);
135 }
136 }
137 }
138 !indices.is_empty()
139 }
140
141 pub fn note_declared(&self, package_name: &str) {
145 if let Some(usage) = self.usage.as_deref() {
146 usage.declared_checked.store(true, Ordering::Relaxed);
147 }
148 let _ = self.is_ignored(package_name);
149 }
150
151 #[must_use]
156 pub fn suppresses(&self, package_name: &str) -> bool {
157 if !self.is_ignored(package_name) {
158 return false;
159 }
160 if let Some(usage) = self.usage.as_deref() {
161 for index in self.matching_entries(package_name) {
162 if let Some(flag) = usage.suppressed.get(index) {
163 flag.store(true, Ordering::Relaxed);
164 }
165 }
166 }
167 true
168 }
169
170 pub fn reset_usage(&self) {
177 let Some(usage) = self.usage.as_deref() else {
178 return;
179 };
180 usage.declared_checked.store(false, Ordering::Relaxed);
181 for flag in usage.matched.iter().chain(&usage.suppressed) {
182 flag.store(false, Ordering::Relaxed);
183 }
184 }
185
186 #[must_use]
191 pub fn unmatched_globs(&self) -> Vec<&str> {
192 let Some(usage) = self.checked_usage() else {
193 return Vec::new();
194 };
195 self.glob_entries
196 .iter()
197 .filter(|&&index| !usage.matched[index].load(Ordering::Relaxed))
198 .map(|&index| usage.entries[index].as_str())
199 .collect()
200 }
201
202 #[must_use]
208 pub fn redundant_entries(&self) -> Vec<&str> {
209 let Some(usage) = self.checked_usage() else {
210 return Vec::new();
211 };
212 let unmatched_glob = |index: usize| {
213 self.glob_entries.contains(&index) && !usage.matched[index].load(Ordering::Relaxed)
214 };
215 usage
216 .entries
217 .iter()
218 .enumerate()
219 .filter(|&(index, _)| {
220 !usage.suppressed[index].load(Ordering::Relaxed) && !unmatched_glob(index)
221 })
222 .map(|(_, entry)| entry.as_str())
223 .collect()
224 }
225
226 fn checked_usage(&self) -> Option<&EntryUsage> {
227 self.usage
228 .as_deref()
229 .filter(|usage| usage.declared_checked.load(Ordering::Relaxed))
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn matcher(entries: &[&str]) -> IgnoreDependencyMatcher {
238 IgnoreDependencyMatcher::compile(
239 &entries
240 .iter()
241 .map(|entry| (*entry).to_string())
242 .collect::<Vec<_>>(),
243 )
244 }
245
246 #[test]
247 fn empty_matcher_ignores_nothing() {
248 let m = matcher(&[]);
249 assert!(m.is_empty());
250 assert!(!m.is_ignored("lodash"));
251 assert!(m.unmatched_globs().is_empty());
252 }
253
254 #[test]
255 fn exact_entry_matches_only_the_same_name() {
256 let m = matcher(&["@acme/lib"]);
257 assert!(m.is_ignored("@acme/lib"));
258 assert!(!m.is_ignored("@acme/lib-extra"));
259 assert!(!m.is_ignored("@acme/other"));
260 }
261
262 #[test]
263 fn scope_glob_matches_every_package_in_the_scope() {
264 let m = matcher(&["@acme/*"]);
265 assert!(m.is_ignored("@acme/lib"));
266 assert!(m.is_ignored("@acme/ui"));
267 assert!(!m.is_ignored("@other/lib"));
268 assert!(!m.is_ignored("acme"));
269 }
270
271 #[test]
272 fn prefix_and_alternation_globs_match() {
273 let m = matcher(&["eslint-plugin-*", "{react,react-dom}"]);
274 assert!(m.is_ignored("eslint-plugin-react"));
275 assert!(m.is_ignored("react-dom"));
276 assert!(!m.is_ignored("eslint"));
277 }
278
279 #[test]
280 fn unmatched_globs_needs_a_declared_check() {
281 let m = matcher(&["@acme/*", "@typo/*", "lodash"]);
282 assert!(m.is_ignored("@acme/lib"));
283 assert!(
284 m.unmatched_globs().is_empty(),
285 "no declared dependency was checked yet"
286 );
287 m.note_declared("react");
288 assert_eq!(m.unmatched_globs(), vec!["@typo/*"]);
289 }
290
291 #[test]
292 fn clones_share_glob_usage() {
293 let m = matcher(&["@acme/*"]);
294 let clone = m.clone();
295 clone.note_declared("@acme/lib");
296 assert!(m.unmatched_globs().is_empty());
297 }
298
299 #[test]
300 fn reset_usage_forgets_hits_of_an_earlier_pass() {
301 let m = matcher(&["@acme/*", "lodash"]);
302 m.note_declared("@acme/lib");
303 assert!(m.suppresses("lodash"));
304 assert!(m.unmatched_globs().is_empty());
305 assert_eq!(m.redundant_entries(), vec!["@acme/*"]);
306
307 m.reset_usage();
308 assert!(
309 m.unmatched_globs().is_empty() && m.redundant_entries().is_empty(),
310 "a pass that checked no declared dependency reports nothing"
311 );
312 m.note_declared("react");
313 assert_eq!(m.unmatched_globs(), vec!["@acme/*"]);
314 assert_eq!(m.redundant_entries(), vec!["lodash"]);
315 }
316
317 #[test]
318 fn redundant_entries_are_the_entries_that_hid_no_finding() {
319 let m = matcher(&["left-pad", "@acme/*", "@typo/*", "lodash", "react"]);
320 m.note_declared("left-pad");
321 m.note_declared("@acme/lib");
322 assert!(m.suppresses("lodash"));
323 assert!(!m.suppresses("vue"));
324 assert_eq!(
325 m.redundant_entries(),
326 vec!["left-pad", "@acme/*", "react"],
327 "an unmatched glob is reported by unmatched_globs instead"
328 );
329 assert_eq!(m.unmatched_globs(), vec!["@typo/*"]);
330 }
331
332 #[test]
333 fn a_suppressed_name_marks_every_matching_entry() {
334 let m = matcher(&["@acme/*", "@acme/lib", "@acme/{lib,ui}"]);
335 m.note_declared("@acme/lib");
336 assert!(m.suppresses("@acme/lib"));
337 assert!(m.redundant_entries().is_empty());
338 }
339
340 #[test]
341 fn metacharacter_detection() {
342 assert!(is_dependency_glob("@acme/*"));
343 assert!(is_dependency_glob("pkg-?"));
344 assert!(is_dependency_glob("pkg-[ab]"));
345 assert!(is_dependency_glob("{a,b}"));
346 assert!(!is_dependency_glob("@acme/lib"));
347 assert!(!is_dependency_glob("bun:sqlite"));
348 }
349}