use std::fmt;
use regex::RegexSet;
use crate::ModuleName;
#[derive(Clone, Debug, get_size2::GetSize)]
pub struct ModuleGlobSet {
#[get_size(ignore)]
regex_set: RegexSet,
globs: Box<[ModuleGlob]>,
}
impl ModuleGlobSet {
pub fn empty() -> Self {
Self {
regex_set: RegexSet::empty(),
globs: Box::default(),
}
}
pub fn from_patterns<I, S>(patterns: I) -> Result<Self, ModuleGlobError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut builder = ModuleGlobSetBuilder::new();
for pattern in patterns {
builder.add(pattern.as_ref())?;
}
builder.build()
}
pub fn matches(&self, module: &ModuleName) -> ModuleNameMatch {
if self.globs.is_empty() {
return ModuleNameMatch::None;
}
let Some(last_match_index) = self.regex_set.matches(module.as_str()).iter().next_back()
else {
return ModuleNameMatch::None;
};
if self.globs[last_match_index].negated {
ModuleNameMatch::Exclude
} else {
ModuleNameMatch::Include
}
}
}
impl PartialEq for ModuleGlobSet {
fn eq(&self, other: &Self) -> bool {
self.globs == other.globs
}
}
impl Eq for ModuleGlobSet {}
impl fmt::Display for ModuleGlobSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list()
.entries(self.globs.iter().map(|g| &g.original))
.finish()
}
}
#[derive(Debug, Default)]
pub struct ModuleGlobSetBuilder {
patterns: Vec<Box<str>>,
globs: Vec<ModuleGlob>,
}
impl ModuleGlobSetBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, pattern: &str) -> Result<&mut Self, ModuleGlobError> {
if pattern.is_empty() {
return Err(ModuleGlobError::EmptyPattern);
}
let (negated, pattern_without_negation) = if let Some(rest) = pattern.strip_prefix('!') {
(true, rest)
} else {
(false, pattern)
};
if pattern_without_negation.is_empty() {
return Err(ModuleGlobError::EmptyPattern);
}
let regex_pattern = glob_to_regex(pattern_without_negation)?;
self.patterns.push(regex_pattern);
self.globs.push(ModuleGlob {
original: pattern.into(),
negated,
});
Ok(self)
}
pub fn build(self) -> Result<ModuleGlobSet, ModuleGlobError> {
let regex_set = RegexSet::new(&self.patterns)?;
Ok(ModuleGlobSet {
regex_set,
globs: self.globs.into_boxed_slice(),
})
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ModuleNameMatch {
None,
Include,
Exclude,
}
impl ModuleNameMatch {
pub const fn is_include(self) -> bool {
matches!(self, ModuleNameMatch::Include)
}
pub const fn is_exclude(self) -> bool {
matches!(self, ModuleNameMatch::Exclude)
}
pub const fn is_none(self) -> bool {
matches!(self, ModuleNameMatch::None)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ModuleGlobError {
#[error("module glob pattern cannot be empty")]
EmptyPattern,
#[error("module glob pattern cannot start with a dot")]
LeadingDot,
#[error("module glob pattern cannot end with a dot")]
TrailingDot,
#[error("module glob pattern cannot contain consecutive dots")]
ConsecutiveDots,
#[error(
"`**` can only appear as a complete component (e.g., `foo.**` or `**.bar`), not combined with other text like `{0}`"
)]
InvalidDoubleStarUsage(Box<str>),
#[error("failed to compile module glob pattern")]
Regex(#[from] regex::Error),
}
#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)]
struct ModuleGlob {
original: Box<str>,
negated: bool,
}
fn glob_to_regex(pattern: &str) -> Result<Box<str>, ModuleGlobError> {
if pattern.is_empty() {
return Err(ModuleGlobError::EmptyPattern);
}
if pattern.starts_with('.') {
return Err(ModuleGlobError::LeadingDot);
}
if pattern.ends_with('.') {
return Err(ModuleGlobError::TrailingDot);
}
let mut regex = String::with_capacity(pattern.len());
regex.push('^');
let mut components = pattern.split('.').peekable();
let mut is_first = true;
let mut prev_was_double_star_at_start = false;
while let Some(component) = components.next() {
if component.is_empty() {
return Err(ModuleGlobError::ConsecutiveDots);
}
if component.contains("**") && component != "**" {
return Err(ModuleGlobError::InvalidDoubleStarUsage(Box::from(
component,
)));
}
let is_last = components.peek().is_none();
if component == "**" {
if is_first {
if is_last {
regex.push_str(".*");
} else {
regex.push_str("(?:[^.]+\\.)*");
prev_was_double_star_at_start = true;
}
} else {
regex.push_str("(?:\\.[^.]+)*");
}
} else {
if !is_first && !prev_was_double_star_at_start {
regex.push_str("\\.");
}
prev_was_double_star_at_start = false;
if component == "*" {
regex.push_str("[^.]+");
} else {
for c in component.chars() {
if c == '*' {
regex.push_str("[^.]*");
} else if regex_syntax::is_meta_character(c) {
regex.push('\\');
regex.push(c);
} else {
regex.push(c);
}
}
}
}
is_first = false;
}
regex.push('$');
Ok(regex.into_boxed_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[track_caller]
fn assert_include(set: &ModuleGlobSet, name: &str) {
let module = ModuleName::new(name).unwrap();
assert_eq!(
set.matches(&module),
ModuleNameMatch::Include,
"expected `{name}` to be included"
);
}
#[track_caller]
fn assert_excludes(set: &ModuleGlobSet, name: &str) {
let module = ModuleName::new(name).unwrap();
assert_eq!(
set.matches(&module),
ModuleNameMatch::Exclude,
"expected `{name}` to be excluded"
);
}
#[track_caller]
fn assert_no_match(set: &ModuleGlobSet, name: &str) {
let module = ModuleName::new(name).unwrap();
assert_eq!(
set.matches(&module),
ModuleNameMatch::None,
"expected `{name}` not to match"
);
}
#[test]
fn test_exact_match() {
let set = ModuleGlobSet::from_patterns(["test"]).unwrap();
assert_include(&set, "test");
assert_no_match(&set, "test2");
assert_no_match(&set, "test_foo");
assert_no_match(&set, "foo");
assert_no_match(&set, "test.foo");
}
#[test]
fn test_single_star_direct_submodule() {
let set = ModuleGlobSet::from_patterns(["test.*"]).unwrap();
assert_include(&set, "test.foo");
assert_include(&set, "test.bar");
assert_no_match(&set, "test");
assert_no_match(&set, "test.foo.bar");
}
#[test]
fn test_single_star_prefix() {
let set = ModuleGlobSet::from_patterns(["*.test"]).unwrap();
assert_include(&set, "foo.test");
assert_include(&set, "bar.test");
assert_no_match(&set, "test");
assert_no_match(&set, "foo.bar.test");
}
#[test]
fn test_single_star_middle() {
let set = ModuleGlobSet::from_patterns(["foo.*.bar"]).unwrap();
assert_include(&set, "foo.x.bar");
assert_include(&set, "foo.y.bar");
assert_no_match(&set, "foo.bar");
assert_no_match(&set, "foo.x.y.bar");
}
#[test]
fn test_star_with_literal_text() {
let set = ModuleGlobSet::from_patterns(["*test.bar"]).unwrap();
assert_include(&set, "test.bar");
assert_include(&set, "mytest.bar");
assert_no_match(&set, "foobar.bar");
}
#[test]
fn test_double_star_end() {
let set = ModuleGlobSet::from_patterns(["test.**"]).unwrap();
assert_include(&set, "test");
assert_include(&set, "test.foo");
assert_include(&set, "test.foo.bar");
assert_include(&set, "test.foo.bar.baz");
assert_no_match(&set, "testing");
}
#[test]
fn test_double_star_start() {
let set = ModuleGlobSet::from_patterns(["**.bar"]).unwrap();
assert_include(&set, "bar");
assert_include(&set, "foo.bar");
assert_include(&set, "foo.baz.bar");
assert_include(&set, "foo.baz.qux.bar");
assert_no_match(&set, "bar.foo");
}
#[test]
fn test_double_star_middle() {
let set = ModuleGlobSet::from_patterns(["test.**.bar"]).unwrap();
assert_include(&set, "test.bar");
assert_include(&set, "test.foo.bar");
assert_include(&set, "test.foo.baz.bar");
assert_include(&set, "test.foo.baz.qux.bar");
assert_no_match(&set, "test");
assert_no_match(&set, "test.bar.foo");
}
#[test]
fn test_just_double_star() {
let set = ModuleGlobSet::from_patterns(["**"]).unwrap();
assert_include(&set, "foo");
assert_include(&set, "foo.bar");
assert_include(&set, "foo.bar.baz");
}
#[test]
fn test_negated_pattern() {
let set = ModuleGlobSet::from_patterns(["test.*", "!test.internal"]).unwrap();
assert_include(&set, "test.foo");
assert_include(&set, "test.bar");
assert_excludes(&set, "test.internal");
}
#[test]
fn test_negated_pattern_override() {
let set = ModuleGlobSet::from_patterns(["!test.internal", "test.*"]).unwrap();
assert_include(&set, "test.foo");
assert_include(&set, "test.bar");
assert_include(&set, "test.internal");
}
#[test]
fn test_negated_only() {
let set = ModuleGlobSet::from_patterns(["!test"]).unwrap();
assert_excludes(&set, "test");
assert_no_match(&set, "other");
}
#[test]
fn test_empty_set() {
let set = ModuleGlobSet::from_patterns::<[&str; 0], _>([]).unwrap();
assert_no_match(&set, "test");
}
#[test]
fn test_display() {
let set = ModuleGlobSet::from_patterns(["test.*", "!test.internal"]).unwrap();
let display = format!("{set}");
assert!(display.contains("test.*"));
assert!(display.contains("!test.internal"));
}
#[test]
fn test_invalid_empty_pattern() {
let result = ModuleGlobSet::from_patterns([""]);
assert!(matches!(result, Err(ModuleGlobError::EmptyPattern)));
}
#[test]
fn test_invalid_just_negation() {
let result = ModuleGlobSet::from_patterns(["!"]);
assert!(matches!(result, Err(ModuleGlobError::EmptyPattern)));
}
#[test]
fn test_invalid_double_star_combined() {
let result = ModuleGlobSet::from_patterns(["foo**"]);
assert!(matches!(
result,
Err(ModuleGlobError::InvalidDoubleStarUsage(_))
));
let result = ModuleGlobSet::from_patterns(["**foo"]);
assert!(matches!(
result,
Err(ModuleGlobError::InvalidDoubleStarUsage(_))
));
let result = ModuleGlobSet::from_patterns(["foo.bar**"]);
assert!(matches!(
result,
Err(ModuleGlobError::InvalidDoubleStarUsage(_))
));
}
#[test]
fn test_invalid_consecutive_dots() {
let result = ModuleGlobSet::from_patterns(["foo..bar"]);
assert!(matches!(result, Err(ModuleGlobError::ConsecutiveDots)));
}
#[test]
fn test_invalid_leading_dot() {
let result = ModuleGlobSet::from_patterns([".foo"]);
assert!(matches!(result, Err(ModuleGlobError::LeadingDot)));
}
#[test]
fn test_invalid_trailing_dot() {
let result = ModuleGlobSet::from_patterns(["foo."]);
assert!(matches!(result, Err(ModuleGlobError::TrailingDot)));
}
#[test]
fn test_underscore_in_module_name() {
let set = ModuleGlobSet::from_patterns(["foo_bar.*"]).unwrap();
assert_include(&set, "foo_bar.baz");
}
#[test]
fn test_numbers_in_module_name() {
let set = ModuleGlobSet::from_patterns(["foo123.*"]).unwrap();
assert_include(&set, "foo123.bar");
}
#[test]
fn test_multiple_patterns() {
let set = ModuleGlobSet::from_patterns(["alpha.*", "beta.*", "gamma"]).unwrap();
assert_include(&set, "alpha.one");
assert_include(&set, "beta.two");
assert_include(&set, "gamma");
assert_no_match(&set, "delta");
}
}