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::FxHashSet;
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: FxHashSet<String>,
28    globs: GlobSet,
29    usage: Option<Arc<GlobUsage>>,
30}
31
32/// Per-glob hit state, shared across clones of the resolved config.
33#[derive(Debug)]
34struct GlobUsage {
35    patterns: Vec<String>,
36    matched: Vec<AtomicBool>,
37    /// Set when the declared dependencies of a manifest were checked, so an
38    /// unmatched glob is a real signal and not only a skipped detector.
39    declared_checked: AtomicBool,
40}
41
42impl IgnoreDependencyMatcher {
43    /// Compile the configured `ignoreDependencies` entries.
44    ///
45    /// # Panics
46    ///
47    /// Panics when a glob entry is invalid. Config loading validates every
48    /// glob entry before resolution, so this does not happen for a loaded
49    /// config.
50    #[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    /// Whether no entries were configured.
88    #[must_use]
89    pub fn is_empty(&self) -> bool {
90        self.exact.is_empty() && self.usage.is_none()
91    }
92
93    /// Whether `package_name` is excluded by an exact entry or a glob entry.
94    #[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    /// Like [`Self::is_ignored`] for a name declared in a `package.json`
112    /// dependency section. Only this check makes
113    /// [`Self::unmatched_globs`] report anything.
114    #[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    /// Forget the glob hits of an earlier analysis pass.
123    ///
124    /// A long-lived process (watch mode, the LSP, an engine session) keeps one
125    /// resolved config for many passes. Each dead-code pass calls this first,
126    /// so a glob that matched in an earlier pass is reported again when its
127    /// dependency is gone.
128    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    /// Glob entries that matched no dependency this run, in config order.
139    ///
140    /// Empty when no glob was configured or when no declared dependency was
141    /// checked, because such a run says nothing about a typo.
142    #[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}