use std::iter::Peekable;
use std::path::{Component, Path};
use std::str::CharIndices;
use anyhow::{Result, bail};
#[derive(Debug, PartialEq)]
pub(crate) enum Seg {
Literal(String),
Wildcard(String),
Globstar,
}
#[derive(Debug)]
pub(crate) struct Pattern {
segs: Vec<Seg>,
}
pub(crate) fn compile(original: &str) -> Result<(bool, Pattern)> {
let mut negated = false;
let mut body = original;
while let Some(rest) = body.strip_prefix('!') {
negated = !negated;
body = rest;
}
if body.starts_with('/') || body.starts_with("\\/") {
bail!("absolute patterns are not supported")
}
let parts = split(body)?;
if parts.iter().all(|part| part.is_empty()) {
bail!("empty patterns are not supported")
}
let mut segs = Vec::new();
for (index, part) in parts.into_iter().enumerate() {
if part.is_empty() || part == "." {
continue;
}
if part == ".." {
bail!("`..` segments are not supported")
}
let seg = classify(part)?;
if index == 0
&& let Seg::Literal(name) = &seg
&& !is_plain_component(name)
{
bail!("drive-prefixed patterns are not supported")
}
segs.push(seg);
}
segs.push(Seg::Literal("package.json".to_owned()));
Ok((negated, Pattern { segs }))
}
fn split(body: &str) -> Result<Vec<&str>> {
let mut parts = Vec::new();
let mut start = 0;
let mut brace_depth: usize = 0;
let mut chars = body.char_indices().peekable();
while let Some((index, c)) = chars.next() {
match c {
'\\' => {
if let Some(&(slash_index, '/')) = chars.peek() {
if brace_depth > 0 {
bail!("`/` inside braces is not supported")
}
chars.next();
parts.push(&body[start..index]);
start = slash_index + 1;
} else {
chars.next();
}
}
'/' => {
if brace_depth > 0 {
bail!("`/` inside braces is not supported")
}
parts.push(&body[start..index]);
start = index + 1;
}
'{' => brace_depth += 1,
'}' => brace_depth = brace_depth.saturating_sub(1),
'[' => skip_class(&mut chars)?,
_ => {}
}
}
parts.push(&body[start..]);
Ok(parts)
}
fn skip_class(chars: &mut Peekable<CharIndices<'_>>) -> Result<()> {
if matches!(chars.peek(), Some((_, '^' | '!'))) {
chars.next();
}
let mut first = true;
while let Some((_, c)) = chars.next() {
match c {
']' if !first => return Ok(()),
'/' => bail!("`/` inside character classes is not supported"),
'\\' => {
if matches!(chars.peek(), Some((_, '/'))) {
bail!("`/` inside character classes is not supported")
}
chars.next();
}
_ => {}
}
first = false;
}
Ok(())
}
pub(crate) fn is_plain_component(name: &str) -> bool {
let mut components = Path::new(name).components();
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
}
fn classify(part: &str) -> Result<Seg> {
if part == "**" {
return Ok(Seg::Globstar);
}
if part.contains(['*', '?', '[', ']', '{', '}', '\\']) {
fast_glob::validate(part)?;
if part.starts_with('!') {
return Ok(Seg::Wildcard(format!("\\{part}")));
}
return Ok(Seg::Wildcard(part.to_owned()));
}
Ok(Seg::Literal(part.to_owned()))
}
impl Pattern {
pub(crate) fn segs(&self) -> &[Seg] {
&self.segs
}
pub(crate) fn is_literal(&self) -> bool {
self.segs.iter().all(|seg| matches!(seg, Seg::Literal(_)))
}
pub(crate) fn matches(&self, rel_manifest: &str, dot_permissive: bool) -> bool {
let names: Vec<&str> = rel_manifest.split('/').collect();
matches_from(&self.segs, &names, dot_permissive)
}
}
fn matches_from(segs: &[Seg], names: &[&str], dot_permissive: bool) -> bool {
let Some((seg, segs_rest)) = segs.split_first() else {
return names.is_empty();
};
if let Seg::Globstar = seg {
if matches_from(segs_rest, names, dot_permissive) {
return true;
}
return match names.split_first() {
Some((name, names_rest)) if seg_matches(seg, name, dot_permissive) => {
matches_from(segs, names_rest, dot_permissive)
}
_ => false,
};
}
match names.split_first() {
Some((name, names_rest)) => {
seg_matches(seg, name, dot_permissive)
&& matches_from(segs_rest, names_rest, dot_permissive)
}
None => false,
}
}
pub(crate) fn seg_matches(seg: &Seg, name: &str, dot_permissive: bool) -> bool {
if !dot_permissive && name.starts_with('.') {
let dot_ok = match seg {
Seg::Literal(text) | Seg::Wildcard(text) => text.starts_with('.'),
Seg::Globstar => false,
};
if !dot_ok {
return false;
}
}
match seg {
Seg::Literal(text) => text == name,
Seg::Wildcard(glob) => fast_glob::glob_match(glob, name),
Seg::Globstar => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lit(text: &str) -> Seg {
Seg::Literal(text.to_owned())
}
fn wild(text: &str) -> Seg {
Seg::Wildcard(text.to_owned())
}
fn positive(pattern: &str) -> Pattern {
let (negated, compiled) = compile(pattern).unwrap();
assert!(!negated, "{pattern}");
compiled
}
fn negation(pattern: &str) -> Pattern {
let (negated, compiled) = compile(pattern).unwrap();
assert!(negated, "{pattern}");
compiled
}
fn error(pattern: &str) -> String {
format!("{:#}", compile(pattern).unwrap_err())
}
#[test]
fn normalizes_dot_and_slash_noise() {
for pattern in ["./x", "x/", "x", "./x/"] {
assert_eq!(
positive(pattern).segs(),
[lit("x"), lit("package.json")],
"{pattern}"
);
}
assert_eq!(
positive("x//y").segs(),
[lit("x"), lit("y"), lit("package.json")]
);
for pattern in [".", "./"] {
assert_eq!(positive(pattern).segs(), [lit("package.json")], "{pattern}");
}
}
#[test]
fn rejects_an_empty_pattern() {
for pattern in ["", "!", "!!"] {
assert!(error(pattern).contains("empty"), "{pattern}");
}
}
#[test]
fn leading_bangs_toggle_the_polarity_by_parity() {
assert_eq!(positive("!!x").segs(), [lit("x"), lit("package.json")]);
assert_eq!(negation("!!!x").segs(), [lit("x"), lit("package.json")]);
}
#[test]
fn rejects_an_absolute_pattern() {
for pattern in ["/abs", "!/abs", "/"] {
assert!(error(pattern).contains("absolute"), "{pattern}");
}
}
#[cfg(windows)]
#[test]
fn rejects_a_leading_drive_prefix() {
for pattern in ["C:/packages/*", "C:x", "c:/x", "C:", "!C:/x"] {
assert!(error(pattern).contains("drive"), "{pattern}");
}
assert!(!is_plain_component("C:"));
assert!(!is_plain_component("C:x"));
assert!(is_plain_component("x"));
}
#[cfg(windows)]
#[test]
fn a_drive_prefix_after_the_first_raw_segment_compiles() {
assert_eq!(
positive("packages/C:/x").segs(),
[lit("packages"), lit("C:"), lit("x"), lit("package.json")]
);
assert_eq!(
positive("./C:/x").segs(),
[lit("C:"), lit("x"), lit("package.json")]
);
}
#[cfg(unix)]
#[test]
fn a_drive_like_segment_is_an_ordinary_name_on_unix() {
assert_eq!(
positive("C:/x").segs(),
[lit("C:"), lit("x"), lit("package.json")]
);
assert_eq!(positive("C:x").segs(), [lit("C:x"), lit("package.json")]);
assert!(is_plain_component("C:"));
}
#[test]
fn rejects_a_parent_segment() {
for pattern in ["../x", "!../x", "a/../b", ".."] {
assert!(error(pattern).contains("`..`"), "{pattern}");
}
}
#[test]
fn classifies_segments() {
assert_eq!(
positive("packages/**").segs(),
[lit("packages"), Seg::Globstar, lit("package.json")]
);
assert_eq!(positive("f**").segs(), [wild("f**"), lit("package.json")]);
assert_eq!(
positive("+(a|b)").segs(),
[lit("+(a|b)"), lit("package.json")]
);
assert_eq!(
positive("a?c/[xy]").segs(),
[wild("a?c"), wild("[xy]"), lit("package.json")]
);
}
#[test]
fn rejects_invalid_glob_syntax() {
assert!(compile("packages/[").is_err());
assert!(compile("src/{a,b").is_err());
assert!(compile("x\\").is_err());
}
#[test]
fn a_bang_after_the_leading_run_is_literal() {
let pattern = positive("packages/!foo*");
assert_eq!(
pattern.segs(),
[lit("packages"), wild("\\!foo*"), lit("package.json")]
);
assert!(pattern.matches("packages/!foox/package.json", false));
assert!(!pattern.matches("packages/foox/package.json", false));
assert!(!pattern.matches("packages/bar/package.json", false));
assert_eq!(
positive("packages/!foo").segs(),
[lit("packages"), lit("!foo"), lit("package.json")]
);
assert_eq!(
positive("a/\\!b*").segs(),
[lit("a"), wild("\\!b*"), lit("package.json")]
);
}
#[test]
fn an_escaped_slash_is_a_separator() {
assert_eq!(
positive("a\\/b").segs(),
[lit("a"), lit("b"), lit("package.json")]
);
assert_eq!(positive("a\\/").segs(), [lit("a"), lit("package.json")]);
assert!(error("\\/").contains("absolute"));
assert!(error("\\/x").contains("absolute"));
}
#[test]
fn rejects_a_slash_inside_braces() {
for pattern in ["{a,b/c}", "{a,b\\/c}", "x/{a,b/c}", "{a,{b/c,d}}"] {
assert!(error(pattern).contains("braces"), "{pattern}");
}
assert_eq!(
positive("x/{a,b}/y").segs(),
[lit("x"), wild("{a,b}"), lit("y"), lit("package.json")]
);
assert_eq!(
positive("\\{a,b\\}/c").segs(),
[wild("\\{a,b\\}"), lit("c"), lit("package.json")]
);
assert_eq!(
positive("a}b/c").segs(),
[wild("a}b"), lit("c"), lit("package.json")]
);
}
#[test]
fn rejects_a_slash_inside_a_character_class() {
for pattern in ["[a/b]", "[a\\/b]", "x[/]y", "[!/]", "x/[a/b]"] {
assert!(error(pattern).contains("character class"), "{pattern}");
}
assert_eq!(
positive("[]]x/y").segs(),
[wild("[]]x"), lit("y"), lit("package.json")]
);
assert_eq!(
positive("[{]/a").segs(),
[wild("[{]"), lit("a"), lit("package.json")]
);
}
#[test]
fn applies_the_dot_rule_per_segment() {
assert!(!seg_matches(&wild("*"), ".x", false));
assert!(seg_matches(&wild(".*"), ".x", false));
assert!(seg_matches(&lit(".github"), ".github", false));
assert!(!seg_matches(&Seg::Globstar, ".x", false));
assert!(seg_matches(&wild("*"), ".x", true));
assert!(seg_matches(&Seg::Globstar, ".x", true));
}
#[test]
fn expands_braces_within_a_segment() {
assert!(seg_matches(&wild("{a,b}"), "a", false));
assert!(seg_matches(&wild("{a,b}"), "b", false));
assert!(!seg_matches(&wild("{a,b}"), "{a,b}", false));
}
#[test]
fn expands_nested_braces() {
for name in ["a", "b", "c"] {
assert!(seg_matches(&wild("{a,{b,c}}"), name, false), "{name}");
}
assert!(!seg_matches(&wild("{a,{b,c}}"), "d", false));
assert!(!seg_matches(&wild("{a,{b,c}}"), "{b,c}", false));
}
#[test]
fn a_negated_character_class_excludes_its_members() {
assert!(seg_matches(&wild("[!b]"), "a", false));
assert!(!seg_matches(&wild("[!b]"), "b", false));
assert!(seg_matches(&wild("x[!b]"), "xc", false));
}
#[test]
fn a_double_star_negation_matches_the_base_directory() {
let pattern = negation("!x/**");
assert!(pattern.matches("x/package.json", true));
assert!(pattern.matches("x/y/package.json", true));
assert!(!pattern.matches("y/package.json", true));
}
#[test]
fn a_negation_matches_dot_segments() {
let pattern = negation("!**/.vercel/**");
assert!(pattern.matches(".vercel/package.json", true));
assert!(pattern.matches("a/.vercel/b/package.json", true));
assert!(!pattern.matches("a/b/package.json", true));
}
#[test]
fn a_full_match_applies_the_dot_rule_when_not_permissive() {
let pattern = positive("*");
assert!(!pattern.matches(".x/package.json", false));
assert!(pattern.matches(".x/package.json", true));
assert!(pattern.matches("x/package.json", false));
}
#[test]
fn a_full_match_expands_braces() {
let pattern = positive("packages/{a,b}");
assert!(pattern.matches("packages/a/package.json", true));
assert!(!pattern.matches("packages/c/package.json", true));
assert!(!pattern.matches("packages/{a,b}/package.json", true));
}
}