Skip to main content

diffler_core/
classify.rs

1//! Which bucket a changed file belongs to, from its path alone. Rule order
2//! matters: Generated outranks Tests so a generated fixture reads as noise,
3//! and Build outranks Config so `Cargo.toml` reads as a manifest.
4
5use 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    /// Sidebar display order.
23    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/// The reader's globs, then git's `linguist-*` attributes, then the built-in
49/// table.
50#[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    /// `declared` is what git's `linguist-*` attributes say.
61    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
71/// The bucket the `linguist-*` git attributes declare. Vendored code counts
72/// as Generated.
73pub 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
83/// The built-in table: first match wins.
84pub 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
111/// Extension without the dot; `.gitignore` has none.
112fn 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
194/// Matched against the lowercased stem. The separator is part of the affix so
195/// `latest.rs` and `protest.rs` stay source.
196const TEST_AFFIXES: &[&str] = &["_test", "_tests", "_spec", "-test", ".test", ".spec"];
197
198/// Matched against the original-case stem, since lowercasing loses the hump
199/// that separates `UserTest` from `latest`.
200const TEST_CAMEL_AFFIXES: &[&str] = &["Test", "Tests"];
201
202/// `FooSpec` is a test only in scalatest; elsewhere it names an API contract
203/// like `OpenApiSpec.ts`.
204const 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
278/// Matched only without an extension, so `src/security.rs` stays source.
279pub(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
326/// Source languages the bundled grammars lack, so they stay out of Other.
327const 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
337/// Gitignore-flavoured glob: `*` and `?` stay inside one segment, `**` spans
338/// any number, a pattern with no `/` matches the basename at any depth, a
339/// leading `/` anchors, and a trailing `/` names a subtree.
340pub(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
372/// Wildcard match within one segment, backtracking on `*` so `a*b*c` behaves.
373fn 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}