use std::path::{Path as StdPath, PathBuf};
use crate::extract::format::{FALLBACK_FORMAT, resolve_format};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Target {
pub(crate) path: PathBuf,
pub(crate) language_id: &'static str,
pub(crate) unreadable: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct WalkOptions {
pub(crate) hidden: bool,
pub(crate) respect_ignore: bool,
pub(crate) follow_symlinks: bool,
pub(crate) format: Option<&'static str>,
}
impl Default for WalkOptions {
fn default() -> Self {
Self {
hidden: false,
respect_ignore: true,
follow_symlinks: false,
format: None,
}
}
}
pub(crate) fn collect(inputs: &[PathBuf], options: &WalkOptions) -> Result<Vec<Target>, String> {
let mut targets = Vec::new();
for input in inputs {
let metadata =
std::fs::metadata(input).map_err(|error| format!("{}: {error}", input.display()))?;
if metadata.is_file() {
targets.push(Target {
path: input.clone(),
language_id: options.format.unwrap_or_else(|| language_for(input)),
unreadable: None,
});
continue;
}
targets.extend(walk_directory(input, options));
}
targets.sort_by(|a, b| a.path.cmp(&b.path));
targets.dedup();
Ok(targets)
}
fn walk_directory(root: &StdPath, options: &WalkOptions) -> Vec<Target> {
let mut builder = ignore::WalkBuilder::new(root);
builder
.hidden(!options.hidden)
.git_ignore(options.respect_ignore)
.git_global(options.respect_ignore)
.git_exclude(options.respect_ignore)
.ignore(options.respect_ignore)
.parents(options.respect_ignore)
.follow_links(options.follow_symlinks);
let mut targets = Vec::new();
for entry in builder.build() {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
targets.push(Target {
path: errored_path(&error).unwrap_or_else(|| root.to_path_buf()),
language_id: FALLBACK_FORMAT,
unreadable: Some(error.to_string()),
});
continue;
}
};
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
continue;
}
targets.push(Target {
path: entry.path().to_path_buf(),
language_id: options.format.unwrap_or_else(|| language_for(entry.path())),
unreadable: None,
});
}
targets
}
fn errored_path(error: &ignore::Error) -> Option<PathBuf> {
match error {
ignore::Error::WithPath { path, .. } => Some(path.clone()),
ignore::Error::Loop { child, .. } => Some(child.clone()),
ignore::Error::WithDepth { err, .. } | ignore::Error::WithLineNumber { err, .. } => {
errored_path(err)
}
ignore::Error::Partial(errors) => errors.iter().find_map(errored_path),
_ => None,
}
}
fn language_for(path: &StdPath) -> &'static str {
path.file_name()
.and_then(|name| name.to_str())
.and_then(|name| resolve_format(None, Some(name)))
.unwrap_or(FALLBACK_FORMAT)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempTree;
fn names(targets: &[Target]) -> Vec<String> {
targets
.iter()
.map(|target| {
target
.path
.file_name()
.expect("a name")
.to_string_lossy()
.into_owned()
})
.collect()
}
#[test]
fn a_directory_yields_every_file_and_names_the_format_of_each() {
let tree = TempTree::new("walk-formats");
tree.write("a.md", "# yes");
tree.write("b.toml", "");
tree.write("c.rs", "// no");
tree.write("d.py", "pass");
let targets = collect(&[tree.path().to_path_buf()], &WalkOptions::default())
.expect("the walk succeeds");
assert_eq!(names(&targets), ["a.md", "b.toml", "c.rs", "d.py"]);
assert_eq!(targets[0].language_id, "markdown");
assert_eq!(targets[2].language_id, FALLBACK_FORMAT);
}
#[test]
fn the_order_is_stable() {
let tree = TempTree::new("walk-order");
for name in ["z.md", "a.md", "m.md"] {
tree.write(name, "{}");
}
let first = collect(&[tree.path().to_path_buf()], &WalkOptions::default())
.expect("the walk succeeds");
let second = collect(&[tree.path().to_path_buf()], &WalkOptions::default())
.expect("the walk succeeds");
assert_eq!(names(&first), ["a.md", "m.md", "z.md"]);
assert_eq!(first, second);
}
#[test]
fn gitignored_files_are_skipped_by_default_and_walked_on_request() {
let tree = TempTree::new("walk-ignore");
tree.mkdir(".git");
tree.write(".gitignore", "skipped.md\n");
tree.write("skipped.md", "x");
tree.write("kept.md", "x");
let default = collect(&[tree.path().to_path_buf()], &WalkOptions::default())
.expect("the walk succeeds");
assert_eq!(names(&default), ["kept.md"]);
let everything = collect(
&[tree.path().to_path_buf()],
&WalkOptions {
respect_ignore: false,
..WalkOptions::default()
},
)
.expect("the walk succeeds");
assert_eq!(names(&everything), ["kept.md", "skipped.md"]);
}
#[test]
fn a_gitignore_outside_a_repository_is_inert() {
let tree = TempTree::new("walk-ignore-nogit");
tree.write(".gitignore", "skipped.md\n");
tree.write("skipped.md", "x");
let targets = collect(&[tree.path().to_path_buf()], &WalkOptions::default())
.expect("the walk succeeds");
assert_eq!(names(&targets), ["skipped.md"]);
}
#[test]
fn hidden_files_are_skipped_by_default_and_walked_on_request() {
let tree = TempTree::new("walk-hidden");
tree.write(".hidden.md", "x");
tree.write("shown.md", "x");
let default = collect(&[tree.path().to_path_buf()], &WalkOptions::default())
.expect("the walk succeeds");
assert_eq!(names(&default), ["shown.md"]);
let everything = collect(
&[tree.path().to_path_buf()],
&WalkOptions {
hidden: true,
..WalkOptions::default()
},
)
.expect("the walk succeeds");
assert_eq!(names(&everything), [".hidden.md", "shown.md"]);
}
#[test]
fn an_explicitly_named_file_beats_the_ignore_rules() {
let tree = TempTree::new("walk-explicit");
tree.write(".gitignore", "skipped.json\n");
let file = tree.write("skipped.md", "x");
let targets = collect(&[file], &WalkOptions::default()).expect("the walk succeeds");
assert_eq!(names(&targets), ["skipped.md"]);
}
#[test]
fn an_explicitly_named_file_of_unknown_format_is_read_anyway() {
let tree = TempTree::new("walk-unknown");
let file = tree.write("notes.rs", "// x");
let targets = collect(&[file], &WalkOptions::default()).expect("the walk succeeds");
assert_eq!(names(&targets), ["notes.rs"]);
assert_eq!(targets[0].language_id, FALLBACK_FORMAT);
}
#[test]
fn a_forced_format_overrides_the_filename() {
let tree = TempTree::new("walk-forced");
let file = tree.write("notes.rs", "x");
let targets = collect(
&[file],
&WalkOptions {
format: Some("markdown"),
..WalkOptions::default()
},
)
.expect("the walk succeeds");
assert_eq!(targets[0].language_id, "markdown");
}
#[test]
fn a_missing_input_is_refused_by_name() {
let tree = TempTree::new("walk-missing");
let error =
collect(&[tree.path().join("nope")], &WalkOptions::default()).expect_err("a refusal");
assert!(error.contains("nope"), "{error}");
}
#[cfg(unix)]
#[test]
fn a_directory_that_cannot_be_entered_is_carried_not_fatal() {
use std::os::unix::fs::PermissionsExt as _;
let tree = TempTree::new("walk-locked");
tree.write("readable.md", "https://a.example\n");
let locked = tree.mkdir("locked");
tree.write("locked/hidden.md", "https://b.example\n");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
.expect("permissions");
let targets = collect(&[tree.path().to_path_buf()], &WalkOptions::default());
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755))
.expect("permissions");
let targets = targets.expect("the walk succeeds");
let Some(unreadable) = targets.iter().find(|t| t.unreadable.is_some()) else {
eprintln!(
"SKIPPED a_directory_that_cannot_be_entered_is_carried_not_fatal: \
this user can read a 0o000 directory"
);
return;
};
assert!(unreadable.path.ends_with("locked"), "{unreadable:?}");
assert!(
names(&targets).contains(&"readable.md".to_string()),
"the rest of the tree survives one unreadable directory"
);
}
#[test]
fn naming_the_same_file_twice_examines_it_once() {
let tree = TempTree::new("walk-dedupe");
let file = tree.write("a.json", "{}");
let targets =
collect(&[file.clone(), file], &WalkOptions::default()).expect("the walk succeeds");
assert_eq!(targets.len(), 1);
}
}