Skip to main content

gonzalo_cli/
walk.rs

1//! Which files of a source tree enter a view.
2//!
3//! The indexer has two drivers — a full tree walk and a git-diff-driven
4//! incremental pass — and they must agree on membership, or the same commit
5//! yields different graphs depending on which driver ran. [`IndexFilter`] is the
6//! single place that decides, so both call it.
7//!
8//! Three rules, in order of precedence:
9//!
10//! 1. An explicit `--include` prefix re-admits a path the built-in rules would
11//!    drop (for repos that vendor code they genuinely want indexed).
12//! 2. Dependency and build-output directories ([`SKIP_DIRS`]), hidden
13//!    directories, and generated or minified files ([`SKIP_SUFFIXES`]) are
14//!    dropped. These are path-only rules, so they apply to both drivers.
15//! 3. `.gitignore` is honoured when the tree is a git repository. This is what
16//!    makes a view reproducible: indexing build output means the graph depends
17//!    on whether someone happened to run a build, so the same commit produces
18//!    different graphs on different machines (#209). `--include` deliberately
19//!    does *not* override it — see [`IndexFilter::is_indexable`].
20
21use crate::Language;
22use anyhow::Result;
23use std::path::{Path, PathBuf};
24
25/// Directory names that conventionally hold dependencies or build output rather
26/// than source. Matched as whole path components, so `contests/` is unaffected
27/// by `tests`-style entries and `rebuild/` is unaffected by `build`.
28const SKIP_DIRS: &[&str] = &[
29    "target",
30    "node_modules",
31    "vendor",
32    "dist",
33    "build",
34    "site-packages",
35    "third_party",
36];
37
38/// Filename suffixes marking generated or minified artifacts. Minified bundles
39/// are the worst case for a name-matched graph: they are single-letter
40/// identifiers defined hundreds of times, which manufactures ambiguity that does
41/// not exist in the real source (#207).
42const SKIP_SUFFIXES: &[&str] = &[".min.js", ".min.css", ".bundle.js", "-lock.json"];
43
44/// Decides which repo-relative paths are eligible for indexing.
45#[derive(Debug, Clone, Default)]
46pub struct IndexFilter {
47    include: Vec<String>,
48}
49
50impl IndexFilter {
51    /// Build a filter whose `include` entries are repo-relative path prefixes
52    /// that override the built-in skip rules.
53    pub fn new(include: &[String]) -> Self {
54        Self {
55            include: include
56                .iter()
57                .map(|p| p.trim_end_matches('/').replace('\\', "/"))
58                .filter(|p| !p.is_empty())
59                .collect(),
60        }
61    }
62
63    /// Whether an explicit `--include` prefix re-admits `rel`. Matched on whole
64    /// path components so `--include src` does not re-admit `srcgen/`.
65    fn is_included(&self, rel: &str) -> bool {
66        self.include.iter().any(|prefix| {
67            rel == prefix
68                || (rel.len() > prefix.len()
69                    && rel.starts_with(prefix.as_str())
70                    && rel.as_bytes()[prefix.len()] == b'/')
71        })
72    }
73
74    /// Whether `rel` passes the path-only rules: dependency/output directories,
75    /// hidden directories, and generated-file suffixes.
76    ///
77    /// This deliberately says nothing about `.gitignore`. The incremental driver
78    /// does not need it — `git2`'s diff already omits ignored files — and the
79    /// full walk applies it separately in [`source_files`], where a repository
80    /// is open. Keeping gitignore out of the `--include` override also keeps the
81    /// reproducibility guarantee intact: no flag can pull build output into a
82    /// view.
83    pub fn is_indexable(&self, rel: &str) -> bool {
84        if self.is_included(rel) {
85            return true;
86        }
87        if rel
88            .split('/')
89            .any(|part| part.starts_with('.') || SKIP_DIRS.contains(&part))
90        {
91            return false;
92        }
93        let name = rel.rsplit('/').next().unwrap_or(rel);
94        !SKIP_SUFFIXES.iter().any(|suffix| name.ends_with(suffix))
95    }
96
97    /// Whether the walk must descend into directory `rel`.
98    ///
99    /// Wider than [`is_indexable`](Self::is_indexable): a directory that is
100    /// itself skipped must still be entered when an `--include` path lives
101    /// underneath it, or the override can never take effect —
102    /// `--include vendor/mylib` is useless if `vendor/` is pruned first.
103    pub fn should_descend(&self, rel: &str) -> bool {
104        self.is_indexable(rel)
105            || self.include.iter().any(|p| {
106                p.len() > rel.len() && p.starts_with(rel) && p.as_bytes()[rel.len()] == b'/'
107            })
108    }
109}
110
111/// How many paths a walk declined to index.
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub struct IgnoredCounts {
114    /// Source files dropped by a rule (vendored, generated, or gitignored).
115    pub files: usize,
116    /// Directories not descended into at all. Their contents are *not* counted
117    /// in `files`, so a pruned `node_modules` costs one entry here rather than
118    /// a walk of everything inside it.
119    pub dirs: usize,
120}
121
122/// Supported source files under `dir` with their [`Language`], sorted by path,
123/// alongside a count of what was skipped.
124///
125/// Applies `filter` plus, when `dir` is a git repository, `.gitignore`.
126pub fn source_files(
127    dir: &Path,
128    filter: &IndexFilter,
129) -> Result<(Vec<(PathBuf, Language)>, IgnoredCounts)> {
130    // Opened once for the whole walk; `is_path_ignored` is a pure query so the
131    // repository is never mutated. A non-git tree simply gets no gitignore rules.
132    let repo = git2::Repository::open(dir).ok();
133    let mut out = Vec::new();
134    let mut ignored = IgnoredCounts::default();
135    source_files_inner(dir, dir, repo.as_ref(), filter, &mut out, &mut ignored)?;
136    out.sort_by(|a, b| a.0.cmp(&b.0));
137    Ok((out, ignored))
138}
139
140/// Of `paths`, those the current rules would no longer admit under the tree at
141/// `root` — applying both the path-only rules and, when `root` is a git
142/// repository, `.gitignore`.
143///
144/// This is what makes an existing view self-healing when the rules tighten. The
145/// incremental driver carries unchanged paths forward untouched, and a vendored
146/// bundle never changes, so it never appears in a git diff and is never
147/// reconsidered. Gitignore has to be part of the check: `docs/guide/book/` is
148/// build output excluded by `.gitignore` rather than by any directory-name rule,
149/// so a path-only prune leaves it behind.
150pub fn stale_entries<'a>(
151    root: &Path,
152    filter: &IndexFilter,
153    paths: impl Iterator<Item = &'a str>,
154) -> Vec<String> {
155    let repo = git2::Repository::open(root).ok();
156    paths
157        .filter(|rel| !filter.is_indexable(rel) || git_ignores(repo.as_ref(), rel))
158        .map(str::to_string)
159        .collect()
160}
161
162/// Whether git considers `rel` ignored. Errors are treated as "not ignored" so a
163/// malformed ignore file cannot silently empty a view.
164fn git_ignores(repo: Option<&git2::Repository>, rel: &str) -> bool {
165    repo.is_some_and(|r| r.is_path_ignored(Path::new(rel)).unwrap_or(false))
166}
167
168fn source_files_inner(
169    root: &Path,
170    dir: &Path,
171    repo: Option<&git2::Repository>,
172    filter: &IndexFilter,
173    out: &mut Vec<(PathBuf, Language)>,
174    ignored: &mut IgnoredCounts,
175) -> Result<()> {
176    for entry in std::fs::read_dir(dir)? {
177        let entry = entry?;
178        let ft = entry.file_type()?;
179        let path = entry.path();
180        let rel = path
181            .strip_prefix(root)
182            .unwrap_or(&path)
183            .to_string_lossy()
184            .replace('\\', "/");
185
186        if ft.is_dir() {
187            if !filter.should_descend(&rel) || git_ignores(repo, &rel) {
188                ignored.dirs += 1;
189                continue;
190            }
191            source_files_inner(root, &path, repo, filter, out, ignored)?;
192        } else if ft.is_file() {
193            let Some(language) = path
194                .extension()
195                .and_then(|e| e.to_str())
196                .and_then(Language::from_extension)
197            else {
198                continue; // not a source file at all — not "ignored", just irrelevant
199            };
200            if !filter.is_indexable(&rel) || git_ignores(repo, &rel) {
201                ignored.files += 1;
202                continue;
203            }
204            out.push((path, language));
205        }
206    }
207    Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::fs;
214    use tempfile::TempDir;
215
216    fn filter() -> IndexFilter {
217        IndexFilter::default()
218    }
219
220    // ---- path-only rules (shared by both drivers) -------------------------
221
222    #[test]
223    fn indexes_ordinary_source_paths() {
224        assert!(filter().is_indexable("crates/gonzalo-cli/src/lib.rs"));
225        assert!(filter().is_indexable("main.rs"));
226    }
227
228    #[test]
229    fn skips_build_and_dependency_directories() {
230        for rel in [
231            "target/debug/foo.rs",
232            "node_modules/left-pad/index.js",
233            "vendor/github.com/x/y.go",
234            "dist/app.js",
235            "build/generated.rs",
236            "third_party/lib/a.c",
237            "x/site-packages/pkg/mod.py",
238        ] {
239            assert!(!filter().is_indexable(rel), "{rel} should be skipped");
240        }
241    }
242
243    #[test]
244    fn skips_hidden_directories() {
245        assert!(!filter().is_indexable(".git/config.rs"));
246        assert!(!filter().is_indexable(".github/scripts/x.py"));
247    }
248
249    #[test]
250    fn matches_directory_names_as_whole_components() {
251        // Substring matches must not trigger: these are real source paths.
252        assert!(filter().is_indexable("rebuild/main.rs"));
253        assert!(filter().is_indexable("distribution/mod.rs"));
254        assert!(filter().is_indexable("vendored_docs/notes.py"));
255    }
256
257    #[test]
258    fn skips_minified_and_generated_files() {
259        for rel in [
260            "docs/guide/mermaid.min.js",
261            "assets/site.min.css",
262            "static/app.bundle.js",
263            "package-lock.json",
264        ] {
265            assert!(!filter().is_indexable(rel), "{rel} should be skipped");
266        }
267    }
268
269    #[test]
270    fn does_not_confuse_a_minified_suffix_with_a_normal_name() {
271        assert!(filter().is_indexable("src/mermaid.js"));
272        assert!(filter().is_indexable("src/bundle.js"));
273    }
274
275    // ---- the --include override -------------------------------------------
276
277    #[test]
278    fn an_include_prefix_readmits_a_vendored_path() {
279        let f = IndexFilter::new(&["vendor/mylib".to_string()]);
280        assert!(f.is_indexable("vendor/mylib/core.go"));
281        // Everything else under vendor/ stays out.
282        assert!(!f.is_indexable("vendor/other/core.go"));
283    }
284
285    #[test]
286    fn an_include_prefix_readmits_a_single_file() {
287        let f = IndexFilter::new(&["docs/guide/mermaid.min.js".to_string()]);
288        assert!(f.is_indexable("docs/guide/mermaid.min.js"));
289        assert!(!f.is_indexable("docs/guide/other.min.js"));
290    }
291
292    #[test]
293    fn an_include_prefix_matches_whole_components_only() {
294        let f = IndexFilter::new(&["build/keep".to_string()]);
295        assert!(f.is_indexable("build/keep/a.rs"));
296        // `build/keepsake` merely starts with the prefix string.
297        assert!(!f.is_indexable("build/keepsake/a.rs"));
298    }
299
300    #[test]
301    fn a_trailing_slash_in_an_include_is_tolerated() {
302        let f = IndexFilter::new(&["vendor/mylib/".to_string()]);
303        assert!(f.is_indexable("vendor/mylib/core.go"));
304    }
305
306    // ---- the full walk ----------------------------------------------------
307
308    fn write(dir: &Path, rel: &str, body: &str) {
309        let p = dir.join(rel);
310        fs::create_dir_all(p.parent().unwrap()).unwrap();
311        fs::write(p, body).unwrap();
312    }
313
314    fn rels(files: &[(PathBuf, Language)], root: &Path) -> Vec<String> {
315        files
316            .iter()
317            .map(|(p, _)| {
318                p.strip_prefix(root)
319                    .unwrap()
320                    .to_string_lossy()
321                    .replace('\\', "/")
322            })
323            .collect()
324    }
325
326    #[test]
327    fn walk_skips_vendored_files_in_a_plain_directory_tree() {
328        let dir = TempDir::new().unwrap();
329        write(dir.path(), "src/lib.rs", "fn a() {}");
330        write(dir.path(), "docs/mermaid.min.js", "var a=1;");
331        write(dir.path(), "node_modules/x/index.js", "var b=2;");
332
333        let (files, ignored) = source_files(dir.path(), &filter()).unwrap();
334        assert_eq!(rels(&files, dir.path()), vec!["src/lib.rs"]);
335        assert_eq!(ignored.files, 1, "the .min.js");
336        assert_eq!(ignored.dirs, 1, "node_modules pruned without descending");
337    }
338
339    #[test]
340    fn walk_honours_gitignore_in_a_git_repository() {
341        let dir = TempDir::new().unwrap();
342        let repo = git2::Repository::init(dir.path()).unwrap();
343        write(dir.path(), ".gitignore", "docs/guide/book/\n");
344        write(dir.path(), "src/lib.rs", "fn a() {}");
345        // mdbook output: present on a machine that ran the build, absent otherwise.
346        write(dir.path(), "docs/guide/book/highlight.js", "var a=1;");
347        drop(repo);
348
349        let (files, ignored) = source_files(dir.path(), &filter()).unwrap();
350        assert_eq!(rels(&files, dir.path()), vec!["src/lib.rs"]);
351        // Two pruned directories: the gitignored `docs/guide/book/`, and `.git`
352        // itself via the hidden-directory rule.
353        assert_eq!(ignored.dirs, 2);
354    }
355
356    #[test]
357    fn walk_descends_into_a_skipped_directory_to_reach_an_include() {
358        let dir = TempDir::new().unwrap();
359        write(dir.path(), "vendor/mylib/core.js", "var a=1;");
360        write(dir.path(), "vendor/other/core.js", "var b=2;");
361
362        let f = IndexFilter::new(&["vendor/mylib".to_string()]);
363        let (files, _) = source_files(dir.path(), &f).unwrap();
364        // `vendor/` is entered only because the include lives under it; its
365        // other children stay excluded.
366        assert_eq!(rels(&files, dir.path()), vec!["vendor/mylib/core.js"]);
367    }
368
369    #[test]
370    fn walk_produces_the_same_view_whether_or_not_build_output_exists() {
371        // The reproducibility criterion from #209: same commit, same graph,
372        // regardless of whether anyone ran the build.
373        let build = |with_output: bool| {
374            let dir = TempDir::new().unwrap();
375            git2::Repository::init(dir.path()).unwrap();
376            write(dir.path(), ".gitignore", "book/\n");
377            write(dir.path(), "src/lib.rs", "fn a() {}");
378            if with_output {
379                write(dir.path(), "book/gen.js", "var a=1;");
380                write(dir.path(), "book/other.js", "var b=2;");
381            }
382            let (files, _) = source_files(dir.path(), &filter()).unwrap();
383            rels(&files, dir.path())
384        };
385        assert_eq!(build(true), build(false));
386    }
387
388    #[test]
389    fn walk_still_works_without_a_repository() {
390        let dir = TempDir::new().unwrap();
391        write(dir.path(), "a.rs", "fn a() {}");
392        write(dir.path(), "target/b.rs", "fn b() {}");
393        let (files, _) = source_files(dir.path(), &filter()).unwrap();
394        assert_eq!(rels(&files, dir.path()), vec!["a.rs"]);
395    }
396
397    #[test]
398    fn walk_include_overrides_a_builtin_rule_but_not_gitignore() {
399        let dir = TempDir::new().unwrap();
400        git2::Repository::init(dir.path()).unwrap();
401        write(dir.path(), ".gitignore", "generated/\n");
402        write(dir.path(), "vendor/mylib/core.js", "var a=1;");
403        write(dir.path(), "generated/out.js", "var b=2;");
404
405        let f = IndexFilter::new(&["vendor/mylib".to_string(), "generated".to_string()]);
406        let (files, _) = source_files(dir.path(), &f).unwrap();
407        // The vendored path is re-admitted; the gitignored one is not, because
408        // reproducibility must not be defeatable by a flag.
409        assert_eq!(rels(&files, dir.path()), vec!["vendor/mylib/core.js"]);
410    }
411
412    // ---- pruning entries a laxer run admitted -----------------------------
413
414    #[test]
415    fn stale_entries_finds_paths_the_builtin_rules_now_reject() {
416        let dir = TempDir::new().unwrap();
417        let paths = ["src/lib.rs", "docs/mermaid.min.js", "node_modules/x/i.js"];
418        let stale = stale_entries(dir.path(), &filter(), paths.into_iter());
419        assert_eq!(stale, vec!["docs/mermaid.min.js", "node_modules/x/i.js"]);
420    }
421
422    #[test]
423    fn stale_entries_finds_gitignored_paths_too() {
424        // `docs/guide/book/` is mdbook output: excluded by .gitignore, and by no
425        // directory-name rule. A path-only prune would leave it in the view.
426        let dir = TempDir::new().unwrap();
427        git2::Repository::init(dir.path()).unwrap();
428        write(dir.path(), ".gitignore", "docs/guide/book/\n");
429        let paths = ["src/lib.rs", "docs/guide/book/highlight.js"];
430        let stale = stale_entries(dir.path(), &filter(), paths.into_iter());
431        assert_eq!(stale, vec!["docs/guide/book/highlight.js"]);
432    }
433
434    #[test]
435    fn stale_entries_keeps_paths_that_are_still_admitted() {
436        let dir = TempDir::new().unwrap();
437        let paths = ["src/lib.rs", "crates/a/src/main.rs"];
438        assert!(stale_entries(dir.path(), &filter(), paths.into_iter()).is_empty());
439    }
440
441    #[test]
442    fn stale_entries_respects_an_include_override() {
443        let dir = TempDir::new().unwrap();
444        let f = IndexFilter::new(&["vendor/mylib".to_string()]);
445        let paths = ["vendor/mylib/core.js", "vendor/other/core.js"];
446        let stale = stale_entries(dir.path(), &f, paths.into_iter());
447        assert_eq!(stale, vec!["vendor/other/core.js"]);
448    }
449
450    #[test]
451    fn walk_ignores_files_with_no_known_language_without_counting_them() {
452        let dir = TempDir::new().unwrap();
453        write(dir.path(), "README.md", "# hi");
454        write(dir.path(), "a.rs", "fn a() {}");
455        let (files, ignored) = source_files(dir.path(), &filter()).unwrap();
456        assert_eq!(rels(&files, dir.path()), vec!["a.rs"]);
457        assert_eq!(ignored.files, 0, "a non-source file is not a skip");
458    }
459}