use std::path::{Path, PathBuf};
use rudb_common::Result;
use crate::Filesystem;
const MAGIC: [char; 3] = ['*', '?', '['];
#[must_use]
pub fn has_magic(pattern: &str) -> bool {
pattern.contains(MAGIC)
}
pub fn expand(filesystem: &dyn Filesystem, pattern: &str) -> Result<Vec<String>> {
if !has_magic(pattern) {
let found = if filesystem.exists(Path::new(pattern)) {
vec![pattern.to_string()]
} else {
Vec::new()
};
return Ok(found);
}
let (root, rest) = split_root(pattern);
let mut here = vec![root];
for segment in rest {
here = step(filesystem, &here, segment)?;
if here.is_empty() {
break;
}
}
let mut found: Vec<String> = here
.into_iter()
.filter(|path| filesystem.exists(path) && !filesystem.is_dir(path))
.map(|path| path.to_string_lossy().into_owned())
.collect();
found.sort();
found.dedup();
Ok(found)
}
fn split_root(pattern: &str) -> (PathBuf, Vec<&str>) {
let mut segments = pattern.split('/').peekable();
let mut root = PathBuf::new();
if pattern.starts_with('/') {
root.push("/");
let _ = segments.next();
}
while let Some(segment) = segments.peek() {
if has_magic(segment) || *segment == "**" {
break;
}
root.push(segments.next().unwrap_or_default());
}
(root, segments.collect())
}
fn step(filesystem: &dyn Filesystem, here: &[PathBuf], segment: &str) -> Result<Vec<PathBuf>> {
let mut next = Vec::new();
for base in here {
if segment == "**" {
next.push(base.clone());
descend(filesystem, base, &mut next)?;
continue;
}
if !has_magic(segment) {
next.push(base.join(segment));
continue;
}
for entry in read_dir(filesystem, base)? {
let name = entry.file_name().unwrap_or_default().to_string_lossy().into_owned();
if matches(segment, &name) {
next.push(entry);
}
}
}
Ok(next)
}
fn descend(filesystem: &dyn Filesystem, base: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for entry in read_dir(filesystem, base)? {
if filesystem.is_dir(&entry) {
descend(filesystem, &entry, out)?;
out.push(entry);
}
}
Ok(())
}
fn read_dir(filesystem: &dyn Filesystem, path: &Path) -> Result<Vec<PathBuf>> {
if !filesystem.is_dir(path) {
return Ok(Vec::new());
}
filesystem.read_dir(path)
}
#[must_use]
pub fn matches(pattern: &str, name: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let name: Vec<char> = name.chars().collect();
at(&pattern, 0, &name, 0)
}
fn at(pattern: &[char], p: usize, name: &[char], n: usize) -> bool {
let Some(¤t) = pattern.get(p) else { return n >= name.len() };
match current {
'*' => (n..=name.len()).any(|skip| at(pattern, p + 1, name, skip)),
'?' => n < name.len() && at(pattern, p + 1, name, n + 1),
'[' => match class(pattern, p, name.get(n).copied()) {
Some(after) => at(pattern, after, name, n + 1),
None => false,
},
_ => name.get(n) == Some(¤t) && at(pattern, p + 1, name, n + 1),
}
}
fn class(pattern: &[char], p: usize, against: Option<char>) -> Option<usize> {
let mut at = p + 1;
let negated = matches!(pattern.get(at), Some('!' | '^'));
if negated {
at += 1;
}
let mut hit = false;
let mut first = true;
loop {
let ¤t = pattern.get(at)?;
if current == ']' && !first {
at += 1;
break;
}
first = false;
let ranged = matches!(pattern.get(at + 1), Some('-'))
&& !matches!(pattern.get(at + 2), Some(']') | None);
if ranged {
let high = *pattern.get(at + 2)?;
if against.is_some_and(|c| c >= current && c <= high) {
hit = true;
}
at += 3;
continue;
}
if against == Some(current) {
hit = true;
}
at += 1;
}
if hit != negated && against.is_some() { Some(at) } else { None }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{OpenMode, SimFilesystem};
#[test]
fn a_star_matches_a_run_of_anything_including_nothing() {
assert!(matches("*.parquet", "hits.parquet"));
assert!(matches("*.parquet", ".parquet"));
assert!(matches("hits*", "hits"));
assert!(!matches("*.parquet", "hits.csv"));
}
#[test]
fn a_question_mark_matches_one_character_and_insists_on_one() {
assert!(matches("p?.parquet", "p1.parquet"));
assert!(!matches("p?.parquet", "p.parquet"));
assert!(!matches("p?.parquet", "p12.parquet"));
}
#[test]
fn a_class_matches_one_of_what_it_lists() {
assert!(matches("p[12].parquet", "p1.parquet"));
assert!(matches("p[12].parquet", "p2.parquet"));
assert!(!matches("p[12].parquet", "p3.parquet"));
assert!(matches("[a-z]ame", "name"));
assert!(!matches("[a-z]ame", "Name"));
}
#[test]
fn a_negated_class_matches_what_it_does_not_list() {
assert!(matches("p[!12].parquet", "p3.parquet"));
assert!(!matches("p[!12].parquet", "p1.parquet"));
assert!(matches("p[^12].parquet", "p3.parquet"));
}
#[test]
fn a_class_that_is_never_closed_is_not_a_class_and_matches_nothing() {
assert!(!matches("p[12.parquet", "p1.parquet"));
}
#[test]
fn several_stars_still_settle_on_an_answer() {
assert!(matches("*a*b*", "xxaybzz"));
assert!(!matches("*a*b*c", "xxaybzz"));
}
#[test]
fn a_name_with_no_magic_in_it_is_not_a_pattern() {
assert!(!has_magic("/data/hits.parquet"));
assert!(has_magic("/data/*.parquet"));
assert!(has_magic("/data/p?.parquet"));
assert!(has_magic("/data/p[12].parquet"));
assert!(!has_magic("/data/{p1,p2}.parquet"));
}
fn holding(paths: &[&str]) -> SimFilesystem {
let filesystem = SimFilesystem::new();
for path in paths {
let at = Path::new(path);
if let Some(parent) = at.parent() {
filesystem.create_dir_all(parent).expect("makes the directory");
}
filesystem.open(at, OpenMode::Create).expect("makes the file");
}
filesystem
}
#[test]
fn a_pattern_with_no_magic_is_the_file_it_names_or_nothing() {
let filesystem = holding(&["/data/hits.parquet"]);
assert_eq!(
expand(&filesystem, "/data/hits.parquet").expect("walks"),
["/data/hits.parquet"]
);
assert!(expand(&filesystem, "/data/other.parquet").expect("walks").is_empty());
}
#[test]
fn a_star_picks_the_files_of_one_directory_and_sorts_them() {
let filesystem = holding(&[
"/data/p2.parquet",
"/data/p1.parquet",
"/data/notes.csv",
"/data/deep/p3.parquet",
]);
assert_eq!(
expand(&filesystem, "/data/*.parquet").expect("walks"),
["/data/p1.parquet", "/data/p2.parquet"]
);
}
#[test]
fn a_star_does_not_cross_a_separator_and_a_middle_one_reaches_one_level() {
let filesystem = holding(&[
"/data/top.parquet",
"/data/one/a.parquet",
"/data/two/b.parquet",
"/data/two/deep/c.parquet",
]);
assert_eq!(
expand(&filesystem, "/data/*/*.parquet").expect("walks"),
["/data/one/a.parquet", "/data/two/b.parquet"]
);
}
#[test]
fn a_double_star_reaches_any_depth_including_none() {
let filesystem =
holding(&["/data/top.parquet", "/data/one/a.parquet", "/data/two/deep/c.parquet"]);
assert_eq!(
expand(&filesystem, "/data/**/*.parquet").expect("walks"),
["/data/one/a.parquet", "/data/top.parquet", "/data/two/deep/c.parquet"]
);
}
#[test]
fn a_directory_is_not_a_file_and_does_not_come_back_as_one() {
let filesystem = holding(&["/data/one/a.parquet"]);
assert!(expand(&filesystem, "/data/*").expect("walks").is_empty());
}
#[test]
fn a_pattern_over_a_place_that_is_not_there_is_empty_rather_than_an_error() {
let filesystem = holding(&["/data/a.parquet"]);
assert!(expand(&filesystem, "/nowhere/*.parquet").expect("walks").is_empty());
}
#[test]
fn a_relative_pattern_stays_relative() {
let filesystem = holding(&["data/a.parquet", "data/b.parquet"]);
assert_eq!(
expand(&filesystem, "data/*.parquet").expect("walks"),
["data/a.parquet", "data/b.parquet"]
);
}
}