1use crate::Language;
22use anyhow::Result;
23use std::path::{Path, PathBuf};
24
25const SKIP_DIRS: &[&str] = &[
29 "target",
30 "node_modules",
31 "vendor",
32 "dist",
33 "build",
34 "site-packages",
35 "third_party",
36];
37
38const SKIP_SUFFIXES: &[&str] = &[".min.js", ".min.css", ".bundle.js", "-lock.json"];
43
44#[derive(Debug, Clone, Default)]
46pub struct IndexFilter {
47 include: Vec<String>,
48}
49
50impl IndexFilter {
51 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 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 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub struct IgnoredCounts {
114 pub files: usize,
116 pub dirs: usize,
120}
121
122pub struct SourceFiles {
125 pub files: Vec<(PathBuf, Language)>,
127 pub ignored: IgnoredCounts,
128 pub unindexed: UnindexedCounts,
129}
130
131pub fn source_files(dir: &Path, filter: &IndexFilter) -> Result<SourceFiles> {
137 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
160pub 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
182fn 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
196pub struct UnindexedCounts {
197 pub files: usize,
199 by_extension: std::collections::BTreeMap<String, usize>,
200}
201
202impl UnindexedCounts {
203 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 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 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 #[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 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 #[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 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 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 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 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 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 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 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 assert_eq!(rels(&files, dir.path()), vec!["vendor/mylib/core.js"]);
474 }
475
476 #[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 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}