use std::path::{Path, PathBuf};
use globset::GlobBuilder;
use ignore::WalkBuilder;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileDiscoveryError {
NonJuliaFilePath { path: PathBuf },
BadGlob { pattern: String, message: String },
Walk { path: PathBuf, message: String },
}
impl std::fmt::Display for FileDiscoveryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileDiscoveryError::NonJuliaFilePath { path } => {
write!(f, "not a Julia (.jl) file: {}", path.display())
}
FileDiscoveryError::BadGlob { pattern, message } => {
write!(f, "invalid glob pattern `{pattern}`: {message}")
}
FileDiscoveryError::Walk { path, message } => {
write!(f, "failed to walk {}: {message}", path.display())
}
}
}
}
impl std::error::Error for FileDiscoveryError {}
pub fn collect_julia_files(paths: &[PathBuf]) -> Result<Vec<PathBuf>, FileDiscoveryError> {
let mut files = Vec::new();
for path in paths {
if path.is_file() {
if !is_julia_file(path) {
return Err(FileDiscoveryError::NonJuliaFilePath { path: path.clone() });
}
files.push(path.clone());
continue;
}
if path.is_dir() {
walk_julia_files(path, None, &mut files)?;
continue;
}
if let Some(pattern) = path.to_str().filter(|p| has_glob_meta(p)) {
collect_glob(pattern, &mut files)?;
continue;
}
return Err(FileDiscoveryError::Walk {
path: path.clone(),
message: "path does not exist".to_string(),
});
}
files.sort();
files.dedup();
Ok(files)
}
fn collect_glob(pattern: &str, files: &mut Vec<PathBuf>) -> Result<(), FileDiscoveryError> {
let matcher = GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.map_err(|err| FileDiscoveryError::BadGlob {
pattern: pattern.to_string(),
message: err.to_string(),
})?
.compile_matcher();
let base = glob_base(pattern);
walk_julia_files(&base, Some(&matcher), files)
}
fn walk_julia_files(
root: &Path,
matcher: Option<&globset::GlobMatcher>,
files: &mut Vec<PathBuf>,
) -> Result<(), FileDiscoveryError> {
let mut builder = WalkBuilder::new(root);
builder.standard_filters(true);
builder.hidden(false);
for entry in builder.build() {
match entry {
Ok(entry) => {
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let entry_path = entry.path();
if !is_julia_file(entry_path) {
continue;
}
if let Some(matcher) = matcher
&& !matcher.is_match(normalize(entry_path))
{
continue;
}
files.push(entry_path.to_path_buf());
}
Err(err) => {
return Err(FileDiscoveryError::Walk {
path: root.to_path_buf(),
message: err.to_string(),
});
}
}
}
Ok(())
}
fn glob_base(pattern: &str) -> PathBuf {
let mut base = PathBuf::new();
for component in Path::new(pattern).components() {
match component.as_os_str().to_str() {
Some(s) if has_glob_meta(s) => break,
_ => base.push(component),
}
}
if base.as_os_str().is_empty() {
PathBuf::from(".")
} else {
base
}
}
fn normalize(path: &Path) -> &Path {
path.strip_prefix(".").unwrap_or(path)
}
fn has_glob_meta(s: &str) -> bool {
s.contains(['*', '?', '[', '{'])
}
fn is_julia_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("jl"))
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch_tree() -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!("fatou-fd-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("src/inner")).unwrap();
std::fs::write(root.join("a.jl"), "x = 1\n").unwrap();
std::fs::write(root.join("readme.md"), "hi\n").unwrap();
std::fs::write(root.join("src/b.jl"), "y = 2\n").unwrap();
std::fs::write(root.join("src/inner/c.jl"), "z = 3\n").unwrap();
root
}
fn names(files: &[PathBuf]) -> Vec<String> {
files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect()
}
#[test]
fn explicit_file_must_be_julia() {
let root = scratch_tree();
let err = collect_julia_files(&[root.join("readme.md")]).unwrap_err();
assert!(matches!(err, FileDiscoveryError::NonJuliaFilePath { .. }));
}
#[test]
fn directory_is_walked_recursively() {
let root = scratch_tree();
let files = collect_julia_files(std::slice::from_ref(&root)).unwrap();
assert_eq!(names(&files), vec!["a.jl", "b.jl", "c.jl"]);
}
#[test]
fn recursive_glob_matches_nested_files() {
let root = scratch_tree();
let pattern = root.join("**/*.jl");
let files = collect_julia_files(&[pattern]).unwrap();
assert_eq!(names(&files), vec!["a.jl", "b.jl", "c.jl"]);
}
#[test]
fn single_star_does_not_cross_directories() {
let root = scratch_tree();
let pattern = root.join("src/*.jl");
let files = collect_julia_files(&[pattern]).unwrap();
assert_eq!(names(&files), vec!["b.jl"]);
}
#[test]
fn missing_non_glob_path_errors() {
let err = collect_julia_files(&[PathBuf::from("does/not/exist.jl")]).unwrap_err();
assert!(matches!(err, FileDiscoveryError::Walk { .. }));
}
}