Skip to main content

fallow_config/config/
finding_ignore.rs

1use globset::{Glob, GlobSet, GlobSetBuilder};
2
3/// Compiled project-relative patterns for hiding source-owned findings.
4///
5/// Positive patterns select paths to hide. Negated patterns (prefixed with
6/// `!`) select report exceptions. When every pattern is negated, all paths
7/// outside those exceptions are hidden, matching Knip's `ignore` semantics.
8#[derive(Debug, Clone, Default)]
9pub struct FindingIgnoreMatcher {
10    hidden: GlobSet,
11    reported: GlobSet,
12    has_patterns: bool,
13}
14
15impl FindingIgnoreMatcher {
16    #[expect(
17        clippy::expect_used,
18        reason = "ignoreFindings patterns are validated before config resolution"
19    )]
20    pub(crate) fn compile(patterns: &[String]) -> Self {
21        if patterns.is_empty() {
22            return Self::default();
23        }
24
25        let (hidden, reported) = Self::build_sets(patterns)
26            .expect("ignoreFindings pattern sets were validated before config resolution");
27
28        Self {
29            hidden,
30            reported,
31            has_patterns: true,
32        }
33    }
34
35    pub(super) fn validate_compilation(patterns: &[String]) -> Result<(), globset::Error> {
36        Self::build_sets(patterns).map(|_| ())
37    }
38
39    fn build_sets(patterns: &[String]) -> Result<(GlobSet, GlobSet), globset::Error> {
40        let mut hidden = GlobSetBuilder::new();
41        let mut reported = GlobSetBuilder::new();
42
43        for pattern in patterns {
44            let (builder, pattern) = if let Some(pattern) = pattern.strip_prefix('!') {
45                (&mut reported, pattern)
46            } else {
47                (&mut hidden, pattern.as_str())
48            };
49            let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
50            builder.add(Glob::new(pattern)?);
51        }
52
53        Ok((hidden.build()?, reported.build()?))
54    }
55
56    /// Whether no finding-ignore patterns were configured.
57    #[must_use]
58    pub const fn is_empty(&self) -> bool {
59        !self.has_patterns
60    }
61
62    /// Whether a normalized project-root-relative source path is hidden.
63    #[must_use]
64    pub fn is_ignored(&self, path: &str) -> bool {
65        self.has_patterns
66            && (self.hidden.is_empty() || self.hidden.is_match(path))
67            && !self.reported.is_match(path)
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    fn matcher(patterns: &[&str]) -> FindingIgnoreMatcher {
76        FindingIgnoreMatcher::compile(
77            &patterns
78                .iter()
79                .map(|pattern| (*pattern).to_string())
80                .collect::<Vec<_>>(),
81        )
82    }
83
84    #[test]
85    fn empty_matcher_ignores_nothing() {
86        let matcher = matcher(&[]);
87
88        assert!(matcher.is_empty());
89        assert!(!matcher.is_ignored("src/app.ts"));
90    }
91
92    #[test]
93    fn positive_patterns_hide_matching_paths() {
94        let matcher = matcher(&["**/*.test.ts"]);
95
96        assert!(matcher.is_ignored("src/app.test.ts"));
97        assert!(!matcher.is_ignored("src/app.ts"));
98    }
99
100    #[test]
101    fn negated_patterns_keep_matching_paths_reported() {
102        let matcher = matcher(&["**/*.ts", "!src/public/**"]);
103
104        assert!(matcher.is_ignored("src/private/app.ts"));
105        assert!(!matcher.is_ignored("src/public/app.ts"));
106    }
107
108    #[test]
109    fn negated_only_patterns_report_only_matching_paths() {
110        let matcher = matcher(&["!src/public/**"]);
111
112        assert!(matcher.is_ignored("src/private/app.ts"));
113        assert!(!matcher.is_ignored("src/public/app.ts"));
114    }
115
116    #[test]
117    fn leading_dot_slash_is_normalized_after_negation() {
118        let matcher = matcher(&["!./src/public/**"]);
119
120        assert!(matcher.is_ignored("src/private/app.ts"));
121        assert!(!matcher.is_ignored("src/public/app.ts"));
122    }
123
124    #[test]
125    fn pattern_order_does_not_change_set_semantics() {
126        let first = matcher(&["**/*.ts", "!src/public/**"]);
127        let second = matcher(&["!src/public/**", "**/*.ts"]);
128
129        for path in ["src/private/app.ts", "src/public/app.ts", "README.md"] {
130            assert_eq!(first.is_ignored(path), second.is_ignored(path));
131        }
132    }
133
134    #[test]
135    fn matches_dotfiles_and_forward_slash_paths() {
136        let matcher = matcher(&["**/*.test.ts", ".storybook/**"]);
137
138        assert!(matcher.is_ignored("packages/ui/src/button.test.ts"));
139        assert!(matcher.is_ignored(".storybook/preview.ts"));
140    }
141}