fallow_config/config/
dependency_ignore.rs1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use rustc_hash::FxHashSet;
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: FxHashSet<String>,
28 globs: GlobSet,
29 usage: Option<Arc<GlobUsage>>,
30}
31
32#[derive(Debug)]
34struct GlobUsage {
35 patterns: Vec<String>,
36 matched: Vec<AtomicBool>,
37 declared_checked: AtomicBool,
40}
41
42impl IgnoreDependencyMatcher {
43 #[expect(
51 clippy::expect_used,
52 reason = "ignoreDependencies globs are validated before config resolution"
53 )]
54 #[must_use]
55 pub fn compile(entries: &[String]) -> Self {
56 let mut exact = FxHashSet::default();
57 let mut builder = GlobSetBuilder::new();
58 let mut patterns = Vec::new();
59 for entry in entries {
60 if is_dependency_glob(entry) {
61 builder.add(
62 Glob::new(entry)
63 .expect("ignoreDependencies glob was validated at config load time"),
64 );
65 patterns.push(entry.clone());
66 } else {
67 exact.insert(entry.clone());
68 }
69 }
70 let globs = builder
71 .build()
72 .expect("ignoreDependencies globs were validated at config load time");
73 let usage = (!patterns.is_empty()).then(|| {
74 Arc::new(GlobUsage {
75 matched: patterns.iter().map(|_| AtomicBool::new(false)).collect(),
76 patterns,
77 declared_checked: AtomicBool::new(false),
78 })
79 });
80 Self {
81 exact,
82 globs,
83 usage,
84 }
85 }
86
87 #[must_use]
89 pub fn is_empty(&self) -> bool {
90 self.exact.is_empty() && self.usage.is_none()
91 }
92
93 #[must_use]
95 pub fn is_ignored(&self, package_name: &str) -> bool {
96 if self.exact.contains(package_name) {
97 return true;
98 }
99 let Some(usage) = self.usage.as_deref() else {
100 return false;
101 };
102 let indices = self.globs.matches(package_name);
103 for &index in &indices {
104 if let Some(flag) = usage.matched.get(index) {
105 flag.store(true, Ordering::Relaxed);
106 }
107 }
108 !indices.is_empty()
109 }
110
111 #[must_use]
115 pub fn is_declared_ignored(&self, package_name: &str) -> bool {
116 if let Some(usage) = self.usage.as_deref() {
117 usage.declared_checked.store(true, Ordering::Relaxed);
118 }
119 self.is_ignored(package_name)
120 }
121
122 pub fn reset_usage(&self) {
129 let Some(usage) = self.usage.as_deref() else {
130 return;
131 };
132 usage.declared_checked.store(false, Ordering::Relaxed);
133 for flag in &usage.matched {
134 flag.store(false, Ordering::Relaxed);
135 }
136 }
137
138 #[must_use]
143 pub fn unmatched_globs(&self) -> Vec<&str> {
144 let Some(usage) = self.usage.as_deref() else {
145 return Vec::new();
146 };
147 if !usage.declared_checked.load(Ordering::Relaxed) {
148 return Vec::new();
149 }
150 usage
151 .patterns
152 .iter()
153 .zip(&usage.matched)
154 .filter(|(_, matched)| !matched.load(Ordering::Relaxed))
155 .map(|(pattern, _)| pattern.as_str())
156 .collect()
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn matcher(entries: &[&str]) -> IgnoreDependencyMatcher {
165 IgnoreDependencyMatcher::compile(
166 &entries
167 .iter()
168 .map(|entry| (*entry).to_string())
169 .collect::<Vec<_>>(),
170 )
171 }
172
173 #[test]
174 fn empty_matcher_ignores_nothing() {
175 let m = matcher(&[]);
176 assert!(m.is_empty());
177 assert!(!m.is_ignored("lodash"));
178 assert!(m.unmatched_globs().is_empty());
179 }
180
181 #[test]
182 fn exact_entry_matches_only_the_same_name() {
183 let m = matcher(&["@acme/lib"]);
184 assert!(m.is_ignored("@acme/lib"));
185 assert!(!m.is_ignored("@acme/lib-extra"));
186 assert!(!m.is_ignored("@acme/other"));
187 }
188
189 #[test]
190 fn scope_glob_matches_every_package_in_the_scope() {
191 let m = matcher(&["@acme/*"]);
192 assert!(m.is_ignored("@acme/lib"));
193 assert!(m.is_ignored("@acme/ui"));
194 assert!(!m.is_ignored("@other/lib"));
195 assert!(!m.is_ignored("acme"));
196 }
197
198 #[test]
199 fn prefix_and_alternation_globs_match() {
200 let m = matcher(&["eslint-plugin-*", "{react,react-dom}"]);
201 assert!(m.is_ignored("eslint-plugin-react"));
202 assert!(m.is_ignored("react-dom"));
203 assert!(!m.is_ignored("eslint"));
204 }
205
206 #[test]
207 fn unmatched_globs_needs_a_declared_check() {
208 let m = matcher(&["@acme/*", "@typo/*", "lodash"]);
209 assert!(m.is_ignored("@acme/lib"));
210 assert!(
211 m.unmatched_globs().is_empty(),
212 "no declared dependency was checked yet"
213 );
214 assert!(!m.is_declared_ignored("react"));
215 assert_eq!(m.unmatched_globs(), vec!["@typo/*"]);
216 }
217
218 #[test]
219 fn clones_share_glob_usage() {
220 let m = matcher(&["@acme/*"]);
221 let clone = m.clone();
222 assert!(clone.is_declared_ignored("@acme/lib"));
223 assert!(m.unmatched_globs().is_empty());
224 }
225
226 #[test]
227 fn reset_usage_forgets_hits_of_an_earlier_pass() {
228 let m = matcher(&["@acme/*"]);
229 assert!(m.is_declared_ignored("@acme/lib"));
230 assert!(m.unmatched_globs().is_empty());
231
232 m.reset_usage();
233 assert!(
234 m.unmatched_globs().is_empty(),
235 "a pass that checked no declared dependency reports nothing"
236 );
237 assert!(!m.is_declared_ignored("react"));
238 assert_eq!(m.unmatched_globs(), vec!["@acme/*"]);
239 }
240
241 #[test]
242 fn metacharacter_detection() {
243 assert!(is_dependency_glob("@acme/*"));
244 assert!(is_dependency_glob("pkg-?"));
245 assert!(is_dependency_glob("pkg-[ab]"));
246 assert!(is_dependency_glob("{a,b}"));
247 assert!(!is_dependency_glob("@acme/lib"));
248 assert!(!is_dependency_glob("bun:sqlite"));
249 }
250}