diff --git a/packages/copper-proc-macros/Cargo.toml b/packages/copper-proc-macros/Cargo.toml index 9432e11..4776802 100644 --- a/packages/copper-proc-macros/Cargo.toml +++ b/packages/copper-proc-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pistonite-cu-proc-macros" -version = "0.2.8" +version = "0.2.9" edition = "2024" description = "Proc-macros for Cu" repository = "https://github.com/Pistonite/cu" @@ -12,7 +12,7 @@ exclude = [ [dependencies.pm] package = "pistonite-pm" -version = "0.2.6" +version = "0.2.7" path = "../promethium" features = ["full"] diff --git a/packages/copper/Cargo.toml b/packages/copper/Cargo.toml index b7843f1..d8c156a 100644 --- a/packages/copper/Cargo.toml +++ b/packages/copper/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pistonite-cu" -version = "0.8.4" +version = "0.9.0" edition = "2024" description = "Battery-included common utils to speed up development of rust tools" repository = "https://github.com/Pistonite/cu" @@ -11,19 +11,19 @@ exclude = [ ] [dependencies] -pistonite-cu-proc-macros = { version = "0.2.8", path = "../copper-proc-macros" } +pistonite-cu-proc-macros = { version = "0.2.9", path = "../copper-proc-macros" } # --- Always enabled --- -anyhow = "1.0.102" -log = "0.4.30" +anyhow = "1.0.103" +log = "0.4.33" # --- Command Line Interface --- oneshot = { version = "0.2.1", optional = true, features = ["std"] } -env_filter = { version = "1.0.1", optional = true } +env_filter = { version = "2.0.0", optional = true } terminal_size = { version = "0.4.4", optional = true } unicode-width = { version = "0.2.2", features = ["cjk"], optional = true } clap = { version = "4.6.1", features = ["derive"], optional = true } -regex = { version = "1.12.3", optional = true } +regex = { version = "1.13.0", optional = true } ctrlc = { version = "3.5.2", optional = true } # --- Coroutine --- @@ -34,11 +34,11 @@ tokio = { version = "1.52.3", optional = true, features = [ # --- File System --- dunce = {version="1.0.5", optional = true} -which = {version = "8.0.2", optional = true } +which = {version = "8.0.4", optional = true } pathdiff = {version = "0.2.3", optional=true} filetime = { version = "0.2.29", optional = true} -glob = { version = "0.3.3", optional = true } -spin = {version = "0.12.0", optional = true} # for PIO +ignore = { version = "0.4.28", optional = true } +spin = {version = "0.12.1", optional = true} # for PIO # serde serde = { version = "1.0.228", features = ["derive"], optional = true } @@ -95,8 +95,7 @@ process = [ # enable spawning child processes "tokio/process", ] fs = [ # enable file system and path util - "dep:which", "dep:pathdiff", "dep:dunce", "dep:filetime", "dep:glob", - "tokio?/fs" + "dep:which", "dep:pathdiff", "dep:dunce", "dep:filetime", "tokio?/fs", "dep:ignore", ] # Enable parsing utils diff --git a/packages/copper/examples/walk.rs b/packages/copper/examples/walk.rs index e5e301b..3142545 100644 --- a/packages/copper/examples/walk.rs +++ b/packages/copper/examples/walk.rs @@ -2,23 +2,25 @@ use pistonite_cu as cu; #[cu::cli] fn main(_: cu::cli::Flags) -> cu::Result<()> { - let mut src = cu::fs::walk("src")?; + let src = cu::fs::walk("src")?; cu::cli::set_thread_name("walk"); - while let Some(entry) = src.next() { + for entry in src { let entry = entry?; cu::info!( - "{} {} {}", - entry.depth, + "{} {} {:?}", + entry.depth(), entry.path().display(), - entry.rel_path().display(), + entry.rel_path()? ) } cu::cli::set_thread_name("glob"); - let glob = cu::fs::glob_from("..", "./**/*.rs")?; - for entry in glob { + let mut glob = cu::fs::walker(".."); + glob.glob_includes(["**/*.rs"])?; + + for entry in glob.walk()? { let entry = entry?; - cu::info!("{}", entry.display()); + cu::info!("{entry:?}"); } Ok(()) diff --git a/packages/copper/src/cli/print_init.rs b/packages/copper/src/cli/print_init.rs index 3e17ac5..f18abfd 100644 --- a/packages/copper/src/cli/print_init.rs +++ b/packages/copper/src/cli/print_init.rs @@ -186,7 +186,14 @@ pub trait LogConfig { pub struct DefaultLogConfig; impl LogConfig for DefaultLogConfig { fn process(&self, record: &lv::LogRecord) -> (lv::Lv, bool) { + // downgrade logs from dependencies to trace + if let Some(m) = record.module_path() { + if m.starts_with("ignore") { + return (lv::T, true); + } + } + let level: lv::Lv = record.level().into(); - (record.level().into(), level == lv::T) + (level, level == lv::T) } } diff --git a/packages/copper/src/fs/dir.rs b/packages/copper/src/fs/dir.rs index 32b9bd3..7d39576 100644 --- a/packages/copper/src/fs/dir.rs +++ b/packages/copper/src/fs/dir.rs @@ -331,24 +331,40 @@ fn rec_copy_inefficiently_impl(from: &Path, to: &Path) -> crate::Result<()> { // cache paths that are known to be directories in `to` let mut dir_cache = BTreeSet::new(); - let mut walk = crate::fs::walk(from)?; - while let Some(entry) = walk.next() { + let mut walker = crate::fs::walker(from); + walker.include_dir_entries(true); + for entry in walker.walk()? { let entry = entry?; + let rel_path = cu::check!(entry.rel_path()?, "failed to get relative path for entry")?; if !entry.is_dir() { + let Some(file_name) = entry.file_name() else { + cu::trace!( + "skipping file with no file name: '{}'", + entry.path().display() + ); + continue; + }; + let containing = if entry.depth() > 1 { + let parent = cu::check!( + rel_path.parent(), + "failed to get relative path of containing directory for entry" + )?; + to.join(parent) + } else { + to.to_path_buf() + }; + let target = containing.join(file_name); // ensure parent directories exists - if !dir_cache.contains(entry.rel_containing) { - let dir_path = to.join(entry.rel_containing); - make_dir_impl(&dir_path)?; - dir_cache.insert(dir_path); + if !dir_cache.contains(&containing) { + make_dir_impl(&containing)?; + dir_cache.insert(containing); } // copy the file - crate::fs::copy( - entry.path(), - to.join(entry.rel_containing).join(&entry.file_name), - )?; + crate::fs::copy(entry.path(), target)?; } else { - let dir_path = to.join(entry.rel_containing); - make_dir_impl(&dir_path)?; + let target_path = to.join(rel_path); + make_dir_impl(&target_path)?; + dir_cache.insert(target_path); } } diff --git a/packages/copper/src/fs/glob.rs b/packages/copper/src/fs/glob.rs deleted file mode 100644 index 7a00e20..0000000 --- a/packages/copper/src/fs/glob.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::pre::*; - -/// Resolve glob pattern from a base path. -/// -/// The pattern is joined onto the base path. If the pattern -/// is absolute, then the base path has no effect, as it will -/// be replaced by the pattern. -/// -/// This is a thin wrapper around rust-lang's `glob` crate. -/// This returns an iterator over the paths that match -/// the glob pattern. -/// -/// If the resulting pattern is absolute, then it will only match absolute -/// paths that matches the pattern (rather than the behavior -/// of, for example, `.gitignore`) -pub fn glob_from(path: impl AsRef, pattern: &str) -> crate::Result { - let path = path.as_ref().join(pattern); - let pattern = crate::check!( - path.into_utf8(), - "base path is not UTF-8 while trying to glob: {pattern}" - )?; - glob(&pattern) -} - -/// Resolve glob pattern from the current directory. -/// -/// This is a thin wrapper around rust-lang's `glob` crate. -/// This returns an iterator over the paths that match -/// the glob pattern. -/// -/// If the pattern is absolute, then it will only match absolute -/// paths that matches the pattern (rather than the behavior -/// of, for example, `.gitignore`) -pub fn glob(pattern: &str) -> crate::Result { - let iter = crate::check!( - ::glob::glob(pattern), - "failed to parse glob pattern: {pattern}" - )?; - Ok(Glob(iter)) -} - -/// Iterator for [`glob`](function@glob) and [`glob_from`] -/// -/// This is a thin wrapper for rust-lang's `glob` crate -/// that converts the error to anyhow. -pub struct Glob(::glob::Paths); -impl Iterator for Glob { - type Item = crate::Result; - - fn next(&mut self) -> Option { - match self.0.next()? { - Ok(p) => Some(Ok(p)), - Err(e) => { - let path = e.path(); - let outer = crate::fmterr!("glob: cannot read '{}'", path.display()); - Some(Err(e.into_error()).context(outer)) - } - } - } -} diff --git a/packages/copper/src/fs/mod.rs b/packages/copper/src/fs/mod.rs index d97f873..c7ff725 100644 --- a/packages/copper/src/fs/mod.rs +++ b/packages/copper/src/fs/mod.rs @@ -12,10 +12,8 @@ mod read; pub use read::*; mod write; pub use write::*; -mod walk; -pub use walk::*; -mod glob; -pub use glob::*; +mod walk2; +pub use walk2::*; pub mod bin; pub use filetime::FileTime as Time; diff --git a/packages/copper/src/fs/walk.rs b/packages/copper/src/fs/walk.rs deleted file mode 100644 index 5321ac1..0000000 --- a/packages/copper/src/fs/walk.rs +++ /dev/null @@ -1,204 +0,0 @@ -use std::ffi::OsString; -use std::fs::{FileType, Metadata}; -use std::path::{Path, PathBuf}; - -use crate::pre::*; - -/// Recursively walk a directory -pub fn walk(path: impl AsRef) -> crate::Result { - walk_with(path, AlwaysRecurse) -} - -pub fn walk_with(path: impl AsRef, should_recurse: F) -> crate::Result> -where - F: for<'a> WalkShouldRecursePredicate>, -{ - let path = path.as_ref().to_path_buf(); - crate::trace!("walk '{}'", path.display()); - let reader = crate::check!( - std::fs::read_dir(&path), - "cannot read directory '{}'", - path.display() - )?; - // reserve with initial capacity - #[allow(clippy::vec_init_then_push)] - let mut stack = Vec::with_capacity(4); - stack.push((reader, 1)); - Ok(Walk { - root: path, - rel_containing: PathBuf::new(), - stack, - should_recurse, - }) -} -pub struct Walk { - root: PathBuf, - /// The path of the containing directory - /// of the current entry, relative from the root of the walk - rel_containing: PathBuf, - /// last element of the stack is the current directory being read - /// (dir, depth) - stack: Vec<(std::fs::ReadDir, usize)>, - - should_recurse: F, -} - -impl Walk -where - F: for<'b> WalkShouldRecursePredicate>, -{ - #[allow(clippy::should_implement_trait)] - // ^ iterator does not allow returning items referencing data from the iterator - pub fn next(&mut self) -> Option>> { - loop { - let (dir, depth) = self.stack.last_mut()?; - // find next item in the current dir - let entry = match dir.next() { - None => { - // current directory is done, go back to parent - self.stack.pop(); - self.rel_containing.pop(); - continue; - } - Some(Err(e)) => { - return Some(Err(e).context(format!( - "failed to read directory entry while walking '{}'", - self.root.display() - ))); - } - Some(Ok(entry)) => entry, - }; - let file_type = match entry.file_type() { - Err(e) => { - return Some(Err(e).context(format!( - "failed to read directory entry type while walking '{}'", - self.root.display() - ))); - } - Ok(x) => x, - }; - let file_name = entry.file_name(); - let depth = *depth; - if file_type.is_dir() { - let entry = WalkEntry { - root: &self.root, - file_type, - rel_containing: &self.rel_containing, - file_name, - depth, - entry, - }; - if !self.should_recurse.should_recurse(&entry) { - continue; - } - // enter the directory - self.rel_containing.push(entry.file_name); - let dir = self.root.join(&self.rel_containing); - let read_dir = match std::fs::read_dir(dir) { - Err(e) => { - let rel_containing2 = self.rel_containing.display().to_string(); - self.rel_containing.pop(); - return Some(Err(e).context(format!( - "failed to read nested directory '{}' while walking '{}'", - rel_containing2, - self.root.display() - ))); - } - Ok(read_dir) => read_dir, - }; - - self.stack.push((read_dir, depth + 1)); - continue; - } - let entry = WalkEntry { - root: &self.root, - file_type, - rel_containing: &self.rel_containing, - file_name, - depth, - entry, - }; - return Some(Ok(entry)); - } - } -} - -pub struct WalkEntry<'a> { - /// Root path of the walk - pub root: &'a Path, - /// Type of the entry - pub file_type: FileType, - /// The directory that contains the current entry, relative - /// to the root where the walk started, without the leading `./`. - pub rel_containing: &'a Path, - /// File name of the current entry being visited - pub file_name: OsString, - - /// Depth of the current entry, compared to root. - /// This equals the number of segments in the relative path, - /// minimum 1 (when the entry is directly under root). - pub depth: usize, - - /// Inner entry - entry: std::fs::DirEntry, -} -impl WalkEntry<'_> { - /// Get the path by joining the walk root and the relative - /// path of the entry - #[inline(always)] - pub fn path(&self) -> PathBuf { - self.entry.path() - } - - /// Get the relative path of this entry, from the walk root - #[inline(always)] - pub fn rel_path(&self) -> PathBuf { - self.rel_containing.join(&self.file_name) - } - - /// Check if the entry is a file. Convenience wrapper for `self.file_type.is_file()` - #[inline(always)] - pub fn is_file(&self) -> bool { - self.file_type.is_file() - } - - /// Check if the entry is a directory. Convenience wrapper for `self.file_type.is_dir()` - #[inline(always)] - pub fn is_dir(&self) -> bool { - self.file_type.is_dir() - } - - /// Check if the entry is a symlink. Convenience wrapper for `self.file_type.is_symlink()` - #[inline(always)] - pub fn is_symlink(&self) -> bool { - self.file_type.is_symlink() - } - - /// Get the entry metadata - pub fn metadata(&self) -> crate::Result { - crate::check!( - self.entry.metadata(), - "failed to get metadata for file '{}' while walking directory '{}'", - self.rel_path().display(), - self.root.display() - ) - } -} - -pub trait WalkShouldRecursePredicate { - fn should_recurse(&mut self, entry: &E) -> bool; -} -pub struct AlwaysRecurse; -impl WalkShouldRecursePredicate for AlwaysRecurse { - fn should_recurse(&mut self, _: &E) -> bool { - true - } -} -impl<'a, F> WalkShouldRecursePredicate> for F -where - F: for<'b> Fn(&WalkEntry<'b>) -> bool, -{ - fn should_recurse(&mut self, entry: &WalkEntry<'a>) -> bool { - (self)(entry) - } -} diff --git a/packages/copper/src/fs/walk2.rs b/packages/copper/src/fs/walk2.rs new file mode 100644 index 0000000..0126715 --- /dev/null +++ b/packages/copper/src/fs/walk2.rs @@ -0,0 +1,460 @@ +use std::ffi::OsStr; +use std::fs::{FileType, Metadata}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use ignore::overrides::OverrideBuilder; +use ignore::{DirEntry as IgnoreDirEntry, Walk as IgnoreWalk, WalkBuilder as IgnoreWalkBuilder}; + +use crate::pre::*; + +/// Create a walker to walk `root` recursively. +/// +/// See [`WalkBuilder`] for configurations. [`cu::fs::walk`] can be used directly for the default +/// configuration. +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// # fn walk_dirs_too() -> cu::Result<()> { +/// let mut builder = cu::fs::walker("."); +/// builder.include_dir_entries(true); +/// for entry in builder.walk()? { +/// let entry = entry?; +/// cu::info!("{} (dir: {})", entry.path().display(), entry.is_dir()); +/// } +/// # Ok(()) } +/// ``` +#[inline(always)] +pub fn walker(root: impl AsRef) -> WalkBuilder { + WalkBuilder::new(root.as_ref().to_path_buf()) +} + +/// Recursively walk `root` with default settings. +/// +/// The defaults are: +/// - includes hidden files, +/// - does not use `.gitignore` or other ignore files, +/// - does not follow symbolic links, +/// - does not apply any glob include/exclude filters, +/// - yields files only (directory entries are skipped). +/// +/// To change the configuration, use [`cu::fs::walker`] +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// # fn count_files() -> cu::Result { +/// let mut count = 0; +/// for entry in cu::fs::walk(".")? { +/// let _entry = entry?; +/// count += 1; +/// } +/// cu::info!("number of files: {count}"); +/// # Ok(count) } +/// ``` +#[inline(always)] +pub fn walk(root: impl AsRef) -> cu::Result { + walker(root).walk() +} + +/// Builder for a directory [`Walk`], providing a simpler API over the +/// `ignore` crate for the most common cases. +/// +/// Created with [`cu::fs::walker`]. +pub struct WalkBuilder { + inner: IgnoreWalkBuilder, + overrides: OverrideBuilder, + has_overrides: bool, + include_dir_entries: bool, + root: PathBuf, +} + +impl WalkBuilder { + fn new(root: PathBuf) -> Self { + let mut inner = IgnoreWalkBuilder::new(root.clone()); + inner.require_git(true); + inner.ignore(false); + inner.hidden(false); + let mut s = Self { + inner, + overrides: OverrideBuilder::new(root.clone()), + has_overrides: false, + include_dir_entries: false, + root, + }; + s.git(false); + s + } + + /// Return the inner WalkBuilder from the `ignore` crate for advanced configuration. + /// Note that the `overrides` matcher will be replaced when building the walker + pub fn as_inner_mut(&mut self) -> &mut IgnoreWalkBuilder { + &mut self.inner + } + + /// Add glob patterns to include. By default all paths are included. + /// + /// Patterns use gitignore-style glob syntax (as implemented by the `ignore` + /// crate), which supports `*`, `**`, `?`, `[...]` character classes, and + /// `{a,b}` brace alternation. Once any include pattern is added, only paths + /// matching at least one include (and no exclude) are yielded. + /// + /// ## Caution + /// If the glob pattern starts with `./`, the dot is removed instead of matching + /// literal `./`. Patterns starting with `../` still + /// matches the literal `../` (meaning the root must start with `../` to produce any match) + /// + /// ```rust,no_run + /// # use pistonite_cu as cu; + /// # fn only_sources() -> cu::Result<()> { + /// let mut walker = cu::fs::walker("."); + /// // include Rust and TOML files anywhere in the tree + /// walker.glob_includes(["**/*.{rs,toml}"])?; + /// for entry in walker.walk()? { + /// cu::info!("{}", entry?.path().display()); + /// } + /// # Ok(()) } + /// ``` + pub fn glob_includes( + &mut self, + globs: impl IntoIterator>, + ) -> crate::Result<&mut Self> { + for g in globs { + let g = g.as_ref(); + if g.starts_with("./") || g.starts_with(".\\") { + let g2 = &g[1..]; + crate::check!( + self.overrides.add(g2), + "failed to add glob include pattern: '{g2}' (resolved from '{g}')" + )?; + } else { + crate::check!( + self.overrides.add(g), + "failed to add glob include pattern: '{g}'" + )?; + } + self.has_overrides = true; + } + Ok(self) + } + + /// Add glob patterns to exclude. By default nothing is excluded. + /// + /// ## Caution + /// If the glob pattern starts with `./`, the dot is removed instead of matching + /// literal `./`. Patterns starting with `../` still + /// matches the literal `../` (meaning the root must start with `../` to produce any match) + /// + /// ```rust,no_run + /// # use pistonite_cu as cu; + /// # fn skip_logs_and_tmp() -> cu::Result<()> { + /// let mut builder = cu::fs::walker("."); + /// builder.glob_excludes(["**/*.{log,tmp}"])?; + /// for entry in builder.walk()? { + /// cu::info!("{}", entry?.path().display()); + /// } + /// # Ok(()) } + /// ``` + pub fn glob_excludes( + &mut self, + globs: impl IntoIterator>, + ) -> crate::Result<&mut Self> { + let mut s = String::new(); + s.push('!'); + for g in globs { + let g = g.as_ref(); + if g.starts_with("./") || g.starts_with(".\\") { + let g2 = &g[1..]; + s.push_str(g2); + crate::check!( + self.overrides.add(&s), + "failed to add glob exclude pattern: '{g2}' (resolved from '{g}')" + )?; + } else { + s.push_str(g); + crate::check!( + self.overrides.add(&s), + "failed to add glob exclude pattern: '{g}'" + )?; + } + s.truncate(1); + self.has_overrides = true; + } + Ok(self) + } + + /// Set whether directory entries are returned while iterating. Default is + /// `false` (only files are yielded). + /// + /// When enabled, directory entries and symlinks-to-directories are also + /// yielded (as well as the root of the walk, at depth 0). Note that the + /// files *inside* a symlinked directory are still not returned unless + /// [`follow_links`](Self::follow_links) is also enabled. + #[inline(always)] + pub fn include_dir_entries(&mut self, include: bool) -> &mut Self { + self.include_dir_entries = include; + self + } + + /// Enable reading `.gitignore` and git exclude configs, (mostly) matching + /// git's own behavior. Default is disabled. + /// + /// Because this relies on git configuration, ignore rules are only applied + /// when the walk root is inside a git repository. + #[inline(always)] + pub fn git(&mut self, yes: bool) -> &mut Self { + self.inner.git_global(yes); + self.inner.git_ignore(yes); + self.inner.git_exclude(yes); + self + } + + /// Skip hidden files. Default is `false` (i.e. hidden files are + /// included). + #[inline(always)] + pub fn ignore_hidden(&mut self, yes: bool) -> &mut Self { + self.inner.hidden(yes); + self + } + + /// Follow symbolic links. Default is `false`. + #[inline(always)] + pub fn follow_links(&mut self, yes: bool) -> &mut Self { + self.inner.follow_links(yes); + self + } + + /// Add a custom ignore file name (in addition to any enabled by [`git`](Self::git)). + /// + /// Files with this name are read as gitignore-style ignore lists. Unlike + /// [`git`](Self::git), custom ignore files are honored regardless of whether + /// the walk root is inside a git repository. + #[inline(always)] + pub fn add_ignore_filename(&mut self, ignore_file: &str) -> &mut Self { + self.inner.add_custom_ignore_filename(ignore_file); + self + } + + /// Build the [`Walk`] iterator from this configuration. + /// + /// Fails if any configured glob patterns cannot be compiled. + pub fn walk(mut self) -> cu::Result { + if self.has_overrides { + let overrides = cu::check!( + self.overrides.build(), + "walk: failed to build glob pattern overrides" + )?; + self.inner.overrides(overrides); + } + let walk = self.inner.build(); + Ok(Walk { + inner: walk, + include_dir_entries: self.include_dir_entries, + root: Arc::new(self.root), + }) + } +} + +/// An iterator over the entries of a directory walk. +/// +/// Created by [`walk`] or [`WalkBuilder::walk`]. Each item is a +/// `cu::Result`; an `Err` indicates a failure reading a particular +/// entry (for example, following a broken symlink) and does not necessarily +/// stop the iteration. +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// fn list() -> cu::Result<()> { +/// for entry in cu::fs::walk(".")? { +/// let entry = entry?; +/// cu::info!("depth {}: {}", entry.depth(), entry.path().display()); +/// } +/// Ok(()) +/// } +/// ``` +pub struct Walk { + inner: IgnoreWalk, + include_dir_entries: bool, + root: Arc, +} + +impl Iterator for Walk { + type Item = crate::Result; + + fn next(&mut self) -> Option { + match self.next_internal() { + Err(e) => Some(Err(e)), + Ok(None) => None, + Ok(Some(e)) => Some(Ok(e)), + } + } +} + +impl Walk { + fn next_internal(&mut self) -> crate::Result> { + let (entry, file_type) = loop { + let entry = crate::some!(self.inner.next()); + let entry = crate::check!(entry, "walk: failed to read the next entry")?; + match entry.file_type() { + None => { + // stdin + continue; + } + Some(t) => { + if self.include_dir_entries { + break (entry, t); + } + // filter out dir entries + if t.is_file() { + break (entry, t); + } + if t.is_dir() { + crate::trace!( + "walk: not emitting entry for directory: '{}'", + entry.path().display() + ); + continue; + } + if t.is_symlink() && entry.path().is_dir() { + crate::trace!( + "walk: not emitting entry for symlinked directory: '{}'", + entry.path().display() + ); + continue; + } + crate::trace!( + "walk: skipping entry with unknown file type: '{}'", + entry.path().display() + ); + continue; + } + } + }; + Ok(Some(WalkEntry { + root: Arc::clone(&self.root), + inner: entry, + file_type, + })) + } +} + +/// A single entry produced by a [`Walk`]. +/// +/// Provides access to the entry's [`path`](Self::path), its +/// [`depth`](Self::depth) relative to the walk root, its +/// [`file_type`](Self::file_type), and lazily-read [`metadata`](Self::metadata). +#[derive(Debug)] +pub struct WalkEntry { + root: Arc, + inner: IgnoreDirEntry, + file_type: FileType, +} + +impl WalkEntry { + /// Get the root of the walk + #[inline(always)] + pub fn root(&self) -> &Path { + &self.root + } + + /// Get the depth of this entry, `0` is root, `1` is an entry in the root, etc. + pub fn depth(&self) -> usize { + self.inner.depth() + } + + /// Get the path by joining the walk root and the relative + /// path of the entry + #[inline(always)] + pub fn path(&self) -> &Path { + self.inner.path() + } + + /// Get this entry's path relative to the walk root, without a leading `./`. + /// + /// Returns `Ok(None)` when the entry *is* the root of the walk (only emitted + /// when [`include_dir_entries`](WalkBuilder::include_dir_entries) is + /// enabled). Returns an error if the entry path is unexpectedly not + /// contained within the walk root. + /// + /// ```rust,no_run + /// # use pistonite_cu as cu; + /// fn print_relative() -> cu::Result<()> { + /// for entry in cu::fs::walk("src")? { + /// let entry = entry?; + /// if let Some(rel) = entry.rel_path()? { + /// cu::info!("{}", rel.display()); + /// } + /// } + /// Ok(()) + /// } + /// ``` + pub fn rel_path(&self) -> cu::Result> { + // ensure root is a prefix of inner path + let root_norm = self.root.normalize()?; + // note we cannot normalize the path after join since it might be a symlink + let path_norm = root_norm.join(self.inner.path()); + let root_iter = root_norm + .components() + .filter(|x| !matches!(x, Component::CurDir)); + let mut path_iter = path_norm + .components() + .filter(|x| !matches!(x, Component::CurDir)); + for root_comp in root_iter { + let path_comp = cu::check!( + path_iter.next(), + "unexpected: walk entry path is shorter than root" + )?; + cu::ensure!( + root_comp == path_comp, + "unexpected: walk entry path is not in root" + )?; + } + let Some(next) = path_iter.next() else { + // is root + return Ok(None); + }; + let mut p = PathBuf::new(); + p.push(next); + p.extend(path_iter); + Ok(Some(p)) + } + + /// Get the file type + #[inline(always)] + pub fn file_type(&self) -> FileType { + self.file_type + } + + /// Check if the entry is a file. + #[inline(always)] + pub fn is_file(&self) -> bool { + self.file_type.is_file() + } + + /// Check if the entry is a directory. + #[inline(always)] + pub fn is_dir(&self) -> bool { + self.file_type.is_dir() + } + + /// Check if the entry is a symlink. + #[inline(always)] + pub fn is_symlink(&self) -> bool { + self.file_type.is_symlink() + } + + /// Get the file name + #[inline(always)] + pub fn file_name(&self) -> Option<&OsStr> { + self.inner.path().file_name() + } + + /// Get the entry metadata + pub fn metadata(&self) -> crate::Result { + crate::check!( + self.inner.metadata(), + "failed to get metadata for file '{}' while walking directory '{}'", + self.inner.path().try_to_rel_from(&*self.root).display(), + self.root.display() + ) + } +} diff --git a/packages/copper/tests/walk2.rs b/packages/copper/tests/walk2.rs new file mode 100644 index 0000000..d5635cb --- /dev/null +++ b/packages/copper/tests/walk2.rs @@ -0,0 +1,486 @@ +//! Fixture tests for `cu::fs::walk`. +//! +//! Each test builds a stub directory tree in its own uniquely-named temp +//! directory (under `CARGO_TARGET_TMPDIR`) so tests can run in parallel, and +//! tears it down with a drop guard. Symlink-dependent cases are `#[cfg(unix)]` +//! since Windows symlink creation requires elevated privileges. +#![cfg(feature = "fs")] + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use cu::pre::*; +use pistonite_cu as cu; + +/// A temp directory that is recursively removed when dropped. +struct Fixture { + root: PathBuf, +} + +impl Drop for Fixture { + fn drop(&mut self) { + // best-effort cleanup; ignore errors during teardown + let _ = cu::fs::rec_remove(&self.root); + } +} + +impl Fixture { + fn root(&self) -> &Path { + &self.root + } +} + +/// Build the fixture tree in a fresh temp dir named after the calling test. +/// +/// Tree: +/// ```text +/// root/ +/// a.txt +/// b.log +/// .hidden.txt +/// .gitignore # "b.log\nignored/\n" +/// .customignore # "d.rs\n" +/// .git/ # empty marker so require_git(true) sees a repo +/// sub/ +/// c.txt +/// d.rs +/// nested/e.txt +/// .hiddendir/f.txt +/// ignored/g.txt +/// ``` +/// On unix, additionally: +/// ```text +/// link_to_file -> a.txt +/// link_to_dir -> sub +/// ``` +/// (A dangling link is created only by the broken-symlink test, since following +/// it is an error and would interfere with the other symlink cases.) +fn make_fixture(name: &str) -> cu::Result { + let root = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("walk2_{name}")); + // start from a clean slate in case a previous run left something behind + cu::fs::make_dir_empty(&root)?; + cu::fs::write(root.join("a.txt"), "a")?; + cu::fs::write(root.join("b.log"), "b")?; + cu::fs::write(root.join(".hidden.txt"), "hidden")?; + cu::fs::write(root.join(".gitignore"), "b.log\nignored/\n")?; + cu::fs::write(root.join(".customignore"), "d.rs\n")?; + cu::fs::make_dir(root.join(".git"))?; + + cu::fs::make_dir(root.join("sub"))?; + cu::fs::write(root.join("sub/c.txt"), "c")?; + cu::fs::write(root.join("sub/d.rs"), "d")?; + cu::fs::make_dir(root.join("sub/nested"))?; + cu::fs::write(root.join("sub/nested/e.txt"), "e")?; + + cu::fs::make_dir(root.join(".hiddendir"))?; + cu::fs::write(root.join(".hiddendir/f.txt"), "f")?; + + cu::fs::make_dir(root.join("ignored"))?; + cu::fs::write(root.join("ignored/g.txt"), "g")?; + + #[cfg(unix)] + { + make_symlink("a.txt", &root.join("link_to_file"))?; + make_symlink("sub", &root.join("link_to_dir"))?; + } + + Ok(Fixture { root }) +} + +/// Create a symlink at `link` pointing at `target`, with error context. +#[cfg(unix)] +fn make_symlink(target: &str, link: &Path) -> cu::Result<()> { + cu::check!( + std::os::unix::fs::symlink(target, link), + "failed to create symlink '{}' -> '{target}'", + link.display() + ) +} + +/// Normalize a relative path to a forward-slash string for stable comparisons. +fn norm(p: &Path) -> String { + p.to_string_lossy().replace('\\', "/") +} + +/// Drain a walk into the set of relative-path strings it yields. +/// +/// The root entry (`rel_path() == None`, only emitted with dir entries enabled) +/// is represented as `"."`. +fn collect(w: cu::fs::Walk) -> cu::Result> { + let mut set = BTreeSet::new(); + for entry in w { + let entry = entry?; + match entry.rel_path()? { + Some(p) => { + set.insert(norm(&p)); + } + None => { + set.insert(".".to_string()); + } + } + } + Ok(set) +} + +fn set(items: &[&str]) -> BTreeSet { + items.iter().map(|s| s.to_string()).collect() +} + +// --- cross-platform cases (no symlinks) -------------------------------------- + +#[test] +fn default_returns_files_only() -> cu::Result<()> { + let fx = make_fixture("default_returns_files_only")?; + let got = collect(cu::fs::walk(fx.root())?)?; + // Every regular file, hidden included; no directories, and (on unix) no + // symlink entries leak in. + let expected = set(&[ + "a.txt", + "b.log", + ".hidden.txt", + ".gitignore", + ".customignore", + "sub/c.txt", + "sub/d.rs", + "sub/nested/e.txt", + ".hiddendir/f.txt", + "ignored/g.txt", + ]); + assert_eq!(got, expected); + Ok(()) +} + +#[test] +fn include_dir_entries_true() -> cu::Result<()> { + let fx = make_fixture("include_dir_entries_true")?; + let mut b = cu::fs::walker(fx.root()); + b.include_dir_entries(true); + let got = collect(b.walk()?)?; + + // directory entries now appear... + for dir in ["sub", "sub/nested", ".hiddendir", "ignored"] { + assert!(got.contains(dir), "expected dir entry {dir:?} in {got:?}"); + } + // ...alongside the files... + assert!(got.contains("sub/c.txt")); + // ...and the root entry (rel_path == None). + assert!(got.contains("."), "expected root entry in {got:?}"); + Ok(()) +} + +#[test] +fn ignore_hidden_true() -> cu::Result<()> { + let fx = make_fixture("ignore_hidden_true")?; + let mut b = cu::fs::walker(fx.root()); + b.ignore_hidden(true); + let got = collect(b.walk()?)?; + + for hidden in [ + ".hidden.txt", + ".gitignore", + ".customignore", + ".hiddendir/f.txt", + ] { + assert!( + !got.contains(hidden), + "hidden {hidden:?} should be excluded: {got:?}" + ); + } + assert!(got.contains("a.txt")); + assert!(got.contains("sub/c.txt")); + Ok(()) +} + +#[test] +fn glob_includes_txt() -> cu::Result<()> { + let fx = make_fixture("glob_includes_txt")?; + let mut b = cu::fs::walker(fx.root()); + b.glob_includes(["*.txt"])?; + let got = collect(b.walk()?)?; + + assert!(got.contains("a.txt")); + assert!(got.contains("sub/c.txt")); + assert!(got.contains("sub/nested/e.txt")); + assert!( + !got.contains("b.log"), + "non-txt should be excluded: {got:?}" + ); + assert!( + !got.contains("sub/d.rs"), + "non-txt should be excluded: {got:?}" + ); + Ok(()) +} + +#[test] +fn glob_excludes_log() -> cu::Result<()> { + let fx = make_fixture("glob_excludes_log")?; + let mut b = cu::fs::walker(fx.root()); + b.glob_excludes(["./*.log"])?; + let got = collect(b.walk()?)?; + + assert!(!got.contains("b.log"), "*.log should be excluded: {got:?}"); + assert!(got.contains("a.txt"), "{got:?}"); + assert!(got.contains("sub/d.rs")); + Ok(()) +} + +#[test] +fn glob_includes_brace_alternation_root() -> cu::Result<()> { + let fx = make_fixture("glob_includes_brace_alternation_root")?; + let mut b = cu::fs::walker(fx.root()); + // brace alternation: include both .txt and .log, but not .rs + b.glob_includes(["./*.{txt,log}"])?; + let got = collect(b.walk()?)?; + + assert!(got.contains("a.txt"), "txt should be included: {got:?}"); + assert!(got.contains("b.log"), "log should be included: {got:?}"); + assert!(!got.contains("sub/c.txt")); + assert!(!got.contains("sub/d.rs"), "rs should be excluded: {got:?}"); + Ok(()) +} + +#[test] +fn glob_includes_brace_alternation() -> cu::Result<()> { + let fx = make_fixture("glob_includes_brace_alternation")?; + let mut b = cu::fs::walker(fx.root()); + // brace alternation: include both .txt and .log, but not .rs + b.glob_includes(["./**/*.{txt,log}", "./*.{txt,log}"])?; + let got = collect(b.walk()?)?; + + assert!(got.contains("a.txt"), "txt should be included: {got:?}"); + assert!(got.contains("b.log"), "log should be included: {got:?}"); + assert!(got.contains("sub/c.txt")); + assert!(!got.contains("sub/d.rs"), "rs should be excluded: {got:?}"); + Ok(()) +} + +#[test] +fn glob_includes_multiple_patterns() -> cu::Result<()> { + let fx = make_fixture("glob_includes_multiple_patterns")?; + let mut b = cu::fs::walker(fx.root()); + // multiple include patterns are OR-ed together + b.glob_includes(["*.rs", "*.log"])?; + let got = collect(b.walk()?)?; + + assert!(got.contains("sub/d.rs"), "{got:?}"); + assert!(got.contains("b.log"), "{got:?}"); + assert!(!got.contains("a.txt"), "txt should be excluded: {got:?}"); + Ok(()) +} + +#[test] +fn glob_excludes_brace_alternation() -> cu::Result<()> { + let fx = make_fixture("glob_excludes_brace_alternation")?; + let mut b = cu::fs::walker(fx.root()); + // brace alternation in an exclude: drop both .log and .rs + b.glob_excludes(["*.{log,rs}"])?; + let got = collect(b.walk()?)?; + + assert!(!got.contains("b.log"), "log should be excluded: {got:?}"); + assert!(!got.contains("sub/d.rs"), "rs should be excluded: {got:?}"); + assert!(got.contains("a.txt"), "txt should remain: {got:?}"); + assert!(got.contains("sub/c.txt")); + Ok(()) +} + +#[test] +fn git_true_respects_gitignore() -> cu::Result<()> { + let fx = make_fixture("git_true_respects_gitignore")?; + let mut b = cu::fs::walker(fx.root()); + b.git(true); + let got = collect(b.walk()?)?; + + assert!( + !got.contains("b.log"), + "gitignored file should be excluded: {got:?}" + ); + assert!( + !got.contains("ignored/g.txt"), + "gitignored dir contents should be excluded: {got:?}" + ); + assert!(got.contains("a.txt")); + assert!(got.contains("sub/c.txt")); + Ok(()) +} + +#[test] +fn custom_ignore_filename() -> cu::Result<()> { + let fx = make_fixture("custom_ignore_filename")?; + let mut b = cu::fs::walker(fx.root()); + b.add_ignore_filename(".customignore"); + let got = collect(b.walk()?)?; + + assert!( + !got.contains("sub/d.rs"), + "custom-ignored file should be excluded: {got:?}" + ); + assert!(got.contains("a.txt")); + assert!(got.contains("sub/c.txt")); + Ok(()) +} + +#[test] +fn entry_metadata_depth_relpath() -> cu::Result<()> { + let fx = make_fixture("entry_metadata_depth_relpath")?; + let mut b = cu::fs::walker(fx.root()); + b.include_dir_entries(true); + + let mut saw_root = false; + let mut saw_top_file = false; + let mut saw_nested_file = false; + let mut saw_dir = false; + + for entry in b.walk()? { + let entry = entry?; + match entry.rel_path()? { + None => { + // the root entry + assert_eq!(entry.depth(), 0, "root depth should be 0"); + assert!(entry.is_dir()); + saw_root = true; + } + Some(rel) => { + let rel = norm(&rel); + // rel_path must never carry a leading "./" + assert!(!rel.starts_with("./"), "rel_path has leading ./: {rel}"); + match rel.as_str() { + "a.txt" => { + assert_eq!(entry.depth(), 1); + assert!(entry.is_file()); + assert_eq!(entry.file_name().unwrap(), "a.txt"); + entry.metadata()?; + saw_top_file = true; + } + "sub/nested/e.txt" => { + assert_eq!(entry.depth(), 3, "nested file should be depth 3"); + assert!(entry.is_file()); + saw_nested_file = true; + } + "sub" => { + assert_eq!(entry.depth(), 1); + assert!(entry.is_dir()); + saw_dir = true; + } + _ => {} + } + } + } + } + + assert!(saw_root, "did not observe root entry"); + assert!(saw_top_file, "did not observe a.txt"); + assert!(saw_nested_file, "did not observe sub/nested/e.txt"); + assert!(saw_dir, "did not observe sub dir"); + Ok(()) +} + +// --- unix-only cases (symlinks) ---------------------------------------------- + +#[cfg(unix)] +#[test] +fn symlink_to_file_skipped_by_default() -> cu::Result<()> { + let fx = make_fixture("symlink_to_file_skipped_by_default")?; + let got = collect(cu::fs::walk(fx.root())?)?; + + // symlink-to-file is not a plain file, so it is skipped by default + assert!( + !got.contains("link_to_file"), + "symlink to file should be skipped: {got:?}" + ); + // symlinked directory is not descended (no follow) + assert!( + !got.contains("link_to_dir"), + "symlinked dir should be skipped: {got:?}" + ); + assert!( + !got.iter().any(|p| p.starts_with("link_to_dir/")), + "symlinked dir must not be descended: {got:?}" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn symlink_entries_with_include_dir_entries() -> cu::Result<()> { + let fx = make_fixture("symlink_entries_with_include_dir_entries")?; + let mut b = cu::fs::walker(fx.root()); + b.include_dir_entries(true); + let got = collect(b.walk()?)?; + + // links surface as entries when dir entries are included... + assert!( + got.contains("link_to_file"), + "expected link_to_file entry: {got:?}" + ); + assert!( + got.contains("link_to_dir"), + "expected link_to_dir entry: {got:?}" + ); + // ...but the symlinked dir is still not descended without follow_links. + assert!( + !got.iter().any(|p| p.starts_with("link_to_dir/")), + "symlinked dir must not be descended without follow_links: {got:?}" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn follow_links_descends_symlinked_dir() -> cu::Result<()> { + let fx = make_fixture("follow_links_descends_symlinked_dir")?; + let mut b = cu::fs::walker(fx.root()); + b.follow_links(true); + let got = collect(b.walk()?)?; + + // following the link exposes the target dir's files under the link name + assert!( + got.contains("link_to_dir/c.txt"), + "follow_links should descend symlinked dir: {got:?}" + ); + assert!(got.contains("link_to_dir/nested/e.txt"), "{got:?}"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn broken_symlink_without_follow_does_not_error() -> cu::Result<()> { + let fx = make_fixture("broken_symlink_without_follow_does_not_error")?; + make_symlink("does_not_exist", &fx.root().join("link_broken"))?; + + // Without following, the entry is read via symlink_metadata, so a dangling + // target is fine: the walk completes without yielding an error. + let mut b = cu::fs::walker(fx.root()); + b.include_dir_entries(true); + let got = collect(b.walk()?)?; + assert!( + got.contains("link_broken"), + "broken link should still surface as an entry: {got:?}" + ); + + // default walk (dir entries off) skips it as a non-file, also without error. + let files = collect(cu::fs::walk(fx.root())?)?; + assert!( + !files.contains("link_broken"), + "broken link is not a file: {files:?}" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn broken_symlink_with_follow_surfaces_error() -> cu::Result<()> { + let fx = make_fixture("broken_symlink_with_follow_surfaces_error")?; + make_symlink("does_not_exist", &fx.root().join("link_broken"))?; + + // With follow_links, resolving the dangling target fails, and the walk + // reports it as an error rather than silently skipping. + let mut b = cu::fs::walker(fx.root()); + b.include_dir_entries(true).follow_links(true); + let saw_error = b.walk()?.any(|entry| entry.is_err()); + assert!( + saw_error, + "following a dangling symlink should surface an error" + ); + Ok(()) +} diff --git a/packages/promethium/Cargo.toml b/packages/promethium/Cargo.toml index 6870ef0..be4075d 100644 --- a/packages/promethium/Cargo.toml +++ b/packages/promethium/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pistonite-pm" -version = "0.2.6" +version = "0.2.7" edition = "2024" description = "Procedural Macro Common Utils" repository = "https://github.com/Pistonite/cu" @@ -12,8 +12,8 @@ exclude = [ [dependencies] proc-macro2 = { version = "1.0.106", default-features = false } -quote = { version = "1.0.45", default-features = false } -syn = { version = "2.0.117", default-features = false } +quote = { version = "1.0.46", default-features = false } +syn = { version = "2.0.118", default-features = false } [features] default = [