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/// What a full walk found: the parseable files, plus what was excluded on
123/// purpose and what gonzalo has no grammar for.
124pub struct SourceFiles {
125    /// Parseable files with their language, sorted by path.
126    pub files: Vec<(PathBuf, Language)>,
127    pub ignored: IgnoredCounts,
128    pub unindexed: UnindexedCounts,
129}
130
131/// Supported source files under `dir` with their [`Language`], sorted by path,
132/// alongside a count of what was deliberately excluded and of what gonzalo has
133/// no grammar for.
134///
135/// Applies `filter` plus, when `dir` is a git repository, `.gitignore`.
136pub fn source_files(dir: &Path, filter: &IndexFilter) -> Result<SourceFiles> {
137    // Opened once for the whole walk; `is_path_ignored` is a pure query so the
138    // repository is never mutated. A non-git tree simply gets no gitignore rules.
139    let repo = git2::Repository::open(dir).ok();
140    let mut out = Vec::new();
141    let mut ignored = IgnoredCounts::default();
142    let mut unindexed = UnindexedCounts::default();
143    source_files_inner(
144        dir,
145        dir,
146        repo.as_ref(),
147        filter,
148        &mut out,
149        &mut ignored,
150        &mut unindexed,
151    )?;
152    out.sort_by(|a, b| a.0.cmp(&b.0));
153    Ok(SourceFiles {
154        files: out,
155        ignored,
156        unindexed,
157    })
158}
159
160/// Of `paths`, those the current rules would no longer admit under the tree at
161/// `root` — applying both the path-only rules and, when `root` is a git
162/// repository, `.gitignore`.
163///
164/// This is what makes an existing view self-healing when the rules tighten. The
165/// incremental driver carries unchanged paths forward untouched, and a vendored
166/// bundle never changes, so it never appears in a git diff and is never
167/// reconsidered. Gitignore has to be part of the check: `docs/guide/book/` is
168/// build output excluded by `.gitignore` rather than by any directory-name rule,
169/// so a path-only prune leaves it behind.
170pub fn stale_entries<'a>(
171    root: &Path,
172    filter: &IndexFilter,
173    paths: impl Iterator<Item = &'a str>,
174) -> Vec<String> {
175    let repo = git2::Repository::open(root).ok();
176    paths
177        .filter(|rel| !filter.is_indexable(rel) || git_ignores(repo.as_ref(), rel))
178        .map(str::to_string)
179        .collect()
180}
181
182/// Whether git considers `rel` ignored. Errors are treated as "not ignored" so a
183/// malformed ignore file cannot silently empty a view.
184fn git_ignores(repo: Option<&git2::Repository>, rel: &str) -> bool {
185    repo.is_some_and(|r| r.is_path_ignored(Path::new(rel)).unwrap_or(false))
186}
187
188/// Files seen during a walk but not parsed, because their extension names no
189/// language gonzalo knows.
190///
191/// Deliberately distinct from [`IgnoredCounts`] (excluded on purpose) and from
192/// the summary's `skipped` (a parse failure). Without it, indexing a tree
193/// gonzalo cannot read reported every count at zero and said nothing — so an
194/// empty view was indistinguishable from an empty repository (#259).
195#[derive(Debug, Clone, Default, PartialEq, Eq)]
196pub struct UnindexedCounts {
197    /// How many such files were seen.
198    pub files: usize,
199    by_extension: std::collections::BTreeMap<String, usize>,
200}
201
202impl UnindexedCounts {
203    /// Record a file gonzalo has no grammar for. `extension` is `None` for an
204    /// extensionless file, which is counted but names nothing.
205    pub fn record(&mut self, extension: Option<&str>) {
206        self.files += 1;
207        if let Some(extension) = extension {
208            *self.by_extension.entry(extension.to_string()).or_default() += 1;
209        }
210    }
211
212    /// The extensions responsible, most common first and alphabetical within a
213    /// tie, so a report can name them rather than print a bare number.
214    pub fn extensions(&self) -> Vec<(String, usize)> {
215        let mut out: Vec<(String, usize)> = self
216            .by_extension
217            .iter()
218            .map(|(extension, count)| (extension.clone(), *count))
219            .collect();
220        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
221        out
222    }
223}
224
225fn source_files_inner(
226    root: &Path,
227    dir: &Path,
228    repo: Option<&git2::Repository>,
229    filter: &IndexFilter,
230    out: &mut Vec<(PathBuf, Language)>,
231    ignored: &mut IgnoredCounts,
232    unindexed: &mut UnindexedCounts,
233) -> Result<()> {
234    for entry in std::fs::read_dir(dir)? {
235        let entry = entry?;
236        let ft = entry.file_type()?;
237        let path = entry.path();
238        let rel = path
239            .strip_prefix(root)
240            .unwrap_or(&path)
241            .to_string_lossy()
242            .replace('\\', "/");
243
244        if ft.is_dir() {
245            if !filter.should_descend(&rel) || git_ignores(repo, &rel) {
246                ignored.dirs += 1;
247                continue;
248            }
249            source_files_inner(root, &path, repo, filter, out, ignored, unindexed)?;
250        } else if ft.is_file() {
251            let Some(language) = path
252                .extension()
253                .and_then(|e| e.to_str())
254                .and_then(Language::from_extension)
255            else {
256                // No grammar for it. Counted only when it would otherwise have
257                // been indexed, so a README inside a vendored tree stays as
258                // invisible as it already is (#259).
259                if filter.is_indexable(&rel) && !git_ignores(repo, &rel) {
260                    unindexed.record(path.extension().and_then(|e| e.to_str()));
261                }
262                continue;
263            };
264            if !filter.is_indexable(&rel) || git_ignores(repo, &rel) {
265                ignored.files += 1;
266                continue;
267            }
268            out.push((path, language));
269        }
270    }
271    Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use std::fs;
278    use tempfile::TempDir;
279
280    fn filter() -> IndexFilter {
281        IndexFilter::default()
282    }
283
284    // ---- path-only rules (shared by both drivers) -------------------------
285
286    #[test]
287    fn indexes_ordinary_source_paths() {
288        assert!(filter().is_indexable("crates/gonzalo-cli/src/lib.rs"));
289        assert!(filter().is_indexable("main.rs"));
290    }
291
292    #[test]
293    fn skips_build_and_dependency_directories() {
294        for rel in [
295            "target/debug/foo.rs",
296            "node_modules/left-pad/index.js",
297            "vendor/github.com/x/y.go",
298            "dist/app.js",
299            "build/generated.rs",
300            "third_party/lib/a.c",
301            "x/site-packages/pkg/mod.py",
302        ] {
303            assert!(!filter().is_indexable(rel), "{rel} should be skipped");
304        }
305    }
306
307    #[test]
308    fn skips_hidden_directories() {
309        assert!(!filter().is_indexable(".git/config.rs"));
310        assert!(!filter().is_indexable(".github/scripts/x.py"));
311    }
312
313    #[test]
314    fn matches_directory_names_as_whole_components() {
315        // Substring matches must not trigger: these are real source paths.
316        assert!(filter().is_indexable("rebuild/main.rs"));
317        assert!(filter().is_indexable("distribution/mod.rs"));
318        assert!(filter().is_indexable("vendored_docs/notes.py"));
319    }
320
321    #[test]
322    fn skips_minified_and_generated_files() {
323        for rel in [
324            "docs/guide/mermaid.min.js",
325            "assets/site.min.css",
326            "static/app.bundle.js",
327            "package-lock.json",
328        ] {
329            assert!(!filter().is_indexable(rel), "{rel} should be skipped");
330        }
331    }
332
333    #[test]
334    fn does_not_confuse_a_minified_suffix_with_a_normal_name() {
335        assert!(filter().is_indexable("src/mermaid.js"));
336        assert!(filter().is_indexable("src/bundle.js"));
337    }
338
339    // ---- the --include override -------------------------------------------
340
341    #[test]
342    fn an_include_prefix_readmits_a_vendored_path() {
343        let f = IndexFilter::new(&["vendor/mylib".to_string()]);
344        assert!(f.is_indexable("vendor/mylib/core.go"));
345        // Everything else under vendor/ stays out.
346        assert!(!f.is_indexable("vendor/other/core.go"));
347    }
348
349    #[test]
350    fn an_include_prefix_readmits_a_single_file() {
351        let f = IndexFilter::new(&["docs/guide/mermaid.min.js".to_string()]);
352        assert!(f.is_indexable("docs/guide/mermaid.min.js"));
353        assert!(!f.is_indexable("docs/guide/other.min.js"));
354    }
355
356    #[test]
357    fn an_include_prefix_matches_whole_components_only() {
358        let f = IndexFilter::new(&["build/keep".to_string()]);
359        assert!(f.is_indexable("build/keep/a.rs"));
360        // `build/keepsake` merely starts with the prefix string.
361        assert!(!f.is_indexable("build/keepsake/a.rs"));
362    }
363
364    #[test]
365    fn a_trailing_slash_in_an_include_is_tolerated() {
366        let f = IndexFilter::new(&["vendor/mylib/".to_string()]);
367        assert!(f.is_indexable("vendor/mylib/core.go"));
368    }
369
370    // ---- the full walk ----------------------------------------------------
371
372    fn write(dir: &Path, rel: &str, body: &str) {
373        let p = dir.join(rel);
374        fs::create_dir_all(p.parent().unwrap()).unwrap();
375        fs::write(p, body).unwrap();
376    }
377
378    fn rels(files: &[(PathBuf, Language)], root: &Path) -> Vec<String> {
379        files
380            .iter()
381            .map(|(p, _)| {
382                p.strip_prefix(root)
383                    .unwrap()
384                    .to_string_lossy()
385                    .replace('\\', "/")
386            })
387            .collect()
388    }
389
390    #[test]
391    fn walk_skips_vendored_files_in_a_plain_directory_tree() {
392        let dir = TempDir::new().unwrap();
393        write(dir.path(), "src/lib.rs", "fn a() {}");
394        write(dir.path(), "docs/mermaid.min.js", "var a=1;");
395        write(dir.path(), "node_modules/x/index.js", "var b=2;");
396
397        let SourceFiles { files, ignored, .. } = source_files(dir.path(), &filter()).unwrap();
398        assert_eq!(rels(&files, dir.path()), vec!["src/lib.rs"]);
399        assert_eq!(ignored.files, 1, "the .min.js");
400        assert_eq!(ignored.dirs, 1, "node_modules pruned without descending");
401    }
402
403    #[test]
404    fn walk_honours_gitignore_in_a_git_repository() {
405        let dir = TempDir::new().unwrap();
406        let repo = git2::Repository::init(dir.path()).unwrap();
407        write(dir.path(), ".gitignore", "docs/guide/book/\n");
408        write(dir.path(), "src/lib.rs", "fn a() {}");
409        // mdbook output: present on a machine that ran the build, absent otherwise.
410        write(dir.path(), "docs/guide/book/highlight.js", "var a=1;");
411        drop(repo);
412
413        let SourceFiles { files, ignored, .. } = source_files(dir.path(), &filter()).unwrap();
414        assert_eq!(rels(&files, dir.path()), vec!["src/lib.rs"]);
415        // Two pruned directories: the gitignored `docs/guide/book/`, and `.git`
416        // itself via the hidden-directory rule.
417        assert_eq!(ignored.dirs, 2);
418    }
419
420    #[test]
421    fn walk_descends_into_a_skipped_directory_to_reach_an_include() {
422        let dir = TempDir::new().unwrap();
423        write(dir.path(), "vendor/mylib/core.js", "var a=1;");
424        write(dir.path(), "vendor/other/core.js", "var b=2;");
425
426        let f = IndexFilter::new(&["vendor/mylib".to_string()]);
427        let SourceFiles { files, .. } = source_files(dir.path(), &f).unwrap();
428        // `vendor/` is entered only because the include lives under it; its
429        // other children stay excluded.
430        assert_eq!(rels(&files, dir.path()), vec!["vendor/mylib/core.js"]);
431    }
432
433    #[test]
434    fn walk_produces_the_same_view_whether_or_not_build_output_exists() {
435        // The reproducibility criterion from #209: same commit, same graph,
436        // regardless of whether anyone ran the build.
437        let build = |with_output: bool| {
438            let dir = TempDir::new().unwrap();
439            git2::Repository::init(dir.path()).unwrap();
440            write(dir.path(), ".gitignore", "book/\n");
441            write(dir.path(), "src/lib.rs", "fn a() {}");
442            if with_output {
443                write(dir.path(), "book/gen.js", "var a=1;");
444                write(dir.path(), "book/other.js", "var b=2;");
445            }
446            let SourceFiles { files, .. } = source_files(dir.path(), &filter()).unwrap();
447            rels(&files, dir.path())
448        };
449        assert_eq!(build(true), build(false));
450    }
451
452    #[test]
453    fn walk_still_works_without_a_repository() {
454        let dir = TempDir::new().unwrap();
455        write(dir.path(), "a.rs", "fn a() {}");
456        write(dir.path(), "target/b.rs", "fn b() {}");
457        let SourceFiles { files, .. } = source_files(dir.path(), &filter()).unwrap();
458        assert_eq!(rels(&files, dir.path()), vec!["a.rs"]);
459    }
460
461    #[test]
462    fn walk_include_overrides_a_builtin_rule_but_not_gitignore() {
463        let dir = TempDir::new().unwrap();
464        git2::Repository::init(dir.path()).unwrap();
465        write(dir.path(), ".gitignore", "generated/\n");
466        write(dir.path(), "vendor/mylib/core.js", "var a=1;");
467        write(dir.path(), "generated/out.js", "var b=2;");
468
469        let f = IndexFilter::new(&["vendor/mylib".to_string(), "generated".to_string()]);
470        let SourceFiles { files, .. } = source_files(dir.path(), &f).unwrap();
471        // The vendored path is re-admitted; the gitignored one is not, because
472        // reproducibility must not be defeatable by a flag.
473        assert_eq!(rels(&files, dir.path()), vec!["vendor/mylib/core.js"]);
474    }
475
476    // ---- pruning entries a laxer run admitted -----------------------------
477
478    #[test]
479    fn stale_entries_finds_paths_the_builtin_rules_now_reject() {
480        let dir = TempDir::new().unwrap();
481        let paths = ["src/lib.rs", "docs/mermaid.min.js", "node_modules/x/i.js"];
482        let stale = stale_entries(dir.path(), &filter(), paths.into_iter());
483        assert_eq!(stale, vec!["docs/mermaid.min.js", "node_modules/x/i.js"]);
484    }
485
486    #[test]
487    fn stale_entries_finds_gitignored_paths_too() {
488        // `docs/guide/book/` is mdbook output: excluded by .gitignore, and by no
489        // directory-name rule. A path-only prune would leave it in the view.
490        let dir = TempDir::new().unwrap();
491        git2::Repository::init(dir.path()).unwrap();
492        write(dir.path(), ".gitignore", "docs/guide/book/\n");
493        let paths = ["src/lib.rs", "docs/guide/book/highlight.js"];
494        let stale = stale_entries(dir.path(), &filter(), paths.into_iter());
495        assert_eq!(stale, vec!["docs/guide/book/highlight.js"]);
496    }
497
498    #[test]
499    fn stale_entries_keeps_paths_that_are_still_admitted() {
500        let dir = TempDir::new().unwrap();
501        let paths = ["src/lib.rs", "crates/a/src/main.rs"];
502        assert!(stale_entries(dir.path(), &filter(), paths.into_iter()).is_empty());
503    }
504
505    #[test]
506    fn stale_entries_respects_an_include_override() {
507        let dir = TempDir::new().unwrap();
508        let f = IndexFilter::new(&["vendor/mylib".to_string()]);
509        let paths = ["vendor/mylib/core.js", "vendor/other/core.js"];
510        let stale = stale_entries(dir.path(), &f, paths.into_iter());
511        assert_eq!(stale, vec!["vendor/other/core.js"]);
512    }
513
514    #[test]
515    fn walk_ignores_files_with_no_known_language_without_counting_them() {
516        let dir = TempDir::new().unwrap();
517        write(dir.path(), "README.md", "# hi");
518        write(dir.path(), "a.rs", "fn a() {}");
519        let SourceFiles { files, ignored, .. } = source_files(dir.path(), &filter()).unwrap();
520        assert_eq!(rels(&files, dir.path()), vec!["a.rs"]);
521        assert_eq!(ignored.files, 0, "a non-source file is not a skip");
522    }
523}