use std::path::{Path, PathBuf};
use globset::GlobBuilder;
use ignore::WalkBuilder;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
#[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 {}
#[derive(Debug, Clone, Default)]
pub struct ExcludeFilter {
matcher: Option<Gitignore>,
force: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExcludeError {
pub pattern: String,
pub message: String,
}
impl std::fmt::Display for ExcludeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"invalid exclude pattern `{}`: {}",
self.pattern, self.message
)
}
}
impl std::error::Error for ExcludeError {}
impl ExcludeFilter {
pub fn none() -> Self {
Self::default()
}
pub fn new(root: &Path, patterns: &[String]) -> Result<Self, ExcludeError> {
if patterns.is_empty() {
return Ok(Self::none());
}
let mut builder = GitignoreBuilder::new(root);
for pattern in patterns.iter().cloned() {
if let Err(err) = builder.add_line(None, &pattern) {
return Err(ExcludeError {
pattern,
message: err.to_string(),
});
}
}
let matcher = builder.build().map_err(|err| ExcludeError {
pattern: String::new(),
message: err.to_string(),
})?;
Ok(Self {
matcher: Some(matcher),
force: false,
})
}
pub fn with_force_exclude(mut self, force: bool) -> Self {
self.force = force;
self
}
pub fn force(&self) -> bool {
self.force
}
pub fn force_excludes(&self, path: &Path) -> bool {
if !self.force {
return false;
}
match &self.matcher {
Some(matcher) => {
if path.has_root() && !path.starts_with(matcher.path()) {
return false;
}
matcher.matched_path_or_any_parents(path, false).is_ignore()
}
None => false,
}
}
fn is_excluded(&self, path: &Path, is_dir: bool) -> bool {
match &self.matcher {
Some(matcher) => matcher.matched(path, is_dir).is_ignore(),
None => false,
}
}
}
pub fn collect_julia_files(
paths: &[PathBuf],
exclude: &ExcludeFilter,
) -> Result<Vec<PathBuf>, FileDiscoveryError> {
let mut files = Vec::new();
for path in paths {
if path.is_file() {
if exclude.force_excludes(path) {
continue;
}
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, exclude, &mut files)?;
continue;
}
if let Some(pattern) = path.to_str().filter(|p| has_glob_meta(p)) {
collect_glob(pattern, exclude, &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,
exclude: &ExcludeFilter,
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), exclude, files)
}
fn walk_julia_files(
root: &Path,
matcher: Option<&globset::GlobMatcher>,
exclude: &ExcludeFilter,
files: &mut Vec<PathBuf>,
) -> Result<(), FileDiscoveryError> {
let mut builder = WalkBuilder::new(root);
builder.standard_filters(true);
builder.hidden(false);
let filter = exclude.clone();
builder.filter_entry(move |entry| {
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
!filter.is_excluded(entry.path(), is_dir)
});
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")], &ExcludeFilter::none()).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), &ExcludeFilter::none()).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], &ExcludeFilter::none()).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], &ExcludeFilter::none()).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")],
&ExcludeFilter::none(),
)
.unwrap_err();
assert!(matches!(err, FileDiscoveryError::Walk { .. }));
}
fn filter(root: &Path, patterns: &[&str]) -> ExcludeFilter {
let patterns: Vec<String> = patterns.iter().map(|p| p.to_string()).collect();
ExcludeFilter::new(root, &patterns).unwrap()
}
#[test]
fn exclude_prunes_directory_walk() {
let root = scratch_tree();
let files =
collect_julia_files(std::slice::from_ref(&root), &filter(&root, &["src/"])).unwrap();
assert_eq!(names(&files), vec!["a.jl"]);
}
#[test]
fn exclude_prunes_glob_walk() {
let root = scratch_tree();
let pattern = root.join("**/*.jl");
let files = collect_julia_files(&[pattern], &filter(&root, &["inner/"])).unwrap();
assert_eq!(names(&files), vec!["a.jl", "b.jl"]);
}
#[test]
fn explicit_file_is_not_excluded_without_force() {
let root = scratch_tree();
let excluded = root.join("src/b.jl");
let files = collect_julia_files(std::slice::from_ref(&excluded), &filter(&root, &["src/"]))
.unwrap();
assert_eq!(files, vec![excluded]);
}
#[test]
fn force_exclude_skips_explicitly_named_file() {
let root = scratch_tree();
let keep = root.join("a.jl");
let excluded = root.join("src/b.jl");
let filter = filter(&root, &["src/"]).with_force_exclude(true);
let files = collect_julia_files(&[excluded, keep.clone()], &filter).unwrap();
assert_eq!(files, vec![keep]);
}
#[test]
fn force_exclude_may_leave_no_files() {
let root = scratch_tree();
let excluded = root.join("src/b.jl");
let filter = filter(&root, &["src/"]).with_force_exclude(true);
let files = collect_julia_files(&[excluded], &filter).unwrap();
assert_eq!(files, Vec::<PathBuf>::new());
}
#[test]
fn force_exclude_matches_parent_directory_pattern() {
let root = scratch_tree();
let nested = root.join("src/inner/c.jl");
let filter = filter(&root, &["src/"]).with_force_exclude(true);
let files = collect_julia_files(&[nested], &filter).unwrap();
assert_eq!(files, Vec::<PathBuf>::new());
}
#[test]
fn force_exclude_skips_excluded_non_julia_file() {
let root = scratch_tree();
let readme = root.join("readme.md");
let filter = filter(&root, &["*.md"]).with_force_exclude(true);
let files = collect_julia_files(&[readme], &filter).unwrap();
assert_eq!(files, Vec::<PathBuf>::new());
}
#[test]
fn force_exclude_ignores_paths_outside_matcher_root() {
let root = scratch_tree();
let other = scratch_tree();
let outside = other.join("a.jl");
let filter = filter(&root, &["a.jl"]).with_force_exclude(true);
let files = collect_julia_files(std::slice::from_ref(&outside), &filter).unwrap();
assert_eq!(files, vec![outside]);
}
#[test]
fn invalid_exclude_pattern_is_reported() {
let err = ExcludeFilter::new(Path::new("."), &["\\".to_string()]).unwrap_err();
assert_eq!(err.pattern, "\\");
}
}