use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub fn expand_globs(base: &Path, patterns: &[String]) -> Vec<(String, PathBuf)> {
let matcher = Matcher::compile(patterns);
if matcher.is_empty() {
return Vec::new();
}
let (root, roots) = (base.to_path_buf(), matcher.roots.clone());
let walker = ignore::WalkBuilder::new(base)
.hidden(false)
.require_git(false)
.git_global(false)
.follow_links(true)
.filter_entry(move |entry| {
entry.file_name() != OsStr::new(".git")
&& entry.file_name() != OsStr::new(".alba")
&& worth_visiting(&root, entry.path(), &roots)
})
.build();
let mut files = Vec::new();
for entry in walker.flatten() {
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
continue;
}
let Ok(relative) = entry.path().strip_prefix(base) else {
continue;
};
let unified = relative.to_string_lossy().replace('\\', "/");
if matcher.set.is_match(&unified) {
files.push((unified, entry.into_path()));
}
}
files.sort();
files
}
pub fn outputs_satisfied(base: &Path, patterns: &[String]) -> bool {
patterns.iter().all(|pattern| {
if pattern.contains(['*', '?', '[']) {
glob::glob(&base.join(pattern).to_string_lossy())
.map(|mut matches| matches.any(|entry| entry.is_ok()))
.unwrap_or(false)
} else {
base.join(pattern).exists()
}
})
}
struct Matcher {
set: globset::GlobSet,
roots: Vec<Vec<String>>,
}
impl Matcher {
fn compile(patterns: &[String]) -> Self {
let mut builder = globset::GlobSetBuilder::new();
let mut roots = Vec::new();
for pattern in patterns {
if let Ok(glob) = globset::Glob::new(pattern) {
builder.add(glob);
roots.push(literal_prefix(pattern));
}
}
Self {
set: builder
.build()
.unwrap_or_else(|_| globset::GlobSet::empty()),
roots,
}
}
fn is_empty(&self) -> bool {
self.set.is_empty()
}
}
fn worth_visiting(base: &Path, path: &Path, roots: &[Vec<String>]) -> bool {
let Ok(relative) = path.strip_prefix(base) else {
return true;
};
let components: Vec<String> = relative
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect();
roots.iter().any(|root| {
root.iter()
.zip(&components)
.all(|(expected, actual)| expected == actual)
})
}
fn literal_prefix(pattern: &str) -> Vec<String> {
const META: [char; 7] = ['*', '?', '[', ']', '{', '}', '\\'];
pattern
.split('/')
.take_while(|component| !component.is_empty() && !component.contains(META))
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_literal_prefix_stops_at_the_first_metacharacter() {
assert_eq!(literal_prefix("crates/**/*.rs"), vec!["crates"]);
assert_eq!(
literal_prefix("crates/alba-core/src/*.rs"),
vec!["crates", "alba-core", "src"]
);
assert_eq!(literal_prefix("Cargo.toml"), vec!["Cargo.toml"]);
assert!(literal_prefix("**/*.rs").is_empty());
assert!(literal_prefix("/absolute/x.rs").is_empty());
}
#[test]
fn a_pattern_without_a_literal_prefix_visits_everything() {
let base = Path::new("/project");
let roots = vec![Vec::new()];
assert!(worth_visiting(base, Path::new("/project/target"), &roots));
}
#[test]
fn only_the_way_down_to_a_root_and_its_contents_are_visited() {
let base = Path::new("/project");
let roots = vec![vec!["crates".to_string(), "alba-core".to_string()]];
assert!(worth_visiting(base, Path::new("/project/crates"), &roots));
assert!(worth_visiting(
base,
Path::new("/project/crates/alba-core/src/files.rs"),
&roots
));
assert!(!worth_visiting(base, Path::new("/project/target"), &roots));
assert!(!worth_visiting(
base,
Path::new("/project/crates/alba-cli"),
&roots
));
assert!(!worth_visiting(
base,
Path::new("/project/crates-extra"),
&roots
));
}
}