use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use globset::{Glob, GlobSet, GlobSetBuilder};
use rustc_hash::FxHashMap;
const GLOB_METACHARACTERS: [char; 4] = ['*', '?', '[', '{'];
#[must_use]
pub fn is_dependency_glob(entry: &str) -> bool {
entry.contains(GLOB_METACHARACTERS)
}
#[derive(Debug, Clone, Default)]
pub struct IgnoreDependencyMatcher {
exact: FxHashMap<String, Vec<usize>>,
globs: GlobSet,
glob_entries: Vec<usize>,
usage: Option<Arc<EntryUsage>>,
}
#[derive(Debug)]
struct EntryUsage {
entries: Vec<String>,
matched: Vec<AtomicBool>,
suppressed: Vec<AtomicBool>,
declared_checked: AtomicBool,
}
impl IgnoreDependencyMatcher {
#[expect(
clippy::expect_used,
reason = "ignoreDependencies globs are validated before config resolution"
)]
#[must_use]
pub fn compile(entries: &[String]) -> Self {
let mut exact: FxHashMap<String, Vec<usize>> = FxHashMap::default();
let mut builder = GlobSetBuilder::new();
let mut glob_entries = Vec::new();
for (index, entry) in entries.iter().enumerate() {
if is_dependency_glob(entry) {
builder.add(
Glob::new(entry)
.expect("ignoreDependencies glob was validated at config load time"),
);
glob_entries.push(index);
} else {
exact.entry(entry.clone()).or_default().push(index);
}
}
let globs = builder
.build()
.expect("ignoreDependencies globs were validated at config load time");
let usage = (!entries.is_empty()).then(|| {
Arc::new(EntryUsage {
matched: entries.iter().map(|_| AtomicBool::new(false)).collect(),
suppressed: entries.iter().map(|_| AtomicBool::new(false)).collect(),
entries: entries.to_vec(),
declared_checked: AtomicBool::new(false),
})
});
Self {
exact,
globs,
glob_entries,
usage,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.usage.is_none()
}
fn matching_entries(&self, package_name: &str) -> Vec<usize> {
let mut indices = self.exact.get(package_name).cloned().unwrap_or_default();
if !self.glob_entries.is_empty() {
indices.extend(
self.globs
.matches(package_name)
.into_iter()
.filter_map(|glob_index| self.glob_entries.get(glob_index).copied()),
);
}
indices
}
#[must_use]
pub fn is_ignored(&self, package_name: &str) -> bool {
if self.exact.contains_key(package_name) {
return true;
}
if self.glob_entries.is_empty() {
return false;
}
let indices = self.globs.matches(package_name);
if let Some(usage) = self.usage.as_deref() {
for &glob_index in &indices {
if let Some(flag) = self
.glob_entries
.get(glob_index)
.and_then(|&index| usage.matched.get(index))
{
flag.store(true, Ordering::Relaxed);
}
}
}
!indices.is_empty()
}
pub fn note_declared(&self, package_name: &str) {
if let Some(usage) = self.usage.as_deref() {
usage.declared_checked.store(true, Ordering::Relaxed);
}
let _ = self.is_ignored(package_name);
}
#[must_use]
pub fn suppresses(&self, package_name: &str) -> bool {
if !self.is_ignored(package_name) {
return false;
}
if let Some(usage) = self.usage.as_deref() {
for index in self.matching_entries(package_name) {
if let Some(flag) = usage.suppressed.get(index) {
flag.store(true, Ordering::Relaxed);
}
}
}
true
}
pub fn reset_usage(&self) {
let Some(usage) = self.usage.as_deref() else {
return;
};
usage.declared_checked.store(false, Ordering::Relaxed);
for flag in usage.matched.iter().chain(&usage.suppressed) {
flag.store(false, Ordering::Relaxed);
}
}
#[must_use]
pub fn unmatched_globs(&self) -> Vec<&str> {
let Some(usage) = self.checked_usage() else {
return Vec::new();
};
self.glob_entries
.iter()
.filter(|&&index| !usage.matched[index].load(Ordering::Relaxed))
.map(|&index| usage.entries[index].as_str())
.collect()
}
#[must_use]
pub fn redundant_entries(&self) -> Vec<&str> {
let Some(usage) = self.checked_usage() else {
return Vec::new();
};
let unmatched_glob = |index: usize| {
self.glob_entries.contains(&index) && !usage.matched[index].load(Ordering::Relaxed)
};
usage
.entries
.iter()
.enumerate()
.filter(|&(index, _)| {
!usage.suppressed[index].load(Ordering::Relaxed) && !unmatched_glob(index)
})
.map(|(_, entry)| entry.as_str())
.collect()
}
fn checked_usage(&self) -> Option<&EntryUsage> {
self.usage
.as_deref()
.filter(|usage| usage.declared_checked.load(Ordering::Relaxed))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn matcher(entries: &[&str]) -> IgnoreDependencyMatcher {
IgnoreDependencyMatcher::compile(
&entries
.iter()
.map(|entry| (*entry).to_string())
.collect::<Vec<_>>(),
)
}
#[test]
fn empty_matcher_ignores_nothing() {
let m = matcher(&[]);
assert!(m.is_empty());
assert!(!m.is_ignored("lodash"));
assert!(m.unmatched_globs().is_empty());
}
#[test]
fn exact_entry_matches_only_the_same_name() {
let m = matcher(&["@acme/lib"]);
assert!(m.is_ignored("@acme/lib"));
assert!(!m.is_ignored("@acme/lib-extra"));
assert!(!m.is_ignored("@acme/other"));
}
#[test]
fn scope_glob_matches_every_package_in_the_scope() {
let m = matcher(&["@acme/*"]);
assert!(m.is_ignored("@acme/lib"));
assert!(m.is_ignored("@acme/ui"));
assert!(!m.is_ignored("@other/lib"));
assert!(!m.is_ignored("acme"));
}
#[test]
fn prefix_and_alternation_globs_match() {
let m = matcher(&["eslint-plugin-*", "{react,react-dom}"]);
assert!(m.is_ignored("eslint-plugin-react"));
assert!(m.is_ignored("react-dom"));
assert!(!m.is_ignored("eslint"));
}
#[test]
fn unmatched_globs_needs_a_declared_check() {
let m = matcher(&["@acme/*", "@typo/*", "lodash"]);
assert!(m.is_ignored("@acme/lib"));
assert!(
m.unmatched_globs().is_empty(),
"no declared dependency was checked yet"
);
m.note_declared("react");
assert_eq!(m.unmatched_globs(), vec!["@typo/*"]);
}
#[test]
fn clones_share_glob_usage() {
let m = matcher(&["@acme/*"]);
let clone = m.clone();
clone.note_declared("@acme/lib");
assert!(m.unmatched_globs().is_empty());
}
#[test]
fn reset_usage_forgets_hits_of_an_earlier_pass() {
let m = matcher(&["@acme/*", "lodash"]);
m.note_declared("@acme/lib");
assert!(m.suppresses("lodash"));
assert!(m.unmatched_globs().is_empty());
assert_eq!(m.redundant_entries(), vec!["@acme/*"]);
m.reset_usage();
assert!(
m.unmatched_globs().is_empty() && m.redundant_entries().is_empty(),
"a pass that checked no declared dependency reports nothing"
);
m.note_declared("react");
assert_eq!(m.unmatched_globs(), vec!["@acme/*"]);
assert_eq!(m.redundant_entries(), vec!["lodash"]);
}
#[test]
fn redundant_entries_are_the_entries_that_hid_no_finding() {
let m = matcher(&["left-pad", "@acme/*", "@typo/*", "lodash", "react"]);
m.note_declared("left-pad");
m.note_declared("@acme/lib");
assert!(m.suppresses("lodash"));
assert!(!m.suppresses("vue"));
assert_eq!(
m.redundant_entries(),
vec!["left-pad", "@acme/*", "react"],
"an unmatched glob is reported by unmatched_globs instead"
);
assert_eq!(m.unmatched_globs(), vec!["@typo/*"]);
}
#[test]
fn a_suppressed_name_marks_every_matching_entry() {
let m = matcher(&["@acme/*", "@acme/lib", "@acme/{lib,ui}"]);
m.note_declared("@acme/lib");
assert!(m.suppresses("@acme/lib"));
assert!(m.redundant_entries().is_empty());
}
#[test]
fn metacharacter_detection() {
assert!(is_dependency_glob("@acme/*"));
assert!(is_dependency_glob("pkg-?"));
assert!(is_dependency_glob("pkg-[ab]"));
assert!(is_dependency_glob("{a,b}"));
assert!(!is_dependency_glob("@acme/lib"));
assert!(!is_dependency_glob("bun:sqlite"));
}
}