use super::file::compile_path_glob;
use crate::paths;
use anyhow::{Context, Result};
use globset::GlobMatcher;
use std::path::{Path, PathBuf};
pub(super) const CONFIG_KEY: &str = "[global] exclude";
#[derive(Debug, Clone)]
pub struct ExcludeSet {
base: PathBuf,
rules: Vec<Rule>,
}
#[derive(Debug, Clone)]
struct Rule {
root: PathBuf,
matcher: GlobMatcher,
directory: Option<GlobMatcher>,
}
impl ExcludeSet {
pub fn new(base: &Path) -> Self {
Self {
base: paths::normalize_lexical(base),
rules: Vec::new(),
}
}
pub fn add(&mut self, root: &Path, patterns: &[String], key: &str) -> Result<()> {
let root = paths::normalize_lexical(root);
for pattern in patterns {
let compile =
|glob: &str| compile_path_glob(glob).with_context(|| format!("Invalid glob in {key}: {pattern:?}"));
self.rules.push(Rule {
root: root.clone(),
matcher: compile(pattern)?,
directory: pattern.strip_suffix("/**").map(compile).transpose()?,
});
}
Ok(())
}
pub fn is_excluded(&self, path: &Path) -> bool {
self.matches(path, false)
}
pub fn prunes_dir(&self, dir: &Path) -> bool {
self.matches(dir, true)
}
pub fn walk_filter(&self) -> impl Fn(&ignore::DirEntry) -> bool + Send + Sync + 'static {
let excludes = self.clone();
move |entry| {
if entry.file_type().is_some_and(|kind| kind.is_dir()) {
!excludes.prunes_dir(entry.path())
} else {
!excludes.is_excluded(entry.path())
}
}
}
fn matches(&self, path: &Path, as_directory: bool) -> bool {
if self.rules.is_empty() {
return false;
}
let candidate = paths::absolute_normalized(&self.base, path);
self.rules.iter().any(|rule| {
let relative = paths::repo_relative(&rule.root, &candidate);
let target = relative.as_deref().unwrap_or(&candidate);
rule.matcher.is_match(target)
|| (as_directory && rule.directory.as_ref().is_some_and(|matcher| matcher.is_match(target)))
})
}
}
pub(super) fn validate(patterns: &[String], key: &str) -> Result<()> {
let mut set = ExcludeSet::new(Path::new(""));
set.add(Path::new(""), patterns, key)
}
#[cfg(test)]
mod tests {
use super::*;
fn set(root: &str, patterns: &[&str]) -> ExcludeSet {
let patterns: Vec<String> = patterns.iter().map(|pattern| (*pattern).to_string()).collect();
let mut set = ExcludeSet::new(Path::new("/repo"));
set.add(Path::new(root), &patterns, CONFIG_KEY).expect("compile globs");
set
}
#[test]
fn every_spelling_of_one_path_answers_alike() {
let set = set("/repo", &["playground/**"]);
for spelling in [
"playground/a.py",
"./playground/a.py",
"/repo/playground/a.py",
"/repo/./playground/a.py",
"src/../playground/a.py",
] {
assert!(set.is_excluded(Path::new(spelling)), "{spelling} should be excluded");
}
assert!(!set.is_excluded(Path::new("src/a.py")));
assert!(!set.is_excluded(Path::new("/elsewhere/playground/a.py")));
}
#[test]
fn a_subtree_rule_covers_every_depth_and_prunes_its_own_directory() {
let set = set("/repo", &["playground/**"]);
assert!(set.is_excluded(Path::new("playground/a.py")));
assert!(set.is_excluded(Path::new("playground/deep/inner/a.py")));
assert!(!set.is_excluded(Path::new("playground")));
assert!(set.prunes_dir(Path::new("playground")));
assert!(!set.prunes_dir(Path::new("src")));
}
#[test]
fn a_glob_is_anchored_at_the_directory_it_was_declared_in() {
let set = set("/repo/pkg", &["generated/**"]);
assert!(set.is_excluded(Path::new("/repo/pkg/generated/a.py")));
assert!(!set.is_excluded(Path::new("/repo/generated/a.py")));
}
#[test]
fn a_candidate_outside_the_anchor_is_matched_as_an_absolute_path() {
let set = set("/home/user/.config/uncomment", &["**/node_modules/**"]);
assert!(set.is_excluded(Path::new("/repo/pkg/node_modules/left-pad/index.js")));
assert!(!set.is_excluded(Path::new("/repo/pkg/src/index.js")));
}
#[test]
fn a_glob_that_does_not_compile_names_the_setting_and_the_glob() {
let mut set = ExcludeSet::new(Path::new("/repo"));
let error = set
.add(Path::new("/repo"), &["playground/[".to_string()], CONFIG_KEY)
.expect_err("an unclosed character class is not a glob");
let message = format!("{error:#}");
assert!(message.contains(CONFIG_KEY), "{message}");
assert!(message.contains("playground/["), "{message}");
}
#[test]
fn an_empty_set_excludes_nothing() {
let set = ExcludeSet::new(Path::new("/repo"));
assert!(!set.is_excluded(Path::new("playground/a.py")));
assert!(!set.prunes_dir(Path::new("playground")));
}
}