use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use napi::bindgen_prelude::*;
pub fn build_glob_pattern(glob: &str, recursive: bool) -> String {
let normalized = glob.replace('\\', "/");
let pattern = if !recursive
|| normalized.contains('/')
|| normalized.starts_with("**")
|| is_exact_brace_union(&normalized)
{
normalized
} else {
format!("**/{normalized}")
};
fix_unclosed_braces(pattern)
}
/// Compile a glob pattern string into a [`GlobSet`].
///
/// When `recursive` is true, simple patterns (no path separators, no leading
/// `**`) are automatically prefixed with `**/`.
pub fn compile_glob(glob: &str, recursive: bool) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
let pattern = build_glob_pattern(glob, recursive);
let glob = GlobBuilder::new(&pattern)
.literal_separator(true)
.build()
.map_err(|err| Error::from_reason(format!("Invalid glob pattern: {err}")))?;
builder.add(glob);
builder
.build()
.map_err(|err| Error::from_reason(format!("Failed to build glob matcher: {err}")))
}
/// Like [`compile_glob`], but accepts an `Option<&str>` — returns `Ok(None)`
/// when the input is `None`, empty, or whitespace-only.
pub fn try_compile_glob(glob: Option<&str>, recursive: bool) -> Result<Option<GlobSet>> {
let Some(glob) = glob.map(str::trim).filter(|v| !v.is_empty()) else {
return Ok(None);
};
compile_glob(glob, recursive).map(Some)
}
/// Close unclosed `{` alternation groups in a glob pattern.
///
/// LLMs occasionally produce patterns like `*.{ts,js` without the closing `}`.
/// Rather than failing, we append the missing braces.
fn fix_unclosed_braces(pattern: String) -> String {
let opens = pattern.chars().filter(|&c| c == '{').count();
let closes = pattern.chars().filter(|&c| c == '}').count();
if opens > closes {
let mut fixed = pattern;
for _ in 0..(opens - closes) {
fixed.push('}');
}
fixed
} else {
pattern
}
}
fn is_exact_brace_union(pattern: &str) -> bool {
if !(pattern.starts_with('{') && pattern.ends_with('}')) {
return false;
}
let inner = &pattern[1..pattern.len() - 1];
!inner.is_empty()
&& !inner
.chars()
.any(|ch| matches!(ch, '*' | '?' | '[' | ']' | '{' | '}'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_pattern_gets_recursive_prefix() {
assert_eq!(build_glob_pattern("*.ts", true), "**/*.ts");
}
#[test]
fn pattern_with_path_stays_as_is() {
assert_eq!(build_glob_pattern("src/*.ts", true), "src/*.ts");
}
#[test]
fn already_recursive_pattern_unchanged() {
assert_eq!(build_glob_pattern("**/*.rs", true), "**/*.rs");
}
#[test]
fn non_recursive_keeps_simple_pattern() {
assert_eq!(build_glob_pattern("*.ts", false), "*.ts");
}
#[test]
fn backslashes_normalized() {
assert_eq!(build_glob_pattern("src\\**\\*.ts", true), "src/**/*.ts");
}
#[test]
fn unclosed_brace_gets_closed() {
assert_eq!(build_glob_pattern("*.{ts,tsx,js", true), "**/*.{ts,tsx,js}");
}
#[test]
fn deeply_unclosed_braces_all_closed() {
assert_eq!(build_glob_pattern("{a,{b,c}", true), "**/{a,{b,c}}");
}
#[test]
fn balanced_braces_unchanged() {
assert_eq!(build_glob_pattern("*.{ts,js}", true), "**/*.{ts,js}");
}
#[test]
fn compile_glob_accepts_valid_pattern() {
assert!(compile_glob("*.ts", true).is_ok());
}
#[test]
fn compile_glob_fixes_unclosed_brace() {
assert!(compile_glob("*.{ts,tsx,js", true).is_ok());
}
#[test]
fn exact_brace_union_stays_non_recursive() {
assert_eq!(build_glob_pattern("{alpha.txt,beta.txt}", true), "{alpha.txt,beta.txt}");
}
#[test]
fn glob_brace_union_still_gets_recursive_prefix() {
assert_eq!(build_glob_pattern("{*.ts,*.tsx}", true), "**/{*.ts,*.tsx}");
}
}