use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use globset::{GlobSet, GlobSetBuilder};
pub const UNLIFTABLE_IGNORE_SEGMENTS: &[&str] = &["node_modules", ".git"];
const GLOB_META: &[char] = &['*', '?', '[', ']', '{', '}', '\\'];
#[derive(Debug, Clone)]
struct ExceptionScope {
prefix: PathBuf,
has_globstar: bool,
hidden_names: Vec<String>,
}
impl ExceptionScope {
fn parse(body: &str) -> Self {
let mut prefix = PathBuf::new();
let mut segments = body.split('/').filter(|segment| !segment.is_empty());
let mut rest: Vec<&str> = Vec::new();
for segment in segments.by_ref() {
if segment.contains(GLOB_META) {
rest.push(segment);
break;
}
prefix.push(segment);
}
rest.extend(segments);
Self {
prefix,
has_globstar: rest.iter().any(|segment| segment.contains("**")),
hidden_names: rest
.iter()
.filter(|segment| segment.starts_with('.') && !segment.contains(GLOB_META))
.map(|segment| (*segment).to_owned())
.collect(),
}
}
fn admits_hidden_dir(&self, dir: &Path) -> bool {
if self.prefix.starts_with(dir) {
return true;
}
if !dir.starts_with(&self.prefix) {
return false;
}
let named = dir
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| self.hidden_names.iter().any(|hidden| hidden == name));
named || (self.has_globstar && !self.prefix.as_os_str().is_empty())
}
}
#[derive(Debug, Clone, Default)]
pub struct IgnorePatternSet {
patterns: GlobSet,
exceptions: GlobSet,
exception_scopes: Vec<ExceptionScope>,
}
impl IgnorePatternSet {
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[must_use]
pub fn new(patterns: GlobSet, exception_bodies: &[&str]) -> Self {
let mut builder = GlobSetBuilder::new();
let mut exception_scopes = Vec::with_capacity(exception_bodies.len());
for body in exception_bodies {
if let Ok(glob) = globset::Glob::new(body) {
builder.add(glob);
exception_scopes.push(ExceptionScope::parse(body));
}
}
Self {
patterns,
exceptions: builder.build().unwrap_or_default(),
exception_scopes,
}
}
pub fn is_match(&self, path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
self.patterns.is_match(path) && !self.is_lifted(path)
}
pub fn matches_into(&self, path: impl AsRef<Path>, into: &mut Vec<usize>) {
self.patterns.matches_into(path.as_ref(), into);
}
#[must_use]
pub fn matches(&self, path: impl AsRef<Path>) -> Vec<usize> {
self.patterns.matches(path.as_ref())
}
#[must_use]
pub fn len(&self) -> usize {
self.patterns.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
}
#[must_use]
pub fn has_exceptions(&self) -> bool {
!self.exceptions.is_empty()
}
pub fn is_lifted(&self, path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
!self.exceptions.is_empty() && self.exceptions.is_match(path) && !is_unliftable(path)
}
#[must_use]
pub fn admits_hidden_dir(&self, dir: &Path) -> bool {
!is_unliftable(dir)
&& self
.exception_scopes
.iter()
.any(|scope| scope.admits_hidden_dir(dir))
}
}
impl From<GlobSet> for IgnorePatternSet {
fn from(patterns: GlobSet) -> Self {
Self::new(patterns, &[])
}
}
fn is_unliftable(path: &Path) -> bool {
path.components().any(|component| {
UNLIFTABLE_IGNORE_SEGMENTS
.iter()
.any(|segment| component.as_os_str() == OsStr::new(segment))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn set(patterns: &[&str], exceptions: &[&str]) -> IgnorePatternSet {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
builder.add(globset::Glob::new(pattern).expect("valid glob"));
}
IgnorePatternSet::new(builder.build().expect("valid set"), exceptions)
}
#[test]
fn an_exception_lifts_a_match() {
let ignores = set(&["**/coverage/**"], &["src/coverage/**"]);
assert!(!ignores.is_match("src/coverage/a.ts"));
assert!(ignores.is_match("coverage/a.ts"));
assert_eq!(ignores.matches("src/coverage/a.ts"), vec![0]);
}
#[test]
fn node_modules_and_git_are_never_lifted() {
let ignores = set(&["**/node_modules/**", "**/.git/**"], &["**"]);
assert!(ignores.is_match("node_modules/dep/index.ts"));
assert!(ignores.is_match(".git/hooks/pre-commit.js"));
assert!(!ignores.admits_hidden_dir(Path::new(".git")));
}
#[test]
fn hidden_directories_open_only_on_a_named_path() {
let exact = set(&[], &[".config/**"]);
assert!(exact.admits_hidden_dir(Path::new(".config")));
assert!(exact.admits_hidden_dir(Path::new(".config/.inner")));
assert!(!exact.admits_hidden_dir(Path::new(".cache")));
assert!(!exact.admits_hidden_dir(Path::new("packages/a/.config")));
let any_depth = set(&[], &["**/.config/**"]);
assert!(any_depth.admits_hidden_dir(Path::new("packages/a/.config")));
assert!(!any_depth.admits_hidden_dir(Path::new(".next")));
let broad = set(&[], &["**/*.ts"]);
assert!(!broad.admits_hidden_dir(Path::new(".next")));
let file = set(&[], &["a/.b/c.ts"]);
assert!(file.admits_hidden_dir(Path::new("a/.b")));
assert!(!file.admits_hidden_dir(Path::new("a/.c")));
}
}