Skip to main content

fallow_config/config/
finding_ignore.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5
6/// Compiled project-relative patterns for hiding source-owned findings.
7///
8/// Positive patterns select paths to hide. Negated patterns (prefixed with
9/// `!`) select report exceptions. When every pattern is negated, all paths
10/// outside those exceptions are hidden, matching Knip's `ignore` semantics.
11#[derive(Debug, Clone, Default)]
12pub struct FindingIgnoreMatcher {
13    hidden: GlobSet,
14    reported: GlobSet,
15    usage: Option<Arc<PatternUsage>>,
16}
17
18/// Per-pattern hit state for the shared matcher.
19///
20/// The matcher is cloned along with the resolved config and consulted from
21/// several pipeline stages, so the state lives behind an `Arc` and every clone
22/// records into the same run.
23#[derive(Debug)]
24struct PatternUsage {
25    patterns: Vec<String>,
26    /// Original pattern index for each glob in `hidden`, in build order.
27    hidden_origins: Vec<usize>,
28    /// Original pattern index for each glob in `reported`, in build order.
29    reported_origins: Vec<usize>,
30    matched: Vec<AtomicBool>,
31    consulted: AtomicBool,
32}
33
34impl PatternUsage {
35    fn record(&self, origins: &[usize], set_indices: &[usize]) {
36        for &index in set_indices {
37            if let Some(&origin) = origins.get(index)
38                && let Some(flag) = self.matched.get(origin)
39            {
40                flag.store(true, Ordering::Relaxed);
41            }
42        }
43    }
44}
45
46/// Compiled glob sets plus the mapping back to the configured pattern order.
47struct CompiledSets {
48    hidden: GlobSet,
49    reported: GlobSet,
50    hidden_origins: Vec<usize>,
51    reported_origins: Vec<usize>,
52}
53
54impl FindingIgnoreMatcher {
55    #[expect(
56        clippy::expect_used,
57        reason = "ignoreFindings patterns are validated before config resolution"
58    )]
59    pub(crate) fn compile(patterns: &[String]) -> Self {
60        if patterns.is_empty() {
61            return Self::default();
62        }
63
64        let sets = Self::build_sets(patterns)
65            .expect("ignoreFindings pattern sets were validated before config resolution");
66
67        Self {
68            hidden: sets.hidden,
69            reported: sets.reported,
70            usage: Some(Arc::new(PatternUsage {
71                patterns: patterns.to_vec(),
72                hidden_origins: sets.hidden_origins,
73                reported_origins: sets.reported_origins,
74                matched: patterns.iter().map(|_| AtomicBool::new(false)).collect(),
75                consulted: AtomicBool::new(false),
76            })),
77        }
78    }
79
80    pub(super) fn validate_compilation(patterns: &[String]) -> Result<(), globset::Error> {
81        Self::build_sets(patterns).map(|_| ())
82    }
83
84    fn build_sets(patterns: &[String]) -> Result<CompiledSets, globset::Error> {
85        let mut hidden = GlobSetBuilder::new();
86        let mut reported = GlobSetBuilder::new();
87        let mut hidden_origins = Vec::new();
88        let mut reported_origins = Vec::new();
89
90        for (index, pattern) in patterns.iter().enumerate() {
91            let (builder, origins, pattern) = if let Some(pattern) = pattern.strip_prefix('!') {
92                (&mut reported, &mut reported_origins, pattern)
93            } else {
94                (&mut hidden, &mut hidden_origins, pattern.as_str())
95            };
96            let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
97            builder.add(Glob::new(pattern)?);
98            origins.push(index);
99        }
100
101        Ok(CompiledSets {
102            hidden: hidden.build()?,
103            reported: reported.build()?,
104            hidden_origins,
105            reported_origins,
106        })
107    }
108
109    /// Whether no finding-ignore patterns were configured.
110    #[must_use]
111    pub const fn is_empty(&self) -> bool {
112        self.usage.is_none()
113    }
114
115    /// Whether a normalized project-root-relative source path is hidden.
116    #[must_use]
117    pub fn is_ignored(&self, path: &str) -> bool {
118        let Some(usage) = self.usage.as_deref() else {
119            return false;
120        };
121        usage.consulted.store(true, Ordering::Relaxed);
122
123        // Both sets are matched unconditionally so a negated pattern is not
124        // reported as a no-op merely because the positive set short-circuited.
125        let mut indices = Vec::new();
126        self.hidden.matches_into(path, &mut indices);
127        let hidden_hit = !indices.is_empty();
128        usage.record(&usage.hidden_origins, &indices);
129
130        self.reported.matches_into(path, &mut indices);
131        let reported_hit = !indices.is_empty();
132        usage.record(&usage.reported_origins, &indices);
133
134        (self.hidden.is_empty() || hidden_hit) && !reported_hit
135    }
136
137    /// Configured patterns that matched no candidate finding path, in config
138    /// order.
139    ///
140    /// Empty when nothing was configured or when the matcher was never
141    /// consulted, because a run without candidate paths says nothing about
142    /// whether a pattern is a typo.
143    #[must_use]
144    pub fn unmatched_patterns(&self) -> Vec<&str> {
145        let Some(usage) = self.usage.as_deref() else {
146            return Vec::new();
147        };
148        if !usage.consulted.load(Ordering::Relaxed) {
149            return Vec::new();
150        }
151        usage
152            .patterns
153            .iter()
154            .zip(&usage.matched)
155            .filter(|(_, matched)| !matched.load(Ordering::Relaxed))
156            .map(|(pattern, _)| pattern.as_str())
157            .collect()
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn matcher(patterns: &[&str]) -> FindingIgnoreMatcher {
166        FindingIgnoreMatcher::compile(
167            &patterns
168                .iter()
169                .map(|pattern| (*pattern).to_string())
170                .collect::<Vec<_>>(),
171        )
172    }
173
174    #[test]
175    fn empty_matcher_ignores_nothing() {
176        let matcher = matcher(&[]);
177
178        assert!(matcher.is_empty());
179        assert!(!matcher.is_ignored("src/app.ts"));
180    }
181
182    #[test]
183    fn positive_patterns_hide_matching_paths() {
184        let matcher = matcher(&["**/*.test.ts"]);
185
186        assert!(matcher.is_ignored("src/app.test.ts"));
187        assert!(!matcher.is_ignored("src/app.ts"));
188    }
189
190    #[test]
191    fn negated_patterns_keep_matching_paths_reported() {
192        let matcher = matcher(&["**/*.ts", "!src/public/**"]);
193
194        assert!(matcher.is_ignored("src/private/app.ts"));
195        assert!(!matcher.is_ignored("src/public/app.ts"));
196    }
197
198    #[test]
199    fn negated_only_patterns_report_only_matching_paths() {
200        let matcher = matcher(&["!src/public/**"]);
201
202        assert!(matcher.is_ignored("src/private/app.ts"));
203        assert!(!matcher.is_ignored("src/public/app.ts"));
204    }
205
206    #[test]
207    fn leading_dot_slash_is_normalized_after_negation() {
208        let matcher = matcher(&["!./src/public/**"]);
209
210        assert!(matcher.is_ignored("src/private/app.ts"));
211        assert!(!matcher.is_ignored("src/public/app.ts"));
212    }
213
214    #[test]
215    fn pattern_order_does_not_change_set_semantics() {
216        let first = matcher(&["**/*.ts", "!src/public/**"]);
217        let second = matcher(&["!src/public/**", "**/*.ts"]);
218
219        for path in ["src/private/app.ts", "src/public/app.ts", "README.md"] {
220            assert_eq!(first.is_ignored(path), second.is_ignored(path));
221        }
222    }
223
224    #[test]
225    fn empty_configuration_reports_no_unmatched_patterns() {
226        let matcher = matcher(&[]);
227
228        assert!(!matcher.is_ignored("src/app.ts"));
229        assert!(matcher.unmatched_patterns().is_empty());
230    }
231
232    #[test]
233    fn matching_pattern_is_not_reported_as_unmatched() {
234        let matcher = matcher(&["**/*.test.ts"]);
235
236        assert!(matcher.is_ignored("src/app.test.ts"));
237        assert!(matcher.unmatched_patterns().is_empty());
238    }
239
240    #[test]
241    fn pattern_matching_nothing_is_reported() {
242        let matcher = matcher(&["**/*.test.ts", "src/legcy/**"]);
243
244        assert!(matcher.is_ignored("src/app.test.ts"));
245        assert_eq!(matcher.unmatched_patterns(), vec!["src/legcy/**"]);
246    }
247
248    #[test]
249    fn unconsulted_matcher_reports_no_unmatched_patterns() {
250        let matcher = matcher(&["src/legcy/**"]);
251
252        assert!(matcher.unmatched_patterns().is_empty());
253    }
254
255    #[test]
256    fn negated_pattern_matching_nothing_is_reported() {
257        let matcher = matcher(&["**/*.ts", "!src/public/**", "!src/publik/**"]);
258
259        assert!(matcher.is_ignored("src/private/app.ts"));
260        assert!(!matcher.is_ignored("src/public/app.ts"));
261        assert_eq!(matcher.unmatched_patterns(), vec!["!src/publik/**"]);
262    }
263
264    #[test]
265    fn usage_state_is_shared_across_clones() {
266        let matcher = matcher(&["**/*.test.ts"]);
267        let clone = matcher.clone();
268
269        assert!(clone.is_ignored("src/app.test.ts"));
270        assert!(matcher.unmatched_patterns().is_empty());
271    }
272
273    #[test]
274    fn matches_dotfiles_and_forward_slash_paths() {
275        let matcher = matcher(&["**/*.test.ts", ".storybook/**"]);
276
277        assert!(matcher.is_ignored("packages/ui/src/button.test.ts"));
278        assert!(matcher.is_ignored(".storybook/preview.ts"));
279    }
280}