1use std::path::Path;
9
10use crate::syntax::registry::REGISTRY;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub enum Kind {
16 Source,
17 Tests,
18 Docs,
19 Config,
20 Build,
21 Generated,
22 Assets,
23 Other,
24}
25
26impl Kind {
27 pub const ALL: [Self; 8] = [
30 Self::Source,
31 Self::Tests,
32 Self::Docs,
33 Self::Config,
34 Self::Build,
35 Self::Generated,
36 Self::Assets,
37 Self::Other,
38 ];
39
40 pub fn label(self) -> &'static str {
41 match self {
42 Self::Source => "Source",
43 Self::Tests => "Tests",
44 Self::Docs => "Docs",
45 Self::Config => "Config",
46 Self::Build => "Build & CI",
47 Self::Generated => "Generated",
48 Self::Assets => "Assets",
49 Self::Other => "Other",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Default)]
56pub struct Rules {
57 overrides: Vec<(Kind, Vec<String>)>,
59}
60
61impl Rules {
62 pub fn new(overrides: Vec<(Kind, Vec<String>)>) -> Self {
63 Self { overrides }
64 }
65
66 pub fn kind(&self, path: &str, declared: Option<Kind>) -> Kind {
69 for (kind, patterns) in &self.overrides {
70 if patterns.iter().any(|pattern| glob_match(pattern, path)) {
71 return *kind;
72 }
73 }
74 declared.unwrap_or_else(|| classify(path))
75 }
76}
77
78pub fn declared(attr: impl Fn(&str) -> bool) -> Option<Kind> {
82 if attr("linguist-generated") || attr("linguist-vendored") {
83 Some(Kind::Generated)
84 } else if attr("linguist-documentation") {
85 Some(Kind::Docs)
86 } else {
87 None
88 }
89}
90
91pub fn classify(path: &str) -> Kind {
93 let lower = path.to_ascii_lowercase();
94 let name = basename(&lower);
95 let ext = extension(name);
96 if generated(&lower, name, ext) {
97 Kind::Generated
98 } else if tests(&lower, name, ext, basename(path)) {
99 Kind::Tests
100 } else if build(&lower, name, ext) {
101 Kind::Build
102 } else if docs(&lower, name, ext) {
103 Kind::Docs
104 } else if config(name, ext) {
105 Kind::Config
106 } else if ASSET_EXTENSIONS.contains(&ext) {
107 Kind::Assets
108 } else if source(path, ext) {
109 Kind::Source
110 } else {
111 Kind::Other
112 }
113}
114
115fn basename(path: &str) -> &str {
116 path.rsplit('/').next().unwrap_or(path)
117}
118
119fn extension(name: &str) -> &str {
122 Path::new(name)
123 .extension()
124 .and_then(std::ffi::OsStr::to_str)
125 .unwrap_or_default()
126}
127
128fn segments(path: &str) -> impl Iterator<Item = &str> {
129 path.split('/')
130}
131
132fn has_segment(path: &str, wanted: &[&str]) -> bool {
133 segments(path).any(|segment| wanted.contains(&segment))
134}
135
136const GENERATED_DIRS: &[&str] = &[
139 "node_modules",
140 "vendor",
141 "third_party",
142 "bower_components",
143 "pods",
144 "godeps",
145 "__generated__",
146 "__pycache__",
147 "generated",
148 "dist",
149 "target",
150 "htmlcov",
151 ".sqlx",
152 ".yarn",
153 ".terraform",
154];
155
156const GENERATED_SUFFIXES: &[&str] = &[
158 ".min.js",
159 ".min.css",
160 ".pb.go",
161 ".pb.cc",
162 ".pb.h",
163 "_pb2.py",
164 "_pb2_grpc.py",
165 "_generated.go",
166 ".generated.cs",
167 ".designer.cs",
168 ".g.dart",
169 ".freezed.dart",
170 ".snap",
171 ".map",
172];
173
174const GENERATED_FILES: &[&str] = &[
175 "go.sum",
176 "package.resolved",
177 "npm-shrinkwrap.json",
178 "packages.lock.json",
179];
180
181fn lockfile(name: &str, ext: &str) -> bool {
185 matches!(ext, "lock" | "lockb") || name.contains("-lock.") || name.contains(".lock.")
186}
187
188fn generated(path: &str, name: &str, ext: &str) -> bool {
189 has_segment(path, GENERATED_DIRS)
190 || lockfile(name, ext)
191 || GENERATED_FILES.contains(&name)
192 || GENERATED_SUFFIXES
193 .iter()
194 .any(|suffix| name.ends_with(suffix))
195}
196
197const TEST_DIRS: &[&str] = &[
198 "test",
199 "tests",
200 "spec",
201 "specs",
202 "__tests__",
203 "__mocks__",
204 "testdata",
205 "e2e",
206 "cypress",
207];
208
209const TEST_AFFIXES: &[&str] = &["_test", "_tests", "_spec", "-test", ".test", ".spec"];
214
215const TEST_CAMEL_AFFIXES: &[&str] = &["Test", "Tests"];
219
220const SPEC_EXTENSIONS: &[&str] = &["scala", "kt", "groovy"];
223
224fn stem<'a>(name: &'a str, ext: &str) -> &'a str {
225 name.strip_suffix(ext)
226 .map_or(name, |rest| rest.trim_end_matches('.'))
227}
228
229fn tests(path: &str, name: &str, ext: &str, raw_name: &str) -> bool {
230 if has_segment(path, TEST_DIRS) {
231 return true;
232 }
233 let lower_stem = stem(name, ext);
234 if lower_stem == "test" || lower_stem == "conftest" || lower_stem.starts_with("test_") {
235 return true;
236 }
237 if TEST_AFFIXES.iter().any(|affix| lower_stem.ends_with(affix)) {
238 return true;
239 }
240 let raw_stem = stem(raw_name, extension(raw_name));
241 TEST_CAMEL_AFFIXES
242 .iter()
243 .any(|affix| raw_stem.ends_with(affix))
244 || (SPEC_EXTENSIONS.contains(&ext) && raw_stem.ends_with("Spec"))
245}
246
247const CI_DIRS: &[&str] = &[".github", ".gitlab", ".forgejo", ".gitea", ".circleci"];
248
249const BUILD_FILES: &[&str] = &[
250 "cargo.toml",
251 "package.json",
252 "go.mod",
253 "pyproject.toml",
254 "setup.py",
255 "setup.cfg",
256 "gemfile",
257 "rakefile",
258 "pom.xml",
259 "build.gradle",
260 "build.gradle.kts",
261 "settings.gradle",
262 "cmakelists.txt",
263 "makefile",
264 "gnumakefile",
265 "justfile",
266 "meson.build",
267 "build",
268 "build.bazel",
269 "workspace",
270 "mix.exs",
271 "build.rs",
272 "jenkinsfile",
273 "flake.nix",
274 "shell.nix",
275 "default.nix",
276 "procfile",
277 "taskfile.yml",
278 ".gitlab-ci.yml",
279 ".travis.yml",
280 "azure-pipelines.yml",
281];
282
283fn build(path: &str, name: &str, ext: &str) -> bool {
284 has_segment(path, CI_DIRS)
285 || BUILD_FILES.contains(&name)
286 || name.starts_with("dockerfile")
287 || name.starts_with("docker-compose.")
288 || name.starts_with("requirements") && ext == "txt"
289 || ext == "nix"
290}
291
292const DOC_DIRS: &[&str] = &["docs", "doc", "man"];
293
294const DOC_EXTENSIONS: &[&str] = &["md", "mdx", "rst", "adoc", "org", "txt", "1"];
295
296const DOC_NAMES: &[&str] = &[
300 "readme",
301 "license",
302 "licence",
303 "copying",
304 "changelog",
305 "contributing",
306 "authors",
307 "notice",
308 "security",
309];
310
311fn docs(path: &str, name: &str, ext: &str) -> bool {
312 has_segment(path, DOC_DIRS)
313 || DOC_EXTENSIONS.contains(&ext)
314 || (ext.is_empty()
315 && DOC_NAMES
316 .iter()
317 .any(|stem| name == *stem || name.starts_with(&format!("{stem}-"))))
318}
319
320const CONFIG_EXTENSIONS: &[&str] = &[
321 "toml",
322 "yaml",
323 "yml",
324 "json",
325 "json5",
326 "jsonc",
327 "ini",
328 "cfg",
329 "conf",
330 "properties",
331 "env",
332 "xml",
333 "plist",
334];
335
336fn config(name: &str, ext: &str) -> bool {
337 CONFIG_EXTENSIONS.contains(&ext) || (name.starts_with('.') && ext.is_empty())
338}
339
340const ASSET_EXTENSIONS: &[&str] = &[
341 "png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "avif", "bmp", "tiff", "woff", "woff2",
342 "ttf", "otf", "eot", "mp3", "mp4", "wav", "ogg", "webm", "mov", "pdf", "zip", "gz", "tar",
343 "bz2", "xz", "7z",
344];
345
346const SOURCE_EXTENSIONS: &[&str] = &[
349 "kt", "kts", "pl", "pm", "r", "jl", "erl", "hrl", "clj", "cljs", "cljc", "fs", "fsi", "fsx",
350 "vb", "groovy", "proto", "graphql", "gql", "vue", "astro", "scss", "sass", "less", "styl",
351 "coffee", "mm", "sol", "v", "vhd", "tcl", "f90",
352];
353
354fn source(path: &str, ext: &str) -> bool {
355 SOURCE_EXTENSIONS.contains(&ext) || REGISTRY.for_path(path).is_some()
356}
357
358fn glob_match(pattern: &str, path: &str) -> bool {
365 let pattern = pattern.trim_start_matches("./").trim_start_matches('/');
366 if let Some(dir) = pattern.strip_suffix('/') {
367 return glob_match(&format!("{dir}/**"), path);
368 }
369 if pattern.contains('/') {
370 let pattern: Vec<&str> = segments(pattern).collect();
371 let path: Vec<&str> = segments(path).collect();
372 match_segments(&pattern, &path)
373 } else {
374 match_segment(pattern, basename(path))
375 }
376}
377
378fn match_segments(pattern: &[&str], path: &[&str]) -> bool {
379 let Some((head, rest)) = pattern.split_first() else {
380 return path.is_empty();
381 };
382 if *head == "**" {
383 return (0..=path.len()).any(|skip| {
384 path.get(skip..)
385 .is_some_and(|tail| match_segments(rest, tail))
386 });
387 }
388 match path.split_first() {
389 Some((segment, tail)) if match_segment(head, segment) => match_segments(rest, tail),
390 _ => false,
391 }
392}
393
394fn match_segment(pattern: &str, text: &str) -> bool {
396 let pattern: Vec<char> = pattern.chars().collect();
397 let text: Vec<char> = text.chars().collect();
398 let (mut p, mut t) = (0usize, 0usize);
399 let (mut star, mut retry) = (None, 0usize);
400 while t < text.len() {
401 match pattern.get(p) {
402 Some('*') => {
403 star = Some(p);
404 retry = t;
405 p += 1;
406 }
407 Some('?') => {
408 p += 1;
409 t += 1;
410 }
411 Some(c) if Some(c) == text.get(t) => {
412 p += 1;
413 t += 1;
414 }
415 _ => match star {
416 Some(at) => {
417 p = at + 1;
418 retry += 1;
419 t = retry;
420 }
421 None => return false,
422 },
423 }
424 }
425 pattern
426 .get(p..)
427 .is_none_or(|rest| rest.iter().all(|c| *c == '*'))
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[track_caller]
435 fn assert_kind(path: &str, expected: Kind) {
436 assert_eq!(classify(path), expected, "{path}");
437 }
438
439 #[test]
440 fn code_lands_in_source() {
441 assert_kind("crates/diffler/src/ui/diff.rs", Kind::Source);
442 assert_kind("app/main.py", Kind::Source);
443 assert_kind("web/src/App.tsx", Kind::Source);
444 assert_kind("cmd/server/main.go", Kind::Source);
445 assert_kind("android/Main.kt", Kind::Source);
446 assert_kind("api/schema.proto", Kind::Source);
447 assert_kind("web/styles/app.scss", Kind::Source);
448 }
449
450 #[test]
451 fn every_language_convention_for_a_test_is_recognised() {
452 for path in [
453 "tests/e2e/test_status.py",
454 "src/foo_test.go",
455 "src/foo_test.rs",
456 "web/src/App.test.tsx",
457 "web/src/App.spec.ts",
458 "src/UserTest.java",
459 "src/UserTests.cs",
460 "spec/models/user_spec.rb",
461 "test/support/helper.ex",
462 "testdata/golden.json",
463 "conftest.py",
464 "cypress/e2e/login.cy.js",
465 ] {
466 assert_kind(path, Kind::Tests);
467 }
468 }
469
470 #[test]
471 fn a_word_that_ends_in_test_is_not_a_test() {
472 assert_kind("src/latest.rs", Kind::Source);
473 assert_kind("src/protest.rs", Kind::Source);
474 assert_kind("src/contests.rs", Kind::Source);
475 assert_kind("src/manifest.rs", Kind::Source);
476 assert_kind("web/src/OpenApiSpec.ts", Kind::Source);
477 assert_kind("src/main/scala/UserSpec.scala", Kind::Tests);
478 }
479
480 #[test]
481 fn a_source_file_named_like_a_doc_stays_source() {
482 assert_kind("src/security.rs", Kind::Source);
483 assert_kind("app/models/license.rb", Kind::Source);
484 assert_kind("pkg/changelog.go", Kind::Source);
485 assert_kind("LICENSE-MIT", Kind::Docs);
486 assert_kind("AUTHORS", Kind::Docs);
487 }
488
489 #[test]
490 fn generated_output_and_lockfiles_are_one_bucket() {
491 for path in [
492 "Cargo.lock",
493 "pnpm-lock.yaml",
494 "go.sum",
495 "node_modules/left-pad/index.js",
496 "vendor/github.com/pkg/errors/errors.go",
497 "api/service.pb.go",
498 "api/service_pb2.py",
499 "web/dist/bundle.min.js",
500 "crates/diffler/src/ui/snapshots/a_pane.snap",
501 "target/debug/build.rs",
502 ] {
503 assert_kind(path, Kind::Generated);
504 }
505 }
506
507 #[test]
508 fn generated_outranks_tests_so_a_snapshot_is_not_a_test() {
509 assert_kind("tests/snapshots/render.snap", Kind::Generated);
510 assert_kind("tests/fixtures/node_modules/dep/index.js", Kind::Generated);
511 }
512
513 #[test]
514 fn manifests_and_pipelines_are_build() {
515 for path in [
516 "Cargo.toml",
517 "package.json",
518 "go.mod",
519 "pyproject.toml",
520 "Makefile",
521 "justfile",
522 "Dockerfile",
523 "docker-compose.yml",
524 ".github/workflows/ci.yml",
525 ".forgejo/workflows/ci.yml",
526 ".gitlab-ci.yml",
527 "flake.nix",
528 "requirements.txt",
529 "crates/diffler/build.rs",
530 ] {
531 assert_kind(path, Kind::Build);
532 }
533 }
534
535 #[test]
536 fn prose_is_docs_wherever_it_sits() {
537 assert_kind("README.md", Kind::Docs);
538 assert_kind("LICENSE", Kind::Docs);
539 assert_kind("CHANGELOG.md", Kind::Docs);
540 assert_kind("docs/config.example.toml", Kind::Docs);
541 assert_kind("notes/design.rst", Kind::Docs);
542 }
543
544 #[test]
545 fn settings_are_config_and_binaries_are_assets() {
546 assert_kind(".diffler/config.toml", Kind::Config);
547 assert_kind(".gitignore", Kind::Config);
548 assert_kind("tsconfig.json", Kind::Config);
549 assert_kind("assets/demo.gif", Kind::Assets);
550 assert_kind("showcase/img/theme.png", Kind::Assets);
551 }
552
553 #[test]
554 fn an_unreadable_extension_falls_through_to_other() {
555 assert_kind("data/model.bin", Kind::Other);
556 assert_kind("weird", Kind::Other);
557 }
558
559 #[test]
560 fn a_reader_glob_outranks_the_table_and_the_repo() {
561 let rules = Rules::new(vec![
562 (Kind::Tests, vec!["e2e/**".to_owned()]),
563 (
564 Kind::Docs,
565 vec!["notes/**".to_owned(), "*_note.rs".to_owned()],
566 ),
567 ]);
568 assert_eq!(rules.kind("e2e/harness.rs", None), Kind::Tests);
569 assert_eq!(rules.kind("notes/plan.rs", None), Kind::Docs);
570 assert_eq!(rules.kind("src/a_note.rs", None), Kind::Docs);
571 assert_eq!(rules.kind("src/lib.rs", None), Kind::Source);
572 assert_eq!(
573 rules.kind("e2e/harness.rs", Some(Kind::Generated)),
574 Kind::Tests,
575 "the reader's own globs win over the repo's attributes"
576 );
577 }
578
579 #[test]
580 fn the_repo_attributes_outrank_the_table() {
581 let rules = Rules::default();
582 assert_eq!(
583 rules.kind("src/lib.rs", Some(Kind::Generated)),
584 Kind::Generated
585 );
586 assert_eq!(rules.kind("src/lib.rs", None), Kind::Source);
587 }
588
589 #[test]
590 fn globs_respect_segment_boundaries() {
591 assert!(glob_match("src/*.rs", "src/lib.rs"));
592 assert!(!glob_match("src/*.rs", "src/app/lib.rs"));
593 assert!(glob_match("src/**/*.rs", "src/app/diff/lib.rs"));
594 assert!(glob_match("src/**", "src/a/b/c.rs"));
595 assert!(glob_match("src/**", "src"));
596 assert!(glob_match("*.rs", "deep/nested/lib.rs"));
597 assert!(!glob_match("*.rs", "deep/lib.rsx"));
598 assert!(glob_match("a*b*c.rs", "axxbyyc.rs"));
599 assert!(!glob_match("a*b*c.rs", "axxbyy.rs"));
600 assert!(glob_match("t?st.rs", "test.rs"));
601 assert!(!glob_match("t?st.rs", "toast.rs"), "? is exactly one char");
602 }
603
604 #[test]
605 fn globs_honour_the_shapes_a_gitignore_reader_writes() {
606 assert!(glob_match("/src/**", "src/lib.rs"), "leading slash anchors");
607 assert!(glob_match("/src/*.rs", "src/lib.rs"));
608 assert!(
609 glob_match("src/", "src/a/b.rs"),
610 "trailing slash is a subtree"
611 );
612 assert!(!glob_match("src/", "srcx/a.rs"));
613 assert!(glob_match("**/fixtures/**", "crates/x/fixtures/a.json"));
614 }
615}