use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};
pub fn expand_globs(base: &Path, patterns: &[String]) -> Vec<(String, PathBuf)> {
let mut anchors: Vec<(PathBuf, Vec<String>)> = Vec::new();
for pattern in patterns {
let (anchor, rest) = anchor_pattern(base, pattern);
match anchors.iter_mut().find(|(dir, _)| *dir == anchor) {
Some((_, group)) => group.push(rest),
None => anchors.push((anchor, vec![rest])),
}
}
let base_components = lexical_components(base);
let mut files: Vec<(String, PathBuf)> = anchors
.iter()
.flat_map(|(anchor, group)| walk(anchor, group))
.map(|absolute| (relative_name(&base_components, &absolute), absolute))
.collect();
files.sort();
files.dedup_by(|later, earlier| later.0 == earlier.0);
files
}
pub fn anchor_pattern(base: &Path, pattern: &str) -> (PathBuf, String) {
let mut anchor = base.to_path_buf();
let mut rest = pattern;
loop {
if let Some(tail) = rest.strip_prefix("./") {
rest = tail;
} else if let Some(tail) = rest.strip_prefix("../") {
if anchor.file_name().is_some() {
anchor.pop();
} else if !anchor.has_root() {
anchor.push("..");
}
rest = tail;
} else {
return (anchor, rest.to_string());
}
}
}
pub fn pattern_dir(base: &Path, pattern: &str) -> PathBuf {
let (anchor, rest) = anchor_pattern(base, pattern);
let mut literal = literal_prefix(&rest);
if literal.len() == rest.split('/').count() {
literal.pop();
}
literal
.iter()
.fold(anchor, |dir, component| dir.join(component))
}
pub(crate) fn has_inner_dot_component(pattern: &str) -> bool {
let (_, rest) = anchor_pattern(Path::new("."), pattern);
rest.split('/')
.any(|component| matches!(component, "." | ".."))
}
fn walk(anchor: &Path, patterns: &[String]) -> Vec<PathBuf> {
let matcher = Matcher::compile(patterns);
if matcher.is_empty() {
return Vec::new();
}
let (root, roots) = (anchor.to_path_buf(), matcher.roots.clone());
let walker = ignore::WalkBuilder::new(anchor)
.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(anchor) else {
continue;
};
let unified = relative.to_string_lossy().replace('\\', "/");
if matcher.set.is_match(&unified) {
files.push(entry.into_path());
}
}
files
}
fn lexical_components(path: &Path) -> Vec<Component<'_>> {
let mut components = Vec::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir if matches!(components.last(), Some(Component::Normal(_))) => {
components.pop();
}
other => components.push(other),
}
}
components
}
fn relative_name(base: &[Component<'_>], path: &Path) -> String {
let path = lexical_components(path);
let shared = base
.iter()
.zip(&path)
.take_while(|(left, right)| left == right)
.count();
let climb = std::iter::repeat_n("..".to_string(), base.len() - shared);
let descend = path[shared..]
.iter()
.map(|component| component.as_os_str().to_string_lossy().into_owned());
climb.chain(descend).collect::<Vec<_>>().join("/")
}
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 leading_dot_components_move_the_anchor_and_leave_the_rest() {
let anchor = |base: &str, pattern: &str| {
let (dir, rest) = anchor_pattern(Path::new(base), pattern);
(dir.to_string_lossy().replace('\\', "/"), rest)
};
assert_eq!(
anchor("/repo/contexts/shared", "../../Dockerfile"),
("/repo".to_string(), "Dockerfile".to_string())
);
assert_eq!(
anchor("/repo", "./src/**/*.rs"),
("/repo".to_string(), "src/**/*.rs".to_string())
);
assert_eq!(
anchor("/repo", "src/**/*.rs"),
("/repo".to_string(), "src/**/*.rs".to_string())
);
assert_eq!(anchor("/", "../x"), ("/".to_string(), "x".to_string()));
assert_eq!(anchor(".", "../x"), ("./..".to_string(), "x".to_string()));
}
#[test]
fn a_name_climbs_out_of_the_base_only_as_far_as_needed() {
let base = lexical_components(Path::new("/repo/contexts/shared"));
let name = |path: &str| relative_name(&base, Path::new(path));
assert_eq!(name("/repo/contexts/shared/src/a.php"), "src/a.php");
assert_eq!(name("/repo/Dockerfile"), "../../Dockerfile");
assert_eq!(
name("/repo/contexts/shared/../../Dockerfile"),
"../../Dockerfile"
);
assert_eq!(name("/repo/contexts/other/b.php"), "../other/b.php");
}
#[test]
fn a_pattern_dir_is_the_deepest_directory_every_match_lies_under() {
let dir = |pattern: &str| {
pattern_dir(Path::new("/repo/project"), pattern)
.to_string_lossy()
.replace('\\', "/")
};
assert_eq!(dir("src/**/*.rs"), "/repo/project/src");
assert_eq!(dir("../shared/config/*.yaml"), "/repo/shared/config");
assert_eq!(dir("../Dockerfile"), "/repo");
assert_eq!(dir("**/*.rs"), "/repo/project");
}
#[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
));
}
}