use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use globset::{Glob, GlobSet, GlobSetBuilder};
#[derive(Debug, Clone, Default)]
pub struct FindingIgnoreMatcher {
hidden: GlobSet,
reported: GlobSet,
usage: Option<Arc<PatternUsage>>,
}
#[derive(Debug)]
struct PatternUsage {
patterns: Vec<String>,
hidden_origins: Vec<usize>,
reported_origins: Vec<usize>,
matched: Vec<AtomicBool>,
consulted: AtomicBool,
}
impl PatternUsage {
fn record(&self, origins: &[usize], set_indices: &[usize]) {
for &index in set_indices {
if let Some(&origin) = origins.get(index)
&& let Some(flag) = self.matched.get(origin)
{
flag.store(true, Ordering::Relaxed);
}
}
}
}
struct CompiledSets {
hidden: GlobSet,
reported: GlobSet,
hidden_origins: Vec<usize>,
reported_origins: Vec<usize>,
}
impl FindingIgnoreMatcher {
#[expect(
clippy::expect_used,
reason = "ignoreFindings patterns are validated before config resolution"
)]
pub(crate) fn compile(patterns: &[String]) -> Self {
if patterns.is_empty() {
return Self::default();
}
let sets = Self::build_sets(patterns)
.expect("ignoreFindings pattern sets were validated before config resolution");
Self {
hidden: sets.hidden,
reported: sets.reported,
usage: Some(Arc::new(PatternUsage {
patterns: patterns.to_vec(),
hidden_origins: sets.hidden_origins,
reported_origins: sets.reported_origins,
matched: patterns.iter().map(|_| AtomicBool::new(false)).collect(),
consulted: AtomicBool::new(false),
})),
}
}
pub(super) fn validate_compilation(patterns: &[String]) -> Result<(), globset::Error> {
Self::build_sets(patterns).map(|_| ())
}
fn build_sets(patterns: &[String]) -> Result<CompiledSets, globset::Error> {
let mut hidden = GlobSetBuilder::new();
let mut reported = GlobSetBuilder::new();
let mut hidden_origins = Vec::new();
let mut reported_origins = Vec::new();
for (index, pattern) in patterns.iter().enumerate() {
let (builder, origins, pattern) = if let Some(pattern) = pattern.strip_prefix('!') {
(&mut reported, &mut reported_origins, pattern)
} else {
(&mut hidden, &mut hidden_origins, pattern.as_str())
};
let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
builder.add(Glob::new(pattern)?);
origins.push(index);
}
Ok(CompiledSets {
hidden: hidden.build()?,
reported: reported.build()?,
hidden_origins,
reported_origins,
})
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.usage.is_none()
}
#[must_use]
pub fn is_ignored(&self, path: &str) -> bool {
let Some(usage) = self.usage.as_deref() else {
return false;
};
usage.consulted.store(true, Ordering::Relaxed);
let mut indices = Vec::new();
self.hidden.matches_into(path, &mut indices);
let hidden_hit = !indices.is_empty();
usage.record(&usage.hidden_origins, &indices);
self.reported.matches_into(path, &mut indices);
let reported_hit = !indices.is_empty();
usage.record(&usage.reported_origins, &indices);
(self.hidden.is_empty() || hidden_hit) && !reported_hit
}
#[must_use]
pub fn unmatched_patterns(&self) -> Vec<&str> {
let Some(usage) = self.usage.as_deref() else {
return Vec::new();
};
if !usage.consulted.load(Ordering::Relaxed) {
return Vec::new();
}
usage
.patterns
.iter()
.zip(&usage.matched)
.filter(|(_, matched)| !matched.load(Ordering::Relaxed))
.map(|(pattern, _)| pattern.as_str())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn matcher(patterns: &[&str]) -> FindingIgnoreMatcher {
FindingIgnoreMatcher::compile(
&patterns
.iter()
.map(|pattern| (*pattern).to_string())
.collect::<Vec<_>>(),
)
}
#[test]
fn empty_matcher_ignores_nothing() {
let matcher = matcher(&[]);
assert!(matcher.is_empty());
assert!(!matcher.is_ignored("src/app.ts"));
}
#[test]
fn positive_patterns_hide_matching_paths() {
let matcher = matcher(&["**/*.test.ts"]);
assert!(matcher.is_ignored("src/app.test.ts"));
assert!(!matcher.is_ignored("src/app.ts"));
}
#[test]
fn negated_patterns_keep_matching_paths_reported() {
let matcher = matcher(&["**/*.ts", "!src/public/**"]);
assert!(matcher.is_ignored("src/private/app.ts"));
assert!(!matcher.is_ignored("src/public/app.ts"));
}
#[test]
fn negated_only_patterns_report_only_matching_paths() {
let matcher = matcher(&["!src/public/**"]);
assert!(matcher.is_ignored("src/private/app.ts"));
assert!(!matcher.is_ignored("src/public/app.ts"));
}
#[test]
fn leading_dot_slash_is_normalized_after_negation() {
let matcher = matcher(&["!./src/public/**"]);
assert!(matcher.is_ignored("src/private/app.ts"));
assert!(!matcher.is_ignored("src/public/app.ts"));
}
#[test]
fn pattern_order_does_not_change_set_semantics() {
let first = matcher(&["**/*.ts", "!src/public/**"]);
let second = matcher(&["!src/public/**", "**/*.ts"]);
for path in ["src/private/app.ts", "src/public/app.ts", "README.md"] {
assert_eq!(first.is_ignored(path), second.is_ignored(path));
}
}
#[test]
fn empty_configuration_reports_no_unmatched_patterns() {
let matcher = matcher(&[]);
assert!(!matcher.is_ignored("src/app.ts"));
assert!(matcher.unmatched_patterns().is_empty());
}
#[test]
fn matching_pattern_is_not_reported_as_unmatched() {
let matcher = matcher(&["**/*.test.ts"]);
assert!(matcher.is_ignored("src/app.test.ts"));
assert!(matcher.unmatched_patterns().is_empty());
}
#[test]
fn pattern_matching_nothing_is_reported() {
let matcher = matcher(&["**/*.test.ts", "src/legcy/**"]);
assert!(matcher.is_ignored("src/app.test.ts"));
assert_eq!(matcher.unmatched_patterns(), vec!["src/legcy/**"]);
}
#[test]
fn unconsulted_matcher_reports_no_unmatched_patterns() {
let matcher = matcher(&["src/legcy/**"]);
assert!(matcher.unmatched_patterns().is_empty());
}
#[test]
fn negated_pattern_matching_nothing_is_reported() {
let matcher = matcher(&["**/*.ts", "!src/public/**", "!src/publik/**"]);
assert!(matcher.is_ignored("src/private/app.ts"));
assert!(!matcher.is_ignored("src/public/app.ts"));
assert_eq!(matcher.unmatched_patterns(), vec!["!src/publik/**"]);
}
#[test]
fn usage_state_is_shared_across_clones() {
let matcher = matcher(&["**/*.test.ts"]);
let clone = matcher.clone();
assert!(clone.is_ignored("src/app.test.ts"));
assert!(matcher.unmatched_patterns().is_empty());
}
#[test]
fn matches_dotfiles_and_forward_slash_paths() {
let matcher = matcher(&["**/*.test.ts", ".storybook/**"]);
assert!(matcher.is_ignored("packages/ui/src/button.test.ts"));
assert!(matcher.is_ignored(".storybook/preview.ts"));
}
}