use std::path::Path;
use crate::engine_contract::{Error, Result};
const MAX_ALTERNATIVES: usize = 1_024;
#[derive(Clone, Debug)]
pub struct Pattern {
alternatives: Vec<Alternative>,
anchored: bool,
source: String,
}
#[derive(Clone, Debug)]
struct Alternative {
components: Vec<Component>,
}
#[derive(Clone, Debug)]
enum Component {
AnyComponents,
Tokens(Vec<Token>),
}
#[derive(Clone, Debug)]
enum Token {
Literal(char),
AnyChar,
AnyRun,
Class {
negated: bool,
ranges: Vec<(char, char)>,
},
}
impl Pattern {
pub fn parse(source: &str) -> Result<Self> {
if source.is_empty() {
return Err(glob_error(source, "expected a pattern, as in `*.rs` or `src/**/*.rs`"));
}
let expanded = expand_braces(source)?;
let anchored = source.contains('/');
let mut alternatives = Vec::with_capacity(expanded.len());
for candidate in expanded {
alternatives.push(Alternative { components: parse_components(source, &candidate)? });
}
Ok(Self { alternatives, anchored, source: source.to_string() })
}
pub fn matches(&self, relative: &Path, name: &str) -> bool {
if self.anchored {
let parts: Vec<String> = relative
.components()
.filter_map(|component| match component {
std::path::Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
_ => None,
})
.collect();
let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
self.alternatives.iter().any(|alt| match_components(&alt.components, &parts))
} else {
self.alternatives.iter().any(|alt| match_components(&alt.components, &[name]))
}
}
pub fn source(&self) -> &str {
&self.source
}
pub(crate) fn retained_heap_bytes(&self) -> usize {
let alternatives = self.alternatives.iter().fold(0_usize, |total, alternative| {
let components = alternative.components.iter().fold(0_usize, |total, component| {
let nested = match component {
Component::AnyComponents => 0,
Component::Tokens(tokens) => tokens.iter().fold(
tokens.capacity().saturating_mul(std::mem::size_of::<Token>()),
|total, token| match token {
Token::Class { ranges, .. } => total.saturating_add(
ranges
.capacity()
.saturating_mul(std::mem::size_of::<(char, char)>()),
),
Token::Literal(_) | Token::AnyChar | Token::AnyRun => total,
},
),
};
total.saturating_add(nested)
});
total
.saturating_add(
alternative
.components
.capacity()
.saturating_mul(std::mem::size_of::<Component>()),
)
.saturating_add(components)
});
self.source
.capacity()
.saturating_add(
self.alternatives.capacity().saturating_mul(std::mem::size_of::<Alternative>()),
)
.saturating_add(alternatives)
}
}
fn expand_braces(source: &str) -> Result<Vec<String>> {
let mut pending = vec![String::new()];
let mut chars = source.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
let escaped = chars
.next()
.ok_or_else(|| glob_error(source, "pattern ends with a trailing `\\`"))?;
for candidate in &mut pending {
candidate.push('\\');
candidate.push(escaped);
}
}
'{' => {
let group = take_group(source, &mut chars)?;
let branches = split_branches(&group);
let mut grown = Vec::with_capacity(pending.len() * branches.len());
for candidate in &pending {
for branch in &branches {
for nested in expand_braces(branch)? {
grown.push(format!("{candidate}{nested}"));
}
}
}
if grown.len() > MAX_ALTERNATIVES {
return Err(glob_error(
source,
"pattern expands to too many alternatives; write several patterns instead",
));
}
pending = grown;
}
'}' => return Err(glob_error(source, "unmatched `}` in pattern")),
_ => {
for candidate in &mut pending {
candidate.push(ch);
}
}
}
}
Ok(pending)
}
fn take_group(source: &str, chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String> {
let mut depth = 1usize;
let mut group = String::new();
for ch in chars.by_ref() {
match ch {
'{' => {
depth += 1;
group.push(ch);
}
'}' => {
depth -= 1;
if depth == 0 {
return Ok(group);
}
group.push(ch);
}
_ => group.push(ch),
}
}
Err(glob_error(source, "unmatched `{` in pattern"))
}
fn split_branches(group: &str) -> Vec<String> {
let mut branches = Vec::new();
let mut current = String::new();
let mut depth = 0usize;
let mut chars = group.chars();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
current.push(ch);
if let Some(escaped) = chars.next() {
current.push(escaped);
}
}
'{' => {
depth += 1;
current.push(ch);
}
'}' => {
depth = depth.saturating_sub(1);
current.push(ch);
}
',' if depth == 0 => branches.push(std::mem::take(&mut current)),
_ => current.push(ch),
}
}
branches.push(current);
branches
}
fn parse_components(source: &str, pattern: &str) -> Result<Vec<Component>> {
let mut components = Vec::new();
for part in pattern.split('/') {
if part.is_empty() {
continue;
}
if part == "**" {
components.push(Component::AnyComponents);
} else {
components.push(Component::Tokens(tokenize(source, part)?));
}
}
Ok(components)
}
fn tokenize(source: &str, part: &str) -> Result<Vec<Token>> {
let mut tokens = Vec::new();
let mut chars = part.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'*' => {
while chars.peek() == Some(&'*') {
chars.next();
}
tokens.push(Token::AnyRun);
}
'?' => tokens.push(Token::AnyChar),
'[' => tokens.push(parse_class(source, &mut chars)?),
'\\' => {
let escaped = chars
.next()
.ok_or_else(|| glob_error(source, "pattern ends with a trailing `\\`"))?;
tokens.push(Token::Literal(escaped));
}
_ => tokens.push(Token::Literal(ch)),
}
}
Ok(tokens)
}
fn parse_class(source: &str, chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<Token> {
let negated = matches!(chars.peek(), Some('!' | '^'));
if negated {
chars.next();
}
let mut ranges = Vec::new();
let mut first = true;
while let Some(ch) = chars.next() {
if ch == ']' && !first {
if ranges.is_empty() {
return Err(glob_error(source, "empty character class in pattern"));
}
return Ok(Token::Class { negated, ranges });
}
first = false;
let start = if ch == '\\' {
chars.next().ok_or_else(|| glob_error(source, "pattern ends with a trailing `\\`"))?
} else {
ch
};
if chars.peek() == Some(&'-') {
chars.next();
match chars.next() {
Some(']') => {
ranges.push((start, start));
ranges.push(('-', '-'));
return Ok(Token::Class { negated, ranges });
}
Some(end) => ranges.push((start, end)),
None => return Err(glob_error(source, "unmatched `[` in pattern")),
}
} else {
ranges.push((start, start));
}
}
Err(glob_error(source, "unmatched `[` in pattern"))
}
fn match_components(pattern: &[Component], parts: &[&str]) -> bool {
match pattern.split_first() {
None => parts.is_empty(),
Some((Component::AnyComponents, rest)) => {
(0..=parts.len()).any(|skip| match_components(rest, &parts[skip..]))
}
Some((Component::Tokens(tokens), rest)) => match parts.split_first() {
Some((part, remaining)) => {
match_tokens(tokens, part) && match_components(rest, remaining)
}
None => false,
},
}
}
fn match_tokens(tokens: &[Token], part: &str) -> bool {
let chars: Vec<char> = part.chars().collect();
match_tokens_at(tokens, &chars)
}
fn match_tokens_at(tokens: &[Token], text: &[char]) -> bool {
match tokens.split_first() {
None => text.is_empty(),
Some((Token::AnyRun, rest)) => {
(0..=text.len()).any(|skip| match_tokens_at(rest, &text[skip..]))
}
Some((token, rest)) => match text.split_first() {
Some((ch, remaining)) => match_one(token, *ch) && match_tokens_at(rest, remaining),
None => false,
},
}
}
fn match_one(token: &Token, ch: char) -> bool {
match token {
Token::Literal(expected) => *expected == ch,
Token::AnyChar => true,
Token::Class { negated, ranges } => {
let inside = ranges.iter().any(|(start, end)| *start <= ch && ch <= *end);
inside != *negated
}
Token::AnyRun => false,
}
}
fn glob_error(source: &str, hint: &str) -> Error {
Error::InvalidValue { kind: "pattern", value: source.to_string(), hint: hint.to_string() }
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn matches(pattern: &str, path: &str) -> bool {
let compiled = Pattern::parse(pattern).expect("pattern compiles");
let relative = PathBuf::from(path);
let name = relative
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
compiled.matches(&relative, &name)
}
fn rejection(pattern: &str) -> String {
match Pattern::parse(pattern) {
Err(Error::InvalidValue { kind: "pattern", hint, .. }) => hint,
other => panic!("expected {pattern:?} to be rejected, got {other:?}"),
}
}
#[test]
fn bare_patterns_match_the_file_name_at_any_depth() {
assert!(matches("*.rs", "main.rs"));
assert!(matches("*.rs", "src/deep/nested/main.rs"));
assert!(!matches("*.rs", "src/main.toml"));
assert!(matches("main.rs", "a/b/c/main.rs"));
}
#[test]
fn patterns_with_a_separator_match_the_whole_relative_path() {
assert!(matches("src/*.rs", "src/main.rs"));
assert!(!matches("src/*.rs", "other/main.rs"));
assert!(!matches("src/*.rs", "src/deep/main.rs"));
}
#[test]
fn double_star_crosses_component_boundaries_including_none() {
assert!(matches("src/**/*.rs", "src/main.rs"), "** matches zero components");
assert!(matches("src/**/*.rs", "src/a/main.rs"));
assert!(matches("src/**/*.rs", "src/a/b/c/main.rs"));
assert!(!matches("src/**/*.rs", "other/a/main.rs"));
assert!(matches("**/target/**", "a/b/target/c/d"));
}
#[test]
fn braces_expand_to_alternatives() {
assert!(matches("*.{rs,toml}", "main.rs"));
assert!(matches("*.{rs,toml}", "Cargo.toml"));
assert!(!matches("*.{rs,toml}", "notes.md"));
assert!(matches("{src,tests}/*.{rs,md}", "tests/readme.md"));
assert!(!matches("{src,tests}/*.{rs,md}", "docs/readme.md"));
}
#[test]
fn character_classes_match_one_character() {
assert!(matches("file[0-9].txt", "file7.txt"));
assert!(!matches("file[0-9].txt", "filex.txt"));
assert!(matches("file[!0-9].txt", "filex.txt"));
assert!(!matches("file[!0-9].txt", "file7.txt"));
assert!(matches("[abc]at", "cat"));
}
#[test]
fn question_mark_matches_exactly_one_character() {
assert!(matches("?.rs", "a.rs"));
assert!(!matches("?.rs", "ab.rs"));
assert!(!matches("?.rs", ".rs"));
}
#[test]
fn escapes_make_metacharacters_literal() {
assert!(matches(r"\*.rs", "*.rs"));
assert!(!matches(r"\*.rs", "main.rs"));
assert!(matches(r"a\?b", "a?b"));
}
#[test]
fn stars_match_empty_runs() {
assert!(matches("*", "anything"));
assert!(matches("*.rs", ".rs"));
assert!(matches("a*b", "ab"));
}
#[test]
fn repeated_stars_inside_a_component_collapse() {
assert!(matches("a***b", "axyzb"));
assert!(matches("a***b", "ab"));
}
#[test]
fn malformed_patterns_are_rejected_with_a_reason() {
assert!(rejection("").contains("expected a pattern"));
assert!(rejection("{a,b").contains("unmatched `{`"));
assert!(rejection("a}b").contains("unmatched `}`"));
assert!(rejection("[abc").contains("unmatched `[`"));
assert!(rejection("[]").contains("unmatched `[`"));
assert!(rejection(r"abc\").contains("trailing `\\`"));
}
#[test]
fn runaway_brace_expansion_is_rejected_rather_than_allocated() {
let bomb = "{a,b}".repeat(11);
assert!(rejection(&bomb).contains("too many alternatives"));
}
#[test]
fn anchored_patterns_match_however_the_platform_spells_a_separator() {
let relative: PathBuf = ["src", "deep", "main.rs"].iter().collect();
let compiled = Pattern::parse("src/**/*.rs").expect("pattern compiles");
assert!(compiled.matches(&relative, "main.rs"));
let shallow: PathBuf = ["src", "main.rs"].iter().collect();
assert!(Pattern::parse("src/*.rs").expect("compiles").matches(&shallow, "main.rs"));
assert!(!Pattern::parse("other/*.rs").expect("compiles").matches(&shallow, "main.rs"));
}
#[test]
fn source_is_retained_for_diagnostics() {
assert_eq!(Pattern::parse("*.rs").expect("compiles").source(), "*.rs");
}
}