Skip to main content

alint_core/
scope.rs

1use std::path::Path;
2
3use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
4
5use crate::config::{PathsSpec, RuleSpec};
6use crate::error::{Error, Result};
7use crate::scope_filter::ScopeFilter;
8use crate::walker::FileIndex;
9
10/// Compiled include/exclude matcher built from a [`PathsSpec`] or raw pattern list,
11/// optionally bundled with a [`ScopeFilter`] ancestor-manifest gate.
12///
13/// Patterns prefixed with `!` are treated as excludes when passed as a flat list.
14/// Paths are matched relative to the repository root. Globs are compiled with
15/// `literal_separator(true)` — i.e., Git-style semantics where `*` never
16/// crosses a path separator. `**` is required to descend into subdirectories.
17///
18/// The optional `scope_filter` is the v0.9.6 [`ScopeFilter`] gate (e.g.
19/// `has_ancestor: Cargo.toml`). v0.9.10 moved it into `Scope` so
20/// `matches(&Path, &FileIndex)` honours it on every call automatically —
21/// the v0.9.6/v0.9.7/v0.9.9 silent-no-op bug class can no longer recur.
22#[derive(Debug, Clone)]
23pub struct Scope {
24    include: GlobSet,
25    exclude: GlobSet,
26    has_include: bool,
27    // The field name `scope_filter` is intentional — the public
28    // accessor and the spec field share it, so renaming to
29    // `filter` would cost more clarity than it saves.
30    #[allow(clippy::struct_field_names)]
31    scope_filter: Option<ScopeFilter>,
32}
33
34fn compile(pattern: &str) -> Result<Glob> {
35    GlobBuilder::new(pattern)
36        .literal_separator(true)
37        .build()
38        .map_err(|source| Error::Glob {
39            pattern: pattern.to_string(),
40            source,
41        })
42}
43
44impl Scope {
45    pub fn from_patterns(patterns: &[String]) -> Result<Self> {
46        let mut include = GlobSetBuilder::new();
47        let mut exclude = GlobSetBuilder::new();
48        let mut has_include = false;
49        for pattern in patterns {
50            if let Some(rest) = pattern.strip_prefix('!') {
51                exclude.add(compile(rest)?);
52            } else {
53                include.add(compile(pattern)?);
54                has_include = true;
55            }
56        }
57        Ok(Self {
58            include: include.build().map_err(|source| Error::Glob {
59                pattern: patterns.join(","),
60                source,
61            })?,
62            exclude: exclude.build().map_err(|source| Error::Glob {
63                pattern: patterns.join(","),
64                source,
65            })?,
66            has_include,
67            scope_filter: None,
68        })
69    }
70
71    pub fn from_paths_spec(spec: &PathsSpec) -> Result<Self> {
72        match spec {
73            PathsSpec::Single(s) => Self::from_patterns(std::slice::from_ref(s)),
74            PathsSpec::Many(v) => Self::from_patterns(v),
75            PathsSpec::IncludeExclude { include, exclude } => {
76                let mut combined = include.clone();
77                for e in exclude {
78                    combined.push(format!("!{e}"));
79                }
80                Self::from_patterns(&combined)
81            }
82        }
83    }
84
85    /// Build a `Scope` from a [`RuleSpec`] — bundles the rule's
86    /// `paths:` (or match-all if absent) AND its `scope_filter:`
87    /// into a single value. This is the canonical constructor
88    /// for rule builders since v0.9.10; preferring it over
89    /// `from_paths_spec` is what compile-enforces every rule to
90    /// honour `scope_filter` (the v0.9.6/.7/.9 silent-no-op
91    /// bug class).
92    pub fn from_spec(spec: &RuleSpec) -> Result<Self> {
93        let mut scope = match &spec.paths {
94            Some(p) => Self::from_paths_spec(p)?,
95            None => Self::match_all(),
96        };
97        scope.scope_filter = spec.parse_scope_filter()?;
98        Ok(scope)
99    }
100
101    /// Match-all scope (used when no `paths` is configured on a rule).
102    pub fn match_all() -> Self {
103        let mut include = GlobSetBuilder::new();
104        include.add(compile("**").expect("`**` must compile"));
105        Self {
106            include: include.build().expect("`**` GlobSet must build"),
107            exclude: GlobSet::empty(),
108            has_include: true,
109            scope_filter: None,
110        }
111    }
112
113    /// Borrow the optional [`ScopeFilter`] this scope carries.
114    /// Used by dispatch sites (e.g. `for_each_dir`'s literal-
115    /// path bypass) that already have a `&Scope` in hand and
116    /// want to consult the filter without going through
117    /// [`matches`](Self::matches).
118    pub fn scope_filter(&self) -> Option<&ScopeFilter> {
119        self.scope_filter.as_ref()
120    }
121
122    /// Returns `true` iff `path` is in scope:
123    /// 1. Excluded patterns reject (dominant).
124    /// 2. Include patterns must match (skipped if no includes).
125    /// 3. `scope_filter` (if any) must match.
126    ///
127    /// The `index` argument is the engine's [`FileIndex`] —
128    /// required because `scope_filter` may need to walk
129    /// ancestors looking for a manifest (e.g.
130    /// `has_ancestor: Cargo.toml`). Callers that don't have a
131    /// `scope_filter` on this scope still pass it; the cost is
132    /// a single `Option::is_none` branch.
133    ///
134    /// `#[inline]` is load-bearing — this method runs on every
135    /// (rule, file) pair in the per-file dispatch hot loop.
136    /// Without it, cross-crate calls from `alint-rules` rules'
137    /// `evaluate` bodies don't inline through `thin` LTO and the
138    /// `Option<ScopeFilter>` None-branch becomes a non-inlined
139    /// function call (~40 % slowdown on S6 10k vs v0.9.9
140    /// without the hint).
141    #[inline]
142    pub fn matches(&self, path: &Path, index: &FileIndex) -> bool {
143        if self.exclude.is_match(path) {
144            return false;
145        }
146        if self.has_include && !self.include.is_match(path) {
147            return false;
148        }
149        if let Some(filter) = &self.scope_filter
150            && !filter.matches(path, index)
151        {
152            return false;
153        }
154        true
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn s(patterns: &[&str]) -> Scope {
163        Scope::from_patterns(
164            &patterns
165                .iter()
166                .map(|p| (*p).to_string())
167                .collect::<Vec<_>>(),
168        )
169        .unwrap()
170    }
171
172    /// Empty file index — sufficient for path-glob-only tests
173    /// where no `scope_filter` ancestor walk happens.
174    fn empty_index() -> FileIndex {
175        FileIndex::from_entries(Vec::new())
176    }
177
178    #[test]
179    fn star_does_not_cross_path_separator() {
180        // Git-style semantics — `*` never matches `/`.
181        let scope = s(&["src/*.rs"]);
182        let idx = empty_index();
183        assert!(scope.matches(Path::new("src/main.rs"), &idx));
184        assert!(!scope.matches(Path::new("src/sub/main.rs"), &idx));
185    }
186
187    #[test]
188    fn double_star_descends_into_subdirectories() {
189        let scope = s(&["src/**/*.rs"]);
190        let idx = empty_index();
191        assert!(scope.matches(Path::new("src/main.rs"), &idx));
192        assert!(scope.matches(Path::new("src/sub/main.rs"), &idx));
193        assert!(scope.matches(Path::new("src/a/b/c/d.rs"), &idx));
194    }
195
196    #[test]
197    fn excludes_apply_before_includes() {
198        // A path matched by both include and exclude is
199        // excluded — exclusion is the dominant operation.
200        let scope = s(&["src/**/*.rs", "!src/**/test_*.rs"]);
201        let idx = empty_index();
202        assert!(scope.matches(Path::new("src/main.rs"), &idx));
203        assert!(!scope.matches(Path::new("src/test_widget.rs"), &idx));
204        assert!(!scope.matches(Path::new("src/sub/test_thing.rs"), &idx));
205    }
206
207    #[test]
208    fn empty_pattern_list_matches_nothing() {
209        // No includes and no excludes → has_include is false
210        // (empty GlobSet) and exclude is empty. `matches` falls
211        // through to `has_include` → true (match-all). Caller
212        // is expected to use Scope::match_all() explicitly.
213        // Verifying actual behaviour rather than asserting an
214        // implicit assumption.
215        let scope = Scope::from_patterns(&[]).unwrap();
216        let idx = empty_index();
217        assert!(
218            scope.matches(Path::new("anything"), &idx),
219            "empty pattern list yields match-all (no excludes, no includes → has_include=false → matches)",
220        );
221    }
222
223    #[test]
224    fn match_all_helper_matches_every_path() {
225        let scope = Scope::match_all();
226        let idx = empty_index();
227        assert!(scope.matches(Path::new("a"), &idx));
228        assert!(scope.matches(Path::new("a/b/c.rs"), &idx));
229        assert!(scope.matches(Path::new("deeply/nested/path/with.ext"), &idx));
230    }
231
232    #[test]
233    fn from_paths_spec_handles_single_string() {
234        let scope = Scope::from_paths_spec(&PathsSpec::Single("src/**/*.rs".into())).unwrap();
235        let idx = empty_index();
236        assert!(scope.matches(Path::new("src/main.rs"), &idx));
237        assert!(!scope.matches(Path::new("docs/intro.md"), &idx));
238    }
239
240    #[test]
241    fn from_paths_spec_handles_many_strings() {
242        let scope = Scope::from_paths_spec(&PathsSpec::Many(vec![
243            "src/**/*.rs".into(),
244            "Cargo.toml".into(),
245        ]))
246        .unwrap();
247        let idx = empty_index();
248        assert!(scope.matches(Path::new("src/main.rs"), &idx));
249        assert!(scope.matches(Path::new("Cargo.toml"), &idx));
250        assert!(!scope.matches(Path::new("docs/intro.md"), &idx));
251    }
252
253    #[test]
254    fn from_paths_spec_handles_include_exclude_form() {
255        let scope = Scope::from_paths_spec(&PathsSpec::IncludeExclude {
256            include: vec!["src/**/*.rs".into()],
257            exclude: vec!["src/**/test_*.rs".into()],
258        })
259        .unwrap();
260        let idx = empty_index();
261        assert!(scope.matches(Path::new("src/main.rs"), &idx));
262        assert!(!scope.matches(Path::new("src/test_x.rs"), &idx));
263    }
264
265    #[test]
266    fn invalid_glob_surfaces_clear_error() {
267        let err = Scope::from_patterns(&["[unterminated".into()]).unwrap_err();
268        let s = err.to_string();
269        assert!(s.contains("[unterminated"), "missing pattern: {s}");
270    }
271
272    #[test]
273    fn brace_expansion_works() {
274        let scope = s(&["src/**/*.{rs,toml}"]);
275        let idx = empty_index();
276        assert!(scope.matches(Path::new("src/main.rs"), &idx));
277        assert!(scope.matches(Path::new("src/Cargo.toml"), &idx));
278        assert!(!scope.matches(Path::new("src/README.md"), &idx));
279    }
280
281    #[test]
282    fn from_spec_bundles_paths_and_scope_filter() {
283        // Synthesise a RuleSpec carrying both paths and scope_filter.
284        // Index has marker.lock at pkg/ — only files under pkg/
285        // satisfy the ancestor predicate.
286        use crate::walker::FileEntry;
287        let yaml = "id: t\nkind: filename_case\npaths: \"**/*.rs\"\n\
288                    scope_filter:\n  has_ancestor: marker.lock\n\
289                    case: snake\nlevel: error\n";
290        let spec: RuleSpec = serde_yaml_ng::from_str(yaml).unwrap();
291        let scope = Scope::from_spec(&spec).unwrap();
292        let entries = vec![
293            FileEntry {
294                path: Path::new("pkg/marker.lock").into(),
295                is_dir: false,
296                size: 1,
297            },
298            FileEntry {
299                path: Path::new("pkg/in_scope.rs").into(),
300                is_dir: false,
301                size: 1,
302            },
303            FileEntry {
304                path: Path::new("other/out_of_scope.rs").into(),
305                is_dir: false,
306                size: 1,
307            },
308        ];
309        let idx = FileIndex::from_entries(entries);
310        // Path glob matches both .rs files; scope_filter narrows
311        // to only the one under pkg/ (marker.lock ancestor).
312        assert!(scope.matches(Path::new("pkg/in_scope.rs"), &idx));
313        assert!(!scope.matches(Path::new("other/out_of_scope.rs"), &idx));
314        assert!(scope.scope_filter().is_some(), "filter should be wired");
315    }
316}