Skip to main content

pistonite_cu/fs/
walk2.rs

1use std::ffi::OsStr;
2use std::fs::{FileType, Metadata};
3use std::path::{Component, Path, PathBuf};
4use std::sync::Arc;
5
6use ignore::overrides::OverrideBuilder;
7use ignore::{DirEntry as IgnoreDirEntry, Walk as IgnoreWalk, WalkBuilder as IgnoreWalkBuilder};
8
9use crate::pre::*;
10
11/// Create a walker to walk `root` recursively.
12///
13/// See [`WalkBuilder`] for configurations. [`cu::fs::walk`] can be used directly for the default
14/// configuration.
15///
16/// ```rust,no_run
17/// # use pistonite_cu as cu;
18/// # fn walk_dirs_too() -> cu::Result<()> {
19/// let mut builder = cu::fs::walker(".");
20/// builder.include_dir_entries(true);
21/// for entry in builder.walk()? {
22///     let entry = entry?;
23///     cu::info!("{} (dir: {})", entry.path().display(), entry.is_dir());
24/// }
25/// # Ok(()) }
26/// ```
27#[inline(always)]
28pub fn walker(root: impl AsRef<Path>) -> WalkBuilder {
29    WalkBuilder::new(root.as_ref().to_path_buf())
30}
31
32/// Recursively walk `root` with default settings.
33///
34/// The defaults are:
35/// - includes hidden files,
36/// - does not use `.gitignore` or other ignore files,
37/// - does not follow symbolic links,
38/// - does not apply any glob include/exclude filters,
39/// - yields files only (directory entries are skipped).
40///
41/// To change the configuration, use [`cu::fs::walker`]
42///
43/// ```rust,no_run
44/// # use pistonite_cu as cu;
45/// # fn count_files() -> cu::Result<usize> {
46/// let mut count = 0;
47/// for entry in cu::fs::walk(".")? {
48///     let _entry = entry?;
49///     count += 1;
50/// }
51/// cu::info!("number of files: {count}");
52/// # Ok(count) }
53/// ```
54#[inline(always)]
55pub fn walk(root: impl AsRef<Path>) -> cu::Result<Walk> {
56    walker(root).walk()
57}
58
59/// Builder for a directory [`Walk`], providing a simpler API over the
60/// `ignore` crate for the most common cases.
61///
62/// Created with [`cu::fs::walker`].
63pub struct WalkBuilder {
64    inner: IgnoreWalkBuilder,
65    overrides: OverrideBuilder,
66    has_overrides: bool,
67    include_dir_entries: bool,
68    root: PathBuf,
69}
70
71impl WalkBuilder {
72    fn new(root: PathBuf) -> Self {
73        let mut inner = IgnoreWalkBuilder::new(root.clone());
74        inner.require_git(true);
75        inner.ignore(false);
76        inner.hidden(false);
77        let mut s = Self {
78            inner,
79            overrides: OverrideBuilder::new(root.clone()),
80            has_overrides: false,
81            include_dir_entries: false,
82            root,
83        };
84        s.git(false);
85        s
86    }
87
88    /// Return the inner WalkBuilder from the `ignore` crate for advanced configuration.
89    /// Note that the `overrides` matcher will be replaced when building the walker
90    pub fn as_inner_mut(&mut self) -> &mut IgnoreWalkBuilder {
91        &mut self.inner
92    }
93
94    /// Add glob patterns to include. By default all paths are included.
95    ///
96    /// Patterns use gitignore-style glob syntax (as implemented by the `ignore`
97    /// crate), which supports `*`, `**`, `?`, `[...]` character classes, and
98    /// `{a,b}` brace alternation. Once any include pattern is added, only paths
99    /// matching at least one include (and no exclude) are yielded.
100    ///
101    /// ## Caution
102    /// If the glob pattern starts with `./`, the dot is removed instead of matching
103    /// literal `./`. Patterns starting with `../` still
104    /// matches the literal `../` (meaning the root must start with `../` to produce any match)
105    ///
106    /// ```rust,no_run
107    /// # use pistonite_cu as cu;
108    /// # fn only_sources() -> cu::Result<()> {
109    /// let mut walker = cu::fs::walker(".");
110    /// // include Rust and TOML files anywhere in the tree
111    /// walker.glob_includes(["**/*.{rs,toml}"])?;
112    /// for entry in walker.walk()? {
113    ///     cu::info!("{}", entry?.path().display());
114    /// }
115    /// # Ok(()) }
116    /// ```
117    pub fn glob_includes(
118        &mut self,
119        globs: impl IntoIterator<Item = impl AsRef<str>>,
120    ) -> crate::Result<&mut Self> {
121        for g in globs {
122            let g = g.as_ref();
123            if g.starts_with("./") || g.starts_with(".\\") {
124                let g2 = &g[1..];
125                crate::check!(
126                    self.overrides.add(g2),
127                    "failed to add glob include pattern: '{g2}' (resolved from '{g}')"
128                )?;
129            } else {
130                crate::check!(
131                    self.overrides.add(g),
132                    "failed to add glob include pattern: '{g}'"
133                )?;
134            }
135            self.has_overrides = true;
136        }
137        Ok(self)
138    }
139
140    /// Add glob patterns to exclude. By default nothing is excluded.
141    ///
142    /// ## Caution
143    /// If the glob pattern starts with `./`, the dot is removed instead of matching
144    /// literal `./`. Patterns starting with `../` still
145    /// matches the literal `../` (meaning the root must start with `../` to produce any match)
146    ///
147    /// ```rust,no_run
148    /// # use pistonite_cu as cu;
149    /// # fn skip_logs_and_tmp() -> cu::Result<()> {
150    /// let mut builder = cu::fs::walker(".");
151    /// builder.glob_excludes(["**/*.{log,tmp}"])?;
152    /// for entry in builder.walk()? {
153    ///     cu::info!("{}", entry?.path().display());
154    /// }
155    /// # Ok(()) }
156    /// ```
157    pub fn glob_excludes(
158        &mut self,
159        globs: impl IntoIterator<Item = impl AsRef<str>>,
160    ) -> crate::Result<&mut Self> {
161        let mut s = String::new();
162        s.push('!');
163        for g in globs {
164            let g = g.as_ref();
165            if g.starts_with("./") || g.starts_with(".\\") {
166                let g2 = &g[1..];
167                s.push_str(g2);
168                crate::check!(
169                    self.overrides.add(&s),
170                    "failed to add glob exclude pattern: '{g2}' (resolved from '{g}')"
171                )?;
172            } else {
173                s.push_str(g);
174                crate::check!(
175                    self.overrides.add(&s),
176                    "failed to add glob exclude pattern: '{g}'"
177                )?;
178            }
179            s.truncate(1);
180            self.has_overrides = true;
181        }
182        Ok(self)
183    }
184
185    /// Set whether directory entries are returned while iterating. Default is
186    /// `false` (only files are yielded).
187    ///
188    /// When enabled, directory entries and symlinks-to-directories are also
189    /// yielded (as well as the root of the walk, at depth 0). Note that the
190    /// files *inside* a symlinked directory are still not returned unless
191    /// [`follow_links`](Self::follow_links) is also enabled.
192    #[inline(always)]
193    pub fn include_dir_entries(&mut self, include: bool) -> &mut Self {
194        self.include_dir_entries = include;
195        self
196    }
197
198    /// Enable reading `.gitignore` and git exclude configs, (mostly) matching
199    /// git's own behavior. Default is disabled.
200    ///
201    /// Because this relies on git configuration, ignore rules are only applied
202    /// when the walk root is inside a git repository.
203    #[inline(always)]
204    pub fn git(&mut self, yes: bool) -> &mut Self {
205        self.inner.git_global(yes);
206        self.inner.git_ignore(yes);
207        self.inner.git_exclude(yes);
208        self
209    }
210
211    /// Skip hidden files. Default is `false` (i.e. hidden files are
212    /// included).
213    #[inline(always)]
214    pub fn ignore_hidden(&mut self, yes: bool) -> &mut Self {
215        self.inner.hidden(yes);
216        self
217    }
218
219    /// Follow symbolic links. Default is `false`.
220    #[inline(always)]
221    pub fn follow_links(&mut self, yes: bool) -> &mut Self {
222        self.inner.follow_links(yes);
223        self
224    }
225
226    /// Add a custom ignore file name (in addition to any enabled by [`git`](Self::git)).
227    ///
228    /// Files with this name are read as gitignore-style ignore lists. Unlike
229    /// [`git`](Self::git), custom ignore files are honored regardless of whether
230    /// the walk root is inside a git repository.
231    #[inline(always)]
232    pub fn add_ignore_filename(&mut self, ignore_file: &str) -> &mut Self {
233        self.inner.add_custom_ignore_filename(ignore_file);
234        self
235    }
236
237    /// Build the [`Walk`] iterator from this configuration.
238    ///
239    /// Fails if any configured glob patterns cannot be compiled.
240    pub fn walk(mut self) -> cu::Result<Walk> {
241        if self.has_overrides {
242            let overrides = cu::check!(
243                self.overrides.build(),
244                "walk: failed to build glob pattern overrides"
245            )?;
246            self.inner.overrides(overrides);
247        }
248        let walk = self.inner.build();
249        Ok(Walk {
250            inner: walk,
251            include_dir_entries: self.include_dir_entries,
252            root: Arc::new(self.root),
253        })
254    }
255}
256
257/// An iterator over the entries of a directory walk.
258///
259/// Created by [`walk`] or [`WalkBuilder::walk`]. Each item is a
260/// `cu::Result<WalkEntry>`; an `Err` indicates a failure reading a particular
261/// entry (for example, following a broken symlink) and does not necessarily
262/// stop the iteration.
263///
264/// ```rust,no_run
265/// # use pistonite_cu as cu;
266/// # fn list() -> cu::Result<()> {
267/// for entry in cu::fs::walk(".")? {
268///     let entry = entry?;
269///     cu::info!("depth {}: {}", entry.depth(), entry.path().display());
270/// }
271/// # Ok(()) }
272/// ```
273pub struct Walk {
274    inner: IgnoreWalk,
275    include_dir_entries: bool,
276    root: Arc<PathBuf>,
277}
278
279impl Iterator for Walk {
280    type Item = crate::Result<WalkEntry>;
281
282    fn next(&mut self) -> Option<Self::Item> {
283        match self.next_internal() {
284            Err(e) => Some(Err(e)),
285            Ok(None) => None,
286            Ok(Some(e)) => Some(Ok(e)),
287        }
288    }
289}
290
291impl Walk {
292    fn next_internal(&mut self) -> crate::Result<Option<WalkEntry>> {
293        let (entry, file_type) = loop {
294            let entry = crate::some!(self.inner.next());
295            let entry = crate::check!(entry, "walk: failed to read the next entry")?;
296            if entry.depth() == 0 {
297                // skip root node
298                continue;
299            }
300            match entry.file_type() {
301                None => {
302                    // stdin
303                    continue;
304                }
305                Some(t) => {
306                    if self.include_dir_entries {
307                        break (entry, t);
308                    }
309                    // filter out dir entries
310                    if t.is_file() {
311                        break (entry, t);
312                    }
313                    if t.is_dir() {
314                        crate::trace!(
315                            "walk: not emitting entry for directory: '{}'",
316                            entry.path().display()
317                        );
318                        continue;
319                    }
320                    if t.is_symlink() && entry.path().is_dir() {
321                        crate::trace!(
322                            "walk: not emitting entry for symlinked directory: '{}'",
323                            entry.path().display()
324                        );
325                        continue;
326                    }
327                    crate::trace!(
328                        "walk: skipping entry with unknown file type: '{}'",
329                        entry.path().display()
330                    );
331                    continue;
332                }
333            }
334        };
335        Ok(Some(WalkEntry {
336            root: Arc::clone(&self.root),
337            inner: entry,
338            file_type,
339        }))
340    }
341}
342
343/// A single entry produced by a [`Walk`].
344///
345/// Provides access to the entry's [`path`](Self::path), its
346/// [`depth`](Self::depth) relative to the walk root, its
347/// [`file_type`](Self::file_type), and lazily-read [`metadata`](Self::metadata).
348#[derive(Debug)]
349pub struct WalkEntry {
350    root: Arc<PathBuf>,
351    inner: IgnoreDirEntry,
352    file_type: FileType,
353}
354
355impl WalkEntry {
356    /// Get the root of the walk
357    #[inline(always)]
358    pub fn root(&self) -> &Path {
359        &self.root
360    }
361
362    /// Get the depth of this entry, `0` is root, `1` is an entry in the root, etc.
363    pub fn depth(&self) -> usize {
364        self.inner.depth()
365    }
366
367    /// Get the path by joining the walk root and the relative
368    /// path of the entry
369    #[inline(always)]
370    pub fn path(&self) -> &Path {
371        self.inner.path()
372    }
373
374    /// Get this entry's path relative to the walk root, without a leading `./`.
375    ///
376    /// Returns `Ok(None)` when the entry *is* the root of the walk (only emitted
377    /// when [`include_dir_entries`](WalkBuilder::include_dir_entries) is
378    /// enabled). Returns an error if the entry path is unexpectedly not
379    /// contained within the walk root.
380    ///
381    /// ```rust,no_run
382    /// # use pistonite_cu as cu;
383    /// # fn print_relative() -> cu::Result<()> {
384    /// for entry in cu::fs::walk("src")? {
385    ///     let entry = entry?;
386    ///     cu::info!("{}", entry.rel_path()?.display());
387    /// }
388    /// # Ok(()) }
389    /// ```
390    pub fn rel_path(&self) -> cu::Result<PathBuf> {
391        // ensure root is a prefix of inner path
392        let root_norm = self.root.normalize()?;
393        // note we cannot normalize the path after join since it might be a symlink
394        let path_norm = root_norm.join(self.inner.path());
395        let root_iter = root_norm
396            .components()
397            .filter(|x| !matches!(x, Component::CurDir));
398        let mut path_iter = path_norm
399            .components()
400            .filter(|x| !matches!(x, Component::CurDir));
401        for root_comp in root_iter {
402            let path_comp = cu::check!(
403                path_iter.next(),
404                "unexpected: walk entry path is shorter than root"
405            )?;
406            cu::ensure!(
407                root_comp == path_comp,
408                "unexpected: walk entry path is not in root"
409            )?;
410        }
411        let Some(next) = path_iter.next() else {
412            cu::bail!("unexpected: walk entry path is same as root");
413        };
414        let mut p = PathBuf::new();
415        p.push(next);
416        p.extend(path_iter);
417        Ok(p)
418    }
419
420    /// Get the file type
421    #[inline(always)]
422    pub fn file_type(&self) -> FileType {
423        self.file_type
424    }
425
426    /// Check if the entry is a file.
427    #[inline(always)]
428    pub fn is_file(&self) -> bool {
429        self.file_type.is_file()
430    }
431
432    /// Check if the entry is a directory.
433    #[inline(always)]
434    pub fn is_dir(&self) -> bool {
435        self.file_type.is_dir()
436    }
437
438    /// Check if the entry is a symlink.
439    #[inline(always)]
440    pub fn is_symlink(&self) -> bool {
441        self.file_type.is_symlink()
442    }
443
444    /// Get the file name
445    #[inline(always)]
446    pub fn file_name(&self) -> Option<&OsStr> {
447        self.inner.path().file_name()
448    }
449
450    /// Get the entry metadata
451    pub fn metadata(&self) -> crate::Result<Metadata> {
452        crate::check!(
453            self.inner.metadata(),
454            "failed to get metadata for file '{}' while walking directory '{}'",
455            self.inner.path().try_to_rel_from(&*self.root).display(),
456            self.root.display()
457        )
458    }
459}