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