5a4a6670b1
- Updated gitignore loading to filter out ancestor ignore rules that match the current explicit walk root. - Retained unrelated parent ignore rules and processed local ignore files within the root normally. - Added a regression test to ensure ignored roots still respect nested ignore rules.
4928 lines
138 KiB
Rust
4928 lines
138 KiB
Rust
//! Reusable platform directory traversal primitives.
|
|
//!
|
|
//! # Overview
|
|
//! `pi-walker` owns the native directory-read fast path that higher-level tools
|
|
//! use for globbing, grep candidate discovery, AST scans, and shell builtins.
|
|
//! The crate exposes plain Rust types, visitor interfaces, cache policy, and a
|
|
//! caller-supplied heartbeat so consumers do not inherit N-API dependencies.
|
|
|
|
mod cache;
|
|
|
|
#[cfg(not(unix))]
|
|
use std::ffi::OsString;
|
|
#[cfg(unix)]
|
|
use std::os::unix::ffi::OsStrExt;
|
|
use std::{
|
|
borrow::Cow,
|
|
cell::{Cell, RefCell},
|
|
cmp::Ordering,
|
|
convert::Infallible,
|
|
ffi::OsStr,
|
|
fmt,
|
|
hash::{Hash, Hasher},
|
|
io::{self, BufRead},
|
|
path::{Path, PathBuf},
|
|
sync::{
|
|
Arc, Mutex,
|
|
atomic::{AtomicBool, Ordering as AtomicOrdering},
|
|
},
|
|
};
|
|
|
|
pub use cache::{
|
|
cache_ttl_ms, classify_file_type, contains_component, empty_recheck_ms, invalidate_all,
|
|
invalidate_path, invalidate_path_string, max_cache_entries, normalize_relative_path,
|
|
parallel_for_each, parallel_for_each_init, resolve_search_path, should_parallelize,
|
|
should_skip_path, walk_workers,
|
|
};
|
|
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
|
|
|
|
const HEARTBEAT_INTERVAL: usize = 128;
|
|
|
|
/// Filesystem entry kind reported by the walker.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum FileType {
|
|
/// Regular file.
|
|
File,
|
|
/// Directory.
|
|
Dir,
|
|
/// Symbolic link.
|
|
Symlink,
|
|
}
|
|
|
|
/// Amount of metadata to collect while reading directories.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum WalkDetail {
|
|
/// Collect only the entry name and file kind.
|
|
Minimal,
|
|
/// Also collect mtime and byte size for regular files.
|
|
Full,
|
|
}
|
|
|
|
/// Traversal order for entries within each directory.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum WalkOrder {
|
|
/// Visit entries in the order returned by the platform API.
|
|
Unordered,
|
|
/// Sort entries by filename before visiting them.
|
|
Path,
|
|
}
|
|
|
|
/// How directory-open errors are handled during traversal.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum DirectoryErrorMode {
|
|
/// Preserve the native glob fast-path contract: silently skip common race or
|
|
/// permission failures and fail on other directory errors.
|
|
SkipSkippable,
|
|
/// Deliver directory errors to [`EntryVisitor::visit_directory_error`] so
|
|
/// GNU-style consumers can report them and continue.
|
|
Visit,
|
|
}
|
|
|
|
/// Symbolic-link traversal policy.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum FollowLinks {
|
|
/// Never follow symbolic links.
|
|
Never,
|
|
/// Follow root operands when they are symbolic links, but not descendants.
|
|
Roots,
|
|
/// Follow symbolic links at every depth.
|
|
Always,
|
|
}
|
|
|
|
impl From<bool> for FollowLinks {
|
|
fn from(follow: bool) -> Self {
|
|
if follow { Self::Always } else { Self::Never }
|
|
}
|
|
}
|
|
|
|
impl FollowLinks {
|
|
const fn follow_at_depth(self, depth: usize) -> bool {
|
|
match self {
|
|
Self::Never => false,
|
|
Self::Roots => depth == 0,
|
|
Self::Always => true,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Shared cache use policy for high-level walk requests.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum CachePolicy {
|
|
/// Collect without using or updating the shared scan cache.
|
|
Disabled,
|
|
/// Use the shared scan cache for owned-entry collection.
|
|
Enabled,
|
|
}
|
|
|
|
/// Empty cached-result revalidation policy for [`WalkRequest::collect`].
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum EmptyRecheck {
|
|
/// Never re-scan an empty cached result.
|
|
Never,
|
|
/// Re-scan empty cached results at or above the configured
|
|
/// [`empty_recheck_ms`] threshold.
|
|
Configured,
|
|
/// Re-scan empty cached results at or above this cache age.
|
|
AfterMillis(u64),
|
|
}
|
|
|
|
/// Size metadata policy for high-level requests.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum SizeHintPolicy {
|
|
/// Preserve the request's [`WalkDetail`] setting.
|
|
FromDetail,
|
|
/// Request minimal metadata even on platforms with cheap size hints.
|
|
Never,
|
|
/// Request full metadata only when the platform exposes cheap file sizes.
|
|
WhenCheap,
|
|
/// Request full metadata for every yielded entry.
|
|
Always,
|
|
}
|
|
|
|
/// Directory visit order for high-level requests.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum VisitOrder {
|
|
/// Yield a directory before its children.
|
|
PreOrder,
|
|
/// Yield a directory after its children when supported by the backend.
|
|
ContentsFirst,
|
|
}
|
|
|
|
/// Concrete compiled glob filter for normalized walk-relative paths.
|
|
///
|
|
/// Patterns are expected to already use the walker's normalized `/` separator
|
|
/// form. Each pattern is compiled with [`GlobBuilder::literal_separator`] so
|
|
/// wildcard matches never cross path separators. Equality and hashing use only
|
|
/// the normalized pattern list, not the compiled matcher internals, which keeps
|
|
/// [`WalkFilter`] suitable for static cacheable traversal policy.
|
|
#[derive(Clone)]
|
|
pub struct CompiledWalkGlob {
|
|
patterns: Arc<[String]>,
|
|
matcher: Arc<GlobSet>,
|
|
}
|
|
|
|
impl CompiledWalkGlob {
|
|
/// Compile normalized glob patterns for walk-relative paths.
|
|
pub fn new<P, I>(patterns: I) -> Result<Self, globset::Error>
|
|
where
|
|
P: Into<String>,
|
|
I: IntoIterator<Item = P>,
|
|
{
|
|
let mut normalized_patterns = Vec::new();
|
|
let mut builder = GlobSetBuilder::new();
|
|
for pattern in patterns {
|
|
let pattern = pattern.into();
|
|
let glob = GlobBuilder::new(&pattern).literal_separator(true).build()?;
|
|
builder.add(glob);
|
|
normalized_patterns.push(pattern);
|
|
}
|
|
Ok(Self { patterns: normalized_patterns.into(), matcher: Arc::new(builder.build()?) })
|
|
}
|
|
|
|
/// Return whether `relative` matches any compiled pattern.
|
|
pub fn is_match(&self, relative: &str) -> bool {
|
|
self.matcher.is_match(relative)
|
|
}
|
|
|
|
/// Return the normalized patterns backing this compiled filter.
|
|
pub fn patterns(&self) -> &[String] {
|
|
&self.patterns
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for CompiledWalkGlob {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("CompiledWalkGlob")
|
|
.field("patterns", &self.patterns)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl PartialEq for CompiledWalkGlob {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.patterns == other.patterns
|
|
}
|
|
}
|
|
|
|
impl Eq for CompiledWalkGlob {}
|
|
|
|
impl Hash for CompiledWalkGlob {
|
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
|
self.patterns.hash(state);
|
|
}
|
|
}
|
|
|
|
/// High-level entry filter applied by collection and streaming APIs.
|
|
#[derive(Clone)]
|
|
pub struct WalkFilter {
|
|
kind: WalkFilterKind,
|
|
max_file_size: Option<u64>,
|
|
skip_node_modules_unless_seen: bool,
|
|
mentions_node_modules: bool,
|
|
glob: Option<CompiledWalkGlob>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
enum WalkFilterKind {
|
|
All,
|
|
Files,
|
|
Dirs,
|
|
}
|
|
|
|
impl Default for WalkFilter {
|
|
fn default() -> Self {
|
|
Self::all()
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for WalkFilter {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("WalkFilter")
|
|
.field("kind", &self.kind)
|
|
.field("max_file_size", &self.max_file_size)
|
|
.field("skip_node_modules_unless_seen", &self.skip_node_modules_unless_seen)
|
|
.field("mentions_node_modules", &self.mentions_node_modules)
|
|
.field("glob", &self.glob)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl PartialEq for WalkFilter {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.kind == other.kind
|
|
&& self.max_file_size == other.max_file_size
|
|
&& self.skip_node_modules_unless_seen == other.skip_node_modules_unless_seen
|
|
&& self.mentions_node_modules == other.mentions_node_modules
|
|
&& self.glob == other.glob
|
|
}
|
|
}
|
|
|
|
impl Eq for WalkFilter {}
|
|
|
|
impl Hash for WalkFilter {
|
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
|
self.kind.hash(state);
|
|
self.max_file_size.hash(state);
|
|
self.skip_node_modules_unless_seen.hash(state);
|
|
self.mentions_node_modules.hash(state);
|
|
self.glob.hash(state);
|
|
}
|
|
}
|
|
|
|
impl WalkFilter {
|
|
/// Return a filter that accepts files and directories.
|
|
pub const fn all() -> Self {
|
|
Self {
|
|
kind: WalkFilterKind::All,
|
|
max_file_size: None,
|
|
skip_node_modules_unless_seen: false,
|
|
mentions_node_modules: false,
|
|
glob: None,
|
|
}
|
|
}
|
|
|
|
/// Return a filter that emits only regular files.
|
|
pub const fn files_only() -> Self {
|
|
Self {
|
|
kind: WalkFilterKind::Files,
|
|
max_file_size: None,
|
|
skip_node_modules_unless_seen: false,
|
|
mentions_node_modules: false,
|
|
glob: None,
|
|
}
|
|
}
|
|
|
|
/// Return a filter that emits only directories.
|
|
pub const fn dirs_only() -> Self {
|
|
Self {
|
|
kind: WalkFilterKind::Dirs,
|
|
max_file_size: None,
|
|
skip_node_modules_unless_seen: false,
|
|
mentions_node_modules: false,
|
|
glob: None,
|
|
}
|
|
}
|
|
|
|
/// Limit emitted regular files to `max_file_size` bytes.
|
|
pub const fn max_file_size(mut self, max_file_size: u64) -> Self {
|
|
self.max_file_size = Some(max_file_size);
|
|
self
|
|
}
|
|
|
|
/// Skip `node_modules` entries unless the caller's query mentioned them.
|
|
pub const fn node_modules_unless_mentioned(mut self, mentions_node_modules: bool) -> Self {
|
|
self.skip_node_modules_unless_seen = true;
|
|
self.mentions_node_modules = mentions_node_modules;
|
|
self
|
|
}
|
|
|
|
/// Accept only entries whose normalized relative path matches `glob`.
|
|
pub fn glob(mut self, glob: CompiledWalkGlob) -> Self {
|
|
self.glob = Some(glob);
|
|
self
|
|
}
|
|
|
|
fn accepts_path(&self, relative_path: &str) -> bool {
|
|
self
|
|
.glob
|
|
.as_ref()
|
|
.is_none_or(|glob| glob.is_match(relative_path))
|
|
}
|
|
|
|
fn accepts_collected(&self, entry: &CollectedEntry) -> bool {
|
|
if self.skip_node_modules_unless_seen
|
|
&& !self.mentions_node_modules
|
|
&& entry
|
|
.path
|
|
.split('/')
|
|
.any(|component| component == "node_modules")
|
|
{
|
|
return false;
|
|
}
|
|
if self.max_file_size.is_some_and(|max| {
|
|
entry.file_type == FileType::File && entry.size.is_some_and(|size| size > max as f64)
|
|
}) {
|
|
return false;
|
|
}
|
|
let accepts_kind = match self.kind {
|
|
WalkFilterKind::All => true,
|
|
WalkFilterKind::Files => entry.file_type == FileType::File,
|
|
WalkFilterKind::Dirs => entry.file_type == FileType::Dir,
|
|
};
|
|
accepts_kind && self.accepts_path(&entry.path)
|
|
}
|
|
|
|
fn stream_decision(&self, meta: &EntryMeta<'_>) -> WalkDecision {
|
|
if self.skip_node_modules_unless_seen
|
|
&& !self.mentions_node_modules
|
|
&& meta
|
|
.relative_path
|
|
.split('/')
|
|
.any(|component| component == "node_modules")
|
|
{
|
|
return if meta.file_type == FileType::Dir {
|
|
WalkDecision::SkipDescend
|
|
} else {
|
|
WalkDecision::Skip
|
|
};
|
|
}
|
|
if self.max_file_size.is_some_and(|max| {
|
|
meta.file_type == FileType::File && meta.size.is_some_and(|size| size > max as f64)
|
|
}) {
|
|
return WalkDecision::Skip;
|
|
}
|
|
let accepts_kind = match self.kind {
|
|
WalkFilterKind::All => true,
|
|
WalkFilterKind::Files => meta.file_type == FileType::File,
|
|
WalkFilterKind::Dirs => meta.file_type == FileType::Dir,
|
|
};
|
|
if !accepts_kind {
|
|
return WalkDecision::Skip;
|
|
}
|
|
if self.accepts_path(meta.relative_path) {
|
|
WalkDecision::Include
|
|
} else {
|
|
WalkDecision::Skip
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Traversal decision returned by [`WalkPredicate`] and closure streaming APIs.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum WalkDecision {
|
|
/// Emit this entry and continue traversal.
|
|
Include,
|
|
/// Do not emit this entry, but continue traversal.
|
|
Skip,
|
|
/// Do not emit this directory and do not descend into it.
|
|
SkipDescend,
|
|
/// Stop traversal immediately.
|
|
Stop,
|
|
}
|
|
|
|
/// Predicate hook for dynamic walk consumers.
|
|
pub trait WalkPredicate {
|
|
/// Decide how the high-level walker should handle `entry`.
|
|
fn decide(&mut self, entry: &EntryMeta<'_>) -> WalkDecision;
|
|
}
|
|
|
|
impl<F> WalkPredicate for F
|
|
where
|
|
F: for<'entry, 'meta> FnMut(&'entry EntryMeta<'meta>) -> WalkDecision,
|
|
{
|
|
fn decide(&mut self, entry: &EntryMeta<'_>) -> WalkDecision {
|
|
self(entry)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
struct IncludeAllPredicate;
|
|
|
|
impl WalkPredicate for IncludeAllPredicate {
|
|
fn decide(&mut self, _entry: &EntryMeta<'_>) -> WalkDecision {
|
|
WalkDecision::Include
|
|
}
|
|
}
|
|
|
|
/// Borrowed metadata view shared by owned and streaming entries.
|
|
pub struct EntryMeta<'a> {
|
|
/// Traversal root used to resolve relative paths.
|
|
pub root: &'a Path,
|
|
/// Absolute filesystem path.
|
|
pub absolute_path: Cow<'a, Path>,
|
|
/// Relative path from the root, using `/` separators.
|
|
pub relative_path: &'a str,
|
|
/// Filesystem entry kind.
|
|
pub file_type: FileType,
|
|
/// Modification time in milliseconds since the Unix epoch, when requested.
|
|
pub mtime: Option<f64>,
|
|
/// File size in bytes for regular files, when requested.
|
|
pub size: Option<f64>,
|
|
/// Depth below the traversal root. Root depth is 0.
|
|
pub depth: usize,
|
|
}
|
|
|
|
impl<'a> EntryMeta<'a> {
|
|
/// Build metadata for an owned collected entry.
|
|
pub fn from_collected(root: &'a Path, entry: &'a CollectedEntry) -> Self {
|
|
Self {
|
|
root,
|
|
absolute_path: Cow::Owned(entry.absolute_path(root)),
|
|
relative_path: &entry.path,
|
|
file_type: entry.file_type,
|
|
mtime: entry.mtime,
|
|
size: entry.size,
|
|
depth: entry.depth(),
|
|
}
|
|
}
|
|
|
|
/// Build metadata for a borrowed streaming entry.
|
|
pub const fn from_entry(root: &'a Path, entry: &Entry<'a>) -> Self {
|
|
Self {
|
|
root,
|
|
absolute_path: Cow::Borrowed(entry.path),
|
|
relative_path: entry.relative,
|
|
file_type: entry.file_type,
|
|
mtime: entry.mtime,
|
|
size: entry.size,
|
|
depth: entry.depth,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Owned regular-file candidate returned by high-level file collection.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct FileCandidate {
|
|
/// Absolute filesystem path to the regular file.
|
|
pub path: PathBuf,
|
|
/// Relative path from the walk root, using `/` separators.
|
|
pub relative: String,
|
|
/// Modification time in milliseconds since the Unix epoch, when requested.
|
|
pub mtime: Option<f64>,
|
|
/// File size in bytes, when requested.
|
|
pub size: Option<f64>,
|
|
}
|
|
|
|
impl FileCandidate {
|
|
fn from_entry(root: &Path, entry: CollectedEntry) -> Self {
|
|
Self {
|
|
path: entry.absolute_path(root),
|
|
relative: entry.path,
|
|
mtime: entry.mtime,
|
|
size: entry.size,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Backend path used by a high-level collection.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum WalkBackend {
|
|
/// The request returned entries from a fresh backend scan.
|
|
Fresh,
|
|
/// The request returned entries from the shared cache.
|
|
Cached,
|
|
}
|
|
|
|
/// Ranking applied to high-level collected entries after filtering.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum WalkRank {
|
|
/// Sort by normalized relative path in ascending byte order.
|
|
PathAsc,
|
|
/// Sort by modification time descending, then normalized relative path
|
|
/// ascending.
|
|
///
|
|
/// Entries without modification times sort after entries with modification
|
|
/// times. [`WalkRequest::collect_ranked_with_heartbeat`] requests full
|
|
/// metadata for this rank so fresh scans can populate the mtime field.
|
|
MtimeDescPathAsc,
|
|
}
|
|
|
|
/// Summary statistics for a high-level collection.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub struct WalkStats {
|
|
/// Age of the cache entry in milliseconds; zero means freshly scanned.
|
|
pub cache_age_ms: u64,
|
|
/// Entries before high-level filtering.
|
|
pub scanned_entries: usize,
|
|
/// Entries removed by high-level filtering.
|
|
pub filtered_entries: usize,
|
|
/// Entries removed by the high-level limit.
|
|
pub limited_entries: usize,
|
|
}
|
|
|
|
/// Owned entries and metadata returned by [`WalkRequest::collect`].
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct WalkOutcome {
|
|
/// Entries after high-level filtering and limits.
|
|
pub entries: Vec<CollectedEntry>,
|
|
/// Collection backend classification.
|
|
pub backend: WalkBackend,
|
|
/// Collection statistics.
|
|
pub stats: WalkStats,
|
|
}
|
|
|
|
/// Options shared by native traversal consumers.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub struct WalkOptions {
|
|
/// Include dot-prefixed entries.
|
|
pub include_hidden: bool,
|
|
/// Honor `.ignore`, `.gitignore`, repository excludes, and global gitignore.
|
|
pub use_gitignore: bool,
|
|
/// Prune `.git` directories during traversal.
|
|
pub skip_git: bool,
|
|
/// Prune `node_modules` directories during traversal.
|
|
pub skip_node_modules: bool,
|
|
/// Symbolic-link traversal policy.
|
|
pub follow_links: FollowLinks,
|
|
/// Metadata detail requested for each yielded entry.
|
|
pub detail: WalkDetail,
|
|
/// Per-directory visit order.
|
|
pub order: WalkOrder,
|
|
/// Yield the traversal root as a depth-0 entry before its children.
|
|
pub emit_root: bool,
|
|
/// Minimum depth yielded to the visitor. Root depth is 0.
|
|
pub min_depth: usize,
|
|
/// Maximum depth traversed and yielded. Root depth is 0.
|
|
pub max_depth: usize,
|
|
/// Yield directory entries after their children.
|
|
pub contents_first: bool,
|
|
/// Directory-open error handling policy.
|
|
pub directory_errors: DirectoryErrorMode,
|
|
/// Stay on the root filesystem when supported by the platform.
|
|
pub same_file_system: bool,
|
|
/// Use the shared scan cache when collecting owned entries.
|
|
pub cache: bool,
|
|
}
|
|
|
|
impl Default for WalkOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
include_hidden: true,
|
|
use_gitignore: false,
|
|
skip_git: false,
|
|
skip_node_modules: false,
|
|
follow_links: FollowLinks::Never,
|
|
detail: WalkDetail::Minimal,
|
|
order: WalkOrder::Path,
|
|
emit_root: false,
|
|
min_depth: 1,
|
|
max_depth: usize::MAX,
|
|
contents_first: false,
|
|
directory_errors: DirectoryErrorMode::Visit,
|
|
same_file_system: false,
|
|
cache: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// High-level traversal request that owns a root and wraps [`WalkOptions`].
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct WalkRequest {
|
|
root: PathBuf,
|
|
options: WalkOptions,
|
|
cache_policy: CachePolicy,
|
|
filter: WalkFilter,
|
|
limit: Option<usize>,
|
|
empty_recheck: EmptyRecheck,
|
|
visit_order: VisitOrder,
|
|
size_hint_policy: SizeHintPolicy,
|
|
}
|
|
|
|
impl WalkRequest {
|
|
/// Create a request rooted at `root` with default [`WalkOptions`].
|
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
|
Self::from_options(root, WalkOptions::default())
|
|
}
|
|
|
|
/// Create a request from existing low-level options.
|
|
pub fn from_options(root: impl Into<PathBuf>, options: WalkOptions) -> Self {
|
|
let cache_policy = if options.cache {
|
|
CachePolicy::Enabled
|
|
} else {
|
|
CachePolicy::Disabled
|
|
};
|
|
let visit_order = if options.contents_first {
|
|
VisitOrder::ContentsFirst
|
|
} else {
|
|
VisitOrder::PreOrder
|
|
};
|
|
Self {
|
|
root: root.into(),
|
|
options,
|
|
cache_policy,
|
|
filter: WalkFilter::default(),
|
|
limit: None,
|
|
empty_recheck: EmptyRecheck::Configured,
|
|
visit_order,
|
|
size_hint_policy: SizeHintPolicy::FromDetail,
|
|
}
|
|
}
|
|
|
|
/// Return the traversal root.
|
|
pub fn root(&self) -> &Path {
|
|
&self.root
|
|
}
|
|
|
|
/// Return low-level options after applying high-level policies.
|
|
pub const fn options(&self) -> WalkOptions {
|
|
self.effective_options()
|
|
}
|
|
|
|
/// Include or exclude dot-prefixed entries.
|
|
pub const fn hidden(mut self, include_hidden: bool) -> Self {
|
|
self.options.include_hidden = include_hidden;
|
|
self
|
|
}
|
|
|
|
/// Enable or disable `.ignore`/gitignore matching.
|
|
pub const fn gitignore(mut self, use_gitignore: bool) -> Self {
|
|
self.options.use_gitignore = use_gitignore;
|
|
self
|
|
}
|
|
|
|
/// Enable or disable pruning `.git` directories.
|
|
pub const fn skip_git(mut self, skip_git: bool) -> Self {
|
|
self.options.skip_git = skip_git;
|
|
self
|
|
}
|
|
|
|
/// Enable or disable pruning `node_modules` directories during traversal.
|
|
pub const fn skip_node_modules(mut self, skip_node_modules: bool) -> Self {
|
|
self.options.skip_node_modules = skip_node_modules;
|
|
self
|
|
}
|
|
|
|
/// Set symbolic-link traversal policy.
|
|
pub const fn follow_links(mut self, follow_links: FollowLinks) -> Self {
|
|
self.options.follow_links = follow_links;
|
|
self
|
|
}
|
|
|
|
/// Set metadata detail collected for entries.
|
|
pub const fn detail(mut self, detail: WalkDetail) -> Self {
|
|
self.options.detail = detail;
|
|
self
|
|
}
|
|
|
|
/// Set per-directory entry order.
|
|
pub const fn order(mut self, order: WalkOrder) -> Self {
|
|
self.options.order = order;
|
|
self
|
|
}
|
|
|
|
/// Enable or disable emitting the root entry.
|
|
pub const fn emit_root(mut self, emit_root: bool) -> Self {
|
|
self.options.emit_root = emit_root;
|
|
self
|
|
}
|
|
|
|
/// Set minimum and maximum traversal depth.
|
|
pub const fn depth(mut self, min_depth: usize, max_depth: usize) -> Self {
|
|
self.options.min_depth = min_depth;
|
|
self.options.max_depth = max_depth;
|
|
self
|
|
}
|
|
|
|
/// Set directory-open error handling.
|
|
pub const fn directory_errors(mut self, directory_errors: DirectoryErrorMode) -> Self {
|
|
self.options.directory_errors = directory_errors;
|
|
self
|
|
}
|
|
|
|
/// Enable or disable staying on the root filesystem.
|
|
pub const fn same_file_system(mut self, same_file_system: bool) -> Self {
|
|
self.options.same_file_system = same_file_system;
|
|
self
|
|
}
|
|
|
|
/// Enable or disable the shared scan cache for owned collection.
|
|
pub const fn cache(mut self, cache: bool) -> Self {
|
|
self.cache_policy = if cache {
|
|
CachePolicy::Enabled
|
|
} else {
|
|
CachePolicy::Disabled
|
|
};
|
|
self.options.cache = cache;
|
|
self
|
|
}
|
|
|
|
/// Set the static high-level filter.
|
|
pub fn filter(mut self, filter: WalkFilter) -> Self {
|
|
self.filter = filter;
|
|
self
|
|
}
|
|
|
|
/// Limit the number of emitted entries after filtering.
|
|
pub const fn limit(mut self, limit: usize) -> Self {
|
|
self.limit = Some(limit);
|
|
self
|
|
}
|
|
|
|
/// Remove any high-level entry limit.
|
|
pub const fn no_limit(mut self) -> Self {
|
|
self.limit = None;
|
|
self
|
|
}
|
|
|
|
/// Set empty cached-result revalidation policy.
|
|
pub const fn empty_recheck(mut self, empty_recheck: EmptyRecheck) -> Self {
|
|
self.empty_recheck = empty_recheck;
|
|
self
|
|
}
|
|
|
|
/// Set high-level directory visit order.
|
|
pub const fn visit_order(mut self, visit_order: VisitOrder) -> Self {
|
|
self.visit_order = visit_order;
|
|
self.options.contents_first = matches!(visit_order, VisitOrder::ContentsFirst);
|
|
self
|
|
}
|
|
|
|
/// Set size metadata policy.
|
|
pub const fn size_hints(mut self, size_hint_policy: SizeHintPolicy) -> Self {
|
|
self.size_hint_policy = size_hint_policy;
|
|
self
|
|
}
|
|
|
|
/// Collect owned entries, then apply high-level filters, limits, and
|
|
/// empty-cache rechecks.
|
|
pub fn collect(&self) -> std::result::Result<WalkOutcome, WalkError<String>> {
|
|
self.collect_with_heartbeat(|| Ok::<(), Infallible>(()))
|
|
}
|
|
|
|
/// Collect owned entries with a caller-supplied heartbeat.
|
|
pub fn collect_with_heartbeat<E, H>(
|
|
&self,
|
|
heartbeat: H,
|
|
) -> std::result::Result<WalkOutcome, WalkError<String>>
|
|
where
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
E: fmt::Display,
|
|
{
|
|
self.collect_with_rank_and_limit(None, self.limit, heartbeat)
|
|
}
|
|
|
|
/// Collect owned entries, apply high-level filters, rank, then truncate to
|
|
/// `limit`.
|
|
///
|
|
/// The request's stored [`WalkRequest::limit`] is intentionally not applied
|
|
/// before ranking; `limit` is the top-N bound for this ranked collection.
|
|
pub fn collect_ranked(
|
|
&self,
|
|
rank: WalkRank,
|
|
limit: usize,
|
|
) -> std::result::Result<WalkOutcome, WalkError<String>> {
|
|
self.collect_ranked_with_heartbeat(rank, limit, || Ok::<(), Infallible>(()))
|
|
}
|
|
|
|
/// Collect owned entries with a caller-supplied heartbeat, apply high-level
|
|
/// filters, rank, then truncate to `limit`.
|
|
///
|
|
/// Ranking happens after all high-level filters and before truncation so
|
|
/// top-N callers observe the best matching entries, not the first traversed
|
|
/// entries.
|
|
pub fn collect_ranked_with_heartbeat<E, H>(
|
|
&self,
|
|
rank: WalkRank,
|
|
limit: usize,
|
|
heartbeat: H,
|
|
) -> std::result::Result<WalkOutcome, WalkError<String>>
|
|
where
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
E: fmt::Display,
|
|
{
|
|
self.collect_with_rank_and_limit(Some(rank), Some(limit), heartbeat)
|
|
}
|
|
|
|
/// Collect regular files accepted by this request.
|
|
pub fn collect_files(&self) -> std::result::Result<Vec<CollectedEntry>, WalkError<String>> {
|
|
self.collect_files_with_heartbeat(|| Ok::<(), Infallible>(()))
|
|
}
|
|
|
|
/// Collect regular files accepted by this request with a caller-supplied
|
|
/// heartbeat.
|
|
pub fn collect_files_with_heartbeat<E, H>(
|
|
&self,
|
|
heartbeat: H,
|
|
) -> std::result::Result<Vec<CollectedEntry>, WalkError<String>>
|
|
where
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
E: fmt::Display,
|
|
{
|
|
let outcome = self.collect_with_heartbeat(heartbeat)?;
|
|
Ok(outcome
|
|
.entries
|
|
.into_iter()
|
|
.filter(CollectedEntry::is_file)
|
|
.collect())
|
|
}
|
|
|
|
/// Collect directories accepted by this request.
|
|
pub fn collect_dirs(&self) -> std::result::Result<Vec<CollectedEntry>, WalkError<String>> {
|
|
self.collect_dirs_with_heartbeat(|| Ok::<(), Infallible>(()))
|
|
}
|
|
|
|
/// Collect directories accepted by this request with a caller-supplied
|
|
/// heartbeat.
|
|
pub fn collect_dirs_with_heartbeat<E, H>(
|
|
&self,
|
|
heartbeat: H,
|
|
) -> std::result::Result<Vec<CollectedEntry>, WalkError<String>>
|
|
where
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
E: fmt::Display,
|
|
{
|
|
let outcome = self.collect_with_heartbeat(heartbeat)?;
|
|
Ok(outcome
|
|
.entries
|
|
.into_iter()
|
|
.filter(CollectedEntry::is_dir)
|
|
.collect())
|
|
}
|
|
|
|
/// Collect regular-file candidates accepted by this request.
|
|
pub fn collect_file_candidates(
|
|
&self,
|
|
) -> std::result::Result<Vec<FileCandidate>, WalkError<String>> {
|
|
self.collect_file_candidates_with_heartbeat(|| Ok::<(), Infallible>(()))
|
|
}
|
|
|
|
/// Collect regular-file candidates accepted by this request with a
|
|
/// caller-supplied heartbeat.
|
|
pub fn collect_file_candidates_with_heartbeat<E, H>(
|
|
&self,
|
|
heartbeat: H,
|
|
) -> std::result::Result<Vec<FileCandidate>, WalkError<String>>
|
|
where
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
E: fmt::Display,
|
|
{
|
|
Ok(self
|
|
.collect_file_candidates_with_stats_with_heartbeat(heartbeat)?
|
|
.0)
|
|
}
|
|
|
|
/// Stream entries through `visitor` after applying high-level filters and
|
|
/// limits.
|
|
pub fn stream<V>(&self, visitor: &mut V) -> std::result::Result<WalkStatus, WalkError<V::Error>>
|
|
where
|
|
V: EntryVisitor,
|
|
{
|
|
self.stream_with_heartbeat(visitor, || Ok::<(), V::Error>(()))
|
|
}
|
|
|
|
/// Stream entries through `visitor` with a caller-supplied heartbeat.
|
|
pub fn stream_with_heartbeat<V, H>(
|
|
&self,
|
|
visitor: &mut V,
|
|
heartbeat: H,
|
|
) -> std::result::Result<WalkStatus, WalkError<V::Error>>
|
|
where
|
|
V: EntryVisitor,
|
|
H: FnMut() -> std::result::Result<(), V::Error>,
|
|
{
|
|
self.stream_with_predicate_and_heartbeat(visitor, IncludeAllPredicate, heartbeat)
|
|
}
|
|
|
|
/// Stream accepted entries through a closure after applying high-level
|
|
/// filters and limits.
|
|
///
|
|
/// This is the closure-based counterpart to [`WalkRequest::stream`]. The
|
|
/// closure receives borrowed [`EntryMeta`] for each entry that passes this
|
|
/// request's static [`WalkFilter`] and dynamic request limit, then returns a
|
|
/// [`WalkDecision`] to control traversal. [`WalkDecision::Include`] and
|
|
/// [`WalkDecision::Skip`] both continue because the entry has already been
|
|
/// delivered to the closure; [`WalkDecision::SkipDescend`] prunes the
|
|
/// current directory's descendants, and [`WalkDecision::Stop`] stops
|
|
/// traversal.
|
|
///
|
|
/// Directory-open errors are ignored and traversal continues when
|
|
/// [`WalkOptions::directory_errors`] is [`DirectoryErrorMode::Visit`]. Use
|
|
/// [`WalkRequest::for_each_entry_with_heartbeat`] to observe those errors or
|
|
/// provide a heartbeat.
|
|
pub fn for_each_entry<E, V>(&self, visit: V) -> std::result::Result<WalkStatus, WalkError<E>>
|
|
where
|
|
V: for<'entry> FnMut(EntryMeta<'entry>) -> std::result::Result<WalkDecision, E>,
|
|
{
|
|
self.for_each_entry_with_heartbeat(
|
|
|| Ok::<(), E>(()),
|
|
visit,
|
|
|_| Ok::<WalkDecision, E>(WalkDecision::Include),
|
|
)
|
|
}
|
|
|
|
/// Stream accepted entries through closures with a caller-supplied
|
|
/// heartbeat.
|
|
///
|
|
/// `heartbeat` is invoked by the same traversal machinery used by
|
|
/// [`WalkRequest::stream_with_heartbeat`]. `visit` receives each accepted
|
|
/// [`EntryMeta`] and returns a [`WalkDecision`]: [`WalkDecision::Include`] and
|
|
/// [`WalkDecision::Skip`] continue, [`WalkDecision::SkipDescend`] skips
|
|
/// descendants of the current directory, and [`WalkDecision::Stop`] stops
|
|
/// the walk. `directory_error` receives [`DirectoryError`] values when the
|
|
/// request's [`WalkOptions::directory_errors`] is
|
|
/// [`DirectoryErrorMode::Visit`] and returns the same traversal decision,
|
|
/// letting GNU-style consumers report an error and continue or stop without
|
|
/// implementing [`EntryVisitor`].
|
|
pub fn for_each_entry_with_heartbeat<E, H, V, D>(
|
|
&self,
|
|
heartbeat: H,
|
|
visit: V,
|
|
directory_error: D,
|
|
) -> std::result::Result<WalkStatus, WalkError<E>>
|
|
where
|
|
H: FnMut() -> std::result::Result<(), E>,
|
|
V: for<'entry> FnMut(EntryMeta<'entry>) -> std::result::Result<WalkDecision, E>,
|
|
D: for<'error> FnMut(DirectoryError<'error>) -> std::result::Result<WalkDecision, E>,
|
|
{
|
|
let mut visitor = ClosureEntryVisitor { root: &self.root, visit, directory_error };
|
|
self.stream_with_heartbeat(&mut visitor, heartbeat)
|
|
}
|
|
|
|
/// Stream entries through `visitor` with an additional dynamic predicate.
|
|
pub fn stream_with_predicate<V, P>(
|
|
&self,
|
|
visitor: &mut V,
|
|
predicate: P,
|
|
) -> std::result::Result<WalkStatus, WalkError<V::Error>>
|
|
where
|
|
V: EntryVisitor,
|
|
P: WalkPredicate,
|
|
{
|
|
self.stream_with_predicate_and_heartbeat(visitor, predicate, || Ok::<(), V::Error>(()))
|
|
}
|
|
|
|
/// Stream entries through `visitor` with a dynamic predicate and
|
|
/// caller-supplied heartbeat.
|
|
pub fn stream_with_predicate_and_heartbeat<V, P, H>(
|
|
&self,
|
|
visitor: &mut V,
|
|
predicate: P,
|
|
mut heartbeat: H,
|
|
) -> std::result::Result<WalkStatus, WalkError<V::Error>>
|
|
where
|
|
V: EntryVisitor,
|
|
P: WalkPredicate,
|
|
H: FnMut() -> std::result::Result<(), V::Error>,
|
|
{
|
|
let options = self.effective_options();
|
|
let mut adapter = RequestVisitor {
|
|
root: &self.root,
|
|
filter: &self.filter,
|
|
limit: self.limit,
|
|
emitted: 0,
|
|
visitor,
|
|
predicate,
|
|
};
|
|
walk_entries(&self.root, options, &mut adapter, &mut heartbeat)
|
|
}
|
|
|
|
/// Run `operation` for each accepted regular file.
|
|
pub fn for_each_file<E>(
|
|
&self,
|
|
operation: impl Fn(&Path) -> std::result::Result<(), E> + Send + Sync,
|
|
) -> std::result::Result<WalkStats, WalkError<String>>
|
|
where
|
|
E: fmt::Display + Send,
|
|
{
|
|
self.for_each_file_with_heartbeat(operation, || Ok::<(), Infallible>(()))
|
|
}
|
|
|
|
/// Run `operation` for each accepted regular file with a caller-supplied
|
|
/// heartbeat.
|
|
pub fn for_each_file_with_heartbeat<E, HE, H>(
|
|
&self,
|
|
operation: impl Fn(&Path) -> std::result::Result<(), E> + Send + Sync,
|
|
heartbeat: H,
|
|
) -> std::result::Result<WalkStats, WalkError<String>>
|
|
where
|
|
E: fmt::Display + Send,
|
|
H: Fn() -> std::result::Result<(), HE> + Sync,
|
|
HE: fmt::Display,
|
|
{
|
|
self.for_each_file_candidate_with_heartbeat(|candidate| operation(&candidate.path), heartbeat)
|
|
}
|
|
|
|
/// Run `operation` for each accepted regular-file candidate.
|
|
pub fn for_each_file_candidate<E>(
|
|
&self,
|
|
operation: impl Fn(&FileCandidate) -> std::result::Result<(), E> + Send + Sync,
|
|
) -> std::result::Result<WalkStats, WalkError<String>>
|
|
where
|
|
E: fmt::Display + Send,
|
|
{
|
|
self.for_each_file_candidate_with_heartbeat(operation, || Ok::<(), Infallible>(()))
|
|
}
|
|
|
|
/// Run `operation` for each accepted regular-file candidate with a
|
|
/// caller-supplied heartbeat.
|
|
pub fn for_each_file_candidate_with_heartbeat<E, HE, H>(
|
|
&self,
|
|
operation: impl Fn(&FileCandidate) -> std::result::Result<(), E> + Send + Sync,
|
|
heartbeat: H,
|
|
) -> std::result::Result<WalkStats, WalkError<String>>
|
|
where
|
|
E: fmt::Display + Send,
|
|
H: Fn() -> std::result::Result<(), HE> + Sync,
|
|
HE: fmt::Display,
|
|
{
|
|
let (candidates, stats) =
|
|
self.collect_file_candidates_with_stats_with_heartbeat(heartbeat)?;
|
|
execute_candidates(&candidates, operation)
|
|
.map_err(|err| WalkError::Interrupted(err.to_string()))?;
|
|
Ok(stats)
|
|
}
|
|
|
|
/// Visit accepted regular-file candidates using an unordered parallel walk.
|
|
///
|
|
/// This is a files-only API for consumers that own their output ordering.
|
|
/// Candidates may be delivered in any order. [`WalkOptions::order`],
|
|
/// [`WalkRequest::visit_order`], [`WalkOptions::emit_root`], and
|
|
/// [`WalkRequest::limit`] are ignored. Directory-open errors are skipped
|
|
/// with grep-style semantics instead of being delivered to visitors.
|
|
///
|
|
/// [`ParallelWalkControl::Stop`] sets a shared stop flag; workers check that
|
|
/// flag before reading each directory and while processing directory
|
|
/// entries, then the method returns [`WalkStatus::Stopped`] after in-flight
|
|
/// work winds down. If a sink or heartbeat returns an error, the first
|
|
/// error wins and is returned as [`WalkError::Interrupted`].
|
|
pub fn for_each_file_candidate_parallel<E>(
|
|
&self,
|
|
sink: impl Fn(&FileCandidate) -> std::result::Result<ParallelWalkControl, E> + Send + Sync,
|
|
heartbeat: impl Fn() -> std::result::Result<(), E> + Send + Sync,
|
|
) -> std::result::Result<WalkStatus, WalkError<E>>
|
|
where
|
|
E: Send,
|
|
{
|
|
run_file_candidate_parallel(self, &sink, &heartbeat)
|
|
}
|
|
|
|
fn collect_with_rank_and_limit<E, H>(
|
|
&self,
|
|
rank: Option<WalkRank>,
|
|
limit: Option<usize>,
|
|
heartbeat: H,
|
|
) -> std::result::Result<WalkOutcome, WalkError<String>>
|
|
where
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
E: fmt::Display,
|
|
{
|
|
let mut options = self.effective_options();
|
|
if matches!(rank, Some(WalkRank::MtimeDescPathAsc)) {
|
|
options.detail = WalkDetail::Full;
|
|
}
|
|
let mut scan = self.collect_entries_with_options(options, &heartbeat)?;
|
|
let mut backend = if scan.cache_age_ms == 0 {
|
|
WalkBackend::Fresh
|
|
} else {
|
|
WalkBackend::Cached
|
|
};
|
|
let filter_entries = |entries: &mut Vec<CollectedEntry>| {
|
|
let scanned_entries = entries.len();
|
|
entries.retain(|entry| self.filter.accepts_collected(entry));
|
|
(scanned_entries, scanned_entries - entries.len())
|
|
};
|
|
let (mut scanned_entries, mut filtered_entries) = filter_entries(&mut scan.entries);
|
|
if scan.entries.is_empty() && self.should_recheck_empty(scan.cache_age_ms) {
|
|
options.cache = false;
|
|
scan = self.collect_entries_with_options(options, &heartbeat)?;
|
|
backend = WalkBackend::Fresh;
|
|
(scanned_entries, filtered_entries) = filter_entries(&mut scan.entries);
|
|
}
|
|
if let Some(rank) = rank {
|
|
Self::rank_entries(&mut scan.entries, rank);
|
|
}
|
|
let limited_entries = if let Some(limit) = limit {
|
|
let limited_entries = scan.entries.len().saturating_sub(limit);
|
|
scan.entries.truncate(limit);
|
|
limited_entries
|
|
} else {
|
|
0
|
|
};
|
|
let stats = WalkStats {
|
|
cache_age_ms: scan.cache_age_ms,
|
|
scanned_entries,
|
|
filtered_entries,
|
|
limited_entries,
|
|
};
|
|
Ok(WalkOutcome { entries: scan.entries, backend, stats })
|
|
}
|
|
|
|
fn rank_entries(entries: &mut [CollectedEntry], rank: WalkRank) {
|
|
match rank {
|
|
WalkRank::PathAsc => entries.sort_by(|left, right| left.path.cmp(&right.path)),
|
|
WalkRank::MtimeDescPathAsc => entries.sort_by(Self::compare_mtime_desc_path_asc),
|
|
}
|
|
}
|
|
|
|
fn compare_mtime_desc_path_asc(left: &CollectedEntry, right: &CollectedEntry) -> Ordering {
|
|
let mtime_order = match (left.mtime, right.mtime) {
|
|
(Some(left_mtime), Some(right_mtime)) => right_mtime.total_cmp(&left_mtime),
|
|
(Some(_), None) => Ordering::Less,
|
|
(None, Some(_)) => Ordering::Greater,
|
|
(None, None) => Ordering::Equal,
|
|
};
|
|
mtime_order.then_with(|| left.path.cmp(&right.path))
|
|
}
|
|
|
|
const fn effective_options(&self) -> WalkOptions {
|
|
let mut options = self.options;
|
|
options.cache = matches!(self.cache_policy, CachePolicy::Enabled);
|
|
options.contents_first = matches!(self.visit_order, VisitOrder::ContentsFirst);
|
|
match self.size_hint_policy {
|
|
SizeHintPolicy::FromDetail => {},
|
|
SizeHintPolicy::Never => options.detail = WalkDetail::Minimal,
|
|
SizeHintPolicy::WhenCheap => {
|
|
options.detail = if supports_cheap_size_hints() {
|
|
WalkDetail::Full
|
|
} else {
|
|
WalkDetail::Minimal
|
|
};
|
|
},
|
|
SizeHintPolicy::Always => options.detail = WalkDetail::Full,
|
|
}
|
|
if self.filter.max_file_size.is_some() {
|
|
options.detail = WalkDetail::Full;
|
|
}
|
|
options
|
|
}
|
|
|
|
fn collect_entries_with_options<E, H>(
|
|
&self,
|
|
options: WalkOptions,
|
|
heartbeat: &H,
|
|
) -> std::result::Result<CollectedEntries, WalkError<String>>
|
|
where
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
E: fmt::Display,
|
|
{
|
|
collect_entries(&self.root, options, heartbeat)
|
|
}
|
|
|
|
fn should_recheck_empty(&self, cache_age_ms: u64) -> bool {
|
|
if cache_age_ms == 0 {
|
|
return false;
|
|
}
|
|
match self.empty_recheck {
|
|
EmptyRecheck::Never => false,
|
|
EmptyRecheck::Configured => {
|
|
let threshold = empty_recheck_ms();
|
|
threshold > 0 && cache_age_ms >= threshold
|
|
},
|
|
EmptyRecheck::AfterMillis(threshold) => cache_age_ms >= threshold,
|
|
}
|
|
}
|
|
|
|
fn collect_file_candidates_with_stats_with_heartbeat<E, H>(
|
|
&self,
|
|
heartbeat: H,
|
|
) -> std::result::Result<(Vec<FileCandidate>, WalkStats), WalkError<String>>
|
|
where
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
E: fmt::Display,
|
|
{
|
|
let outcome = self.collect_with_heartbeat(heartbeat)?;
|
|
let candidates = outcome
|
|
.entries
|
|
.into_iter()
|
|
.filter(CollectedEntry::is_file)
|
|
.map(|entry| FileCandidate::from_entry(&self.root, entry))
|
|
.collect();
|
|
Ok((candidates, outcome.stats))
|
|
}
|
|
}
|
|
|
|
/// Execute work for regular-file candidates using the centralized walker pool.
|
|
pub fn execute_candidates<E>(
|
|
candidates: &[FileCandidate],
|
|
operation: impl Fn(&FileCandidate) -> std::result::Result<(), E> + Send + Sync,
|
|
) -> std::result::Result<(), E>
|
|
where
|
|
E: Send,
|
|
{
|
|
parallel_for_each(candidates, operation)
|
|
}
|
|
|
|
/// Execute work for regular-file candidates with per-worker state.
|
|
pub fn execute_candidates_init<S, E>(
|
|
candidates: &[FileCandidate],
|
|
init: impl Fn() -> S + Send + Sync,
|
|
operation: impl Fn(&mut S, &FileCandidate) -> std::result::Result<(), E> + Send + Sync,
|
|
) -> std::result::Result<(), E>
|
|
where
|
|
S: Send,
|
|
E: Send,
|
|
{
|
|
parallel_for_each_init(candidates, init, operation)
|
|
}
|
|
|
|
struct SerialCandidateVisitor<'a, S> {
|
|
filter: &'a WalkFilter,
|
|
sink: &'a S,
|
|
}
|
|
|
|
impl<E, S> EntryVisitor for SerialCandidateVisitor<'_, S>
|
|
where
|
|
S: Fn(&FileCandidate) -> std::result::Result<ParallelWalkControl, E> + Sync,
|
|
{
|
|
type Error = E;
|
|
|
|
fn visit(&mut self, _entry: Entry<'_>) -> std::result::Result<WalkControl, Self::Error> {
|
|
Ok(WalkControl::Continue)
|
|
}
|
|
|
|
fn visit_pre_decided(
|
|
&mut self,
|
|
entry: Entry<'_>,
|
|
) -> std::result::Result<WalkControl, Self::Error> {
|
|
if entry.file_type != FileType::File {
|
|
return Ok(WalkControl::Continue);
|
|
}
|
|
let candidate = FileCandidate {
|
|
path: entry.path.to_path_buf(),
|
|
relative: entry.relative.to_string(),
|
|
mtime: entry.mtime,
|
|
size: entry.size,
|
|
};
|
|
match (self.sink)(&candidate)? {
|
|
ParallelWalkControl::Continue => Ok(WalkControl::Continue),
|
|
ParallelWalkControl::Stop => Ok(WalkControl::Quit),
|
|
}
|
|
}
|
|
|
|
fn decide_pre_descend(
|
|
&mut self,
|
|
meta: &EntryMeta<'_>,
|
|
) -> std::result::Result<PreDescendDecision, Self::Error> {
|
|
let is_dir = meta.file_type == FileType::Dir;
|
|
Ok(match self.filter.stream_decision(meta) {
|
|
WalkDecision::Include => {
|
|
PreDescendDecision { emit: true, descend: is_dir, stop: false }
|
|
},
|
|
WalkDecision::Skip => {
|
|
PreDescendDecision { emit: false, descend: is_dir, stop: false }
|
|
},
|
|
WalkDecision::SkipDescend => {
|
|
PreDescendDecision { emit: false, descend: false, stop: false }
|
|
},
|
|
WalkDecision::Stop => PreDescendDecision { emit: false, descend: false, stop: true },
|
|
})
|
|
}
|
|
}
|
|
|
|
struct ParallelWalkContext {
|
|
root: PathBuf,
|
|
options: WalkOptions,
|
|
filter: WalkFilter,
|
|
matcher: FastIgnore,
|
|
}
|
|
|
|
struct ParallelWalkShared<'a, E, S, H> {
|
|
stop: AtomicBool,
|
|
error: Mutex<Option<E>>,
|
|
sink: &'a S,
|
|
heartbeat: &'a H,
|
|
}
|
|
|
|
impl<'a, E, S, H> ParallelWalkShared<'a, E, S, H> {
|
|
const fn new(sink: &'a S, heartbeat: &'a H) -> Self {
|
|
Self { stop: AtomicBool::new(false), error: Mutex::new(None), sink, heartbeat }
|
|
}
|
|
|
|
fn request_stop(&self) {
|
|
self.stop.store(true, AtomicOrdering::Release);
|
|
}
|
|
|
|
fn should_stop(&self) -> bool {
|
|
self.stop.load(AtomicOrdering::Acquire)
|
|
}
|
|
|
|
fn record_error(&self, error: E) {
|
|
let mut slot = match self.error.lock() {
|
|
Ok(slot) => slot,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
if slot.is_none() {
|
|
*slot = Some(error);
|
|
}
|
|
self.request_stop();
|
|
}
|
|
|
|
fn take_error(&self) -> Option<E> {
|
|
match self.error.lock() {
|
|
Ok(mut slot) => slot.take(),
|
|
Err(poisoned) => poisoned.into_inner().take(),
|
|
}
|
|
}
|
|
}
|
|
|
|
thread_local! {
|
|
static PARALLEL_HEARTBEAT_COUNTER: Cell<usize> = const { Cell::new(0) };
|
|
static PARALLEL_SCRATCH_POOL: RefCell<Vec<DirScratch>> = const { RefCell::new(Vec::new()) };
|
|
}
|
|
|
|
fn should_use_parallel_file_candidate_walk(options: WalkOptions) -> bool {
|
|
walk_workers() > 1 && options.follow_links == FollowLinks::Never && !options.same_file_system
|
|
}
|
|
|
|
fn run_file_candidate_parallel<E, S, H>(
|
|
request: &WalkRequest,
|
|
sink: &S,
|
|
heartbeat: &H,
|
|
) -> std::result::Result<WalkStatus, WalkError<E>>
|
|
where
|
|
E: Send,
|
|
S: Fn(&FileCandidate) -> std::result::Result<ParallelWalkControl, E> + Sync,
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
{
|
|
let mut options = request.effective_options();
|
|
if options.min_depth > options.max_depth {
|
|
return Ok(WalkStatus::Complete);
|
|
}
|
|
heartbeat().map_err(WalkError::Interrupted)?;
|
|
if !should_use_parallel_file_candidate_walk(options) {
|
|
return run_file_candidate_serial(request, options, sink, heartbeat);
|
|
}
|
|
|
|
options.cache = false;
|
|
let Some(root_entry) = root_entry(&request.root, options.detail, options.follow_links)? else {
|
|
return Ok(WalkStatus::Complete);
|
|
};
|
|
let context = ParallelWalkContext {
|
|
root: request.root.clone(),
|
|
options,
|
|
filter: request.filter.clone(),
|
|
matcher: FastIgnore::new(options.use_gitignore),
|
|
};
|
|
let root_ignore = context.matcher.root_state(&context.root);
|
|
let shared = ParallelWalkShared::new(sink, heartbeat);
|
|
|
|
if root_entry.file_type == FileType::File && options.min_depth == 0 {
|
|
emit_parallel_root_file(&context, &shared, &root_entry);
|
|
}
|
|
if root_entry.file_type == FileType::Dir && options.max_depth > 0 && !shared.should_stop() {
|
|
let root_dir = context.root.clone();
|
|
cache::with_walk_pool(|| {
|
|
rayon::scope(|scope| {
|
|
scope.spawn(|scope| {
|
|
walk_parallel_dir(
|
|
scope,
|
|
&context,
|
|
&shared,
|
|
root_dir,
|
|
String::new(),
|
|
0,
|
|
root_ignore,
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
if let Some(error) = shared.take_error() {
|
|
Err(WalkError::Interrupted(error))
|
|
} else if shared.should_stop() {
|
|
Ok(WalkStatus::Stopped)
|
|
} else {
|
|
Ok(WalkStatus::Complete)
|
|
}
|
|
}
|
|
|
|
fn run_file_candidate_serial<E, S, H>(
|
|
request: &WalkRequest,
|
|
mut options: WalkOptions,
|
|
sink: &S,
|
|
heartbeat: &H,
|
|
) -> std::result::Result<WalkStatus, WalkError<E>>
|
|
where
|
|
S: Fn(&FileCandidate) -> std::result::Result<ParallelWalkControl, E> + Sync,
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
{
|
|
options.cache = false;
|
|
options.order = WalkOrder::Unordered;
|
|
options.contents_first = false;
|
|
options.emit_root = true;
|
|
options.directory_errors = DirectoryErrorMode::Visit;
|
|
let mut visitor = SerialCandidateVisitor { filter: &request.filter, sink };
|
|
walk_entries(&request.root, options, &mut visitor, heartbeat)
|
|
}
|
|
|
|
fn emit_parallel_root_file<E, S, H>(
|
|
context: &ParallelWalkContext,
|
|
shared: &ParallelWalkShared<'_, E, S, H>,
|
|
root_entry: &RootEntry,
|
|
) where
|
|
E: Send,
|
|
S: Fn(&FileCandidate) -> std::result::Result<ParallelWalkControl, E> + Sync,
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
{
|
|
let meta = EntryMeta {
|
|
root: &context.root,
|
|
absolute_path: Cow::Borrowed(context.root.as_path()),
|
|
relative_path: "",
|
|
file_type: FileType::File,
|
|
mtime: root_entry.mtime,
|
|
size: root_entry.size,
|
|
depth: 0,
|
|
};
|
|
match context.filter.stream_decision(&meta) {
|
|
WalkDecision::Include => {
|
|
let candidate = FileCandidate {
|
|
path: context.root.clone(),
|
|
relative: String::new(),
|
|
mtime: root_entry.mtime,
|
|
size: root_entry.size,
|
|
};
|
|
let _ = emit_parallel_candidate(shared, &candidate);
|
|
},
|
|
WalkDecision::Stop => shared.request_stop(),
|
|
WalkDecision::Skip | WalkDecision::SkipDescend => {},
|
|
}
|
|
}
|
|
|
|
fn take_parallel_scratch() -> DirScratch {
|
|
let mut scratch = PARALLEL_SCRATCH_POOL
|
|
.with(|pool| pool.borrow_mut().pop())
|
|
.unwrap_or_default();
|
|
scratch.clear_listing();
|
|
scratch
|
|
}
|
|
|
|
fn recycle_parallel_scratch(mut scratch: DirScratch) {
|
|
scratch.clear_listing();
|
|
PARALLEL_SCRATCH_POOL.with(|pool| pool.borrow_mut().push(scratch));
|
|
}
|
|
|
|
fn parallel_heartbeat<E, S, H>(shared: &ParallelWalkShared<'_, E, S, H>) -> bool
|
|
where
|
|
E: Send,
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
{
|
|
if shared.should_stop() {
|
|
return false;
|
|
}
|
|
let should_call = PARALLEL_HEARTBEAT_COUNTER.with(|counter| {
|
|
let visited = counter.get();
|
|
if visited == 0 || visited >= HEARTBEAT_INTERVAL {
|
|
counter.set(1);
|
|
true
|
|
} else {
|
|
counter.set(visited + 1);
|
|
false
|
|
}
|
|
});
|
|
if !should_call {
|
|
return true;
|
|
}
|
|
match (shared.heartbeat)() {
|
|
Ok(()) => true,
|
|
Err(error) => {
|
|
shared.record_error(error);
|
|
false
|
|
},
|
|
}
|
|
}
|
|
|
|
fn emit_parallel_candidate<E, S, H>(
|
|
shared: &ParallelWalkShared<'_, E, S, H>,
|
|
candidate: &FileCandidate,
|
|
) -> bool
|
|
where
|
|
E: Send,
|
|
S: Fn(&FileCandidate) -> std::result::Result<ParallelWalkControl, E> + Sync,
|
|
{
|
|
match (shared.sink)(candidate) {
|
|
Ok(ParallelWalkControl::Continue) => true,
|
|
Ok(ParallelWalkControl::Stop) => {
|
|
shared.request_stop();
|
|
false
|
|
},
|
|
Err(error) => {
|
|
shared.record_error(error);
|
|
false
|
|
},
|
|
}
|
|
}
|
|
|
|
fn walk_parallel_dir<'scope, E, S, H>(
|
|
scope: &rayon::Scope<'scope>,
|
|
context: &'scope ParallelWalkContext,
|
|
shared: &'scope ParallelWalkShared<'_, E, S, H>,
|
|
dir: PathBuf,
|
|
relative_dir: String,
|
|
depth: usize,
|
|
ignore_state: Arc<IgnoreState>,
|
|
derive_ignore_from_entries: bool,
|
|
) where
|
|
E: Send + 'scope,
|
|
S: Fn(&FileCandidate) -> std::result::Result<ParallelWalkControl, E> + Sync + 'scope,
|
|
H: Fn() -> std::result::Result<(), E> + Sync + 'scope,
|
|
{
|
|
if shared.should_stop() {
|
|
return;
|
|
}
|
|
let mut scratch = take_parallel_scratch();
|
|
let ignore_entries = match collect_directory_entries(
|
|
&dir,
|
|
context.options.detail,
|
|
&mut scratch,
|
|
&context.matcher,
|
|
derive_ignore_from_entries,
|
|
) {
|
|
Ok(ignore_entries) => ignore_entries,
|
|
Err(ReadDirError::Io(_) | ReadDirError::Walk(WalkError::InvalidData { .. })) => {
|
|
recycle_parallel_scratch(scratch);
|
|
return;
|
|
},
|
|
Err(ReadDirError::Walk(WalkError::Interrupted(error))) => {
|
|
shared.record_error(error);
|
|
recycle_parallel_scratch(scratch);
|
|
return;
|
|
},
|
|
};
|
|
let dir_ignore = context.matcher.state_from_entries(
|
|
&ignore_state,
|
|
&dir,
|
|
ignore_entries,
|
|
derive_ignore_from_entries,
|
|
);
|
|
let mut absolute = dir;
|
|
let mut relative = relative_dir;
|
|
|
|
for index in 0..scratch.entries.len() {
|
|
if !parallel_heartbeat(shared) {
|
|
break;
|
|
}
|
|
let entry = &scratch.entries[index];
|
|
let name = scratch.name(entry);
|
|
if is_dot_entry(name) {
|
|
continue;
|
|
}
|
|
if !context.options.include_hidden && is_hidden_name(name) {
|
|
continue;
|
|
}
|
|
if (context.options.skip_git && is_git_name(name))
|
|
|| (context.options.skip_node_modules && is_node_modules_name(name))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let name_str = entry_name(name);
|
|
if name_str.is_empty() {
|
|
continue;
|
|
}
|
|
let next_depth = depth + 1;
|
|
if next_depth > context.options.max_depth {
|
|
continue;
|
|
}
|
|
|
|
let relative_len = relative.len();
|
|
absolute.push(name);
|
|
push_relative_name(&mut relative, &name_str);
|
|
let is_dir = entry.file_type == FileType::Dir;
|
|
if !context.matcher.is_ignored(&dir_ignore, &absolute, is_dir) {
|
|
let decision = {
|
|
let meta = EntryMeta {
|
|
root: &context.root,
|
|
absolute_path: Cow::Borrowed(absolute.as_path()),
|
|
relative_path: &relative,
|
|
file_type: entry.file_type,
|
|
mtime: entry.mtime,
|
|
size: entry.size,
|
|
depth: next_depth,
|
|
};
|
|
context.filter.stream_decision(&meta)
|
|
};
|
|
match decision {
|
|
WalkDecision::Include => {
|
|
if entry.file_type == FileType::File && next_depth >= context.options.min_depth {
|
|
let candidate = FileCandidate {
|
|
path: absolute.clone(),
|
|
relative: relative.clone(),
|
|
mtime: entry.mtime,
|
|
size: entry.size,
|
|
};
|
|
if !emit_parallel_candidate(shared, &candidate) {
|
|
absolute.pop();
|
|
relative.truncate(relative_len);
|
|
break;
|
|
}
|
|
}
|
|
if is_dir && next_depth < context.options.max_depth && !shared.should_stop() {
|
|
let child_dir = absolute.clone();
|
|
let child_relative = relative.clone();
|
|
let child_ignore = Arc::clone(&dir_ignore);
|
|
scope.spawn(move |scope| {
|
|
walk_parallel_dir(
|
|
scope,
|
|
context,
|
|
shared,
|
|
child_dir,
|
|
child_relative,
|
|
next_depth,
|
|
child_ignore,
|
|
true,
|
|
);
|
|
});
|
|
}
|
|
},
|
|
WalkDecision::Skip => {
|
|
if is_dir && next_depth < context.options.max_depth && !shared.should_stop() {
|
|
let child_dir = absolute.clone();
|
|
let child_relative = relative.clone();
|
|
let child_ignore = Arc::clone(&dir_ignore);
|
|
scope.spawn(move |scope| {
|
|
walk_parallel_dir(
|
|
scope,
|
|
context,
|
|
shared,
|
|
child_dir,
|
|
child_relative,
|
|
next_depth,
|
|
child_ignore,
|
|
true,
|
|
);
|
|
});
|
|
}
|
|
},
|
|
WalkDecision::SkipDescend => {},
|
|
WalkDecision::Stop => {
|
|
shared.request_stop();
|
|
absolute.pop();
|
|
relative.truncate(relative_len);
|
|
break;
|
|
},
|
|
}
|
|
}
|
|
absolute.pop();
|
|
relative.truncate(relative_len);
|
|
}
|
|
recycle_parallel_scratch(scratch);
|
|
}
|
|
|
|
/// Visitor decision for streaming traversal.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum WalkControl {
|
|
/// Continue traversing remaining entries.
|
|
Continue,
|
|
/// Skip descending into this directory entry.
|
|
SkipDescend,
|
|
/// Stop traversal immediately.
|
|
Quit,
|
|
}
|
|
|
|
/// Status returned by native traversal.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum WalkStatus {
|
|
/// Traversal visited every reachable entry.
|
|
Complete,
|
|
/// The visitor stopped traversal early.
|
|
Stopped,
|
|
}
|
|
|
|
/// Control returned by unordered parallel file-candidate sinks.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ParallelWalkControl {
|
|
/// Continue walking and delivering candidates.
|
|
Continue,
|
|
/// Stop all workers as promptly as possible.
|
|
Stop,
|
|
}
|
|
|
|
/// Owned entry returned by [`collect_entries`].
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct CollectedEntry {
|
|
/// Relative path from the root, using `/` separators.
|
|
pub path: String,
|
|
/// Filesystem entry kind.
|
|
pub file_type: FileType,
|
|
/// Modification time in milliseconds since the Unix epoch, when requested.
|
|
pub mtime: Option<f64>,
|
|
/// File size in bytes for regular files, when requested.
|
|
pub size: Option<f64>,
|
|
}
|
|
|
|
impl CollectedEntry {
|
|
/// Return this entry's absolute path under `root`.
|
|
pub fn absolute_path(&self, root: &Path) -> PathBuf {
|
|
if self.path.is_empty() {
|
|
root.to_path_buf()
|
|
} else {
|
|
root.join(&self.path)
|
|
}
|
|
}
|
|
|
|
/// Return this entry's depth below the traversal root.
|
|
pub fn depth(&self) -> usize {
|
|
if self.path.is_empty() {
|
|
0
|
|
} else {
|
|
self
|
|
.path
|
|
.split('/')
|
|
.filter(|component| !component.is_empty())
|
|
.count()
|
|
}
|
|
}
|
|
|
|
/// Return whether this entry is a regular file.
|
|
pub const fn is_file(&self) -> bool {
|
|
matches!(self.file_type, FileType::File)
|
|
}
|
|
|
|
/// Return whether this entry is a directory.
|
|
pub const fn is_dir(&self) -> bool {
|
|
matches!(self.file_type, FileType::Dir)
|
|
}
|
|
}
|
|
|
|
/// Return whether `parent` is a walk-relative ancestor of `child`.
|
|
///
|
|
/// Both paths use the walker's normalized `/` separator. The root-relative
|
|
/// empty path is an ancestor of every non-root entry.
|
|
pub fn is_relative_ancestor(parent: &str, child: &str) -> bool {
|
|
if parent == child {
|
|
return false;
|
|
}
|
|
if parent.is_empty() {
|
|
return !child.is_empty();
|
|
}
|
|
child
|
|
.strip_prefix(parent)
|
|
.is_some_and(|suffix| suffix.starts_with('/'))
|
|
}
|
|
|
|
/// Compare normalized walk-relative paths in depth-first,
|
|
/// contents-before-parent order.
|
|
pub fn compare_depth_first_paths(left: &str, right: &str) -> Ordering {
|
|
if left == right {
|
|
return Ordering::Equal;
|
|
}
|
|
if is_relative_ancestor(left, right) {
|
|
return Ordering::Greater;
|
|
}
|
|
if is_relative_ancestor(right, left) {
|
|
return Ordering::Less;
|
|
}
|
|
left.cmp(right)
|
|
}
|
|
|
|
/// Sort collected entries in depth-first, contents-before-parent path order.
|
|
pub fn sort_collected_depth_first(entries: &mut [CollectedEntry]) {
|
|
entries.sort_unstable_by(|left, right| compare_depth_first_paths(&left.path, &right.path));
|
|
}
|
|
|
|
/// Return whether `relative` is below any pruned normalized directory path.
|
|
pub fn is_under_pruned_relative_dir(relative: &str, pruned_dirs: &[String]) -> bool {
|
|
pruned_dirs
|
|
.iter()
|
|
.any(|dir| is_relative_ancestor(dir, relative))
|
|
}
|
|
|
|
/// Return the root device id used by same-filesystem traversal filters.
|
|
///
|
|
/// Non-Unix platforms return `None`, making same-filesystem filtering a no-op.
|
|
#[cfg(unix)]
|
|
pub fn root_device_id(path: &Path, follow_links: FollowLinks) -> Option<u64> {
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
metadata_for_follow_policy(path, follow_links.follow_at_depth(0))
|
|
.ok()
|
|
.map(|metadata| metadata.dev())
|
|
}
|
|
|
|
/// Return the root device id used by same-filesystem traversal filters.
|
|
///
|
|
/// Non-Unix platforms return `None`, making same-filesystem filtering a no-op.
|
|
#[cfg(not(unix))]
|
|
pub fn root_device_id(_path: &Path, _follow_links: FollowLinks) -> Option<u64> {
|
|
None
|
|
}
|
|
|
|
/// Return whether `path` is on the root filesystem represented by
|
|
/// `root_device`.
|
|
///
|
|
/// When `root_device` is `None`, this returns true. On non-Unix platforms this
|
|
/// is always true, matching the existing no-op same-filesystem behavior there.
|
|
#[cfg(unix)]
|
|
pub fn is_path_on_root_file_system(
|
|
path: &Path,
|
|
depth: usize,
|
|
follow_links: FollowLinks,
|
|
root_device: Option<u64>,
|
|
) -> bool {
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
let Some(root_device) = root_device else {
|
|
return true;
|
|
};
|
|
metadata_for_follow_policy(path, follow_links.follow_at_depth(depth))
|
|
.is_ok_and(|metadata| metadata.dev() == root_device)
|
|
}
|
|
|
|
/// Return whether `path` is on the root filesystem represented by
|
|
/// `root_device`.
|
|
///
|
|
/// When `root_device` is `None`, this returns true. On non-Unix platforms this
|
|
/// is always true, matching the existing no-op same-filesystem behavior there.
|
|
#[cfg(not(unix))]
|
|
pub fn is_path_on_root_file_system(
|
|
_path: &Path,
|
|
_depth: usize,
|
|
_follow_links: FollowLinks,
|
|
_root_device: Option<u64>,
|
|
) -> bool {
|
|
true
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn is_effective_path_on_root_file_system(
|
|
path: &Path,
|
|
depth: usize,
|
|
follow_links: FollowLinks,
|
|
root_device: Option<u64>,
|
|
followed_metadata: Option<&std::fs::Metadata>,
|
|
) -> bool {
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
let Some(root_device) = root_device else {
|
|
return true;
|
|
};
|
|
if let Some(metadata) = followed_metadata {
|
|
return metadata.dev() == root_device;
|
|
}
|
|
metadata_for_follow_policy(path, follow_links.follow_at_depth(depth))
|
|
.is_ok_and(|metadata| metadata.dev() == root_device)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn is_effective_path_on_root_file_system(
|
|
_path: &Path,
|
|
_depth: usize,
|
|
_follow_links: FollowLinks,
|
|
_root_device: Option<u64>,
|
|
_followed_metadata: Option<&std::fs::Metadata>,
|
|
) -> bool {
|
|
true
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn metadata_for_follow_policy(path: &Path, follow: bool) -> io::Result<std::fs::Metadata> {
|
|
if follow {
|
|
std::fs::metadata(path)
|
|
} else {
|
|
std::fs::symlink_metadata(path)
|
|
}
|
|
}
|
|
|
|
/// Owned entries returned by [`collect_entries`] plus cache metadata.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct CollectedEntries {
|
|
/// Entries collected with the requested traversal contract.
|
|
pub entries: Vec<CollectedEntry>,
|
|
/// Age of the cache entry in milliseconds; zero means freshly scanned.
|
|
pub cache_age_ms: u64,
|
|
}
|
|
|
|
/// Borrowed entry passed to [`EntryVisitor`].
|
|
pub struct Entry<'a> {
|
|
/// Absolute filesystem path for this entry.
|
|
pub path: &'a Path,
|
|
/// Relative path from the root, using `/` separators.
|
|
pub relative: &'a str,
|
|
/// Basename as returned by the platform directory API.
|
|
pub name: &'a OsStr,
|
|
/// Filesystem entry kind.
|
|
pub file_type: FileType,
|
|
/// Modification time in milliseconds since the Unix epoch, when requested.
|
|
pub mtime: Option<f64>,
|
|
/// File size in bytes for regular files, when requested.
|
|
pub size: Option<f64>,
|
|
/// Depth below the traversal root. Direct children have depth 1.
|
|
pub depth: usize,
|
|
}
|
|
|
|
/// Directory-open error delivered to visitors when configured by
|
|
/// [`WalkOptions::directory_errors`].
|
|
pub struct DirectoryError<'a> {
|
|
/// Directory path that could not be read.
|
|
pub path: &'a Path,
|
|
/// Underlying platform I/O error.
|
|
pub error: &'a io::Error,
|
|
}
|
|
|
|
/// Pre-descend decision made before a directory's children are traversed.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct PreDescendDecision {
|
|
/// Whether this entry should be emitted in the requested visit order.
|
|
pub emit: bool,
|
|
/// Whether this directory's descendants should be traversed.
|
|
pub descend: bool,
|
|
/// Whether traversal should stop immediately.
|
|
pub stop: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
enum DirectoryIdentity {
|
|
#[cfg(unix)]
|
|
Unix { dev: u64, ino: u64 },
|
|
#[cfg(not(unix))]
|
|
Generic(std::path::PathBuf),
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct SymlinkAncestorStack {
|
|
stack: Vec<DirectoryIdentity>,
|
|
}
|
|
|
|
impl SymlinkAncestorStack {
|
|
fn contains(&self, identity: &DirectoryIdentity) -> bool {
|
|
self.stack.contains(identity)
|
|
}
|
|
|
|
fn push(&mut self, identity: DirectoryIdentity) {
|
|
self.stack.push(identity);
|
|
}
|
|
|
|
fn pop(&mut self) {
|
|
self.stack.pop();
|
|
}
|
|
}
|
|
|
|
fn directory_identity(path: &Path) -> io::Result<DirectoryIdentity> {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::MetadataExt;
|
|
let metadata = std::fs::metadata(path)?;
|
|
Ok(DirectoryIdentity::Unix { dev: metadata.dev(), ino: metadata.ino() })
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let canonical = std::fs::canonicalize(path)?;
|
|
Ok(DirectoryIdentity::Generic(canonical))
|
|
}
|
|
}
|
|
|
|
/// Consumer hook invoked for every accepted entry.
|
|
pub trait EntryVisitor {
|
|
/// Error type used by visitor and heartbeat callbacks.
|
|
type Error;
|
|
|
|
/// Visit one filesystem entry and choose how traversal continues.
|
|
fn visit(&mut self, entry: Entry<'_>) -> std::result::Result<WalkControl, Self::Error>;
|
|
|
|
/// Handle a directory-open error and choose whether traversal continues.
|
|
fn visit_directory_error(
|
|
&mut self,
|
|
_error: DirectoryError<'_>,
|
|
) -> std::result::Result<WalkControl, Self::Error> {
|
|
Ok(WalkControl::Continue)
|
|
}
|
|
|
|
/// Optional hook for making pre-descend traversal decisions.
|
|
fn decide_pre_descend(
|
|
&mut self,
|
|
_meta: &EntryMeta<'_>,
|
|
) -> std::result::Result<PreDescendDecision, Self::Error> {
|
|
Ok(PreDescendDecision { emit: true, descend: true, stop: false })
|
|
}
|
|
|
|
/// Visit an entry whose filter/predicate decisions have already been made.
|
|
fn visit_pre_decided(
|
|
&mut self,
|
|
entry: Entry<'_>,
|
|
) -> std::result::Result<WalkControl, Self::Error> {
|
|
self.visit(entry)
|
|
}
|
|
}
|
|
struct RequestVisitor<'a, V, P> {
|
|
root: &'a Path,
|
|
filter: &'a WalkFilter,
|
|
limit: Option<usize>,
|
|
emitted: usize,
|
|
visitor: &'a mut V,
|
|
predicate: P,
|
|
}
|
|
|
|
impl<V, P> EntryVisitor for RequestVisitor<'_, V, P>
|
|
where
|
|
V: EntryVisitor,
|
|
P: WalkPredicate,
|
|
{
|
|
type Error = V::Error;
|
|
|
|
fn visit(&mut self, entry: Entry<'_>) -> std::result::Result<WalkControl, Self::Error> {
|
|
if self.limit.is_some_and(|limit| self.emitted >= limit) {
|
|
return Ok(WalkControl::Quit);
|
|
}
|
|
let meta = EntryMeta {
|
|
root: self.root,
|
|
absolute_path: Cow::Borrowed(entry.path),
|
|
relative_path: entry.relative,
|
|
file_type: entry.file_type,
|
|
mtime: entry.mtime,
|
|
size: entry.size,
|
|
depth: entry.depth,
|
|
};
|
|
match self.filter.stream_decision(&meta) {
|
|
WalkDecision::Include => {},
|
|
WalkDecision::Skip => return Ok(WalkControl::Continue),
|
|
WalkDecision::SkipDescend => return Ok(WalkControl::SkipDescend),
|
|
WalkDecision::Stop => return Ok(WalkControl::Quit),
|
|
}
|
|
match self.predicate.decide(&meta) {
|
|
WalkDecision::Include => {},
|
|
WalkDecision::Skip => return Ok(WalkControl::Continue),
|
|
WalkDecision::SkipDescend => return Ok(WalkControl::SkipDescend),
|
|
WalkDecision::Stop => return Ok(WalkControl::Quit),
|
|
}
|
|
self.emitted += 1;
|
|
self.visitor.visit(entry)
|
|
}
|
|
|
|
fn visit_directory_error(
|
|
&mut self,
|
|
error: DirectoryError<'_>,
|
|
) -> std::result::Result<WalkControl, Self::Error> {
|
|
self.visitor.visit_directory_error(error)
|
|
}
|
|
|
|
fn visit_pre_decided(
|
|
&mut self,
|
|
entry: Entry<'_>,
|
|
) -> std::result::Result<WalkControl, Self::Error> {
|
|
if self.limit.is_some_and(|limit| self.emitted >= limit) {
|
|
return Ok(WalkControl::Quit);
|
|
}
|
|
self.emitted += 1;
|
|
self.visitor.visit(entry)
|
|
}
|
|
|
|
fn decide_pre_descend(
|
|
&mut self,
|
|
meta: &EntryMeta<'_>,
|
|
) -> std::result::Result<PreDescendDecision, Self::Error> {
|
|
let is_dir = meta.file_type == FileType::Dir;
|
|
|
|
match self.filter.stream_decision(meta) {
|
|
WalkDecision::Include => {},
|
|
WalkDecision::Skip => {
|
|
return Ok(PreDescendDecision { emit: false, descend: is_dir, stop: false });
|
|
},
|
|
WalkDecision::SkipDescend => {
|
|
return Ok(PreDescendDecision { emit: false, descend: false, stop: false });
|
|
},
|
|
WalkDecision::Stop => {
|
|
return Ok(PreDescendDecision { emit: false, descend: false, stop: true });
|
|
},
|
|
}
|
|
|
|
match self.predicate.decide(meta) {
|
|
WalkDecision::Include => {
|
|
Ok(PreDescendDecision { emit: true, descend: is_dir, stop: false })
|
|
},
|
|
WalkDecision::Skip => {
|
|
Ok(PreDescendDecision { emit: false, descend: is_dir, stop: false })
|
|
},
|
|
WalkDecision::SkipDescend => {
|
|
Ok(PreDescendDecision { emit: false, descend: false, stop: false })
|
|
},
|
|
WalkDecision::Stop => {
|
|
Ok(PreDescendDecision { emit: false, descend: false, stop: true })
|
|
},
|
|
}
|
|
}
|
|
}
|
|
struct ClosureEntryVisitor<'a, V, D> {
|
|
root: &'a Path,
|
|
visit: V,
|
|
directory_error: D,
|
|
}
|
|
|
|
impl<E, V, D> EntryVisitor for ClosureEntryVisitor<'_, V, D>
|
|
where
|
|
V: for<'entry> FnMut(EntryMeta<'entry>) -> std::result::Result<WalkDecision, E>,
|
|
D: for<'error> FnMut(DirectoryError<'error>) -> std::result::Result<WalkDecision, E>,
|
|
{
|
|
type Error = E;
|
|
|
|
fn visit(&mut self, entry: Entry<'_>) -> std::result::Result<WalkControl, Self::Error> {
|
|
let meta = EntryMeta {
|
|
root: self.root,
|
|
absolute_path: Cow::Borrowed(entry.path),
|
|
relative_path: entry.relative,
|
|
file_type: entry.file_type,
|
|
mtime: entry.mtime,
|
|
size: entry.size,
|
|
depth: entry.depth,
|
|
};
|
|
(self.visit)(meta).map(walk_decision_to_control)
|
|
}
|
|
|
|
fn visit_directory_error(
|
|
&mut self,
|
|
error: DirectoryError<'_>,
|
|
) -> std::result::Result<WalkControl, Self::Error> {
|
|
(self.directory_error)(error).map(walk_decision_to_control)
|
|
}
|
|
}
|
|
|
|
const fn walk_decision_to_control(decision: WalkDecision) -> WalkControl {
|
|
match decision {
|
|
WalkDecision::Include | WalkDecision::Skip => WalkControl::Continue,
|
|
WalkDecision::SkipDescend => WalkControl::SkipDescend,
|
|
WalkDecision::Stop => WalkControl::Quit,
|
|
}
|
|
}
|
|
|
|
/// Error returned by native traversal.
|
|
#[derive(Debug)]
|
|
pub enum WalkError<E> {
|
|
/// A caller-supplied heartbeat or visitor returned an error.
|
|
Interrupted(E),
|
|
/// A platform directory API returned malformed data or an unskippable error.
|
|
InvalidData {
|
|
/// Directory whose scan failed.
|
|
path: PathBuf,
|
|
/// Human-readable failure detail.
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
impl<E: fmt::Display> fmt::Display for WalkError<E> {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Interrupted(err) => write!(f, "native directory scan interrupted: {err}"),
|
|
Self::InvalidData { path, message } => {
|
|
write!(f, "native directory scan failed for {}: {message}", path.display())
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<E> std::error::Error for WalkError<E>
|
|
where
|
|
E: std::error::Error + 'static,
|
|
{
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
match self {
|
|
Self::Interrupted(err) => Some(err),
|
|
Self::InvalidData { .. } => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CollectVisitor<E> {
|
|
entries: Vec<CollectedEntry>,
|
|
_error: std::marker::PhantomData<fn() -> E>,
|
|
}
|
|
|
|
impl<E> CollectVisitor<E> {
|
|
const fn new() -> Self {
|
|
Self { entries: Vec::new(), _error: std::marker::PhantomData }
|
|
}
|
|
}
|
|
|
|
impl<E> EntryVisitor for CollectVisitor<E> {
|
|
type Error = E;
|
|
|
|
fn visit(&mut self, entry: Entry<'_>) -> std::result::Result<WalkControl, Self::Error> {
|
|
self.entries.push(CollectedEntry {
|
|
path: entry.relative.to_string(),
|
|
file_type: entry.file_type,
|
|
mtime: entry.mtime,
|
|
size: entry.size,
|
|
});
|
|
Ok(WalkControl::Continue)
|
|
}
|
|
}
|
|
|
|
fn root_device_for_options(root: &Path, options: WalkOptions) -> Option<u64> {
|
|
if options.same_file_system {
|
|
root_device_id(root, options.follow_links)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
struct RawDirEntry<'a> {
|
|
name: Cow<'a, OsStr>,
|
|
file_type: FileType,
|
|
mtime: Option<f64>,
|
|
size: Option<f64>,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
struct DirEntryRecord {
|
|
name_start: usize,
|
|
name_len: usize,
|
|
file_type: FileType,
|
|
mtime: Option<f64>,
|
|
size: Option<f64>,
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
struct DirEntryRecord {
|
|
name: OsString,
|
|
file_type: FileType,
|
|
mtime: Option<f64>,
|
|
size: Option<f64>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct DirScratch {
|
|
entries: Vec<DirEntryRecord>,
|
|
#[cfg(unix)]
|
|
name_bytes: Vec<u8>,
|
|
read_buffer: Vec<u8>,
|
|
}
|
|
|
|
impl DirScratch {
|
|
fn clear_listing(&mut self) {
|
|
self.entries.clear();
|
|
#[cfg(unix)]
|
|
self.name_bytes.clear();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn push(&mut self, entry: RawDirEntry<'_>) {
|
|
let name_os: &OsStr = entry.name.as_ref();
|
|
let name = name_os.as_bytes();
|
|
let name_start = self.name_bytes.len();
|
|
self.name_bytes.extend_from_slice(name);
|
|
self.entries.push(DirEntryRecord {
|
|
name_start,
|
|
name_len: name.len(),
|
|
file_type: entry.file_type,
|
|
mtime: entry.mtime,
|
|
size: entry.size,
|
|
});
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn push(&mut self, entry: RawDirEntry<'_>) {
|
|
self.entries.push(DirEntryRecord {
|
|
name: entry.name.into_owned(),
|
|
file_type: entry.file_type,
|
|
mtime: entry.mtime,
|
|
size: entry.size,
|
|
});
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn sort_by_name(&mut self) {
|
|
let names = &self.name_bytes;
|
|
self.entries.sort_unstable_by(|left, right| {
|
|
let left_name = &names[left.name_start..left.name_start + left.name_len];
|
|
let right_name = &names[right.name_start..right.name_start + right.name_len];
|
|
left_name.cmp(right_name)
|
|
});
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn sort_by_name(&mut self) {
|
|
self
|
|
.entries
|
|
.sort_unstable_by(|left, right| left.name.cmp(&right.name));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn name<'a>(&'a self, entry: &DirEntryRecord) -> &'a OsStr {
|
|
OsStr::from_bytes(&self.name_bytes[entry.name_start..entry.name_start + entry.name_len])
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn name<'a>(&'a self, entry: &'a DirEntryRecord) -> &'a OsStr {
|
|
&entry.name
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum ReadDirControl {
|
|
Continue,
|
|
Stop,
|
|
}
|
|
|
|
enum ReadDirError<E> {
|
|
Io(io::Error),
|
|
Walk(WalkError<E>),
|
|
}
|
|
|
|
impl<E> From<io::Error> for ReadDirError<E> {
|
|
fn from(err: io::Error) -> Self {
|
|
Self::Io(err)
|
|
}
|
|
}
|
|
|
|
fn file_type_from_metadata(metadata: &std::fs::Metadata) -> Option<FileType> {
|
|
let file_type = metadata.file_type();
|
|
if file_type.is_symlink() {
|
|
Some(FileType::Symlink)
|
|
} else if file_type.is_dir() {
|
|
Some(FileType::Dir)
|
|
} else if file_type.is_file() {
|
|
Some(FileType::File)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
struct RootEntry {
|
|
file_type: FileType,
|
|
mtime: Option<f64>,
|
|
size: Option<f64>,
|
|
}
|
|
|
|
fn root_entry<E>(
|
|
root: &Path,
|
|
detail: WalkDetail,
|
|
follow_links: FollowLinks,
|
|
) -> std::result::Result<Option<RootEntry>, WalkError<E>> {
|
|
let metadata = if follow_links.follow_at_depth(0) {
|
|
match std::fs::metadata(root) {
|
|
Ok(metadata) => metadata,
|
|
Err(err) if is_missing_metadata_error(&err) => {
|
|
std::fs::symlink_metadata(root).map_err(|err| WalkError::InvalidData {
|
|
path: root.to_path_buf(),
|
|
message: err.to_string(),
|
|
})?
|
|
},
|
|
Err(err) => {
|
|
return Err(WalkError::InvalidData {
|
|
path: root.to_path_buf(),
|
|
message: err.to_string(),
|
|
});
|
|
},
|
|
}
|
|
} else {
|
|
std::fs::symlink_metadata(root).map_err(|err| WalkError::InvalidData {
|
|
path: root.to_path_buf(),
|
|
message: err.to_string(),
|
|
})?
|
|
};
|
|
Ok(entry_from_metadata(&metadata, detail))
|
|
}
|
|
|
|
fn entry_from_metadata(metadata: &std::fs::Metadata, detail: WalkDetail) -> Option<RootEntry> {
|
|
let file_type = file_type_from_metadata(metadata)?;
|
|
let size = if detail == WalkDetail::Full && file_type == FileType::File {
|
|
Some(metadata.len() as f64)
|
|
} else {
|
|
None
|
|
};
|
|
let mtime = if detail == WalkDetail::Full {
|
|
metadata
|
|
.modified()
|
|
.ok()
|
|
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|duration| duration.as_millis() as f64)
|
|
} else {
|
|
None
|
|
};
|
|
Some(RootEntry { file_type, mtime, size })
|
|
}
|
|
|
|
fn is_missing_metadata_error(err: &io::Error) -> bool {
|
|
matches!(err.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory)
|
|
}
|
|
|
|
/// Scans entries using the shared cache when [`WalkOptions::cache`] is true.
|
|
///
|
|
/// The native scanner implements every [`WalkOptions`] traversal contract.
|
|
pub fn collect_entries<E, H>(
|
|
root: &Path,
|
|
options: WalkOptions,
|
|
heartbeat: H,
|
|
) -> std::result::Result<CollectedEntries, WalkError<String>>
|
|
where
|
|
H: Fn() -> std::result::Result<(), E> + Sync,
|
|
E: fmt::Display,
|
|
{
|
|
cache::collect_entries(root, options, heartbeat)
|
|
}
|
|
|
|
fn collect_entries_native<E, H>(
|
|
root: &Path,
|
|
options: WalkOptions,
|
|
heartbeat: H,
|
|
) -> std::result::Result<CollectedEntries, WalkError<E>>
|
|
where
|
|
H: FnMut() -> std::result::Result<(), E>,
|
|
{
|
|
let mut collector = CollectVisitor::new();
|
|
let _status = walk_entries(root, options, &mut collector, heartbeat)?;
|
|
if options.contents_first {
|
|
sort_collected_depth_first(&mut collector.entries);
|
|
} else {
|
|
collector
|
|
.entries
|
|
.sort_unstable_by(|a, b| a.path.cmp(&b.path));
|
|
}
|
|
Ok(CollectedEntries { entries: collector.entries, cache_age_ms: 0 })
|
|
}
|
|
|
|
/// Scans entries without cancellation using platform syscalls when supported.
|
|
pub fn collect_entries_without_heartbeat(
|
|
root: &Path,
|
|
options: WalkOptions,
|
|
) -> std::result::Result<CollectedEntries, WalkError<String>> {
|
|
collect_entries(root, options, || Ok::<(), Infallible>(()))
|
|
}
|
|
|
|
/// Streams entries using the native scanner for every [`WalkOptions`]
|
|
/// traversal contract.
|
|
pub fn walk_entries<V, H>(
|
|
root: &Path,
|
|
options: WalkOptions,
|
|
visitor: &mut V,
|
|
heartbeat: H,
|
|
) -> std::result::Result<WalkStatus, WalkError<V::Error>>
|
|
where
|
|
V: EntryVisitor,
|
|
H: FnMut() -> std::result::Result<(), V::Error>,
|
|
{
|
|
if options.min_depth > options.max_depth {
|
|
return Ok(WalkStatus::Complete);
|
|
}
|
|
|
|
let root_device = root_device_for_options(root, options);
|
|
let matcher = FastIgnore::new(options.use_gitignore);
|
|
let root_ignore = matcher.root_state(root);
|
|
|
|
let mut context = WalkContext {
|
|
root_path: root,
|
|
options,
|
|
root_device,
|
|
symlink_ancestors: SymlinkAncestorStack::default(),
|
|
matcher,
|
|
absolute_path: root.to_path_buf(),
|
|
relative_path: String::new(),
|
|
scratch_pool: Vec::new(),
|
|
visited: 0,
|
|
heartbeat,
|
|
};
|
|
|
|
context.walk_root(root, &root_ignore, visitor)
|
|
}
|
|
|
|
struct WalkContext<'a, H> {
|
|
root_path: &'a Path,
|
|
options: WalkOptions,
|
|
root_device: Option<u64>,
|
|
symlink_ancestors: SymlinkAncestorStack,
|
|
matcher: FastIgnore,
|
|
absolute_path: PathBuf,
|
|
relative_path: String,
|
|
scratch_pool: Vec<DirScratch>,
|
|
visited: usize,
|
|
heartbeat: H,
|
|
}
|
|
|
|
impl<H> WalkContext<'_, H> {
|
|
fn walk_root<V>(
|
|
&mut self,
|
|
root: &Path,
|
|
root_ignore: &Arc<IgnoreState>,
|
|
visitor: &mut V,
|
|
) -> std::result::Result<WalkStatus, WalkError<V::Error>>
|
|
where
|
|
V: EntryVisitor,
|
|
H: FnMut() -> std::result::Result<(), V::Error>,
|
|
{
|
|
let root_entry = root_entry(root, self.options.detail, self.options.follow_links)?;
|
|
let Some(root_entry) = root_entry else {
|
|
return Ok(WalkStatus::Complete);
|
|
};
|
|
|
|
// Seed the ancestor stack only when descendant symlink traversal can loop.
|
|
if self.options.follow_links == FollowLinks::Always
|
|
&& let Ok(id) = directory_identity(root)
|
|
{
|
|
self.symlink_ancestors.push(id);
|
|
}
|
|
|
|
let meta = EntryMeta {
|
|
root,
|
|
absolute_path: Cow::Borrowed(root),
|
|
relative_path: "",
|
|
file_type: root_entry.file_type,
|
|
mtime: root_entry.mtime,
|
|
size: root_entry.size,
|
|
depth: 0,
|
|
};
|
|
|
|
let decision = visitor
|
|
.decide_pre_descend(&meta)
|
|
.map_err(WalkError::Interrupted)?;
|
|
if decision.stop {
|
|
return Ok(WalkStatus::Stopped);
|
|
}
|
|
|
|
if !self.options.contents_first
|
|
&& self.options.emit_root
|
|
&& self.options.min_depth == 0
|
|
&& decision.emit
|
|
{
|
|
let name = root.file_name().unwrap_or(root.as_os_str());
|
|
match visitor
|
|
.visit_pre_decided(Entry {
|
|
path: root,
|
|
relative: "",
|
|
name,
|
|
file_type: root_entry.file_type,
|
|
mtime: root_entry.mtime,
|
|
size: root_entry.size,
|
|
depth: 0,
|
|
})
|
|
.map_err(WalkError::Interrupted)?
|
|
{
|
|
WalkControl::Quit => return Ok(WalkStatus::Stopped),
|
|
WalkControl::SkipDescend => return Ok(WalkStatus::Complete),
|
|
WalkControl::Continue => {},
|
|
}
|
|
}
|
|
|
|
let stopped =
|
|
if root_entry.file_type == FileType::Dir && self.options.max_depth > 0 && decision.descend
|
|
{
|
|
self.walk_dir(0, root_ignore, false, visitor)?
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if stopped {
|
|
return Ok(WalkStatus::Stopped);
|
|
}
|
|
|
|
if self.options.contents_first
|
|
&& self.options.emit_root
|
|
&& self.options.min_depth == 0
|
|
&& decision.emit
|
|
{
|
|
let name = root.file_name().unwrap_or(root.as_os_str());
|
|
match visitor
|
|
.visit_pre_decided(Entry {
|
|
path: root,
|
|
relative: "",
|
|
name,
|
|
file_type: root_entry.file_type,
|
|
mtime: root_entry.mtime,
|
|
size: root_entry.size,
|
|
depth: 0,
|
|
})
|
|
.map_err(WalkError::Interrupted)?
|
|
{
|
|
WalkControl::Quit => return Ok(WalkStatus::Stopped),
|
|
WalkControl::SkipDescend | WalkControl::Continue => {},
|
|
}
|
|
}
|
|
|
|
Ok(WalkStatus::Complete)
|
|
}
|
|
|
|
fn take_scratch(&mut self) -> DirScratch {
|
|
let mut scratch = self.scratch_pool.pop().unwrap_or_default();
|
|
scratch.clear_listing();
|
|
scratch
|
|
}
|
|
|
|
fn recycle_scratch(&mut self, mut scratch: DirScratch) {
|
|
scratch.clear_listing();
|
|
self.scratch_pool.push(scratch);
|
|
}
|
|
|
|
fn push_entry_path(&mut self, name: &OsStr, name_str: &str) -> usize {
|
|
let relative_len = self.relative_path.len();
|
|
self.absolute_path.push(name);
|
|
push_relative_name(&mut self.relative_path, name_str);
|
|
relative_len
|
|
}
|
|
|
|
fn pop_entry_path(&mut self, relative_len: usize) {
|
|
self.relative_path.truncate(relative_len);
|
|
self.absolute_path.pop();
|
|
}
|
|
|
|
fn walk_dir<V>(
|
|
&mut self,
|
|
depth: usize,
|
|
ignore_state: &Arc<IgnoreState>,
|
|
derive_ignore_from_entries: bool,
|
|
visitor: &mut V,
|
|
) -> std::result::Result<bool, WalkError<V::Error>>
|
|
where
|
|
V: EntryVisitor,
|
|
H: FnMut() -> std::result::Result<(), V::Error>,
|
|
{
|
|
if self.options.follow_links == FollowLinks::Always
|
|
&& let Ok(identity) = directory_identity(&self.absolute_path)
|
|
{
|
|
self.symlink_ancestors.push(identity);
|
|
let result = self.walk_dir_inner(depth, ignore_state, derive_ignore_from_entries, visitor);
|
|
self.symlink_ancestors.pop();
|
|
return result;
|
|
}
|
|
|
|
self.walk_dir_inner(depth, ignore_state, derive_ignore_from_entries, visitor)
|
|
}
|
|
|
|
fn walk_dir_inner<V>(
|
|
&mut self,
|
|
depth: usize,
|
|
ignore_state: &Arc<IgnoreState>,
|
|
derive_ignore_from_entries: bool,
|
|
visitor: &mut V,
|
|
) -> std::result::Result<bool, WalkError<V::Error>>
|
|
where
|
|
V: EntryVisitor,
|
|
H: FnMut() -> std::result::Result<(), V::Error>,
|
|
{
|
|
let mut scratch = self.take_scratch();
|
|
let ignore_entries = match collect_directory_entries(
|
|
&self.absolute_path,
|
|
self.options.detail,
|
|
&mut scratch,
|
|
&self.matcher,
|
|
derive_ignore_from_entries,
|
|
) {
|
|
Ok(ignore_entries) => ignore_entries,
|
|
Err(err) => {
|
|
let dir = self.absolute_path.clone();
|
|
self.recycle_scratch(scratch);
|
|
return handle_read_dir_error(&dir, err, self.options, visitor);
|
|
},
|
|
};
|
|
if self.options.order == WalkOrder::Path {
|
|
scratch.sort_by_name();
|
|
}
|
|
let dir_ignore = self.matcher.state_from_entries(
|
|
ignore_state,
|
|
&self.absolute_path,
|
|
ignore_entries,
|
|
derive_ignore_from_entries,
|
|
);
|
|
|
|
for index in 0..scratch.entries.len() {
|
|
if self.visited == 0 || self.visited >= HEARTBEAT_INTERVAL {
|
|
self.visited = 0;
|
|
(self.heartbeat)().map_err(WalkError::Interrupted)?;
|
|
}
|
|
self.visited += 1;
|
|
|
|
let entry = &scratch.entries[index];
|
|
let name = scratch.name(entry);
|
|
if is_dot_entry(name) {
|
|
continue;
|
|
}
|
|
if !self.options.include_hidden && is_hidden_name(name) {
|
|
continue;
|
|
}
|
|
if (self.options.skip_git && is_git_name(name))
|
|
|| (self.options.skip_node_modules && is_node_modules_name(name))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let name_str = entry_name(name);
|
|
if name_str.is_empty() {
|
|
continue;
|
|
}
|
|
let next_depth = depth + 1;
|
|
if next_depth > self.options.max_depth {
|
|
continue;
|
|
}
|
|
|
|
let relative_len = self.push_entry_path(name, &name_str);
|
|
let entry_result = self.walk_current_entry(
|
|
name,
|
|
entry.file_type,
|
|
entry.mtime,
|
|
entry.size,
|
|
next_depth,
|
|
&dir_ignore,
|
|
visitor,
|
|
);
|
|
self.pop_entry_path(relative_len);
|
|
if entry_result? {
|
|
self.recycle_scratch(scratch);
|
|
return Ok(true);
|
|
}
|
|
}
|
|
|
|
self.recycle_scratch(scratch);
|
|
Ok(false)
|
|
}
|
|
|
|
fn walk_current_entry<V>(
|
|
&mut self,
|
|
name: &OsStr,
|
|
entry_file_type: FileType,
|
|
entry_mtime: Option<f64>,
|
|
entry_size: Option<f64>,
|
|
next_depth: usize,
|
|
dir_ignore: &Arc<IgnoreState>,
|
|
visitor: &mut V,
|
|
) -> std::result::Result<bool, WalkError<V::Error>>
|
|
where
|
|
V: EntryVisitor,
|
|
H: FnMut() -> std::result::Result<(), V::Error>,
|
|
{
|
|
let mut file_type = entry_file_type;
|
|
let mut mtime = entry_mtime;
|
|
let mut size = entry_size;
|
|
let mut is_dir = entry_file_type == FileType::Dir;
|
|
let mut descend = is_dir;
|
|
let mut followed_symlink_dir = false;
|
|
let followed_metadata = if entry_file_type == FileType::Symlink
|
|
&& self.options.follow_links == FollowLinks::Always
|
|
{
|
|
match std::fs::metadata(&self.absolute_path) {
|
|
Ok(metadata) => Some(metadata),
|
|
Err(err) => {
|
|
if self.options.directory_errors == DirectoryErrorMode::SkipSkippable
|
|
&& is_skippable_directory_error(&err)
|
|
{
|
|
return Ok(false);
|
|
}
|
|
if self.options.directory_errors == DirectoryErrorMode::Visit {
|
|
match visitor
|
|
.visit_directory_error(DirectoryError {
|
|
path: &self.absolute_path,
|
|
error: &err,
|
|
})
|
|
.map_err(WalkError::Interrupted)?
|
|
{
|
|
WalkControl::Quit => return Ok(true),
|
|
WalkControl::SkipDescend | WalkControl::Continue => {
|
|
return Ok(false);
|
|
},
|
|
}
|
|
}
|
|
return Err(WalkError::InvalidData {
|
|
path: self.absolute_path.clone(),
|
|
message: err.to_string(),
|
|
});
|
|
},
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(target_metadata) = followed_metadata.as_ref() {
|
|
let Some(target_file_type) = file_type_from_metadata(target_metadata) else {
|
|
return Ok(false);
|
|
};
|
|
file_type = target_file_type;
|
|
if target_file_type == FileType::Dir {
|
|
is_dir = true;
|
|
descend = true;
|
|
followed_symlink_dir = true;
|
|
} else {
|
|
is_dir = false;
|
|
descend = false;
|
|
}
|
|
if self.options.detail == WalkDetail::Full {
|
|
if target_file_type == FileType::File {
|
|
size = Some(target_metadata.len() as f64);
|
|
} else {
|
|
size = None;
|
|
}
|
|
mtime = target_metadata
|
|
.modified()
|
|
.ok()
|
|
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|duration| duration.as_millis() as f64);
|
|
}
|
|
}
|
|
|
|
if self
|
|
.matcher
|
|
.is_ignored(dir_ignore, &self.absolute_path, is_dir)
|
|
{
|
|
return Ok(false);
|
|
}
|
|
|
|
if !is_effective_path_on_root_file_system(
|
|
&self.absolute_path,
|
|
next_depth,
|
|
self.options.follow_links,
|
|
self.root_device,
|
|
followed_metadata.as_ref(),
|
|
) {
|
|
return Ok(false);
|
|
}
|
|
|
|
if followed_symlink_dir && descend {
|
|
match directory_identity(&self.absolute_path) {
|
|
Ok(target_id) => {
|
|
if self.symlink_ancestors.contains(&target_id) {
|
|
let loop_err = io::Error::other("filesystem loop detected");
|
|
if self.options.directory_errors == DirectoryErrorMode::Visit {
|
|
match visitor
|
|
.visit_directory_error(DirectoryError {
|
|
path: &self.absolute_path,
|
|
error: &loop_err,
|
|
})
|
|
.map_err(WalkError::Interrupted)?
|
|
{
|
|
WalkControl::Quit => return Ok(true),
|
|
WalkControl::SkipDescend | WalkControl::Continue => {
|
|
return Ok(false);
|
|
},
|
|
}
|
|
} else if self.options.directory_errors == DirectoryErrorMode::SkipSkippable {
|
|
return Ok(false);
|
|
}
|
|
return Err(WalkError::InvalidData {
|
|
path: self.absolute_path.clone(),
|
|
message: "filesystem loop detected".to_string(),
|
|
});
|
|
}
|
|
},
|
|
Err(err) => {
|
|
if self.options.directory_errors == DirectoryErrorMode::SkipSkippable
|
|
&& is_skippable_directory_error(&err)
|
|
{
|
|
return Ok(false);
|
|
}
|
|
if self.options.directory_errors == DirectoryErrorMode::Visit {
|
|
match visitor
|
|
.visit_directory_error(DirectoryError {
|
|
path: &self.absolute_path,
|
|
error: &err,
|
|
})
|
|
.map_err(WalkError::Interrupted)?
|
|
{
|
|
WalkControl::Quit => return Ok(true),
|
|
WalkControl::SkipDescend | WalkControl::Continue => {
|
|
return Ok(false);
|
|
},
|
|
}
|
|
}
|
|
return Err(WalkError::InvalidData {
|
|
path: self.absolute_path.clone(),
|
|
message: err.to_string(),
|
|
});
|
|
},
|
|
}
|
|
}
|
|
|
|
let decision = {
|
|
let meta = EntryMeta {
|
|
root: self.root_path,
|
|
absolute_path: Cow::Borrowed(self.absolute_path.as_path()),
|
|
relative_path: &self.relative_path,
|
|
file_type,
|
|
mtime,
|
|
size,
|
|
depth: next_depth,
|
|
};
|
|
visitor
|
|
.decide_pre_descend(&meta)
|
|
.map_err(WalkError::Interrupted)?
|
|
};
|
|
if decision.stop {
|
|
return Ok(true);
|
|
}
|
|
|
|
if !self.options.contents_first && decision.emit && next_depth >= self.options.min_depth {
|
|
match visitor
|
|
.visit_pre_decided(Entry {
|
|
path: self.absolute_path.as_path(),
|
|
relative: &self.relative_path,
|
|
name,
|
|
file_type,
|
|
mtime,
|
|
size,
|
|
depth: next_depth,
|
|
})
|
|
.map_err(WalkError::Interrupted)?
|
|
{
|
|
WalkControl::Quit => return Ok(true),
|
|
WalkControl::SkipDescend => return Ok(false),
|
|
WalkControl::Continue => {},
|
|
}
|
|
}
|
|
|
|
let child_stopped = if descend && next_depth < self.options.max_depth && decision.descend {
|
|
self.walk_dir(next_depth, dir_ignore, true, visitor)?
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if child_stopped {
|
|
return Ok(true);
|
|
}
|
|
|
|
if self.options.contents_first && decision.emit && next_depth >= self.options.min_depth {
|
|
match visitor
|
|
.visit_pre_decided(Entry {
|
|
path: self.absolute_path.as_path(),
|
|
relative: &self.relative_path,
|
|
name,
|
|
file_type,
|
|
mtime,
|
|
size,
|
|
depth: next_depth,
|
|
})
|
|
.map_err(WalkError::Interrupted)?
|
|
{
|
|
WalkControl::Quit => return Ok(true),
|
|
WalkControl::SkipDescend | WalkControl::Continue => {},
|
|
}
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
fn collect_directory_entries<E>(
|
|
dir: &Path,
|
|
detail: WalkDetail,
|
|
scratch: &mut DirScratch,
|
|
matcher: &FastIgnore,
|
|
derive_ignore_from_entries: bool,
|
|
) -> std::result::Result<IgnoreEntryNames, ReadDirError<E>> {
|
|
scratch.clear_listing();
|
|
let mut ignore_entries = IgnoreEntryNames::default();
|
|
let track_ignore_entries = derive_ignore_from_entries && matcher.use_gitignore;
|
|
let mut read_buffer = std::mem::take(&mut scratch.read_buffer);
|
|
let result = platform::read_dir_entries(dir, detail, &mut read_buffer, |entry| {
|
|
if track_ignore_entries {
|
|
ignore_entries.record(entry.name.as_ref(), entry.file_type);
|
|
}
|
|
scratch.push(entry);
|
|
Ok(ReadDirControl::Continue)
|
|
});
|
|
scratch.read_buffer = read_buffer;
|
|
result?;
|
|
Ok(ignore_entries)
|
|
}
|
|
/// Return whether [`WalkDetail::Full`] provides file sizes without per-entry
|
|
/// metadata syscalls on this platform.
|
|
pub const fn supports_cheap_size_hints() -> bool {
|
|
platform::CHEAP_SIZE_HINTS
|
|
}
|
|
|
|
struct IgnoreState {
|
|
parent: Option<Arc<Self>>,
|
|
ignore_matcher: Option<ignore::gitignore::Gitignore>,
|
|
gitignore_matcher: Option<ignore::gitignore::Gitignore>,
|
|
git_exclude_matcher: Option<ignore::gitignore::Gitignore>,
|
|
has_git: bool,
|
|
chain_has_matchers: bool,
|
|
any_git: bool,
|
|
}
|
|
|
|
struct FastIgnore {
|
|
global: Option<ignore::gitignore::Gitignore>,
|
|
use_gitignore: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Default)]
|
|
struct IgnoreEntryNames {
|
|
ignore_file: bool,
|
|
gitignore_file: bool,
|
|
git_dir: bool,
|
|
repo_marker: bool,
|
|
}
|
|
|
|
impl IgnoreEntryNames {
|
|
fn record(&mut self, name: &OsStr, file_type: FileType) {
|
|
if matches!(file_type, FileType::File | FileType::Symlink) {
|
|
if name == OsStr::new(".ignore") {
|
|
self.ignore_file = true;
|
|
} else if name == OsStr::new(".gitignore") {
|
|
self.gitignore_file = true;
|
|
}
|
|
}
|
|
if name == OsStr::new(".git") {
|
|
self.git_dir = true;
|
|
self.repo_marker = true;
|
|
} else if name == OsStr::new(".jj") {
|
|
self.repo_marker = true;
|
|
}
|
|
}
|
|
|
|
const fn has_relevant(self) -> bool {
|
|
self.ignore_file || self.gitignore_file || self.git_dir || self.repo_marker
|
|
}
|
|
}
|
|
|
|
fn has_repo_marker(dir: &Path) -> bool {
|
|
dir.join(".git").exists() || dir.join(".jj").exists()
|
|
}
|
|
|
|
fn ignore_line_covers_root(
|
|
matcher_root: &Path,
|
|
source: &Path,
|
|
line: &str,
|
|
explicit_root: &Path,
|
|
) -> bool {
|
|
let mut builder = ignore::gitignore::GitignoreBuilder::new(matcher_root);
|
|
builder.add_line(Some(source.to_path_buf()), line).is_ok()
|
|
&& builder.build().is_ok_and(|matcher| {
|
|
matcher
|
|
.matched_path_or_any_parents(explicit_root, true)
|
|
.is_ignore()
|
|
})
|
|
}
|
|
|
|
/// Load an ignore source, removing ancestor rules that cover an explicit walk
|
|
/// root.
|
|
///
|
|
/// Unrelated parent rules remain active, while ignore files discovered at or
|
|
/// below the root are loaded without filtering.
|
|
fn load_gitignore(
|
|
matcher_root: &Path,
|
|
file: &Path,
|
|
explicit_root: Option<&Path>,
|
|
) -> Option<ignore::gitignore::Gitignore> {
|
|
if !file.is_file() {
|
|
return None;
|
|
}
|
|
let mut builder = ignore::gitignore::GitignoreBuilder::new(matcher_root);
|
|
let _ = builder.add(file);
|
|
let matcher = builder.build().ok().filter(|matcher| !matcher.is_empty())?;
|
|
let Some(explicit_root) = explicit_root else {
|
|
return Some(matcher);
|
|
};
|
|
if !matcher
|
|
.matched_path_or_any_parents(explicit_root, true)
|
|
.is_ignore()
|
|
{
|
|
return Some(matcher);
|
|
}
|
|
|
|
let handle = std::fs::File::open(file).ok()?;
|
|
let mut filtered = ignore::gitignore::GitignoreBuilder::new(matcher_root);
|
|
let source = Some(file.to_path_buf());
|
|
for (index, line) in io::BufReader::new(handle).lines().enumerate() {
|
|
let Ok(line) = line else {
|
|
break;
|
|
};
|
|
let line = if index == 0 {
|
|
line.trim_start_matches('\u{feff}')
|
|
} else {
|
|
line.as_str()
|
|
};
|
|
if ignore_line_covers_root(matcher_root, file, line, explicit_root) {
|
|
continue;
|
|
}
|
|
let _ = filtered.add_line(source.clone(), line);
|
|
}
|
|
filtered.build().ok().filter(|matcher| !matcher.is_empty())
|
|
}
|
|
|
|
impl IgnoreState {
|
|
fn build(dir: &Path, parent: Option<Arc<Self>>) -> Arc<Self> {
|
|
let has_git = has_repo_marker(dir);
|
|
let git_exclude = dir.join(".git/info/exclude");
|
|
Self::new(
|
|
parent,
|
|
load_gitignore(dir, &dir.join(".ignore"), None),
|
|
load_gitignore(dir, &dir.join(".gitignore"), None),
|
|
if has_git {
|
|
load_gitignore(dir, &git_exclude, None)
|
|
} else {
|
|
None
|
|
},
|
|
has_git,
|
|
)
|
|
}
|
|
|
|
fn build_parent(dir: &Path, parent: Option<Arc<Self>>, explicit_root: &Path) -> Arc<Self> {
|
|
let has_git = has_repo_marker(dir);
|
|
let git_exclude = dir.join(".git/info/exclude");
|
|
Self::new(
|
|
parent,
|
|
load_gitignore(dir, &dir.join(".ignore"), Some(explicit_root)),
|
|
load_gitignore(dir, &dir.join(".gitignore"), Some(explicit_root)),
|
|
if has_git {
|
|
load_gitignore(dir, &git_exclude, Some(explicit_root))
|
|
} else {
|
|
None
|
|
},
|
|
has_git,
|
|
)
|
|
}
|
|
|
|
fn build_from_entry_names(dir: &Path, parent: &Arc<Self>, names: IgnoreEntryNames) -> Arc<Self> {
|
|
if !names.has_relevant() {
|
|
return Arc::clone(parent);
|
|
}
|
|
let git_exclude = dir.join(".git/info/exclude");
|
|
Self::new(
|
|
Some(Arc::clone(parent)),
|
|
if names.ignore_file {
|
|
load_gitignore(dir, &dir.join(".ignore"), None)
|
|
} else {
|
|
None
|
|
},
|
|
if names.gitignore_file {
|
|
load_gitignore(dir, &dir.join(".gitignore"), None)
|
|
} else {
|
|
None
|
|
},
|
|
if names.git_dir {
|
|
load_gitignore(dir, &git_exclude, None)
|
|
} else {
|
|
None
|
|
},
|
|
names.repo_marker,
|
|
)
|
|
}
|
|
|
|
fn new(
|
|
parent: Option<Arc<Self>>,
|
|
ignore_matcher: Option<ignore::gitignore::Gitignore>,
|
|
gitignore_matcher: Option<ignore::gitignore::Gitignore>,
|
|
git_exclude_matcher: Option<ignore::gitignore::Gitignore>,
|
|
has_git: bool,
|
|
) -> Arc<Self> {
|
|
let parent_has_matchers = parent
|
|
.as_ref()
|
|
.is_some_and(|parent| parent.chain_has_matchers);
|
|
let parent_has_git = parent.as_ref().is_some_and(|parent| parent.any_git);
|
|
let has_matchers =
|
|
ignore_matcher.is_some() || gitignore_matcher.is_some() || git_exclude_matcher.is_some();
|
|
Arc::new(Self {
|
|
parent,
|
|
ignore_matcher,
|
|
gitignore_matcher,
|
|
git_exclude_matcher,
|
|
has_git,
|
|
chain_has_matchers: has_matchers || parent_has_matchers,
|
|
any_git: has_git || parent_has_git,
|
|
})
|
|
}
|
|
|
|
fn build_parents(root: &Path, use_gitignore: bool) -> Option<Arc<Self>> {
|
|
if !use_gitignore {
|
|
return None;
|
|
}
|
|
let mut ancestors = Vec::new();
|
|
let mut current = root.parent();
|
|
let mut repo_start = None;
|
|
while let Some(path) = current {
|
|
ancestors.push(path);
|
|
if repo_start.is_none() && has_repo_marker(path) {
|
|
repo_start = Some(ancestors.len() - 1);
|
|
}
|
|
current = path.parent();
|
|
}
|
|
|
|
let repo_start = repo_start?;
|
|
let mut parent = None;
|
|
for ancestor in ancestors[..=repo_start].iter().rev() {
|
|
parent = Some(Self::build_parent(ancestor, parent, root));
|
|
}
|
|
parent
|
|
}
|
|
}
|
|
|
|
impl FastIgnore {
|
|
fn new(use_gitignore: bool) -> Self {
|
|
let global = if use_gitignore {
|
|
let (matcher, _err) = ignore::gitignore::Gitignore::global();
|
|
if matcher.is_empty() {
|
|
None
|
|
} else {
|
|
Some(matcher)
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
Self { global, use_gitignore }
|
|
}
|
|
|
|
fn root_state(&self, root: &Path) -> Arc<IgnoreState> {
|
|
IgnoreState::build(root, IgnoreState::build_parents(root, self.use_gitignore))
|
|
}
|
|
|
|
fn state_from_entries(
|
|
&self,
|
|
parent: &Arc<IgnoreState>,
|
|
dir: &Path,
|
|
names: IgnoreEntryNames,
|
|
derive_ignore_from_entries: bool,
|
|
) -> Arc<IgnoreState> {
|
|
if self.use_gitignore && derive_ignore_from_entries {
|
|
IgnoreState::build_from_entry_names(dir, parent, names)
|
|
} else {
|
|
Arc::clone(parent)
|
|
}
|
|
}
|
|
|
|
fn is_ignored(&self, state: &Arc<IgnoreState>, path: &Path, is_dir: bool) -> bool {
|
|
if !self.use_gitignore {
|
|
return false;
|
|
}
|
|
|
|
let any_git = state.any_git;
|
|
let global_matcher_applies = any_git && self.global.is_some();
|
|
if !state.chain_has_matchers && !global_matcher_applies {
|
|
return false;
|
|
}
|
|
|
|
let mut saw_git = false;
|
|
let mut ignore_match = ignore::Match::None;
|
|
let mut gitignore_match = ignore::Match::None;
|
|
let mut git_exclude_match = ignore::Match::None;
|
|
|
|
if state.chain_has_matchers {
|
|
let mut current = Some(state.as_ref());
|
|
while let Some(frame) = current {
|
|
if ignore_match.is_none()
|
|
&& let Some(matcher) = &frame.ignore_matcher
|
|
{
|
|
ignore_match = matcher.matched(path, is_dir);
|
|
}
|
|
if gitignore_match.is_none()
|
|
&& let Some(matcher) = &frame.gitignore_matcher
|
|
{
|
|
gitignore_match = matcher.matched(path, is_dir);
|
|
}
|
|
if any_git
|
|
&& !saw_git
|
|
&& git_exclude_match.is_none()
|
|
&& let Some(matcher) = &frame.git_exclude_matcher
|
|
{
|
|
git_exclude_match = matcher.matched(path, is_dir);
|
|
}
|
|
saw_git = saw_git || frame.has_git;
|
|
current = frame.parent.as_deref();
|
|
}
|
|
}
|
|
match ignore_match {
|
|
ignore::Match::Ignore(_) => return true,
|
|
ignore::Match::Whitelist(_) => return false,
|
|
ignore::Match::None => {},
|
|
}
|
|
match gitignore_match {
|
|
ignore::Match::Ignore(_) => return true,
|
|
ignore::Match::Whitelist(_) => return false,
|
|
ignore::Match::None => {},
|
|
}
|
|
match git_exclude_match {
|
|
ignore::Match::Ignore(_) => return true,
|
|
ignore::Match::Whitelist(_) => return false,
|
|
ignore::Match::None => {},
|
|
}
|
|
if any_git && let Some(global) = &self.global {
|
|
match global.matched(path, is_dir) {
|
|
ignore::Match::Ignore(_) => return true,
|
|
ignore::Match::Whitelist(_) => return false,
|
|
ignore::Match::None => {},
|
|
}
|
|
}
|
|
false
|
|
}
|
|
}
|
|
|
|
fn handle_read_dir_error<V>(
|
|
dir: &Path,
|
|
err: ReadDirError<V::Error>,
|
|
options: WalkOptions,
|
|
visitor: &mut V,
|
|
) -> std::result::Result<bool, WalkError<V::Error>>
|
|
where
|
|
V: EntryVisitor,
|
|
{
|
|
match err {
|
|
ReadDirError::Walk(err) => Err(err),
|
|
ReadDirError::Io(err)
|
|
if options.directory_errors == DirectoryErrorMode::SkipSkippable
|
|
&& is_skippable_directory_error(&err) =>
|
|
{
|
|
Ok(false)
|
|
},
|
|
ReadDirError::Io(err) if options.directory_errors == DirectoryErrorMode::Visit => {
|
|
match visitor
|
|
.visit_directory_error(DirectoryError { path: dir, error: &err })
|
|
.map_err(WalkError::Interrupted)?
|
|
{
|
|
WalkControl::Quit => Ok(true),
|
|
WalkControl::SkipDescend | WalkControl::Continue => Ok(false),
|
|
}
|
|
},
|
|
ReadDirError::Io(err) => {
|
|
Err(WalkError::InvalidData { path: dir.to_path_buf(), message: err.to_string() })
|
|
},
|
|
}
|
|
}
|
|
|
|
fn is_skippable_directory_error(err: &io::Error) -> bool {
|
|
matches!(
|
|
err.kind(),
|
|
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory | io::ErrorKind::PermissionDenied
|
|
)
|
|
}
|
|
|
|
fn is_dot_entry(name: &OsStr) -> bool {
|
|
name == OsStr::new(".") || name == OsStr::new("..")
|
|
}
|
|
|
|
fn is_git_name(name: &OsStr) -> bool {
|
|
name == OsStr::new(".git")
|
|
}
|
|
|
|
fn is_node_modules_name(name: &OsStr) -> bool {
|
|
name == OsStr::new("node_modules")
|
|
}
|
|
|
|
fn entry_name(name: &OsStr) -> Cow<'_, str> {
|
|
name
|
|
.to_str()
|
|
.map_or_else(|| name.to_string_lossy(), Cow::Borrowed)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn is_hidden_name(name: &OsStr) -> bool {
|
|
use std::os::unix::ffi::OsStrExt;
|
|
name.as_bytes().first() == Some(&b'.')
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn is_hidden_name(name: &OsStr) -> bool {
|
|
use std::os::windows::ffi::OsStrExt;
|
|
name.encode_wide().next() == Some(b'.' as u16)
|
|
}
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
fn is_hidden_name(name: &OsStr) -> bool {
|
|
name
|
|
.to_str()
|
|
.is_some_and(|value| value.as_bytes().first() == Some(&b'.'))
|
|
}
|
|
|
|
fn push_relative_name(relative: &mut String, name: &str) {
|
|
if !relative.is_empty() {
|
|
relative.push('/');
|
|
}
|
|
relative.push_str(name);
|
|
}
|
|
|
|
fn mtime_millis(seconds: i64, nanos: i64) -> Option<f64> {
|
|
if seconds < 0 {
|
|
return None;
|
|
}
|
|
Some((seconds as f64).mul_add(1000.0, nanos.max(0) as f64 / 1_000_000.0))
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
mod platform {
|
|
use std::{
|
|
borrow::Cow,
|
|
ffi::{CString, OsStr},
|
|
io,
|
|
mem::size_of,
|
|
os::{fd::RawFd, unix::ffi::OsStrExt},
|
|
path::Path,
|
|
};
|
|
|
|
use super::{
|
|
FileType, RawDirEntry, ReadDirControl, ReadDirError, WalkDetail, WalkError, mtime_millis,
|
|
};
|
|
|
|
/// `getattrlistbulk` can return data length in the same batch, but
|
|
/// requesting full-detail attributes (size + mtime) measurably slows the
|
|
/// bulk scan (~+50% walk time on APFS), which outweighs saving one fstat
|
|
/// per opened file. Benchmarked via `perf_walk_collect_full_detail` vs
|
|
/// minimal detail.
|
|
pub const CHEAP_SIZE_HINTS: bool = false;
|
|
|
|
const BUFFER_SIZE: usize = 256 * 1024;
|
|
const VREG: u32 = 1;
|
|
const VDIR: u32 = 2;
|
|
const VLNK: u32 = 5;
|
|
|
|
struct FdGuard(RawFd);
|
|
|
|
impl Drop for FdGuard {
|
|
fn drop(&mut self) {
|
|
// SAFETY: `FdGuard` owns this file descriptor and closes it exactly once.
|
|
unsafe { libc::close(self.0) };
|
|
}
|
|
}
|
|
|
|
pub fn read_dir_entries<F, E>(
|
|
path: &Path,
|
|
detail: WalkDetail,
|
|
buffer: &mut Vec<u8>,
|
|
mut emit: F,
|
|
) -> std::result::Result<ReadDirControl, ReadDirError<E>>
|
|
where
|
|
F: FnMut(RawDirEntry<'_>) -> std::result::Result<ReadDirControl, WalkError<E>>,
|
|
{
|
|
let fd = open_dir(path)?;
|
|
let mut attrs = libc::attrlist {
|
|
bitmapcount: libc::ATTR_BIT_MAP_COUNT,
|
|
reserved: 0,
|
|
commonattr: libc::ATTR_CMN_NAME | libc::ATTR_CMN_OBJTYPE,
|
|
volattr: 0,
|
|
dirattr: 0,
|
|
fileattr: 0,
|
|
forkattr: 0,
|
|
};
|
|
if detail == WalkDetail::Full {
|
|
attrs.commonattr |= libc::ATTR_CMN_MODTIME;
|
|
attrs.fileattr |= libc::ATTR_FILE_DATALENGTH;
|
|
}
|
|
|
|
if buffer.len() != BUFFER_SIZE {
|
|
buffer.resize(BUFFER_SIZE, 0);
|
|
}
|
|
loop {
|
|
// SAFETY: `fd` is an open directory descriptor, `attrs` points to a valid
|
|
// attrlist for the duration of the call, and `buffer` is writable.
|
|
let count = unsafe {
|
|
libc::getattrlistbulk(
|
|
fd.0,
|
|
std::ptr::addr_of_mut!(attrs).cast(),
|
|
buffer.as_mut_ptr().cast(),
|
|
buffer.len(),
|
|
libc::FSOPT_NOFOLLOW as u64,
|
|
)
|
|
};
|
|
if count == 0 {
|
|
break;
|
|
}
|
|
if count < 0 {
|
|
let err = io::Error::last_os_error();
|
|
if err.kind() == io::ErrorKind::Interrupted {
|
|
continue;
|
|
}
|
|
if is_unsupported_dir_scan(&err) {
|
|
return read_dir_entries_std(path, detail, emit);
|
|
}
|
|
return Err(ReadDirError::Io(err));
|
|
}
|
|
|
|
let mut offset = 0usize;
|
|
for _ in 0..count {
|
|
if offset + size_of::<u32>() > buffer.len() {
|
|
return Err(invalid_data("truncated getattrlistbulk record length").into());
|
|
}
|
|
let record_len = u32::from_ne_bytes(
|
|
buffer[offset..offset + size_of::<u32>()]
|
|
.try_into()
|
|
.expect("slice length checked"),
|
|
) as usize;
|
|
if record_len < size_of::<u32>() || offset + record_len > buffer.len() {
|
|
return Err(invalid_data("invalid getattrlistbulk record length").into());
|
|
}
|
|
let record = &buffer[offset..offset + record_len];
|
|
if let Some(entry) = parse_record(record, detail)?
|
|
&& emit(entry).map_err(ReadDirError::Walk)? == ReadDirControl::Stop
|
|
{
|
|
return Ok(ReadDirControl::Stop);
|
|
}
|
|
offset += record_len;
|
|
}
|
|
}
|
|
Ok(ReadDirControl::Continue)
|
|
}
|
|
|
|
fn read_dir_entries_std<F, E>(
|
|
path: &Path,
|
|
detail: WalkDetail,
|
|
mut emit: F,
|
|
) -> std::result::Result<ReadDirControl, ReadDirError<E>>
|
|
where
|
|
F: FnMut(RawDirEntry<'_>) -> std::result::Result<ReadDirControl, WalkError<E>>,
|
|
{
|
|
let read_dir = std::fs::read_dir(path)?;
|
|
for entry in read_dir {
|
|
let entry = entry?;
|
|
let file_type = match entry.file_type() {
|
|
Ok(file_type) => file_type,
|
|
Err(err) if is_skippable_entry_error(&err) => continue,
|
|
Err(err) => return Err(err.into()),
|
|
};
|
|
let file_type = if file_type.is_symlink() {
|
|
Some(FileType::Symlink)
|
|
} else if file_type.is_dir() {
|
|
Some(FileType::Dir)
|
|
} else if file_type.is_file() {
|
|
Some(FileType::File)
|
|
} else {
|
|
None
|
|
};
|
|
let Some(file_type) = file_type else {
|
|
continue;
|
|
};
|
|
|
|
let mut mtime = None;
|
|
let mut size = None;
|
|
if detail == WalkDetail::Full {
|
|
match std::fs::symlink_metadata(entry.path()) {
|
|
Ok(metadata) => {
|
|
if file_type == FileType::File {
|
|
size = Some(metadata.len() as f64);
|
|
}
|
|
mtime = metadata
|
|
.modified()
|
|
.ok()
|
|
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|duration| duration.as_millis() as f64);
|
|
},
|
|
Err(err) if is_skippable_entry_error(&err) => continue,
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
}
|
|
|
|
let raw_entry =
|
|
RawDirEntry { name: Cow::Owned(entry.file_name()), file_type, mtime, size };
|
|
|
|
if emit(raw_entry).map_err(ReadDirError::Walk)? == ReadDirControl::Stop {
|
|
return Ok(ReadDirControl::Stop);
|
|
}
|
|
}
|
|
Ok(ReadDirControl::Continue)
|
|
}
|
|
|
|
fn open_dir(path: &Path) -> io::Result<FdGuard> {
|
|
let path = CString::new(path.as_os_str().as_bytes())
|
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
|
|
// SAFETY: `path` is a NUL-terminated C string; flags open the directory for
|
|
// metadata traversal only and do not transfer ownership of the string.
|
|
let fd =
|
|
unsafe { libc::open(path.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC) };
|
|
if fd < 0 {
|
|
Err(io::Error::last_os_error())
|
|
} else {
|
|
Ok(FdGuard(fd))
|
|
}
|
|
}
|
|
|
|
fn parse_record(record: &[u8], detail: WalkDetail) -> io::Result<Option<RawDirEntry<'_>>> {
|
|
let mut cursor = size_of::<u32>();
|
|
let name_ref_start = cursor;
|
|
let name_ref = read_value::<libc::attrreference_t>(record, &mut cursor)?;
|
|
let obj_type = read_value::<u32>(record, &mut cursor)?;
|
|
let (mtime, data_length) = if detail == WalkDetail::Full {
|
|
let modified = read_value::<libc::timespec>(record, &mut cursor)?;
|
|
let data_length = read_value::<u64>(record, &mut cursor)?;
|
|
(mtime_millis(modified.tv_sec as i64, modified.tv_nsec as i64), Some(data_length))
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
let name_start = checked_attr_offset(name_ref_start, name_ref.attr_dataoffset)?;
|
|
let name_len = name_ref.attr_length as usize;
|
|
if name_len == 0 || name_start + name_len > record.len() {
|
|
return Err(invalid_data("invalid getattrlistbulk name reference"));
|
|
}
|
|
let name_bytes = trim_nul(&record[name_start..name_start + name_len]);
|
|
if name_bytes.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let Some(file_type) = file_type_from_vtype(obj_type) else {
|
|
return Ok(None);
|
|
};
|
|
let size = if file_type == FileType::File {
|
|
data_length.map(|value| value as f64)
|
|
} else {
|
|
None
|
|
};
|
|
Ok(Some(RawDirEntry { name: OsStr::from_bytes(name_bytes).into(), file_type, mtime, size }))
|
|
}
|
|
|
|
fn read_value<T: Copy>(record: &[u8], cursor: &mut usize) -> io::Result<T> {
|
|
let end = cursor.saturating_add(size_of::<T>());
|
|
if end > record.len() {
|
|
return Err(invalid_data("truncated getattrlistbulk attribute"));
|
|
}
|
|
let ptr = record[*cursor..end].as_ptr();
|
|
*cursor = end;
|
|
// SAFETY: Bounds were checked above; `getattrlistbulk` records are byte
|
|
// packed, so unaligned reads are required and do not outlive `record`.
|
|
Ok(unsafe { std::ptr::read_unaligned(ptr.cast::<T>()) })
|
|
}
|
|
|
|
fn checked_attr_offset(base: usize, offset: i32) -> io::Result<usize> {
|
|
if offset < 0 {
|
|
return Err(invalid_data("negative getattrlistbulk attribute offset"));
|
|
}
|
|
base
|
|
.checked_add(offset as usize)
|
|
.ok_or_else(|| invalid_data("overflowing getattrlistbulk attribute offset"))
|
|
}
|
|
|
|
fn trim_nul(bytes: &[u8]) -> &[u8] {
|
|
let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len());
|
|
&bytes[..end]
|
|
}
|
|
|
|
const fn file_type_from_vtype(value: u32) -> Option<FileType> {
|
|
match value {
|
|
VREG => Some(FileType::File),
|
|
VDIR => Some(FileType::Dir),
|
|
VLNK => Some(FileType::Symlink),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn is_unsupported_dir_scan(err: &io::Error) -> bool {
|
|
matches!(err.raw_os_error(), Some(libc::ENOTSUP | libc::EINVAL))
|
|
}
|
|
|
|
fn is_skippable_entry_error(err: &io::Error) -> bool {
|
|
matches!(err.kind(), io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied)
|
|
}
|
|
|
|
fn invalid_data(message: &'static str) -> io::Error {
|
|
io::Error::new(io::ErrorKind::InvalidData, message)
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod platform {
|
|
use std::{
|
|
ffi::{CString, OsStr},
|
|
io,
|
|
mem::{size_of, zeroed},
|
|
os::unix::ffi::OsStrExt,
|
|
path::Path,
|
|
};
|
|
|
|
use super::{
|
|
FileType, RawDirEntry, ReadDirControl, ReadDirError, WalkDetail, WalkError, mtime_millis,
|
|
};
|
|
|
|
pub const CHEAP_SIZE_HINTS: bool = false;
|
|
|
|
const BUFFER_SIZE: usize = 256 * 1024;
|
|
const LINUX_DIRENT64_NAME_OFFSET: usize = 19;
|
|
const STATX_TYPE: u32 = 0x0001;
|
|
const STATX_SIZE: u32 = 0x0200;
|
|
const STATX_MTIME: u32 = 0x0040;
|
|
const STATX_BASIC_STATS: u32 = 0x07ff;
|
|
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy)]
|
|
struct StatxTimestamp {
|
|
tv_sec: i64,
|
|
tv_nsec: u32,
|
|
__reserved: i32,
|
|
}
|
|
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy)]
|
|
struct Statx {
|
|
stx_mask: u32,
|
|
stx_blksize: u32,
|
|
stx_attributes: u64,
|
|
stx_nlink: u32,
|
|
stx_uid: u32,
|
|
stx_gid: u32,
|
|
stx_mode: u16,
|
|
__spare0: [u16; 1],
|
|
stx_ino: u64,
|
|
stx_size: u64,
|
|
stx_blocks: u64,
|
|
stx_attributes_mask: u64,
|
|
stx_atime: StatxTimestamp,
|
|
stx_btime: StatxTimestamp,
|
|
stx_ctime: StatxTimestamp,
|
|
stx_mtime: StatxTimestamp,
|
|
stx_rdev_major: u32,
|
|
stx_rdev_minor: u32,
|
|
stx_dev_major: u32,
|
|
stx_dev_minor: u32,
|
|
stx_mnt_id: u64,
|
|
stx_dio_mem_align: u32,
|
|
stx_dio_offset_align: u32,
|
|
__spare3: [u64; 12],
|
|
}
|
|
|
|
struct FdGuard(libc::c_int);
|
|
|
|
impl Drop for FdGuard {
|
|
fn drop(&mut self) {
|
|
// SAFETY: `FdGuard` owns this file descriptor and closes it exactly once.
|
|
unsafe { libc::close(self.0) };
|
|
}
|
|
}
|
|
|
|
struct EntryStat {
|
|
file_type: FileType,
|
|
mtime: Option<f64>,
|
|
size: Option<f64>,
|
|
}
|
|
|
|
pub fn read_dir_entries<F, E>(
|
|
path: &Path,
|
|
detail: WalkDetail,
|
|
buffer: &mut Vec<u8>,
|
|
mut emit: F,
|
|
) -> std::result::Result<ReadDirControl, ReadDirError<E>>
|
|
where
|
|
F: FnMut(RawDirEntry<'_>) -> std::result::Result<ReadDirControl, WalkError<E>>,
|
|
{
|
|
let fd = open_dir(path)?;
|
|
if buffer.len() != BUFFER_SIZE {
|
|
buffer.resize(BUFFER_SIZE, 0);
|
|
}
|
|
loop {
|
|
// SAFETY: `fd` is an open directory descriptor and `buffer` is writable.
|
|
let read = unsafe {
|
|
libc::syscall(
|
|
libc::SYS_getdents64,
|
|
fd.0,
|
|
buffer.as_mut_ptr().cast::<libc::c_void>(),
|
|
buffer.len(),
|
|
)
|
|
};
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
if read < 0 {
|
|
let err = io::Error::last_os_error();
|
|
if err.kind() == io::ErrorKind::Interrupted {
|
|
continue;
|
|
}
|
|
return Err(err.into());
|
|
}
|
|
|
|
let mut offset = 0usize;
|
|
let read_len = read as usize;
|
|
while offset < read_len {
|
|
if offset + LINUX_DIRENT64_NAME_OFFSET > read_len {
|
|
return Err(invalid_data("truncated getdents64 record").into());
|
|
}
|
|
let reclen = read_u16(&buffer[offset + 16..read_len])? as usize;
|
|
if reclen < LINUX_DIRENT64_NAME_OFFSET || offset + reclen > read_len {
|
|
return Err(invalid_data("invalid getdents64 record length").into());
|
|
}
|
|
let d_type = buffer[offset + 18];
|
|
let name_bytes =
|
|
trim_nul(&buffer[offset + LINUX_DIRENT64_NAME_OFFSET..offset + reclen]);
|
|
offset += reclen;
|
|
if name_bytes.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let dtype_file_type = file_type_from_dtype(d_type);
|
|
let stat = if detail == WalkDetail::Full || dtype_file_type.is_none() {
|
|
match stat_entry(fd.0, name_bytes, detail) {
|
|
Ok(Some(stat)) => Some(stat),
|
|
Ok(None) => continue,
|
|
Err(err) if is_skippable_entry_error(&err) => continue,
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let file_type = stat
|
|
.as_ref()
|
|
.map_or(dtype_file_type, |stat| Some(stat.file_type));
|
|
let Some(file_type) = file_type else {
|
|
continue;
|
|
};
|
|
let entry = RawDirEntry {
|
|
name: OsStr::from_bytes(name_bytes).into(),
|
|
file_type,
|
|
mtime: stat.as_ref().and_then(|stat| stat.mtime),
|
|
size: stat.as_ref().and_then(|stat| stat.size),
|
|
};
|
|
if emit(entry).map_err(ReadDirError::Walk)? == ReadDirControl::Stop {
|
|
return Ok(ReadDirControl::Stop);
|
|
}
|
|
}
|
|
}
|
|
Ok(ReadDirControl::Continue)
|
|
}
|
|
|
|
fn open_dir(path: &Path) -> io::Result<FdGuard> {
|
|
let path = CString::new(path.as_os_str().as_bytes())
|
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
|
|
// SAFETY: `path` is a NUL-terminated C string; flags request a directory
|
|
// descriptor used only with getdents/statx and do not retain the pointer.
|
|
let fd =
|
|
unsafe { libc::open(path.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC) };
|
|
if fd < 0 {
|
|
Err(io::Error::last_os_error())
|
|
} else {
|
|
Ok(FdGuard(fd))
|
|
}
|
|
}
|
|
|
|
fn stat_entry(
|
|
dirfd: libc::c_int,
|
|
name: &[u8],
|
|
detail: WalkDetail,
|
|
) -> io::Result<Option<EntryStat>> {
|
|
let name = CString::new(name)
|
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "entry name contains NUL"))?;
|
|
match statx_entry(dirfd, &name, detail) {
|
|
Ok(value) => Ok(value),
|
|
Err(err) if matches!(err.raw_os_error(), Some(libc::ENOSYS | libc::EINVAL)) => {
|
|
fstatat_entry(dirfd, &name, detail)
|
|
},
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
fn statx_entry(
|
|
dirfd: libc::c_int,
|
|
name: &CString,
|
|
detail: WalkDetail,
|
|
) -> io::Result<Option<EntryStat>> {
|
|
// SAFETY: `Statx` is a plain-old-data buffer whose all-zero value is a
|
|
// valid initialization before the kernel fills it.
|
|
let mut statx = unsafe { zeroed::<Statx>() };
|
|
let mask = if detail == WalkDetail::Full {
|
|
STATX_BASIC_STATS
|
|
} else {
|
|
STATX_TYPE
|
|
};
|
|
// SAFETY: `name` is NUL-terminated, `statx` is writable, and `dirfd` is an
|
|
// open directory descriptor for an AT_* relative metadata query.
|
|
let rc = unsafe {
|
|
libc::syscall(
|
|
libc::SYS_statx,
|
|
dirfd,
|
|
name.as_ptr(),
|
|
libc::AT_SYMLINK_NOFOLLOW | libc::AT_NO_AUTOMOUNT,
|
|
mask,
|
|
std::ptr::addr_of_mut!(statx),
|
|
)
|
|
};
|
|
if rc != 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
let Some(file_type) = file_type_from_mode(statx.stx_mode as libc::mode_t) else {
|
|
return Ok(None);
|
|
};
|
|
let mtime = if detail == WalkDetail::Full && statx.stx_mask & STATX_MTIME != 0 {
|
|
mtime_millis(statx.stx_mtime.tv_sec, i64::from(statx.stx_mtime.tv_nsec))
|
|
} else {
|
|
None
|
|
};
|
|
let size = if detail == WalkDetail::Full
|
|
&& file_type == FileType::File
|
|
&& statx.stx_mask & STATX_SIZE != 0
|
|
{
|
|
Some(statx.stx_size as f64)
|
|
} else {
|
|
None
|
|
};
|
|
Ok(Some(EntryStat { file_type, mtime, size }))
|
|
}
|
|
|
|
fn fstatat_entry(
|
|
dirfd: libc::c_int,
|
|
name: &CString,
|
|
detail: WalkDetail,
|
|
) -> io::Result<Option<EntryStat>> {
|
|
// SAFETY: `libc::stat` is a POD buffer filled by fstatat.
|
|
let mut stat = unsafe { zeroed::<libc::stat>() };
|
|
// SAFETY: `name` is NUL-terminated, `stat` is writable, and `dirfd` is an
|
|
// open directory descriptor for an AT_* relative metadata query.
|
|
let rc = unsafe {
|
|
libc::fstatat(
|
|
dirfd,
|
|
name.as_ptr(),
|
|
std::ptr::addr_of_mut!(stat),
|
|
libc::AT_SYMLINK_NOFOLLOW,
|
|
)
|
|
};
|
|
if rc != 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
let Some(file_type) = file_type_from_mode(stat.st_mode) else {
|
|
return Ok(None);
|
|
};
|
|
let mtime = if detail == WalkDetail::Full {
|
|
mtime_millis(stat.st_mtime, stat.st_mtime_nsec as i64)
|
|
} else {
|
|
None
|
|
};
|
|
let size = if detail == WalkDetail::Full && file_type == FileType::File {
|
|
Some(stat.st_size as f64)
|
|
} else {
|
|
None
|
|
};
|
|
Ok(Some(EntryStat { file_type, mtime, size }))
|
|
}
|
|
|
|
fn read_u16(bytes: &[u8]) -> io::Result<u16> {
|
|
if bytes.len() < size_of::<u16>() {
|
|
return Err(invalid_data("truncated u16"));
|
|
}
|
|
Ok(u16::from_ne_bytes(
|
|
bytes[..size_of::<u16>()]
|
|
.try_into()
|
|
.expect("slice length checked"),
|
|
))
|
|
}
|
|
|
|
fn trim_nul(bytes: &[u8]) -> &[u8] {
|
|
let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len());
|
|
&bytes[..end]
|
|
}
|
|
|
|
const fn file_type_from_dtype(value: u8) -> Option<FileType> {
|
|
match value {
|
|
libc::DT_REG => Some(FileType::File),
|
|
libc::DT_DIR => Some(FileType::Dir),
|
|
libc::DT_LNK => Some(FileType::Symlink),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
const fn file_type_from_mode(mode: libc::mode_t) -> Option<FileType> {
|
|
match mode & libc::S_IFMT {
|
|
libc::S_IFREG => Some(FileType::File),
|
|
libc::S_IFDIR => Some(FileType::Dir),
|
|
libc::S_IFLNK => Some(FileType::Symlink),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn is_skippable_entry_error(err: &io::Error) -> bool {
|
|
matches!(
|
|
err.kind(),
|
|
io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied | io::ErrorKind::NotADirectory
|
|
)
|
|
}
|
|
|
|
fn invalid_data(message: &'static str) -> io::Error {
|
|
io::Error::new(io::ErrorKind::InvalidData, message)
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
mod platform {
|
|
use std::{
|
|
ffi::OsString,
|
|
io,
|
|
os::windows::ffi::{OsStrExt, OsStringExt},
|
|
path::Path,
|
|
};
|
|
|
|
use windows_sys::{
|
|
Wdk::Storage::FileSystem::{
|
|
FILE_ID_FULL_DIR_INFORMATION, FileIdFullDirectoryInformation, NtQueryDirectoryFile,
|
|
},
|
|
Win32::{
|
|
Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, STATUS_NO_MORE_FILES},
|
|
Storage::FileSystem::{
|
|
CreateFileW, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT,
|
|
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_LIST_DIRECTORY,
|
|
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
|
},
|
|
System::IO::IO_STATUS_BLOCK,
|
|
},
|
|
};
|
|
|
|
use super::{
|
|
FileType, RawDirEntry, ReadDirControl, ReadDirError, WalkDetail, WalkError, mtime_millis,
|
|
};
|
|
|
|
pub const CHEAP_SIZE_HINTS: bool = true;
|
|
|
|
const BUFFER_SIZE: usize = 256 * 1024;
|
|
const WINDOWS_TICK: i64 = 10_000_000;
|
|
const UNIX_EPOCH_AS_FILETIME: i64 = 116_444_736_000_000_000;
|
|
|
|
struct HandleGuard(HANDLE);
|
|
|
|
impl Drop for HandleGuard {
|
|
fn drop(&mut self) {
|
|
// SAFETY: `HandleGuard` owns this handle and closes it exactly once.
|
|
unsafe { CloseHandle(self.0) };
|
|
}
|
|
}
|
|
|
|
pub fn read_dir_entries<F, E>(
|
|
path: &Path,
|
|
detail: WalkDetail,
|
|
buffer: &mut Vec<u8>,
|
|
mut emit: F,
|
|
) -> std::result::Result<ReadDirControl, ReadDirError<E>>
|
|
where
|
|
F: FnMut(RawDirEntry<'_>) -> std::result::Result<ReadDirControl, WalkError<E>>,
|
|
{
|
|
let handle = open_dir(path)?;
|
|
if buffer.len() != BUFFER_SIZE {
|
|
buffer.resize(BUFFER_SIZE, 0);
|
|
}
|
|
let mut restart = true;
|
|
|
|
loop {
|
|
let mut iosb = IO_STATUS_BLOCK::default();
|
|
// SAFETY: `handle` is an open directory handle, `buffer` is writable, and
|
|
// the query class matches the record parser below.
|
|
let status = unsafe {
|
|
NtQueryDirectoryFile(
|
|
handle.0,
|
|
std::ptr::null_mut(),
|
|
None,
|
|
std::ptr::null(),
|
|
std::ptr::addr_of_mut!(iosb),
|
|
buffer.as_mut_ptr().cast(),
|
|
buffer.len() as u32,
|
|
FileIdFullDirectoryInformation,
|
|
false,
|
|
std::ptr::null(),
|
|
restart,
|
|
)
|
|
};
|
|
restart = false;
|
|
if status == STATUS_NO_MORE_FILES {
|
|
break;
|
|
}
|
|
if status < 0 {
|
|
return Err(io::Error::from_raw_os_error(status).into());
|
|
}
|
|
|
|
let mut offset = 0usize;
|
|
loop {
|
|
if offset + std::mem::size_of::<FILE_ID_FULL_DIR_INFORMATION>() > buffer.len() {
|
|
return Err(invalid_data("truncated NtQueryDirectoryFile record").into());
|
|
}
|
|
let info = unsafe {
|
|
// SAFETY: Bounds were checked above; records are byte-packed in the
|
|
// buffer and may not be aligned for Rust references.
|
|
std::ptr::read_unaligned(
|
|
buffer[offset..]
|
|
.as_ptr()
|
|
.cast::<FILE_ID_FULL_DIR_INFORMATION>(),
|
|
)
|
|
};
|
|
let name_offset = offset + std::mem::offset_of!(FILE_ID_FULL_DIR_INFORMATION, FileName);
|
|
let name_len = info.FileNameLength as usize;
|
|
if name_len % 2 != 0 || name_offset + name_len > buffer.len() {
|
|
return Err(invalid_data("invalid NtQueryDirectoryFile name length").into());
|
|
}
|
|
let name_units: Vec<u16> = buffer[name_offset..name_offset + name_len]
|
|
.chunks_exact(2)
|
|
.map(|chunk| u16::from_ne_bytes([chunk[0], chunk[1]]))
|
|
.collect();
|
|
let name = OsString::from_wide(&name_units);
|
|
if let Some(file_type) = file_type_from_attributes(info.FileAttributes) {
|
|
let size = if detail == WalkDetail::Full && file_type == FileType::File {
|
|
Some(info.EndOfFile.max(0) as f64)
|
|
} else {
|
|
None
|
|
};
|
|
let mtime = if detail == WalkDetail::Full {
|
|
mtime_from_filetime(info.LastWriteTime)
|
|
} else {
|
|
None
|
|
};
|
|
let entry = RawDirEntry { name: name.into(), file_type, mtime, size };
|
|
if emit(entry).map_err(ReadDirError::Walk)? == ReadDirControl::Stop {
|
|
return Ok(ReadDirControl::Stop);
|
|
}
|
|
}
|
|
if info.NextEntryOffset == 0 {
|
|
break;
|
|
}
|
|
offset = offset.saturating_add(info.NextEntryOffset as usize);
|
|
}
|
|
}
|
|
Ok(ReadDirControl::Continue)
|
|
}
|
|
|
|
fn open_dir(path: &Path) -> io::Result<HandleGuard> {
|
|
let mut path: Vec<u16> = path.as_os_str().encode_wide().collect();
|
|
path.push(0);
|
|
// SAFETY: `path` is NUL-terminated; the returned handle is owned by
|
|
// `HandleGuard` on success.
|
|
let handle = unsafe {
|
|
CreateFileW(
|
|
path.as_ptr(),
|
|
FILE_LIST_DIRECTORY,
|
|
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
|
std::ptr::null(),
|
|
OPEN_EXISTING,
|
|
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
|
|
std::ptr::null_mut(),
|
|
)
|
|
};
|
|
if handle == INVALID_HANDLE_VALUE {
|
|
Err(io::Error::last_os_error())
|
|
} else {
|
|
Ok(HandleGuard(handle))
|
|
}
|
|
}
|
|
|
|
fn file_type_from_attributes(attributes: u32) -> Option<FileType> {
|
|
if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
|
Some(FileType::Symlink)
|
|
} else if attributes & FILE_ATTRIBUTE_DIRECTORY != 0 {
|
|
Some(FileType::Dir)
|
|
} else {
|
|
Some(FileType::File)
|
|
}
|
|
}
|
|
|
|
fn mtime_from_filetime(filetime: i64) -> Option<f64> {
|
|
let ticks = filetime.checked_sub(UNIX_EPOCH_AS_FILETIME)?;
|
|
let seconds = ticks / WINDOWS_TICK;
|
|
let nanos = (ticks % WINDOWS_TICK) * 100;
|
|
mtime_millis(seconds, nanos)
|
|
}
|
|
|
|
fn invalid_data(message: &'static str) -> io::Error {
|
|
io::Error::new(io::ErrorKind::InvalidData, message)
|
|
}
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
|
mod platform {
|
|
use std::{borrow::Cow, io, path::Path};
|
|
|
|
use super::{FileType, RawDirEntry, ReadDirControl, ReadDirError, WalkDetail, WalkError};
|
|
|
|
pub const CHEAP_SIZE_HINTS: bool = false;
|
|
|
|
pub fn read_dir_entries<F, E>(
|
|
path: &Path,
|
|
detail: WalkDetail,
|
|
_buffer: &mut Vec<u8>,
|
|
mut emit: F,
|
|
) -> std::result::Result<ReadDirControl, ReadDirError<E>>
|
|
where
|
|
F: FnMut(RawDirEntry<'_>) -> std::result::Result<ReadDirControl, WalkError<E>>,
|
|
{
|
|
let read_dir = std::fs::read_dir(path)?;
|
|
for entry in read_dir {
|
|
let entry = entry?;
|
|
let file_type_res = entry.file_type();
|
|
let file_type = match file_type_res {
|
|
Ok(ft) => ft,
|
|
Err(err) if is_skippable_entry_error(&err) => continue,
|
|
Err(err) => return Err(err.into()),
|
|
};
|
|
let custom_file_type = if file_type.is_symlink() {
|
|
Some(FileType::Symlink)
|
|
} else if file_type.is_dir() {
|
|
Some(FileType::Dir)
|
|
} else if file_type.is_file() {
|
|
Some(FileType::File)
|
|
} else {
|
|
None
|
|
};
|
|
let Some(custom_file_type) = custom_file_type else {
|
|
continue; // skip unsupported special files
|
|
};
|
|
|
|
let mut mtime = None;
|
|
let mut size = None;
|
|
if detail == WalkDetail::Full {
|
|
match std::fs::symlink_metadata(entry.path()) {
|
|
Ok(metadata) => {
|
|
if custom_file_type == FileType::File {
|
|
size = Some(metadata.len() as f64);
|
|
}
|
|
mtime = metadata
|
|
.modified()
|
|
.ok()
|
|
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|duration| duration.as_millis() as f64);
|
|
},
|
|
Err(err) if is_skippable_entry_error(&err) => continue,
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
}
|
|
|
|
let raw_entry = RawDirEntry {
|
|
name: Cow::Owned(entry.file_name()),
|
|
file_type: custom_file_type,
|
|
mtime,
|
|
size,
|
|
};
|
|
|
|
if emit(raw_entry).map_err(ReadDirError::Walk)? == ReadDirControl::Stop {
|
|
return Ok(ReadDirControl::Stop);
|
|
}
|
|
}
|
|
Ok(ReadDirControl::Continue)
|
|
}
|
|
|
|
fn is_skippable_entry_error(err: &io::Error) -> bool {
|
|
matches!(err.kind(), io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
fs,
|
|
path::PathBuf,
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use super::*;
|
|
|
|
struct TempTree {
|
|
root: PathBuf,
|
|
}
|
|
|
|
impl TempTree {
|
|
fn path(&self) -> &Path {
|
|
&self.root
|
|
}
|
|
}
|
|
|
|
impl Drop for TempTree {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.root);
|
|
}
|
|
}
|
|
|
|
fn temp_tree(name: &str) -> TempTree {
|
|
let unique = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system time should be after UNIX_EPOCH")
|
|
.as_nanos();
|
|
let root = std::env::temp_dir().join(format!("pi-walker-{name}-{unique}"));
|
|
fs::create_dir_all(&root).expect("temp root should be created");
|
|
TempTree { root }
|
|
}
|
|
|
|
struct CachePathGuard {
|
|
root: PathBuf,
|
|
}
|
|
|
|
impl CachePathGuard {
|
|
fn new(root: &Path) -> Self {
|
|
invalidate_path(root);
|
|
Self { root: root.to_path_buf() }
|
|
}
|
|
}
|
|
|
|
impl Drop for CachePathGuard {
|
|
fn drop(&mut self) {
|
|
invalidate_path(&self.root);
|
|
}
|
|
}
|
|
|
|
fn wait_for_nonzero_cache_age() {
|
|
let started = std::time::Instant::now();
|
|
while started.elapsed() < Duration::from_millis(1) {
|
|
std::thread::yield_now();
|
|
}
|
|
}
|
|
|
|
fn test_options() -> WalkOptions {
|
|
WalkOptions {
|
|
include_hidden: true,
|
|
use_gitignore: false,
|
|
skip_git: true,
|
|
skip_node_modules: true,
|
|
follow_links: FollowLinks::Never,
|
|
detail: WalkDetail::Minimal,
|
|
order: WalkOrder::Path,
|
|
emit_root: false,
|
|
min_depth: 1,
|
|
max_depth: usize::MAX,
|
|
contents_first: false,
|
|
directory_errors: DirectoryErrorMode::SkipSkippable,
|
|
same_file_system: false,
|
|
cache: false,
|
|
}
|
|
}
|
|
|
|
fn collect_file_paths(root: &Path, use_gitignore: bool) -> Vec<String> {
|
|
WalkRequest::from_options(root, WalkOptions { use_gitignore, ..test_options() })
|
|
.filter(WalkFilter::files_only())
|
|
.collect()
|
|
.expect("walk should collect successfully")
|
|
.entries
|
|
.into_iter()
|
|
.map(|entry| entry.path)
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn parent_ignore_outside_repo_does_not_filter_explicit_root() {
|
|
let tree = temp_tree("parent-ignore-outside-repo");
|
|
fs::write(tree.path().join(".gitignore"), "*.nix\n")
|
|
.expect("parent gitignore should be written");
|
|
let project = tree.path().join("projects").join("home-manager");
|
|
fs::create_dir_all(project.join("modules").join("common"))
|
|
.expect("project modules should be created");
|
|
fs::write(project.join("flake.nix"), "flake").expect("flake should be written");
|
|
fs::write(project.join("modules").join("common").join("zsh.nix"), "zsh")
|
|
.expect("module should be written");
|
|
|
|
let paths = collect_file_paths(&project, true);
|
|
|
|
assert_eq!(
|
|
paths,
|
|
vec!["flake.nix", "modules/common/zsh.nix"],
|
|
"an explicit non-repo search root must not inherit ignore files from unrelated parents"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn repo_parent_ignore_still_filters_subdirectory_root() {
|
|
let tree = temp_tree("repo-parent-ignore");
|
|
fs::create_dir_all(tree.path().join(".git")).expect("repo marker should be created");
|
|
fs::write(tree.path().join(".gitignore"), "*.nix\n")
|
|
.expect("repo gitignore should be written");
|
|
let project = tree.path().join("projects").join("home-manager");
|
|
fs::create_dir_all(project.join("modules").join("common"))
|
|
.expect("project modules should be created");
|
|
fs::write(project.join("flake.nix"), "flake").expect("flake should be written");
|
|
fs::write(project.join("modules").join("common").join("zsh.nix"), "zsh")
|
|
.expect("module should be written");
|
|
|
|
let paths = collect_file_paths(&project, true);
|
|
|
|
assert!(
|
|
paths.is_empty(),
|
|
"repo-root .gitignore should still apply when walking a subdirectory root, got {paths:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_ignored_root_keeps_unrelated_parent_and_nested_ignore_rules() {
|
|
let tree = temp_tree("explicit-ignored-root");
|
|
fs::create_dir_all(tree.path().join(".git")).expect("repo marker should be created");
|
|
fs::write(tree.path().join(".gitignore"), "*.log\nignored/**\n")
|
|
.expect("repo gitignore should be written");
|
|
let project = tree.path().join("ignored").join("package");
|
|
let nested = project.join("nested");
|
|
fs::create_dir_all(&nested).expect("ignored project tree should be created");
|
|
fs::write(project.join("keep.ts"), "keep").expect("kept file should be written");
|
|
fs::write(project.join("trace.log"), "trace").expect("parent-ignored file should be written");
|
|
fs::write(nested.join(".gitignore"), "generated.ts\n")
|
|
.expect("nested gitignore should be written");
|
|
fs::write(nested.join("generated.ts"), "generated")
|
|
.expect("nested ignored file should be written");
|
|
fs::write(nested.join("keep.ts"), "nested keep").expect("nested kept file should be written");
|
|
|
|
let paths = collect_file_paths(&project, true);
|
|
|
|
assert_eq!(paths, vec!["keep.ts", "nested/.gitignore", "nested/keep.ts"]);
|
|
}
|
|
|
|
#[test]
|
|
fn walk_request_files_only_returns_relative_files_and_excludes_directories() {
|
|
let tree = temp_tree("request-files-only");
|
|
fs::write(tree.path().join("alpha.txt"), "alpha").expect("top-level file should be written");
|
|
fs::create_dir_all(tree.path().join("nested")).expect("nested dir should be created");
|
|
fs::write(tree.path().join("nested").join("beta.txt"), "beta")
|
|
.expect("nested file should be written");
|
|
|
|
let outcome = WalkRequest::from_options(tree.path(), test_options())
|
|
.filter(WalkFilter::files_only())
|
|
.collect()
|
|
.expect("files-only request should collect successfully");
|
|
let paths = outcome
|
|
.entries
|
|
.iter()
|
|
.map(|entry| entry.path.as_str())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
paths,
|
|
vec!["alpha.txt", "nested/beta.txt"],
|
|
"files-only requests should preserve relative file paths and exclude directory entries"
|
|
);
|
|
assert!(
|
|
outcome.entries.iter().all(CollectedEntry::is_file),
|
|
"files-only requests should not return directories or other entry kinds: {:?}",
|
|
outcome.entries
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compare_depth_first_paths_orders_children_before_parent() {
|
|
let mut paths = vec!["dir", "alpha", "dir/child", "dir/child/grandchild"];
|
|
|
|
paths.sort_unstable_by(|left, right| compare_depth_first_paths(left, right));
|
|
|
|
assert_eq!(
|
|
paths,
|
|
vec!["alpha", "dir/child/grandchild", "dir/child", "dir"],
|
|
"depth-first ordering should keep lexical siblings stable while placing descendants \
|
|
before ancestors"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walk_request_collect_contents_first_uses_collected_fallback() {
|
|
let tree = temp_tree("request-contents-first");
|
|
fs::create_dir_all(tree.path().join("nested")).expect("nested dir should be created");
|
|
fs::write(tree.path().join("nested").join("leaf.txt"), "leaf")
|
|
.expect("nested file should be written");
|
|
|
|
let outcome = WalkRequest::from_options(tree.path(), test_options())
|
|
.visit_order(VisitOrder::ContentsFirst)
|
|
.collect()
|
|
.expect("contents-first request should collect through high-level fallback");
|
|
let paths = outcome
|
|
.entries
|
|
.iter()
|
|
.map(|entry| entry.path.as_str())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
paths,
|
|
vec!["nested/leaf.txt", "nested"],
|
|
"contents-first collection should replay children before their directory"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walk_request_stream_fallback_preserves_predicate_skip_descend() {
|
|
struct RecordingVisitor {
|
|
paths: Vec<String>,
|
|
}
|
|
|
|
impl EntryVisitor for RecordingVisitor {
|
|
type Error = Infallible;
|
|
|
|
fn visit(&mut self, entry: Entry<'_>) -> std::result::Result<WalkControl, Self::Error> {
|
|
self.paths.push(entry.relative.to_string());
|
|
Ok(WalkControl::Continue)
|
|
}
|
|
}
|
|
|
|
let tree = temp_tree("request-stream-skip-descend");
|
|
fs::create_dir_all(tree.path().join("keep")).expect("keep dir should be created");
|
|
fs::write(tree.path().join("keep").join("leaf.txt"), "leaf")
|
|
.expect("kept leaf should be written");
|
|
fs::create_dir_all(tree.path().join("skip")).expect("skip dir should be created");
|
|
fs::write(tree.path().join("skip").join("hidden.txt"), "hidden")
|
|
.expect("skipped leaf should be written");
|
|
|
|
let mut visitor = RecordingVisitor { paths: Vec::new() };
|
|
let status = WalkRequest::from_options(tree.path(), test_options())
|
|
.visit_order(VisitOrder::ContentsFirst)
|
|
.stream_with_predicate(&mut visitor, |meta: &EntryMeta<'_>| {
|
|
if meta.relative_path == "skip" {
|
|
WalkDecision::SkipDescend
|
|
} else {
|
|
WalkDecision::Include
|
|
}
|
|
})
|
|
.expect("contents-first stream fallback should complete");
|
|
|
|
assert_eq!(status, WalkStatus::Complete);
|
|
assert_eq!(
|
|
visitor.paths,
|
|
vec!["keep/leaf.txt", "keep"],
|
|
"predicate SkipDescend should prune the skipped directory and its descendants before \
|
|
contents-first replay"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walk_request_collect_with_heartbeat_propagates_interruptions() {
|
|
let tree = temp_tree("request-heartbeat");
|
|
fs::write(tree.path().join("alpha.txt"), "alpha").expect("file should be written");
|
|
|
|
let result = WalkRequest::from_options(tree.path(), test_options())
|
|
.collect_with_heartbeat(|| Err("stop requested"));
|
|
|
|
let Err(WalkError::Interrupted(message)) = result else {
|
|
panic!("heartbeat interruption should be surfaced as WalkError::Interrupted");
|
|
};
|
|
assert_eq!(message, "stop requested");
|
|
}
|
|
|
|
#[test]
|
|
fn walk_request_rechecks_stale_empty_cached_files_only_result() {
|
|
let tree = temp_tree("request-empty-recheck");
|
|
let _cache_guard = CachePathGuard::new(tree.path());
|
|
let request = WalkRequest::from_options(tree.path(), test_options())
|
|
.cache(true)
|
|
.filter(WalkFilter::files_only())
|
|
.empty_recheck(EmptyRecheck::Never);
|
|
|
|
let primed = request
|
|
.collect()
|
|
.expect("empty request should collect successfully");
|
|
assert_eq!(primed.backend, WalkBackend::Fresh);
|
|
assert!(
|
|
primed.entries.is_empty(),
|
|
"empty root should prime an empty files-only cache entry, got {:?}",
|
|
primed.entries
|
|
);
|
|
|
|
fs::write(tree.path().join("created.txt"), "created")
|
|
.expect("file created after cache prime should be written");
|
|
wait_for_nonzero_cache_age();
|
|
|
|
let stale = request
|
|
.collect()
|
|
.expect("empty recheck disabled request should read the cached empty result");
|
|
assert_eq!(stale.backend, WalkBackend::Cached);
|
|
assert!(
|
|
stale.stats.cache_age_ms > 0,
|
|
"cached empty result should be old enough to exercise empty recheck"
|
|
);
|
|
assert!(
|
|
stale.entries.is_empty(),
|
|
"disabled empty recheck should leave the cached empty files-only result untouched"
|
|
);
|
|
|
|
let refreshed = request
|
|
.empty_recheck(EmptyRecheck::AfterMillis(0))
|
|
.collect()
|
|
.expect("stale empty cache should be rechecked without manual invalidation");
|
|
let paths = refreshed
|
|
.entries
|
|
.iter()
|
|
.map(|entry| entry.path.as_str())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
refreshed.backend,
|
|
WalkBackend::Fresh,
|
|
"stale empty cache recheck should force a fresh scan"
|
|
);
|
|
assert_eq!(
|
|
paths,
|
|
vec!["created.txt"],
|
|
"empty cached files-only requests should observe files created after the cache was primed"
|
|
);
|
|
assert!(
|
|
refreshed.entries.iter().all(CollectedEntry::is_file),
|
|
"files-only empty recheck should still return only files: {:?}",
|
|
refreshed.entries
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walk_request_rechecks_stale_cache_empty_after_glob_filter() {
|
|
let tree = temp_tree("request-filtered-empty-recheck");
|
|
let _cache_guard = CachePathGuard::new(tree.path());
|
|
fs::write(tree.path().join("old.txt"), "old")
|
|
.expect("nonmatching file should be written before cache prime");
|
|
let request = WalkRequest::from_options(tree.path(), test_options())
|
|
.cache(true)
|
|
.filter(
|
|
WalkFilter::files_only()
|
|
.glob(CompiledWalkGlob::new(["*.rs"]).expect("test glob should compile")),
|
|
)
|
|
.empty_recheck(EmptyRecheck::Never);
|
|
|
|
let primed = request
|
|
.collect()
|
|
.expect("filtered request should prime the raw nonempty cache successfully");
|
|
assert_eq!(primed.backend, WalkBackend::Fresh);
|
|
assert_eq!(
|
|
primed.stats.scanned_entries, 1,
|
|
"cache prime should scan the nonmatching file before filtering"
|
|
);
|
|
assert_eq!(
|
|
primed.stats.filtered_entries, 1,
|
|
"glob filter should remove the nonmatching cached file"
|
|
);
|
|
assert!(
|
|
primed.entries.is_empty(),
|
|
"nonmatching file should leave the filtered prime result empty: {:?}",
|
|
primed.entries
|
|
);
|
|
|
|
fs::write(tree.path().join("new.rs"), "new")
|
|
.expect("matching file should be written after cache prime");
|
|
wait_for_nonzero_cache_age();
|
|
|
|
let refreshed = request
|
|
.empty_recheck(EmptyRecheck::AfterMillis(0))
|
|
.collect()
|
|
.expect("filtered empty cached result should be rechecked without manual invalidation");
|
|
let paths = refreshed
|
|
.entries
|
|
.iter()
|
|
.map(|entry| entry.path.as_str())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
refreshed.backend,
|
|
WalkBackend::Fresh,
|
|
"stale cache that is empty only after filtering should force a fresh scan"
|
|
);
|
|
assert_eq!(
|
|
paths,
|
|
vec!["new.rs"],
|
|
"filtered empty-cache recheck should return the file that matches the glob"
|
|
);
|
|
assert!(
|
|
refreshed.entries.iter().all(CollectedEntry::is_file),
|
|
"glob-filtered files-only recheck should still return only files: {:?}",
|
|
refreshed.entries
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collect_entries_honors_gitignore_without_repo_marker() {
|
|
let tree = temp_tree("plain-gitignore");
|
|
fs::write(tree.path().join(".gitignore"), "ignored.txt\n")
|
|
.expect(".gitignore should be written");
|
|
fs::write(tree.path().join("ignored.txt"), "ignored")
|
|
.expect("ignored file should be written");
|
|
fs::write(tree.path().join("kept.txt"), "keep").expect("kept file should be written");
|
|
|
|
let scan = collect_entries(
|
|
tree.path(),
|
|
WalkOptions { use_gitignore: true, cache: false, ..test_options() },
|
|
|| Ok::<(), Infallible>(()),
|
|
)
|
|
.expect("collection should not fail");
|
|
let paths = scan
|
|
.entries
|
|
.into_iter()
|
|
.map(|entry| entry.path)
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
paths.iter().any(|path| path == "kept.txt"),
|
|
"collect_entries should include kept.txt from a plain directory, got: {paths:?}"
|
|
);
|
|
assert!(
|
|
!paths.iter().any(|path| path == "ignored.txt"),
|
|
"collect_entries should exclude .gitignore matches without a .git marker, got: {paths:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collect_entries_ignores_parent_gitignore_above_non_repo_root() {
|
|
let tree = temp_tree("ancestor-gitignore-non-repo-root");
|
|
let search_root = tree.path().join("project");
|
|
fs::create_dir_all(&search_root).expect("explicit search root should be created");
|
|
fs::write(tree.path().join(".gitignore"), "*.nix\n")
|
|
.expect("ancestor .gitignore should be written");
|
|
fs::write(search_root.join("module.nix"), "nix").expect("nix file should be written");
|
|
fs::write(search_root.join("kept.txt"), "keep").expect("kept file should be written");
|
|
|
|
let scan = collect_entries(
|
|
&search_root,
|
|
WalkOptions { use_gitignore: true, cache: false, ..test_options() },
|
|
|| Ok::<(), Infallible>(()),
|
|
)
|
|
.expect("collection should not fail");
|
|
let paths = scan
|
|
.entries
|
|
.into_iter()
|
|
.map(|entry| entry.path)
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
paths.iter().any(|path| path == "module.nix"),
|
|
"ancestor .gitignore outside a non-repo explicit root should not hide module.nix, got: \
|
|
{paths:?}"
|
|
);
|
|
assert!(
|
|
paths.iter().any(|path| path == "kept.txt"),
|
|
"sanity check should include kept.txt from the explicit root, got: {paths:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collect_entries_applies_repo_root_gitignore_to_subdirectory_search_root() {
|
|
let tree = temp_tree("repo-root-gitignore-subdir-root");
|
|
let search_root = tree.path().join("src");
|
|
fs::create_dir_all(tree.path().join(".git")).expect("repo marker should be created");
|
|
fs::create_dir_all(&search_root).expect("explicit search root should be created");
|
|
fs::write(tree.path().join(".gitignore"), "*.nix\n")
|
|
.expect("repo-root .gitignore should be written");
|
|
fs::write(search_root.join("module.nix"), "nix").expect("ignored nix file should be written");
|
|
fs::write(search_root.join("kept.txt"), "keep").expect("kept file should be written");
|
|
|
|
let scan = collect_entries(
|
|
&search_root,
|
|
WalkOptions { use_gitignore: true, cache: false, ..test_options() },
|
|
|| Ok::<(), Infallible>(()),
|
|
)
|
|
.expect("collection should not fail");
|
|
let paths = scan
|
|
.entries
|
|
.into_iter()
|
|
.map(|entry| entry.path)
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
!paths.iter().any(|path| path == "module.nix"),
|
|
"repo-root .gitignore should hide module.nix when searching inside that repo, got: \
|
|
{paths:?}"
|
|
);
|
|
assert!(
|
|
paths.iter().any(|path| path == "kept.txt"),
|
|
"repo subdirectory search should still include nonignored files, got: {paths:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collect_entries_with_exact_workspace_options() {
|
|
let tree = temp_tree("exact-options");
|
|
fs::write(tree.path().join(".gitignore"), "ignored.txt\n")
|
|
.expect(".gitignore should be written");
|
|
fs::write(tree.path().join("ignored.txt"), "ignored")
|
|
.expect("ignored file should be written");
|
|
fs::write(tree.path().join("kept.txt"), "keep").expect("kept file should be written");
|
|
|
|
let scan = collect_entries(
|
|
tree.path(),
|
|
WalkOptions {
|
|
include_hidden: false,
|
|
use_gitignore: true,
|
|
detail: WalkDetail::Full,
|
|
max_depth: 4,
|
|
..test_options()
|
|
},
|
|
|| Ok::<(), Infallible>(()),
|
|
)
|
|
.expect("collection should not fail");
|
|
let paths = scan
|
|
.entries
|
|
.into_iter()
|
|
.map(|entry| entry.path)
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
!paths.iter().any(|path| path == "ignored.txt"),
|
|
"should exclude ignored.txt, got: {paths:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collect_entries_prunes_hidden_git_and_node_modules() {
|
|
let tree = temp_tree("filters");
|
|
fs::write(tree.path().join("visible.txt"), "ok").expect("visible file should be written");
|
|
fs::write(tree.path().join(".hidden"), "hidden").expect("hidden file should be written");
|
|
fs::create_dir_all(tree.path().join(".git")).expect(".git should be created");
|
|
fs::write(tree.path().join(".git").join("config"), "git")
|
|
.expect("git file should be written");
|
|
fs::create_dir_all(tree.path().join("node_modules")).expect("node_modules should be created");
|
|
fs::write(tree.path().join("node_modules").join("pkg.js"), "pkg")
|
|
.expect("node module file should be written");
|
|
|
|
let scan = collect_entries(
|
|
tree.path(),
|
|
WalkOptions { include_hidden: false, ..test_options() },
|
|
|| Ok::<(), Infallible>(()),
|
|
)
|
|
.expect("collection should not fail");
|
|
let paths = scan
|
|
.entries
|
|
.into_iter()
|
|
.map(|entry| entry.path)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(paths, vec!["visible.txt"]);
|
|
}
|
|
|
|
struct PruneVisitor {
|
|
seen: Vec<String>,
|
|
}
|
|
|
|
impl EntryVisitor for PruneVisitor {
|
|
type Error = Infallible;
|
|
|
|
fn visit(&mut self, entry: Entry<'_>) -> std::result::Result<WalkControl, Self::Error> {
|
|
self.seen.push(entry.relative.to_string());
|
|
if entry.relative == "skip" {
|
|
Ok(WalkControl::SkipDescend)
|
|
} else {
|
|
Ok(WalkControl::Continue)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
struct PathsVisitor {
|
|
seen: Vec<String>,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl EntryVisitor for PathsVisitor {
|
|
type Error = Infallible;
|
|
|
|
fn visit(&mut self, entry: Entry<'_>) -> std::result::Result<WalkControl, Self::Error> {
|
|
self.seen.push(entry.relative.to_string());
|
|
Ok(WalkControl::Continue)
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn walk_paths(root: &Path, follow_links: FollowLinks) -> Vec<String> {
|
|
let mut visitor = PathsVisitor { seen: Vec::new() };
|
|
let status =
|
|
walk_entries(root, WalkOptions { follow_links, ..test_options() }, &mut visitor, || {
|
|
Ok::<(), Infallible>(())
|
|
})
|
|
.expect("walk should not fail");
|
|
assert_eq!(status, WalkStatus::Complete);
|
|
visitor.seen
|
|
}
|
|
|
|
#[test]
|
|
fn skip_descend_prunes_directory_children() {
|
|
let tree = temp_tree("skip-descend");
|
|
fs::create_dir_all(tree.path().join("keep")).expect("keep dir should be created");
|
|
fs::write(tree.path().join("keep").join("file.txt"), "ok")
|
|
.expect("keep file should be written");
|
|
fs::create_dir_all(tree.path().join("skip")).expect("skip dir should be created");
|
|
fs::write(tree.path().join("skip").join("file.txt"), "no")
|
|
.expect("skip file should be written");
|
|
|
|
let mut visitor = PruneVisitor { seen: Vec::new() };
|
|
let _status =
|
|
walk_entries(tree.path(), test_options(), &mut visitor, || Ok::<(), Infallible>(()))
|
|
.expect("walk should not fail");
|
|
assert!(visitor.seen.iter().any(|path| path == "skip"));
|
|
assert!(visitor.seen.iter().any(|path| path == "keep/file.txt"));
|
|
assert!(!visitor.seen.iter().any(|path| path == "skip/file.txt"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn walk_entries_always_follows_descendant_symlink_directories() {
|
|
let tree = temp_tree("follow-always");
|
|
fs::create_dir_all(tree.path().join("target")).expect("target dir should be created");
|
|
fs::write(tree.path().join("target").join("child.txt"), "ok")
|
|
.expect("target child should be written");
|
|
std::os::unix::fs::symlink(tree.path().join("target"), tree.path().join("link"))
|
|
.expect("directory symlink should be created");
|
|
|
|
let paths = walk_paths(tree.path(), FollowLinks::Always);
|
|
|
|
assert!(
|
|
paths.iter().any(|path| path == "link/child.txt"),
|
|
"FollowLinks::Always should yield descendants through symlink paths, got: {paths:?}"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn walk_entries_roots_follows_root_symlink_but_not_descendant_symlinks() {
|
|
let target = temp_tree("follow-roots-target");
|
|
fs::write(target.path().join("child.txt"), "ok").expect("root child should be written");
|
|
|
|
let linked_target = temp_tree("follow-roots-linked-target");
|
|
fs::write(linked_target.path().join("linked-child.txt"), "linked")
|
|
.expect("linked child should be written");
|
|
std::os::unix::fs::symlink(linked_target.path(), target.path().join("descendant-link"))
|
|
.expect("descendant directory symlink should be created");
|
|
|
|
let link_parent = temp_tree("follow-roots-link-parent");
|
|
let root_link = link_parent.path().join("root-link");
|
|
std::os::unix::fs::symlink(target.path(), &root_link)
|
|
.expect("root directory symlink should be created");
|
|
|
|
let paths = walk_paths(&root_link, FollowLinks::Roots);
|
|
|
|
assert!(
|
|
paths.iter().any(|path| path == "child.txt"),
|
|
"FollowLinks::Roots should traverse children of a symlink root, got: {paths:?}"
|
|
);
|
|
assert!(
|
|
!paths
|
|
.iter()
|
|
.any(|path| path == "descendant-link/linked-child.txt"),
|
|
"FollowLinks::Roots should not traverse descendant symlink directories, got: {paths:?}"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn walk_entries_never_does_not_follow_root_symlink_directory() {
|
|
let target = temp_tree("follow-never-target");
|
|
fs::write(target.path().join("child.txt"), "ok").expect("root child should be written");
|
|
|
|
let link_parent = temp_tree("follow-never-link-parent");
|
|
let root_link = link_parent.path().join("root-link");
|
|
std::os::unix::fs::symlink(target.path(), &root_link)
|
|
.expect("root directory symlink should be created");
|
|
|
|
let paths = walk_paths(&root_link, FollowLinks::Never);
|
|
|
|
assert!(
|
|
!paths.iter().any(|path| path == "child.txt"),
|
|
"FollowLinks::Never should not traverse a symlink root, got: {paths:?}"
|
|
);
|
|
}
|
|
}
|