Skip to main content

whycodes_index/
walk.rs

1//! `ignore`-crate walker: gitignore-aware, policy-pruned, capped, cancellable.
2//!
3//! The walker never follows symlinks (confinement: a scanned tree cannot
4//! enumerate paths outside its root) and applies gitignore rules even outside
5//! git checkouts (`require_git(false)`), mirroring ripgrep defaults.
6
7use std::path::Path;
8use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9
10use ignore::{DirEntry, WalkBuilder, WalkState};
11
12use crate::policy;
13
14pub(crate) fn allow_dir_entry(depth: usize, name: &str, is_dir: bool) -> bool {
15    if depth == 0 {
16        return true;
17    }
18    if is_dir {
19        !policy::is_pruned_dir(name)
20    } else {
21        !policy::is_pruned_file(name)
22    }
23}
24
25#[cfg(test)]
26pub(crate) fn rel_is_empty(rel: &str) -> bool {
27    rel.is_empty()
28}
29
30#[cfg(test)]
31pub(crate) fn entry_is_err<T, E>(r: &Result<T, E>) -> bool {
32    r.is_err()
33}
34
35/// One discovered entry, root-relative with `/` separators.
36#[derive(Debug, Clone)]
37pub struct WalkEntry {
38    /// Relative path, e.g. `crates/tui/src/app.rs`.
39    pub rel: Box<str>,
40    /// True for directories (symlinks are reported as non-dirs).
41    pub is_dir: bool,
42    /// File size in bytes (0 for dirs and symlinks).
43    pub size: u64,
44}
45
46/// Outcome of a walk.
47#[derive(Debug, Default, Clone, Copy)]
48pub struct WalkStats {
49    /// Entries delivered to the sink.
50    pub scanned: usize,
51    /// True when the walk stopped early at the entry cap.
52    pub truncated: bool,
53}
54
55/// Walk `root` recursively, invoking `on_entry` for every accepted entry.
56///
57/// `on_entry` may be called from multiple worker threads (it must be `Sync`).
58/// `scanned` is incremented per delivered entry (drives progress UIs).
59/// The walk stops early when `cancel` is set or `max_entries` is reached.
60pub fn walk_root(
61    root: &Path,
62    threads: usize,
63    max_entries: usize,
64    scanned: &AtomicUsize,
65    cancel: &AtomicBool,
66    on_entry: &(dyn Fn(WalkEntry) + Sync),
67) -> WalkStats {
68    walk_root_limited(
69        root,
70        threads,
71        max_entries,
72        usize::MAX,
73        scanned,
74        cancel,
75        on_entry,
76    )
77}
78
79/// Like [`walk_root`] with a directory-depth cap (`max_depth`, 1 = children of
80/// `root` only). Used by the `list` tool's recursive mode.
81pub fn walk_root_limited(
82    root: &Path,
83    threads: usize,
84    max_entries: usize,
85    max_depth: usize,
86    scanned: &AtomicUsize,
87    cancel: &AtomicBool,
88    on_entry: &(dyn Fn(WalkEntry) + Sync),
89) -> WalkStats {
90    let mut builder = WalkBuilder::new(root);
91    builder
92        .hidden(false) // hidden entries are filtered by our policy (whitelisted ones stay)
93        .follow_links(false)
94        .git_ignore(true)
95        .git_global(true)
96        .git_exclude(true)
97        .ignore(true)
98        .require_git(false)
99        .max_depth(if max_depth == usize::MAX {
100            None
101        } else {
102            Some(max_depth)
103        })
104        .threads(threads.max(1));
105
106    // `filter_entry` drives descent control in both serial and parallel mode:
107    // a pruned directory is never descended into.
108    builder.filter_entry(|entry| {
109        allow_dir_entry(
110            entry.depth(),
111            &entry.file_name().to_string_lossy(),
112            entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false),
113        )
114    });
115
116    // Root (depth 0) is always entered; hidden/pruned names are skipped.
117
118    let accept = |entry: &DirEntry| -> Option<WalkEntry> {
119        if entry.depth() == 0 {
120            return None; // the root itself is not an entry
121        }
122        let ft = entry.file_type()?;
123        let is_symlink = ft.is_symlink();
124        let is_dir = ft.is_dir() && !is_symlink;
125        let rel = entry.path().strip_prefix(root).ok()?;
126        let rel = rel.to_string_lossy().replace('\\', "/");
127        let size = if is_dir || is_symlink {
128            0
129        } else {
130            entry.metadata().map(|m| m.len()).unwrap_or(0)
131        };
132        Some(WalkEntry {
133            rel: rel.into_boxed_str(),
134            is_dir,
135            size,
136        })
137    };
138
139    let delivered = AtomicUsize::new(0);
140    let truncated = AtomicBool::new(false);
141
142    let visit = |entry: Result<DirEntry, ignore::Error>| -> WalkState {
143        if cancel.load(Ordering::Relaxed) {
144            return WalkState::Quit;
145        }
146        let Some(entry) = entry.ok() else {
147            return WalkState::Continue;
148        };
149        let Some(we) = accept(&entry) else {
150            return WalkState::Continue;
151        };
152        // Check-then-inc: parallel walkers may overshoot the cap by a few
153        // entries — acceptable for a guard rail, exact in serial mode.
154        if delivered.load(Ordering::Relaxed) >= max_entries {
155            truncated.store(true, Ordering::Relaxed);
156            return WalkState::Quit;
157        }
158        delivered.fetch_add(1, Ordering::Relaxed);
159        scanned.fetch_add(1, Ordering::Relaxed);
160        on_entry(we);
161        WalkState::Continue
162    };
163
164    if threads > 1 {
165        let visit = &visit;
166        builder.build_parallel().run(|| Box::new(visit));
167    } else {
168        // Serial fallback: `Walk` spawns no threads of its own.
169        let visit = &visit;
170        for entry in builder.build() {
171            if matches!(visit(entry), WalkState::Quit) {
172                break;
173            }
174        }
175    }
176
177    WalkStats {
178        scanned: delivered.load(Ordering::Relaxed),
179        truncated: truncated.load(Ordering::Relaxed),
180    }
181}
182
183#[cfg(test)]
184#[path = "walk_tests.rs"]
185mod tests;