Skip to main content

fallow_config/config/
dependency_ignore.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use rustc_hash::FxHashMap;
6
7/// Characters that turn an `ignoreDependencies` entry into a glob.
8///
9/// npm package names can not contain any of these, so an entry without them
10/// keeps the historical exact-name match.
11const GLOB_METACHARACTERS: [char; 4] = ['*', '?', '[', '{'];
12
13/// Whether an `ignoreDependencies` entry is a glob pattern rather than an
14/// exact package name.
15#[must_use]
16pub fn is_dependency_glob(entry: &str) -> bool {
17    entry.contains(GLOB_METACHARACTERS)
18}
19
20/// Compiled `ignoreDependencies` entries.
21///
22/// Entries without glob metacharacters match a package name exactly. Entries
23/// with glob metacharacters (`@acme/*`, `@types/*`) use the same glob syntax as
24/// `ignorePatterns`, matched against the package name.
25#[derive(Debug, Clone, Default)]
26pub struct IgnoreDependencyMatcher {
27    /// Exact package name to the config indices of the entries that name it.
28    exact: FxHashMap<String, Vec<usize>>,
29    globs: GlobSet,
30    /// Config index of each glob in `globs`, in build order.
31    glob_entries: Vec<usize>,
32    usage: Option<Arc<EntryUsage>>,
33}
34
35/// Per-entry hit state, shared across clones of the resolved config.
36#[derive(Debug)]
37struct EntryUsage {
38    entries: Vec<String>,
39    /// Set when a glob entry matched a package name.
40    matched: Vec<AtomicBool>,
41    /// Set when the entry hid a dependency finding that the run would
42    /// otherwise report.
43    suppressed: Vec<AtomicBool>,
44    /// Set when the declared dependencies of a manifest were checked, so an
45    /// unmatched glob or an entry that hid nothing is a real signal and not
46    /// only a skipped detector.
47    declared_checked: AtomicBool,
48}
49
50impl IgnoreDependencyMatcher {
51    /// Compile the configured `ignoreDependencies` entries.
52    ///
53    /// # Panics
54    ///
55    /// Panics when a glob entry is invalid. Config loading validates every
56    /// glob entry before resolution, so this does not happen for a loaded
57    /// config.
58    #[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    /// Whether no entries were configured.
98    #[must_use]
99    pub fn is_empty(&self) -> bool {
100        self.usage.is_none()
101    }
102
103    /// Config indices of the entries that match `package_name`.
104    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    /// Whether `package_name` is excluded by an exact entry or a glob entry.
118    #[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    /// Record a name declared in a `package.json` dependency section. Only
142    /// this check makes [`Self::unmatched_globs`] and
143    /// [`Self::redundant_entries`] report anything.
144    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    /// Whether an entry hides the finding that a detector found for
152    /// `package_name`. Call it only for a name that the detector reports when
153    /// no entry matches it: every matching entry is then recorded as one that
154    /// had an effect.
155    #[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    /// Forget the hits of an earlier analysis pass.
171    ///
172    /// A long-lived process (watch mode, the LSP, an engine session) keeps one
173    /// resolved config for many passes. Each dead-code pass calls this first,
174    /// so a glob that matched in an earlier pass is reported again when its
175    /// dependency is gone.
176    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    /// Glob entries that matched no dependency this run, in config order.
187    ///
188    /// Empty when no glob was configured or when no declared dependency was
189    /// checked, because such a run says nothing about a typo.
190    #[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    /// Entries that hid no dependency finding this run, in config order.
203    ///
204    /// A glob that matched no package name is left out, because
205    /// [`Self::unmatched_globs`] reports it. Empty when no declared
206    /// dependency was checked.
207    #[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}