use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::{LazyLock, RwLock},
};
use globset::GlobMatcher;
static GLOB_CACHE: LazyLock<RwLock<BTreeMap<String, GlobMatcher>>> =
LazyLock::new(|| RwLock::new(BTreeMap::new()));
#[must_use]
pub fn get_or_compile_glob(pattern: &str) -> Option<GlobMatcher> {
if let Some(matcher) = GLOB_CACHE.read().ok().and_then(|c| c.get(pattern).cloned()) {
return Some(matcher);
}
let matcher = globset::Glob::new(pattern).ok()?.compile_matcher();
if let Ok(mut cache) = GLOB_CACHE.write() {
cache.insert(pattern.to_string(), matcher.clone());
}
Some(matcher)
}
#[must_use]
pub fn contains_glob_chars(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
}
pub async fn expand_workspace_globs(
root: &Path,
patterns: &[&str],
manifest_file: &str,
) -> Vec<String> {
let mut expanded = Vec::new();
for pattern in patterns {
if pattern.starts_with('!') {
log::trace!("Skipping exclusion pattern: {pattern}");
continue;
}
if contains_glob_chars(pattern) {
let matches = expand_simple_glob_pattern(root, pattern, manifest_file).await;
expanded.extend(matches);
} else {
let full_path = root.join(pattern);
let manifest_path = full_path.join(manifest_file);
if switchy_fs::unsync::exists(&manifest_path).await {
expanded.push((*pattern).to_string());
}
}
}
expanded
}
async fn expand_simple_glob_pattern(
root: &Path,
pattern: &str,
manifest_file: &str,
) -> Vec<String> {
let mut results = Vec::new();
if pattern.ends_with("/*") && !pattern[..pattern.len() - 2].contains('*') {
let base_dir = &pattern[..pattern.len() - 2];
let full_base = root.join(base_dir);
if let Ok(entries) = switchy_fs::unsync::read_dir_sorted(&full_base).await {
for entry in entries {
let entry_path = entry.path();
if !switchy_fs::unsync::is_dir(&entry_path).await {
continue;
}
let manifest_path = entry_path.join(manifest_file);
if !switchy_fs::unsync::exists(&manifest_path).await {
continue;
}
if let Some(name) = entry_path.file_name().and_then(|n| n.to_str()) {
results.push(format!("{base_dir}/{name}"));
}
}
}
return results;
}
if let Some(matcher) = get_or_compile_glob(pattern) {
let matches = walk_and_match(root, &matcher, manifest_file).await;
results.extend(matches);
}
results
}
async fn walk_and_match(root: &Path, matcher: &GlobMatcher, manifest_file: &str) -> Vec<String> {
let mut results = Vec::new();
let mut stack = vec![PathBuf::new()];
while let Some(rel_path) = stack.pop() {
let full_path = if rel_path.as_os_str().is_empty() {
root.to_path_buf()
} else {
root.join(&rel_path)
};
if !rel_path.as_os_str().is_empty() {
let rel_str = rel_path.to_string_lossy();
if matcher.is_match(rel_str.as_ref()) {
let manifest_path = full_path.join(manifest_file);
if switchy_fs::unsync::exists(&manifest_path).await {
results.push(rel_str.into_owned());
continue;
}
}
}
if let Ok(entries) = switchy_fs::unsync::read_dir_sorted(&full_path).await {
for entry in entries {
let entry_path = entry.path();
if !switchy_fs::unsync::is_dir(&entry_path).await {
continue;
}
let Some(name) = entry_path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('.') || name == "node_modules" || name == "target" {
continue;
}
let child_rel = if rel_path.as_os_str().is_empty() {
PathBuf::from(name)
} else {
rel_path.join(name)
};
stack.push(child_rel);
}
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_contains_glob_chars() {
assert!(contains_glob_chars("packages/*"));
assert!(contains_glob_chars("packages/**"));
assert!(contains_glob_chars("pkg_[abc]"));
assert!(contains_glob_chars("pkg_?"));
assert!(!contains_glob_chars("packages/foo"));
assert!(!contains_glob_chars("src/lib"));
}
#[test]
fn test_get_or_compile_glob() {
let matcher = get_or_compile_glob("packages/*");
assert!(matcher.is_some());
let matcher = matcher.unwrap();
assert!(matcher.is_match("packages/foo"));
assert!(!matcher.is_match("src/foo"));
}
}