Skip to main content

alint_core/
scope.rs

1use std::path::Path;
2
3use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
4
5use crate::config::PathsSpec;
6use crate::error::{Error, Result};
7
8/// Compiled include/exclude matcher built from a [`PathsSpec`] or raw pattern list.
9///
10/// Patterns prefixed with `!` are treated as excludes when passed as a flat list.
11/// Paths are matched relative to the repository root. Globs are compiled with
12/// `literal_separator(true)` — i.e., Git-style semantics where `*` never
13/// crosses a path separator. `**` is required to descend into subdirectories.
14#[derive(Debug, Clone)]
15pub struct Scope {
16    include: GlobSet,
17    exclude: GlobSet,
18    has_include: bool,
19}
20
21fn compile(pattern: &str) -> Result<Glob> {
22    GlobBuilder::new(pattern)
23        .literal_separator(true)
24        .build()
25        .map_err(|source| Error::Glob {
26            pattern: pattern.to_string(),
27            source,
28        })
29}
30
31impl Scope {
32    pub fn from_patterns(patterns: &[String]) -> Result<Self> {
33        let mut include = GlobSetBuilder::new();
34        let mut exclude = GlobSetBuilder::new();
35        let mut has_include = false;
36        for pattern in patterns {
37            if let Some(rest) = pattern.strip_prefix('!') {
38                exclude.add(compile(rest)?);
39            } else {
40                include.add(compile(pattern)?);
41                has_include = true;
42            }
43        }
44        Ok(Self {
45            include: include.build().map_err(|source| Error::Glob {
46                pattern: patterns.join(","),
47                source,
48            })?,
49            exclude: exclude.build().map_err(|source| Error::Glob {
50                pattern: patterns.join(","),
51                source,
52            })?,
53            has_include,
54        })
55    }
56
57    pub fn from_paths_spec(spec: &PathsSpec) -> Result<Self> {
58        match spec {
59            PathsSpec::Single(s) => Self::from_patterns(std::slice::from_ref(s)),
60            PathsSpec::Many(v) => Self::from_patterns(v),
61            PathsSpec::IncludeExclude { include, exclude } => {
62                let mut combined = include.clone();
63                for e in exclude {
64                    combined.push(format!("!{e}"));
65                }
66                Self::from_patterns(&combined)
67            }
68        }
69    }
70
71    /// Match-all scope (used when no `paths` is configured on a rule).
72    pub fn match_all() -> Self {
73        let mut include = GlobSetBuilder::new();
74        include.add(compile("**").expect("`**` must compile"));
75        Self {
76            include: include.build().expect("`**` GlobSet must build"),
77            exclude: GlobSet::empty(),
78            has_include: true,
79        }
80    }
81
82    pub fn matches(&self, path: &Path) -> bool {
83        if self.exclude.is_match(path) {
84            return false;
85        }
86        if !self.has_include {
87            return true;
88        }
89        self.include.is_match(path)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    fn s(patterns: &[&str]) -> Scope {
98        Scope::from_patterns(
99            &patterns
100                .iter()
101                .map(|p| (*p).to_string())
102                .collect::<Vec<_>>(),
103        )
104        .unwrap()
105    }
106
107    #[test]
108    fn star_does_not_cross_path_separator() {
109        // Git-style semantics — `*` never matches `/`.
110        let scope = s(&["src/*.rs"]);
111        assert!(scope.matches(Path::new("src/main.rs")));
112        assert!(!scope.matches(Path::new("src/sub/main.rs")));
113    }
114
115    #[test]
116    fn double_star_descends_into_subdirectories() {
117        let scope = s(&["src/**/*.rs"]);
118        assert!(scope.matches(Path::new("src/main.rs")));
119        assert!(scope.matches(Path::new("src/sub/main.rs")));
120        assert!(scope.matches(Path::new("src/a/b/c/d.rs")));
121    }
122
123    #[test]
124    fn excludes_apply_before_includes() {
125        // A path matched by both include and exclude is
126        // excluded — exclusion is the dominant operation.
127        let scope = s(&["src/**/*.rs", "!src/**/test_*.rs"]);
128        assert!(scope.matches(Path::new("src/main.rs")));
129        assert!(!scope.matches(Path::new("src/test_widget.rs")));
130        assert!(!scope.matches(Path::new("src/sub/test_thing.rs")));
131    }
132
133    #[test]
134    fn empty_pattern_list_matches_nothing() {
135        // No includes and no excludes → has_include is false
136        // (empty GlobSet) and exclude is empty. `matches` falls
137        // through to `has_include` → true (match-all). Caller
138        // is expected to use Scope::match_all() explicitly.
139        // Verifying actual behaviour rather than asserting an
140        // implicit assumption.
141        let scope = Scope::from_patterns(&[]).unwrap();
142        assert!(
143            scope.matches(Path::new("anything")),
144            "empty pattern list yields match-all (no excludes, no includes → has_include=false → matches)",
145        );
146    }
147
148    #[test]
149    fn match_all_helper_matches_every_path() {
150        let scope = Scope::match_all();
151        assert!(scope.matches(Path::new("a")));
152        assert!(scope.matches(Path::new("a/b/c.rs")));
153        assert!(scope.matches(Path::new("deeply/nested/path/with.ext")));
154    }
155
156    #[test]
157    fn from_paths_spec_handles_single_string() {
158        let scope = Scope::from_paths_spec(&PathsSpec::Single("src/**/*.rs".into())).unwrap();
159        assert!(scope.matches(Path::new("src/main.rs")));
160        assert!(!scope.matches(Path::new("docs/intro.md")));
161    }
162
163    #[test]
164    fn from_paths_spec_handles_many_strings() {
165        let scope = Scope::from_paths_spec(&PathsSpec::Many(vec![
166            "src/**/*.rs".into(),
167            "Cargo.toml".into(),
168        ]))
169        .unwrap();
170        assert!(scope.matches(Path::new("src/main.rs")));
171        assert!(scope.matches(Path::new("Cargo.toml")));
172        assert!(!scope.matches(Path::new("docs/intro.md")));
173    }
174
175    #[test]
176    fn from_paths_spec_handles_include_exclude_form() {
177        let scope = Scope::from_paths_spec(&PathsSpec::IncludeExclude {
178            include: vec!["src/**/*.rs".into()],
179            exclude: vec!["src/**/test_*.rs".into()],
180        })
181        .unwrap();
182        assert!(scope.matches(Path::new("src/main.rs")));
183        assert!(!scope.matches(Path::new("src/test_x.rs")));
184    }
185
186    #[test]
187    fn invalid_glob_surfaces_clear_error() {
188        let err = Scope::from_patterns(&["[unterminated".into()]).unwrap_err();
189        let s = err.to_string();
190        assert!(s.contains("[unterminated"), "missing pattern: {s}");
191    }
192
193    #[test]
194    fn brace_expansion_works() {
195        let scope = s(&["src/**/*.{rs,toml}"]);
196        assert!(scope.matches(Path::new("src/main.rs")));
197        assert!(scope.matches(Path::new("src/Cargo.toml")));
198        assert!(!scope.matches(Path::new("src/README.md")));
199    }
200}