Skip to main content

diffler_core/
classify.rs

1//! Which bucket a changed file belongs to, from its path alone. The rule order
2//! is the design: Generated outranks Tests so a generated fixture reads as
3//! noise, and Build outranks Config so `Cargo.toml` reads as a manifest.
4//!
5//! [`Rules`] layers the two things a repo can say for itself over the built-in
6//! table: the reader's own globs, then git's `linguist-*` attributes.
7
8use std::path::Path;
9
10use crate::syntax::registry::REGISTRY;
11
12/// A sidebar bucket. The set is fixed across repos so the reader's muscle
13/// memory carries between them.
14#[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    /// Display order in the sidebar: what the reader came to review first,
28    /// what they came to skip last.
29    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/// The built-in table plus whatever the repo says for itself.
55#[derive(Debug, Clone, Default)]
56pub struct Rules {
57    /// Glob patterns per bucket, in the order they are consulted.
58    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    /// The bucket for `path`. `declared` is what git's `linguist-*` attributes
67    /// say, which the reader's own globs still outrank.
68    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
78/// What a repo declares about a path through the `linguist-*` git attributes
79/// forges already honour, given a reader for one attribute. Vendored code
80/// joins Generated: both mean the reader did not write it.
81pub 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
91/// The built-in table: first match wins.
92pub 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
119/// Extension without the dot, empty for a file that has none. A dotfile with
120/// no second dot (`.gitignore`) has no extension, matching how git names it.
121fn 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
136/// Trees nobody wrote by hand: vendored dependencies, build output, and the
137/// caches tools leave behind.
138const 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
156/// Suffixes a code generator stamps on its output.
157const 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
181/// Every shape a dependency lockfile takes: an extension (`Cargo.lock`,
182/// `bun.lockb`), an infix before another one (`pnpm-lock.yaml`,
183/// `.terraform.lock.hcl`).
184fn 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
209/// Affixes that name a test in the languages that have a convention. Checked
210/// against the basename with its extension stripped, so one entry covers every
211/// language sharing the affix. The separator is part of the affix: without it
212/// `latest.rs` and `protest.rs` read as tests.
213const TEST_AFFIXES: &[&str] = &["_test", "_tests", "_spec", "-test", ".test", ".spec"];
214
215/// The camel-cased conventions, checked against the untouched basename: the
216/// lowercased one cannot see the hump that makes `UserTest` a test and
217/// `latest` a word.
218const TEST_CAMEL_AFFIXES: &[&str] = &["Test", "Tests"];
219
220/// `FooSpec` is scalatest, and only there: elsewhere the camel form names an
221/// API contract, as in `OpenApiSpec.ts`.
222const 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
296/// The files a repo keeps at its root with no extension at all. They are
297/// matched only when the extension is empty: `src/security.rs` and
298/// `models/license.rb` are code that happens to share the word.
299pub(crate) const 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
346/// Code the bundled grammars do not cover: the registry answers for everything
347/// diffler can highlight, this list keeps the rest out of Other.
348const 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
358/// Gitignore-flavoured glob: `*` and `?` stay inside one path segment, `**`
359/// spans any number of them, and a pattern with no `/` matches the basename at
360/// any depth. The shapes a reader carries over from `.gitignore` are honoured
361/// rather than silently matching nothing: a leading `/` is the anchoring a
362/// pattern with a slash already has, and a trailing `/` names a directory's
363/// whole subtree.
364fn 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
394/// Wildcard match within one segment, backtracking on `*` so `a*b*c` behaves.
395fn 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}