use std::borrow::Cow;
use ignore::gitignore::GitignoreBuilder;
pub fn is_negated_glob(pattern: &str) -> bool {
let mut chars = pattern.chars();
let first_char = chars.next();
let second_char = chars.next();
first_char == Some('!') && second_char != Some('(')
}
pub fn non_negated_glob(pattern: &str) -> &str {
if is_negated_glob(pattern) { &pattern[1..] } else { pattern }
}
pub fn is_pattern(pattern: &str) -> bool {
if pattern.starts_with('!') {
return true;
}
let mut was_last_escape = false;
for c in pattern.chars() {
if !was_last_escape && matches!(c, '*' | '{' | '?' | '[') {
return true;
}
was_last_escape = c == '\\' && !was_last_escape;
}
false
}
pub fn expand_braces(pattern: String) -> impl Iterator<Item = String> {
let (pattern, expansions) = match try_expand_braces(&pattern) {
Some(expansions) => (None, expansions),
None => (Some(pattern), Vec::new()),
};
pattern.into_iter().chain(expansions)
}
pub fn pattern_names_dir(pattern: &str, dir_name: &str) -> bool {
if !is_pattern(pattern) {
return unescape_glob_text(pattern) == dir_name;
}
if dir_name.contains('/') || dir_name.contains('\\') {
return false;
}
let mut builder = GitignoreBuilder::new("");
if builder.add_line(None, pattern).is_err() {
return false;
}
match builder.build() {
Ok(gitignore) => gitignore.matched(dir_name, true).is_ignore(),
Err(_) => false,
}
}
pub fn escape_glob_text(text: &str) -> String {
let mut result = String::with_capacity(text.len());
for c in text.chars() {
if matches!(c, '\\' | '*' | '{' | '}' | '?' | '[' | ']' | '!') {
result.push('\\');
}
result.push(c);
}
result
}
pub fn escape_glob_text_for_cli(text: &str) -> String {
let mut result = String::with_capacity(text.len());
for c in text.chars() {
match c {
'[' | ']' | '{' | '}' | '*' | '?' => {
result.push('[');
result.push(c);
result.push(']');
}
_ => result.push(c),
}
}
result
}
pub fn unescape_glob_text(text: &str) -> Cow<'_, str> {
if !text.contains('\\') {
return Cow::Borrowed(text);
}
let mut result = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some(next) => result.push(next),
None => result.push(c),
}
} else {
result.push(c);
}
}
Cow::Owned(result)
}
pub fn is_absolute_pattern(pattern: &str) -> bool {
let pattern = if is_negated_glob(pattern) { &pattern[1..] } else { pattern };
pattern.starts_with('/') || is_windows_absolute_pattern(pattern)
}
fn is_windows_absolute_pattern(pattern: &str) -> bool {
let mut chars = pattern.chars();
let next_char = chars.next();
if next_char.is_none() || !next_char.unwrap().is_ascii_alphabetic() {
return false;
}
let mut next_char = chars.next();
while next_char.is_some() && next_char.unwrap().is_ascii_alphabetic() {
next_char = chars.next();
}
if next_char != Some(':') {
return false;
}
let next_char = chars.next();
matches!(next_char, Some('/'))
}
fn try_expand_braces(pattern: &str) -> Option<Vec<String>> {
let is_negated = is_negated_glob(pattern);
let text = non_negated_glob(pattern);
find_brace_group_spanning_path_components(text, 0)?;
let mut expansions = Vec::new();
expand_braces_from(text.to_string(), 0, &mut expansions);
let is_anchored = text.trim_end_matches('/').contains('/');
for expansion in &mut expansions {
while let Some(index) = expansion.find("/./") {
expansion.replace_range(index..index + 2, "");
}
if expansion.len() > "./.".len() && expansion.ends_with("/.") {
expansion.truncate(expansion.len() - "/.".len());
}
if expansion.starts_with('/') && !text.starts_with('/') {
expansion.insert(0, '.');
}
if is_anchored && !expansion.trim_end_matches('/').contains('/') {
expansion.insert_str(0, "./");
}
if is_negated {
expansion.insert(0, '!');
}
}
Some(expansions)
}
fn expand_braces_from(text: String, start: usize, expansions: &mut Vec<String>) {
let Some(group) = find_brace_group_spanning_path_components(&text, start) else {
expansions.push(text);
return;
};
let (before, after) = (&text[..group.open], &text[group.close + 1..]);
let body = &text[group.open + 1..group.close];
let mut alternative_start = 0;
for comma_index in top_level_comma_indexes(body).chain(std::iter::once(body.len())) {
let alternative = &body[alternative_start..comma_index];
let mut expanded = String::with_capacity(before.len() + alternative.len() + after.len());
expanded.push_str(before);
expanded.push_str(alternative);
expanded.push_str(after);
expand_braces_from(expanded, group.open, expansions);
alternative_start = comma_index + 1;
}
}
struct BraceGroup {
open: usize,
close: usize,
has_alternatives: bool,
}
impl BraceGroup {
fn spans_path_components(&self, text: &str) -> bool {
let body = &text[self.open + 1..self.close];
self.has_alternatives && (body.contains('/') || body.split(['{', '}', ',']).any(|part| matches!(part, "." | "..")))
}
}
fn find_brace_group_spanning_path_components(text: &str, start: usize) -> Option<BraceGroup> {
let mut start = start;
loop {
let group = find_brace_group(text, start)?;
if group.spans_path_components(text) {
return Some(group);
}
start = group.open + 1;
}
}
fn find_brace_group(text: &str, start: usize) -> Option<BraceGroup> {
let bytes = text.as_bytes();
let mut index = start;
while index < bytes.len() {
match bytes[index] {
b'\\' => index += 2,
b'[' => index = skip_char_class(bytes, index),
b'{' => {
let mut tokens = BraceTokens::new(&text[index + 1..]);
let has_alternatives = tokens.by_ref().count() > 0;
return tokens.close.map(|close| BraceGroup {
open: index,
close: index + 1 + close,
has_alternatives,
});
}
_ => index += 1,
}
}
None
}
fn top_level_comma_indexes(body: &str) -> impl Iterator<Item = usize> + '_ {
BraceTokens::new(body)
}
struct BraceTokens<'a> {
bytes: &'a [u8],
index: usize,
depth: usize,
close: Option<usize>,
}
impl<'a> BraceTokens<'a> {
fn new(text: &'a str) -> Self {
Self {
bytes: text.as_bytes(),
index: 0,
depth: 1,
close: None,
}
}
}
impl Iterator for BraceTokens<'_> {
type Item = usize;
fn next(&mut self) -> Option<usize> {
if self.close.is_some() {
return None;
}
while self.index < self.bytes.len() {
let index = self.index;
match self.bytes[index] {
b'\\' => {
self.index += 2;
continue;
}
b'[' => {
self.index = skip_char_class(self.bytes, index);
continue;
}
b'{' => self.depth += 1,
b'}' => {
self.depth -= 1;
if self.depth == 0 {
self.close = Some(index);
return None;
}
}
b',' if self.depth == 1 => {
self.index += 1;
return Some(index);
}
_ => {}
}
self.index += 1;
}
None
}
}
fn skip_char_class(bytes: &[u8], open: usize) -> usize {
let mut index = open + 1;
if matches!(bytes.get(index), Some(b'!' | b'^')) {
index += 1;
}
if bytes.get(index) == Some(&b']') {
index += 1;
}
while index < bytes.len() {
if bytes[index] == b']' {
return index + 1;
}
index += 1;
}
open + 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_escape_and_unescape_glob_text() {
assert_eq!(escape_glob_text("routes/[id].svelte"), "routes/\\[id\\].svelte");
assert_eq!(escape_glob_text("{{myfile}}.yaml"), "\\{\\{myfile\\}\\}.yaml");
assert_eq!(escape_glob_text("a*b?c!d\\e"), "a\\*b\\?c\\!d\\\\e");
assert_eq!(escape_glob_text("plain/file.txt"), "plain/file.txt");
assert_eq!(escape_glob_text_for_cli("/[app]/dir"), "/[[]app[]]/dir");
assert_eq!(escape_glob_text_for_cli("/plain/dir"), "/plain/dir");
assert_eq!(unescape_glob_text("routes/\\[id\\].svelte"), "routes/[id].svelte");
assert_eq!(unescape_glob_text("plain/file.txt"), "plain/file.txt");
assert_eq!(unescape_glob_text("a\\\\b"), "a\\b");
assert_eq!(unescape_glob_text("a\\"), "a\\");
assert!(is_pattern("routes/[id].svelte"));
assert!(!is_pattern(&escape_glob_text("routes/[id].svelte")));
assert_eq!(unescape_glob_text(&escape_glob_text("a*b?c!d\\e[]{}")), "a*b?c!d\\e[]{}");
assert!(is_pattern("a\\\\*b"));
assert_eq!(unescape_glob_text("a\\\\*b"), "a\\*b");
assert!(!is_pattern("a\\*b"));
assert_eq!(unescape_glob_text("a\\*b"), "a*b");
}
#[test]
fn should_expand_braces_spanning_path_components() {
#[track_caller]
fn run(pattern: &str, expected: &[&str]) {
assert_eq!(expand_braces(pattern.to_string()).collect::<Vec<_>>(), expected);
}
run("{.,src/**,worker}/*.js", &["./*.js", "src/**/*.js", "worker/*.js"]);
run("!{.,src/**}/*.js", &["!./*.js", "!src/**/*.js"]);
run("src/{.,sub}/*.js", &["src/*.js", "src/sub/*.js"]);
run("{a/b,c}/*.{ts,js}", &["a/b/*.{ts,js}", "c/*.{ts,js}"]);
run("**/*.{ts,js}/{a/b,c}", &["**/*.{ts,js}/a/b", "**/*.{ts,js}/c"]);
run("{a,b/{c,d/e}}/f", &["a/f", "b/c/f", "b/d/e/f"]);
run("{a/{b,c},d}", &["a/{b,c}", "./d"]);
run("{a,{.,b/c}}/d", &["a/d", "./d", "b/c/d"]);
run("{a/b,c}/{d/e,f}", &["a/b/d/e", "a/b/f", "c/d/e", "c/f"]);
run("{a/b,c}", &["a/b", "./c"]);
run("{,src/}*.js", &["./*.js", "src/*.js"]);
run("./{a/b,c}", &["./a/b", "./c"]);
run("{,sub/dir}/*.js", &["./*.js", "sub/dir/*.js"]);
run("/{a/b,c}", &["/a/b", "/c"]);
run("src/{.,sub}", &["./src", "src/sub"]);
run("{..,src}/*.js", &["../*.js", "src/*.js"]);
run("a/{b/c,}/d", &["a/b/c/d", "a//d"]);
run("{{a/b,c}}/x", &["{a/b}/x", "{c}/x"]);
run("é{ü/ñ,日本}/x", &["éü/ñ/x", "é日本/x"]);
run("**/*.ts", &["**/*.ts"]);
run("**/*.{ts,js}", &["**/*.{ts,js}"]);
run("{a,b}/*.ts", &["{a,b}/*.ts"]);
run("{{myfile}}.yaml", &["{{myfile}}.yaml"]);
run("{a/b}/c", &["{a/b}/c"]);
run("a/[{]b/c,d}", &["a/[{]b/c,d}"]);
run("a/[]{]b/c,d}", &["a/[]{]b/c,d}"]);
run("a/\\{b/c,d\\}", &["a/\\{b/c,d\\}"]);
run("{a/b,c", &["{a/b,c"]);
}
#[test]
fn should_get_if_pattern_names_dir() {
assert!(pattern_names_dir("dist", "dist"));
assert!(!pattern_names_dir("dist", "other"));
assert!(pattern_names_dir("su*", "sub"));
assert!(!pattern_names_dir("su*", "other"));
assert!(pattern_names_dir("?ub", "sub"));
assert!(pattern_names_dir("[sd]ist", "dist"));
assert!(!pattern_names_dir("[sd]ist", "list"));
assert!(pattern_names_dir("*", "anything"));
assert!(pattern_names_dir("*.min.js", "a.min.js"));
assert!(pattern_names_dir("\\[id\\]", "[id]"));
assert!(pattern_names_dir("[[]id[]]", "[id]"));
assert!(!pattern_names_dir("dist", "dist/sub"));
assert!(!pattern_names_dir("*", "dist/sub"));
}
#[test]
fn should_get_if_absolute_pattern() {
assert_eq!(is_absolute_pattern("test.ts"), false);
assert_eq!(is_absolute_pattern("!test.ts"), false);
assert_eq!(is_absolute_pattern("/test.ts"), true);
assert_eq!(is_absolute_pattern("!/test.ts"), true);
assert_eq!(is_absolute_pattern("D:/test.ts"), true);
assert_eq!(is_absolute_pattern("!D:/test.ts"), true);
}
}