Skip to main content

greplm_core/
walk.rs

1//! Filesystem traversal.
2//!
3//! Uses ripgrep's `ignore` crate, which honors `.gitignore`/`.ignore`, prunes
4//! ignored directories early, and uses `d_type` from `getdents64` to avoid an
5//! extra `stat` per entry. We walk sequentially (the walk is rarely the
6//! bottleneck) and feed the resulting file list to a parallel read/parse stage.
7
8use ignore::overrides::OverrideBuilder;
9use ignore::{WalkBuilder, WalkState};
10use std::fs::Metadata;
11use std::path::PathBuf;
12use std::sync::mpsc::channel;
13
14use crate::config::Config;
15use crate::error::{Error, Result};
16use crate::paths::Paths;
17
18/// A file discovered during the walk.
19#[derive(Debug, Clone)]
20pub struct WalkEntry {
21    /// Absolute path on disk.
22    pub path: PathBuf,
23    /// Path relative to the project root, using `/` separators.
24    pub rel: String,
25    pub metadata: Metadata,
26}
27
28/// Why a file `grep` would have searched was left out of the index. Used to make
29/// exclusions visible instead of silently dropping files. Note that files pruned
30/// by `.gitignore`/hidden rules are removed inside the `ignore` crate before the
31/// closure runs and are therefore *not* itemized here (by design — that pruning
32/// is the configured intent, not a surprise).
33#[derive(
34    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
35)]
36#[serde(rename_all = "snake_case")]
37pub enum SkipReason {
38    /// Larger than `max_file_size`.
39    TooLarge,
40    /// Zero-byte file (and `index_empty` is off).
41    Empty,
42    /// Contains NUL bytes (and `index_binary` is off).
43    Binary,
44    /// The file could not be read.
45    ReadError,
46    /// The directory walk reported an error for this entry.
47    WalkError,
48    /// `stat`/metadata lookup failed.
49    StatError,
50}
51
52impl SkipReason {
53    pub fn as_str(self) -> &'static str {
54        match self {
55            SkipReason::TooLarge => "too_large",
56            SkipReason::Empty => "empty",
57            SkipReason::Binary => "binary",
58            SkipReason::ReadError => "read_error",
59            SkipReason::WalkError => "walk_error",
60            SkipReason::StatError => "stat_error",
61        }
62    }
63}
64
65/// A file that was skipped, with the reason.
66#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67pub struct Skipped {
68    /// Path relative to the project root (best-effort; falls back to the
69    /// absolute path when it can't be made relative).
70    pub rel: String,
71    pub reason: SkipReason,
72}
73
74/// The product of a walk: the indexable entries plus the files that were
75/// skipped during traversal (size/empty/error). Binary and read-error skips are
76/// detected later, in the indexer's read stage.
77#[derive(Debug, Default)]
78pub struct WalkResult {
79    pub entries: Vec<WalkEntry>,
80    pub skipped: Vec<Skipped>,
81}
82
83/// Walk the project root and return all candidate text files plus skip records.
84pub fn walk(paths: &Paths, config: &Config) -> Result<WalkResult> {
85    let mut overrides = OverrideBuilder::new(&paths.root);
86    for pat in &config.include {
87        overrides
88            .add(pat)
89            .map_err(|e| Error::other(format!("bad include glob {pat:?}: {e}")))?;
90    }
91    for pat in &config.exclude {
92        overrides
93            .add(&format!("!{pat}"))
94            .map_err(|e| Error::other(format!("bad exclude glob {pat:?}: {e}")))?;
95    }
96    let overrides = overrides
97        .build()
98        .map_err(|e| Error::other(format!("invalid overrides: {e}")))?;
99
100    let mut builder = WalkBuilder::new(&paths.root);
101    builder
102        .hidden(!config.index_hidden)
103        .git_ignore(config.respect_gitignore)
104        .git_global(config.respect_gitignore)
105        .git_exclude(config.respect_gitignore)
106        .ignore(config.respect_gitignore)
107        .parents(config.respect_gitignore)
108        .overrides(overrides)
109        .follow_links(false);
110
111    // Parallel walk: worker threads send entries (and skip records) over
112    // channels so traversal overlaps with downstream processing and uses all
113    // cores.
114    let (tx, rx) = channel::<WalkEntry>();
115    let (stx, srx) = channel::<Skipped>();
116    let root = paths.root.clone();
117    // `0` means "no size cap" (grep parity).
118    let max_size = config.max_file_size;
119    let index_empty = config.index_empty;
120    builder.build_parallel().run(|| {
121        let tx = tx.clone();
122        let stx = stx.clone();
123        let root = root.clone();
124        let rel_of = move |p: &std::path::Path| match p.strip_prefix(&root) {
125            Ok(r) => r.to_string_lossy().replace('\\', "/"),
126            Err(_) => p.to_string_lossy().to_string(),
127        };
128        Box::new(move |result| {
129            let dent = match result {
130                Ok(d) => d,
131                Err(e) => {
132                    // `ignore::Error` doesn't reliably expose a path; keep the
133                    // message so the skip is still attributable.
134                    let _ = stx.send(Skipped {
135                        rel: format!("<walk error: {e}>"),
136                        reason: SkipReason::WalkError,
137                    });
138                    return WalkState::Continue;
139                }
140            };
141            match dent.file_type() {
142                Some(ft) if ft.is_file() => {}
143                _ => return WalkState::Continue,
144            }
145            let metadata = match dent.metadata() {
146                Ok(m) => m,
147                Err(_) => {
148                    let _ = stx.send(Skipped {
149                        rel: rel_of(dent.path()),
150                        reason: SkipReason::StatError,
151                    });
152                    return WalkState::Continue;
153                }
154            };
155            let path = dent.into_path();
156            let rel = rel_of(&path);
157            let len = metadata.len();
158            if len == 0 {
159                if !index_empty {
160                    let _ = stx.send(Skipped {
161                        rel,
162                        reason: SkipReason::Empty,
163                    });
164                }
165                return WalkState::Continue;
166            }
167            if max_size != 0 && len > max_size {
168                let _ = stx.send(Skipped {
169                    rel,
170                    reason: SkipReason::TooLarge,
171                });
172                return WalkState::Continue;
173            }
174            let _ = tx.send(WalkEntry {
175                path,
176                rel,
177                metadata,
178            });
179            WalkState::Continue
180        })
181    });
182    drop(tx);
183    drop(stx);
184    Ok(WalkResult {
185        entries: rx.into_iter().collect(),
186        skipped: srx.into_iter().collect(),
187    })
188}