use std::path::Path;
use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
use crate::config::{PathsSpec, RuleSpec};
use crate::error::{Error, Result};
use crate::scope_filter::ScopeFilter;
use crate::walker::FileIndex;
#[derive(Debug, Clone)]
pub struct Scope {
include: GlobSet,
exclude: GlobSet,
has_include: bool,
#[allow(clippy::struct_field_names)]
scope_filter: Option<ScopeFilter>,
}
fn compile(pattern: &str) -> Result<Glob> {
GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.map_err(|source| Error::Glob {
pattern: pattern.to_string(),
source,
})
}
impl Scope {
pub fn from_patterns(patterns: &[String]) -> Result<Self> {
let mut include = GlobSetBuilder::new();
let mut exclude = GlobSetBuilder::new();
let mut has_include = false;
for pattern in patterns {
if let Some(rest) = pattern.strip_prefix('!') {
exclude.add(compile(rest)?);
} else {
include.add(compile(pattern)?);
has_include = true;
}
}
Ok(Self {
include: include.build().map_err(|source| Error::Glob {
pattern: patterns.join(","),
source,
})?,
exclude: exclude.build().map_err(|source| Error::Glob {
pattern: patterns.join(","),
source,
})?,
has_include,
scope_filter: None,
})
}
pub fn from_paths_spec(spec: &PathsSpec) -> Result<Self> {
match spec {
PathsSpec::Single(s) => Self::from_patterns(std::slice::from_ref(s)),
PathsSpec::Many(v) => Self::from_patterns(v),
PathsSpec::IncludeExclude { include, exclude } => {
let mut combined = include.clone();
for e in exclude {
combined.push(format!("!{e}"));
}
Self::from_patterns(&combined)
}
}
}
pub fn from_spec(spec: &RuleSpec) -> Result<Self> {
let mut scope = match &spec.paths {
Some(p) => Self::from_paths_spec(p)?,
None => Self::match_all(),
};
scope.scope_filter = spec.parse_scope_filter()?;
Ok(scope)
}
pub fn match_all() -> Self {
let mut include = GlobSetBuilder::new();
include.add(compile("**").expect("`**` must compile"));
Self {
include: include.build().expect("`**` GlobSet must build"),
exclude: GlobSet::empty(),
has_include: true,
scope_filter: None,
}
}
pub fn scope_filter(&self) -> Option<&ScopeFilter> {
self.scope_filter.as_ref()
}
#[inline]
pub fn matches(&self, path: &Path, index: &FileIndex) -> bool {
if self.exclude.is_match(path) {
return false;
}
if self.has_include && !self.include.is_match(path) {
return false;
}
if let Some(filter) = &self.scope_filter
&& !filter.matches(path, index)
{
return false;
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn s(patterns: &[&str]) -> Scope {
Scope::from_patterns(
&patterns
.iter()
.map(|p| (*p).to_string())
.collect::<Vec<_>>(),
)
.unwrap()
}
fn empty_index() -> FileIndex {
FileIndex::from_entries(Vec::new())
}
#[test]
fn star_does_not_cross_path_separator() {
let scope = s(&["src/*.rs"]);
let idx = empty_index();
assert!(scope.matches(Path::new("src/main.rs"), &idx));
assert!(!scope.matches(Path::new("src/sub/main.rs"), &idx));
}
#[test]
fn double_star_descends_into_subdirectories() {
let scope = s(&["src/**/*.rs"]);
let idx = empty_index();
assert!(scope.matches(Path::new("src/main.rs"), &idx));
assert!(scope.matches(Path::new("src/sub/main.rs"), &idx));
assert!(scope.matches(Path::new("src/a/b/c/d.rs"), &idx));
}
#[test]
fn excludes_apply_before_includes() {
let scope = s(&["src/**/*.rs", "!src/**/test_*.rs"]);
let idx = empty_index();
assert!(scope.matches(Path::new("src/main.rs"), &idx));
assert!(!scope.matches(Path::new("src/test_widget.rs"), &idx));
assert!(!scope.matches(Path::new("src/sub/test_thing.rs"), &idx));
}
#[test]
fn empty_pattern_list_matches_nothing() {
let scope = Scope::from_patterns(&[]).unwrap();
let idx = empty_index();
assert!(
scope.matches(Path::new("anything"), &idx),
"empty pattern list yields match-all (no excludes, no includes → has_include=false → matches)",
);
}
#[test]
fn match_all_helper_matches_every_path() {
let scope = Scope::match_all();
let idx = empty_index();
assert!(scope.matches(Path::new("a"), &idx));
assert!(scope.matches(Path::new("a/b/c.rs"), &idx));
assert!(scope.matches(Path::new("deeply/nested/path/with.ext"), &idx));
}
#[test]
fn from_paths_spec_handles_single_string() {
let scope = Scope::from_paths_spec(&PathsSpec::Single("src/**/*.rs".into())).unwrap();
let idx = empty_index();
assert!(scope.matches(Path::new("src/main.rs"), &idx));
assert!(!scope.matches(Path::new("docs/intro.md"), &idx));
}
#[test]
fn from_paths_spec_handles_many_strings() {
let scope = Scope::from_paths_spec(&PathsSpec::Many(vec![
"src/**/*.rs".into(),
"Cargo.toml".into(),
]))
.unwrap();
let idx = empty_index();
assert!(scope.matches(Path::new("src/main.rs"), &idx));
assert!(scope.matches(Path::new("Cargo.toml"), &idx));
assert!(!scope.matches(Path::new("docs/intro.md"), &idx));
}
#[test]
fn from_paths_spec_handles_include_exclude_form() {
let scope = Scope::from_paths_spec(&PathsSpec::IncludeExclude {
include: vec!["src/**/*.rs".into()],
exclude: vec!["src/**/test_*.rs".into()],
})
.unwrap();
let idx = empty_index();
assert!(scope.matches(Path::new("src/main.rs"), &idx));
assert!(!scope.matches(Path::new("src/test_x.rs"), &idx));
}
#[test]
fn invalid_glob_surfaces_clear_error() {
let err = Scope::from_patterns(&["[unterminated".into()]).unwrap_err();
let s = err.to_string();
assert!(s.contains("[unterminated"), "missing pattern: {s}");
}
#[test]
fn brace_expansion_works() {
let scope = s(&["src/**/*.{rs,toml}"]);
let idx = empty_index();
assert!(scope.matches(Path::new("src/main.rs"), &idx));
assert!(scope.matches(Path::new("src/Cargo.toml"), &idx));
assert!(!scope.matches(Path::new("src/README.md"), &idx));
}
#[test]
fn from_spec_bundles_paths_and_scope_filter() {
use crate::walker::FileEntry;
let yaml = "id: t\nkind: filename_case\npaths: \"**/*.rs\"\n\
scope_filter:\n has_ancestor: marker.lock\n\
case: snake\nlevel: error\n";
let spec: RuleSpec = serde_yaml_ng::from_str(yaml).unwrap();
let scope = Scope::from_spec(&spec).unwrap();
let entries = vec![
FileEntry {
path: Path::new("pkg/marker.lock").into(),
is_dir: false,
size: 1,
},
FileEntry {
path: Path::new("pkg/in_scope.rs").into(),
is_dir: false,
size: 1,
},
FileEntry {
path: Path::new("other/out_of_scope.rs").into(),
is_dir: false,
size: 1,
},
];
let idx = FileIndex::from_entries(entries);
assert!(scope.matches(Path::new("pkg/in_scope.rs"), &idx));
assert!(!scope.matches(Path::new("other/out_of_scope.rs"), &idx));
assert!(scope.scope_filter().is_some(), "filter should be wired");
}
}