Skip to main content

fallow_config/config/
finding_ignore.rs

1use std::path::Path;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use fallow_types::discover::StableFileKey;
6use globset::{GlobSet, GlobSetBuilder};
7
8/// Compiled project-relative patterns for hiding source-owned findings.
9///
10/// Positive patterns select paths to hide. Negated patterns (prefixed with
11/// `!`) select report exceptions. When every pattern is negated, all paths
12/// outside those exceptions are hidden, matching Knip's `ignore` semantics.
13#[derive(Debug, Clone, Default)]
14pub struct FindingIgnoreMatcher {
15    hidden: GlobSet,
16    reported: GlobSet,
17    usage: Option<Arc<PatternUsage>>,
18}
19
20/// Per-pattern hit state for the shared matcher.
21///
22/// The matcher is cloned along with the resolved config and consulted from
23/// several pipeline stages, so the state lives behind an `Arc` and every clone
24/// records into the same run.
25#[derive(Debug)]
26struct PatternUsage {
27    patterns: Vec<String>,
28    /// Original pattern index for each glob in `hidden`, in build order.
29    hidden_origins: Vec<usize>,
30    /// Original pattern index for each glob in `reported`, in build order.
31    reported_origins: Vec<usize>,
32    matched: Vec<AtomicBool>,
33    consulted: AtomicBool,
34}
35
36impl PatternUsage {
37    fn record(&self, origins: &[usize], set_indices: &[usize]) {
38        for &index in set_indices {
39            if let Some(&origin) = origins.get(index)
40                && let Some(flag) = self.matched.get(origin)
41            {
42                flag.store(true, Ordering::Relaxed);
43            }
44        }
45    }
46}
47
48/// Compiled glob sets plus the mapping back to the configured pattern order.
49struct CompiledSets {
50    hidden: GlobSet,
51    reported: GlobSet,
52    hidden_origins: Vec<usize>,
53    reported_origins: Vec<usize>,
54}
55
56impl FindingIgnoreMatcher {
57    #[expect(
58        clippy::expect_used,
59        reason = "ignoreFindings patterns are validated before config resolution"
60    )]
61    pub(crate) fn compile(patterns: &[String]) -> Self {
62        if patterns.is_empty() {
63            return Self::default();
64        }
65
66        let sets = Self::build_sets(patterns)
67            .expect("ignoreFindings pattern sets were validated before config resolution");
68
69        Self {
70            hidden: sets.hidden,
71            reported: sets.reported,
72            usage: Some(Arc::new(PatternUsage {
73                patterns: patterns.to_vec(),
74                hidden_origins: sets.hidden_origins,
75                reported_origins: sets.reported_origins,
76                matched: patterns.iter().map(|_| AtomicBool::new(false)).collect(),
77                consulted: AtomicBool::new(false),
78            })),
79        }
80    }
81
82    /// Compile `ignoreFindings` patterns outside config resolution.
83    ///
84    /// # Errors
85    ///
86    /// Returns the glob error of the first invalid pattern.
87    pub fn try_compile(patterns: &[String]) -> Result<Self, globset::Error> {
88        Self::build_sets(patterns)?;
89        Ok(Self::compile(patterns))
90    }
91
92    pub(super) fn validate_compilation(patterns: &[String]) -> Result<(), globset::Error> {
93        Self::build_sets(patterns).map(|_| ())
94    }
95
96    fn build_sets(patterns: &[String]) -> Result<CompiledSets, globset::Error> {
97        let mut hidden = GlobSetBuilder::new();
98        let mut reported = GlobSetBuilder::new();
99        let mut hidden_origins = Vec::new();
100        let mut reported_origins = Vec::new();
101
102        for (index, pattern) in patterns.iter().enumerate() {
103            let (builder, origins, pattern) = if let Some(pattern) = pattern.strip_prefix('!') {
104                (&mut reported, &mut reported_origins, pattern)
105            } else {
106                (&mut hidden, &mut hidden_origins, pattern.as_str())
107            };
108            let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
109            builder.add(super::compile_path_glob(pattern)?);
110            origins.push(index);
111        }
112
113        Ok(CompiledSets {
114            hidden: hidden.build()?,
115            reported: reported.build()?,
116            hidden_origins,
117            reported_origins,
118        })
119    }
120
121    /// Whether no finding-ignore patterns were configured.
122    #[must_use]
123    pub const fn is_empty(&self) -> bool {
124        self.usage.is_none()
125    }
126
127    /// Whether a normalized project-root-relative source path is hidden.
128    #[must_use]
129    pub fn is_ignored(&self, path: &str) -> bool {
130        let Some(usage) = self.usage.as_deref() else {
131            return false;
132        };
133        usage.consulted.store(true, Ordering::Relaxed);
134
135        // Both sets are matched unconditionally so a negated pattern is not
136        // reported as a no-op merely because the positive set short-circuited.
137        let mut indices = Vec::new();
138        self.hidden.matches_into(path, &mut indices);
139        let hidden_hit = !indices.is_empty();
140        usage.record(&usage.hidden_origins, &indices);
141
142        self.reported.matches_into(path, &mut indices);
143        let reported_hit = !indices.is_empty();
144        usage.record(&usage.reported_origins, &indices);
145
146        (self.hidden.is_empty() || hidden_hit) && !reported_hit
147    }
148
149    /// Whether a source path is hidden, for an absolute or a root-relative path.
150    ///
151    /// An absolute path outside `root` is never hidden.
152    #[must_use]
153    pub fn is_ignored_path(&self, root: &Path, path: &Path) -> bool {
154        if self.is_empty() {
155            return false;
156        }
157        // Strip the root before the absolute check: a root such as `/project`
158        // is not absolute on Windows, and a path below it must still match.
159        let key = if let Ok(relative) = path.strip_prefix(root) {
160            StableFileKey::from_relative(relative)
161        } else if path.is_absolute() {
162            return false;
163        } else {
164            StableFileKey::from_relative(path)
165        };
166        self.is_ignored(key.as_str())
167    }
168
169    /// Forget the pattern hits of an earlier analysis pass.
170    ///
171    /// A long-lived process (watch mode, the LSP, an engine session) keeps one
172    /// resolved config for many passes. Each dead-code pass calls this first,
173    /// so a pattern that matched in an earlier pass is reported again when its
174    /// findings are gone.
175    pub fn reset_usage(&self) {
176        let Some(usage) = self.usage.as_deref() else {
177            return;
178        };
179        usage.consulted.store(false, Ordering::Relaxed);
180        for flag in &usage.matched {
181            flag.store(false, Ordering::Relaxed);
182        }
183    }
184
185    /// Configured patterns that matched no candidate finding path, in config
186    /// order.
187    ///
188    /// Empty when nothing was configured or when the matcher was never
189    /// consulted, because a run without candidate paths says nothing about
190    /// whether a pattern is a typo.
191    #[must_use]
192    pub fn unmatched_patterns(&self) -> Vec<&str> {
193        let Some(usage) = self.usage.as_deref() else {
194            return Vec::new();
195        };
196        if !usage.consulted.load(Ordering::Relaxed) {
197            return Vec::new();
198        }
199        usage
200            .patterns
201            .iter()
202            .zip(&usage.matched)
203            .filter(|(_, matched)| !matched.load(Ordering::Relaxed))
204            .map(|(pattern, _)| pattern.as_str())
205            .collect()
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn matcher(patterns: &[&str]) -> FindingIgnoreMatcher {
214        FindingIgnoreMatcher::compile(
215            &patterns
216                .iter()
217                .map(|pattern| (*pattern).to_string())
218                .collect::<Vec<_>>(),
219        )
220    }
221
222    #[test]
223    fn empty_matcher_ignores_nothing() {
224        let matcher = matcher(&[]);
225
226        assert!(matcher.is_empty());
227        assert!(!matcher.is_ignored("src/app.ts"));
228    }
229
230    #[test]
231    fn positive_patterns_hide_matching_paths() {
232        let matcher = matcher(&["**/*.test.ts"]);
233
234        assert!(matcher.is_ignored("src/app.test.ts"));
235        assert!(!matcher.is_ignored("src/app.ts"));
236    }
237
238    #[test]
239    fn path_lookup_accepts_absolute_and_relative_paths() {
240        let matcher = matcher(&["test/fixtures/**"]);
241        // A path such as `/project` is not absolute on Windows, so build the
242        // absolute paths from the platform temp directory.
243        let base = std::env::temp_dir();
244        let root = base.join("project");
245        let root = root.as_path();
246
247        assert!(matcher.is_ignored_path(root, &root.join("test/fixtures/bad.js")));
248        assert!(matcher.is_ignored_path(root, Path::new("test/fixtures/bad.js")));
249        assert!(!matcher.is_ignored_path(root, &root.join("src/app.js")));
250        assert!(!matcher.is_ignored_path(root, &base.join("elsewhere/test/fixtures/bad.js")));
251    }
252
253    #[test]
254    fn path_lookup_strips_a_root_that_is_not_absolute() {
255        let matcher = matcher(&["test/fixtures/**"]);
256        // `/project` has no drive prefix on Windows, so it is not absolute
257        // there. A path below it must still match relative to the root.
258        let root = Path::new("project");
259
260        assert!(matcher.is_ignored_path(root, &root.join("test/fixtures/bad.js")));
261        assert!(!matcher.is_ignored_path(root, &root.join("src/app.js")));
262    }
263
264    #[test]
265    fn negated_patterns_keep_matching_paths_reported() {
266        let matcher = matcher(&["**/*.ts", "!src/public/**"]);
267
268        assert!(matcher.is_ignored("src/private/app.ts"));
269        assert!(!matcher.is_ignored("src/public/app.ts"));
270    }
271
272    #[test]
273    fn negated_only_patterns_report_only_matching_paths() {
274        let matcher = matcher(&["!src/public/**"]);
275
276        assert!(matcher.is_ignored("src/private/app.ts"));
277        assert!(!matcher.is_ignored("src/public/app.ts"));
278    }
279
280    #[test]
281    fn leading_dot_slash_is_normalized_after_negation() {
282        let matcher = matcher(&["!./src/public/**"]);
283
284        assert!(matcher.is_ignored("src/private/app.ts"));
285        assert!(!matcher.is_ignored("src/public/app.ts"));
286    }
287
288    #[test]
289    fn pattern_order_does_not_change_set_semantics() {
290        let first = matcher(&["**/*.ts", "!src/public/**"]);
291        let second = matcher(&["!src/public/**", "**/*.ts"]);
292
293        for path in ["src/private/app.ts", "src/public/app.ts", "README.md"] {
294            assert_eq!(first.is_ignored(path), second.is_ignored(path));
295        }
296    }
297
298    #[test]
299    fn empty_configuration_reports_no_unmatched_patterns() {
300        let matcher = matcher(&[]);
301
302        assert!(!matcher.is_ignored("src/app.ts"));
303        assert!(matcher.unmatched_patterns().is_empty());
304    }
305
306    #[test]
307    fn matching_pattern_is_not_reported_as_unmatched() {
308        let matcher = matcher(&["**/*.test.ts"]);
309
310        assert!(matcher.is_ignored("src/app.test.ts"));
311        assert!(matcher.unmatched_patterns().is_empty());
312    }
313
314    #[test]
315    fn pattern_matching_nothing_is_reported() {
316        let matcher = matcher(&["**/*.test.ts", "src/legcy/**"]);
317
318        assert!(matcher.is_ignored("src/app.test.ts"));
319        assert_eq!(matcher.unmatched_patterns(), vec!["src/legcy/**"]);
320    }
321
322    #[test]
323    fn unconsulted_matcher_reports_no_unmatched_patterns() {
324        let matcher = matcher(&["src/legcy/**"]);
325
326        assert!(matcher.unmatched_patterns().is_empty());
327    }
328
329    #[test]
330    fn negated_pattern_matching_nothing_is_reported() {
331        let matcher = matcher(&["**/*.ts", "!src/public/**", "!src/publik/**"]);
332
333        assert!(matcher.is_ignored("src/private/app.ts"));
334        assert!(!matcher.is_ignored("src/public/app.ts"));
335        assert_eq!(matcher.unmatched_patterns(), vec!["!src/publik/**"]);
336    }
337
338    #[test]
339    fn usage_state_is_shared_across_clones() {
340        let matcher = matcher(&["**/*.test.ts"]);
341        let clone = matcher.clone();
342
343        assert!(clone.is_ignored("src/app.test.ts"));
344        assert!(matcher.unmatched_patterns().is_empty());
345    }
346
347    #[test]
348    fn reset_usage_forgets_hits_of_an_earlier_pass() {
349        let matcher = matcher(&["src/legacy/**"]);
350        assert!(matcher.is_ignored("src/legacy/old.ts"));
351        assert!(matcher.unmatched_patterns().is_empty());
352
353        matcher.reset_usage();
354        assert!(
355            matcher.unmatched_patterns().is_empty(),
356            "a pass that consulted nothing reports nothing"
357        );
358        assert!(!matcher.is_ignored("src/app.ts"));
359        assert_eq!(matcher.unmatched_patterns(), vec!["src/legacy/**"]);
360    }
361
362    #[test]
363    fn matches_dotfiles_and_forward_slash_paths() {
364        let matcher = matcher(&["**/*.test.ts", ".storybook/**"]);
365
366        assert!(matcher.is_ignored("packages/ui/src/button.test.ts"));
367        assert!(matcher.is_ignored(".storybook/preview.ts"));
368    }
369}