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#[derive(Debug, Clone)]
23pub struct Scope {
24 include: GlobSet,
25 exclude: GlobSet,
26 has_include: bool,
27 #[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 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 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 pub fn scope_filter(&self) -> Option<&ScopeFilter> {
119 self.scope_filter.as_ref()
120 }
121
122 #[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 fn empty_index() -> FileIndex {
175 FileIndex::from_entries(Vec::new())
176 }
177
178 #[test]
179 fn star_does_not_cross_path_separator() {
180 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 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 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 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 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}