use std::collections::BTreeMap;
use std::sync::{Mutex, MutexGuard, PoisonError};
use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
use regex::RegexSet;
use skif_core::HashMap;
use crate::input::ScopedEntryRegex;
#[derive(Debug, Clone, Default)]
pub(crate) struct Globs {
include: GlobSet,
exclude: GlobSet,
has_include: bool,
include_regex: Option<EntryRegexes>,
also: Option<Box<Globs>>,
}
fn compile(pattern: &str) -> Vec<Glob> {
let pattern = pattern.trim().trim_start_matches("./").trim_end_matches('!');
if pattern.is_empty() {
return Vec::new();
}
skif_js_plugins::glob::expand_empty_alternatives(pattern)
.iter()
.filter_map(|variant| GlobBuilder::new(variant).literal_separator(true).build().ok())
.collect()
}
fn build(globs: Vec<Glob>) -> GlobSet {
let mut builder = GlobSetBuilder::new();
for glob in globs {
builder.add(glob);
}
builder.build().unwrap_or_default()
}
impl Globs {
pub(crate) fn new<I, S>(patterns: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut include = Vec::new();
let mut exclude = Vec::new();
for pattern in patterns {
let pattern = pattern.as_ref();
if let Some(negated) = pattern.strip_prefix('!') {
exclude.extend(compile(negated));
} else {
include.extend(compile(pattern));
}
}
Self {
has_include: !include.is_empty(),
include: build(include),
exclude: build(exclude),
include_regex: None,
also: None,
}
}
#[must_use]
pub(crate) fn with_regexes(mut self, regexes: Option<EntryRegexes>) -> Self {
self.has_include |= regexes.is_some();
self.include_regex = regexes;
self
}
#[must_use]
pub(crate) fn or(mut self, other: Self) -> Self {
if !other.is_empty() {
self.also = Some(Box::new(other));
}
self
}
pub(crate) fn is_empty(&self) -> bool {
!self.has_include && self.also.as_ref().is_none_or(|also| also.is_empty())
}
pub(crate) fn is_match(&self, path: &str) -> bool {
(self.includes(path) && !self.exclude.is_match(path))
|| self.also.as_ref().is_some_and(|also| also.is_match(path))
}
pub(crate) fn matches_any(&self, path: &str) -> bool {
self.includes(path) || self.also.as_ref().is_some_and(|also| also.matches_any(path))
}
fn includes(&self, path: &str) -> bool {
self.has_include
&& (self.include.is_match(path)
|| self
.include_regex
.as_ref()
.is_some_and(|regexes| regexes.is_match(path)))
}
}
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct IncompleteScopeGlobs {
pub(crate) patterns: Vec<String>,
pub(crate) globs: Globs,
}
impl IncompleteScopeGlobs {
#[must_use]
pub fn new(patterns: Vec<String>) -> Self {
Self {
globs: Globs::new(&patterns),
patterns,
}
}
#[must_use]
pub fn patterns(&self) -> &[String] {
&self.patterns
}
#[must_use]
pub fn is_match(&self, rel: &str) -> bool {
self.globs.is_match(rel)
}
}
#[derive(Debug, Clone)]
pub(crate) struct EntryRegexes {
package: Option<RegexSet>,
scoped: Vec<(String, RegexSet)>,
}
impl EntryRegexes {
pub(crate) fn new(package: &[String], scoped: &[ScopedEntryRegex]) -> Option<Self> {
let mut by_scope: BTreeMap<String, Vec<&str>> = BTreeMap::new();
for regex in scoped {
let scope = format!("{}/", regex.scope.trim_end_matches('/'));
by_scope.entry(scope).or_default().push(®ex.regex);
}
let regexes = Self {
package: regex_set(package),
scoped: by_scope
.into_iter()
.filter_map(|(scope, regexes)| regex_set(regexes.as_slice()).map(|set| (scope, set)))
.collect(),
};
(regexes.package.is_some() || !regexes.scoped.is_empty()).then_some(regexes)
}
fn is_match(&self, path: &str) -> bool {
self.package
.as_ref()
.is_some_and(|set| set.is_match(&format!("/{path}")))
|| self.scoped.iter().any(|(scope, set)| {
path.starts_with(scope.as_str()) && set.is_match(&path[scope.len() - 1..])
})
}
}
fn regex_set<S: AsRef<str>>(regexes: &[S]) -> Option<RegexSet> {
if regexes.is_empty() {
None
} else {
RegexSet::new(regexes).ok()
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Names {
set: GlobSet,
}
impl Names {
pub(crate) fn new<I, S>(patterns: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let globs = patterns
.into_iter()
.flat_map(|pattern| skif_js_plugins::glob::expand_empty_alternatives(pattern.as_ref()))
.filter_map(|pattern| Glob::new(&pattern).ok())
.collect();
Self { set: build(globs) }
}
pub(crate) fn is_match(&self, name: &str) -> bool {
self.set.is_match(name)
}
}
#[derive(Debug, Default)]
pub(crate) struct GlobCache {
compiled: Mutex<HashMap<Vec<String>, Globs>>,
}
impl GlobCache {
pub(crate) fn globs(&self, patterns: &[String]) -> Globs {
if let Some(found) = self.lock().get(patterns) {
return found.clone();
}
let compiled = Globs::new(patterns);
self.lock().entry(patterns.to_vec()).or_insert(compiled).clone()
}
fn lock(&self) -> MutexGuard<'_, HashMap<Vec<String>, Globs>> {
self.compiled.lock().unwrap_or_else(PoisonError::into_inner)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_cache_compiles_each_pattern_list_once_and_matches_as_compiled() {
let cache = GlobCache::default();
let patterns = vec!["src/**/*.ts".to_owned(), "!**/*.test.ts".to_owned()];
let first = cache.globs(&patterns);
let second = cache.globs(&patterns);
let other = cache.globs(&["lib/**".to_owned()]);
assert_eq!(cache.lock().len(), 2);
for globs in [&first, &second] {
assert!(globs.is_match("src/a.ts"));
assert!(!globs.is_match("src/a.test.ts"));
assert!(!globs.is_match("lib/a.ts"));
}
assert!(other.is_match("lib/a.ts"));
assert!(cache.globs(&[]).is_empty());
}
#[test]
fn include_and_exclude() {
let globs = Globs::new(["**/*.ts", "!**/*.test.ts", "./src/**/*.{js,jsx}"]);
assert!(globs.is_match("src/a.ts"));
assert!(globs.is_match("a.ts"));
assert!(!globs.is_match("src/a.test.ts"));
assert!(globs.is_match("src/deep/b.jsx"));
assert!(!globs.is_match("lib/b.jsx"));
assert!(!Globs::new(["!a"]).is_match("a"));
}
#[test]
fn a_narrowed_incomplete_scope_matches_paths_relative_to_the_package() {
let patterns = vec!["src/**/*.stories.tsx".to_owned(), "./.storybook/*.ts".to_owned()];
let scope = IncompleteScopeGlobs::new(patterns);
assert!(scope.is_match("src/ui/Button.stories.tsx"));
assert!(scope.is_match(".storybook/main.ts"));
assert!(!scope.is_match("src/ui/Button.tsx"));
assert!(!scope.is_match(".storybook/deep/main.ts"), "`*` stops at `/`");
assert_eq!(scope.patterns(), ["src/**/*.stories.tsx", "./.storybook/*.ts"]);
assert!(!IncompleteScopeGlobs::new(Vec::new()).is_match("src/a.ts"));
}
#[test]
fn regular_expressions_match_the_path_after_a_slash_and_the_exclusions_apply() {
let regexes = [r"\.check\.js$".to_owned(), r"\/__tests__\/".to_owned()];
let jest = Globs::new(["**/__mocks__/**/*.js", "!vendor/**"]).with_regexes(EntryRegexes::new(®exes, &[]));
assert!(jest.is_match("src/a.check.js"));
assert!(jest.is_match("src/__tests__/a.js"));
assert!(jest.is_match("__tests__/a.js"));
assert!(jest.is_match("__mocks__/fs.js"));
assert!(!jest.is_match("src/a.test.js"));
assert!(!jest.is_match("src/x__tests__/a.js"));
assert!(!jest.is_match("vendor/a.check.js"));
assert!(jest.matches_any("vendor/a.check.js"));
let only = Globs::new(Vec::<String>::new()).with_regexes(EntryRegexes::new(&[r"^/e2e/".to_owned()], &[]));
assert!(!only.is_empty());
assert!(only.is_match("e2e/flow.js"));
assert!(!only.is_match("lib/e2e/flow.js"));
assert!(EntryRegexes::new(&[], &[]).is_none());
assert!(Globs::new(Vec::<String>::new()).with_regexes(None).is_empty());
}
#[test]
fn scoped_regular_expressions_match_below_their_scope_only() {
let scoped = |regex: &str| ScopedEntryRegex {
scope: "apps/web".to_owned(),
regex: regex.to_owned(),
};
let globs = Globs::new(Vec::<String>::new()).with_regexes(EntryRegexes::new(
&[],
&[scoped(r".*\.spec\.ts$"), scoped(r"^.*\.e2e\.ts$")],
));
assert!(globs.is_match("apps/web/src/a.spec.ts"));
assert!(globs.is_match("apps/web/a.spec.ts"));
assert!(globs.is_match("apps/web/e2e/flow.e2e.ts"));
assert!(!globs.is_match("apps/web2/a.spec.ts"));
assert!(!globs.is_match("apps/web2/flow.e2e.ts"));
assert!(!globs.is_match("apps/web.spec.ts"));
assert!(!globs.is_match("apps/api/a.spec.ts"));
assert!(!globs.is_match("a.spec.ts"));
let anchored = Globs::new(Vec::<String>::new()).with_regexes(EntryRegexes::new(&[], &[scoped("^/e2e/")]));
assert!(anchored.is_match("apps/web/e2e/a.ts"));
assert!(!anchored.is_match("apps/web/src/e2e/a.ts"));
assert!(!anchored.is_match("e2e/a.ts"));
let exact = ScopedEntryRegex {
scope: "apps/web/".to_owned(),
regex: r"^/a\.ts$".to_owned(),
};
let exact = Globs::new(Vec::<String>::new()).with_regexes(EntryRegexes::new(&[], &[exact]));
assert!(exact.is_match("apps/web/a.ts"));
assert!(!exact.is_match("apps/web/src/a.ts"));
assert!(!exact.is_match("apps/weba.ts"));
}
#[test]
fn names() {
let names = Names::new(["@types/*", "eslint"]);
assert!(names.is_match("@types/node"));
assert!(names.is_match("eslint"));
assert!(!names.is_match("eslint-plugin-x"));
}
#[test]
fn single_star_stays_in_one_segment() {
let globs = Globs::new(["src/*.ts"]);
assert!(globs.is_match("src/a.ts"));
assert!(!globs.is_match("src/deep/a.ts"));
}
}