Skip to main content

_diffctx/
testfiles.rs

1//! One answer to "is this a test file".
2//!
3//! There used to be two: a per-language dispatch in
4//! `edges::structural::testing` gating `TestEdge` emission, and a flat suffix
5//! list in `utility::needs` gating test-need match strength. They disagreed —
6//! a `.kts` file was a test to one and not the other — so the two halves of
7//! "this is a test for the changed code" could each hold independently (#182).
8//!
9//! Both also accepted any stem ending in `test`, because the JVM and Scala
10//! conventions carry no separator (`FooTest.java`) and both lowercased the name
11//! before comparing, which destroys the CamelCase boundary that makes the
12//! convention readable. That matched `latest`, `greatest`, `contest` and
13//! `attest`. The rule here is that a `test`/`spec` marker counts only at a
14//! **word boundary**: its own segment between separators, or a capitalised
15//! `Test`/`Spec` in the original name.
16//!
17//! Two families are accepted false positives, because no name-based rule
18//! separates them: `PodSpec`/`JobSpec` (Kubernetes API types) look exactly like
19//! `AuthSpec` (a Scala test), and `ABTest` (an experiment) looks exactly like
20//! `FooTest`. Both over-classify — a K8s model contributes test-need strength
21//! it should not — and both are preferred to under-classifying the conventions
22//! they collide with, which are far more common in the corpora this feeds.
23
24use std::path::Path;
25
26/// Directory names that mark everything beneath them as test material.
27const TEST_DIRS: &[&str] = &["test", "tests", "__tests__", "spec", "specs"];
28
29/// Segment markers, compared case-insensitively against a whole segment.
30const SEGMENT_MARKERS: &[&str] = &["test", "tests", "spec", "specs"];
31
32/// CamelCase markers, compared against the original case — the capital letter
33/// *is* the word boundary.
34const CAMEL_MARKERS: &[&str] = &["Test", "Tests", "Spec", "Specs"];
35
36fn has_test_directory(path: &Path) -> bool {
37    path.components().any(|c| {
38        let segment = c.as_os_str().to_string_lossy().to_lowercase();
39        TEST_DIRS.contains(&segment.as_str())
40    })
41}
42
43/// True when `stem` carries a test marker as its own `_`/`-`/`.`-delimited
44/// segment: `test_auth`, `auth_test`, `widget.spec`, `widget-test`, `tests`.
45fn has_marker_segment(stem: &str) -> bool {
46    stem.split(['_', '-', '.'])
47        .any(|segment| SEGMENT_MARKERS.contains(&segment.to_lowercase().as_str()))
48}
49
50/// True when `stem` carries a capitalised marker at a CamelCase boundary:
51/// `FooTest`, `TestFoo`, `AuthSpec`. Deliberately case-sensitive — lowercasing
52/// first is what made `latest` look like a test.
53fn has_camel_marker(stem: &str) -> bool {
54    for marker in CAMEL_MARKERS {
55        // A capital `T` *is* the word boundary: in CamelCase an uppercase letter
56        // always starts a new word, and these markers are compared case
57        // sensitively, so a match cannot be the tail of a longer word.
58        // `latest`/`contest`/`attest` carry a lowercase `t` and never reach here.
59        //
60        // This used to also require the preceding character to be lowercase,
61        // which rejected every acronym-prefixed name the JVM conventions are
62        // full of — `XMLTest`, `HTTPTest`, `DBTest`, `UITest`, `IOTest` were all
63        // classified as ordinary source. The guard prevented no false positive:
64        // the only stems it excluded were exactly those acronyms.
65        if stem.strip_suffix(marker).is_some() {
66            return true;
67        }
68        if let Some(rest) = stem.strip_prefix(marker) {
69            // `TestFoo` — the next character starts a new word. A bare `Test`
70            // stem is already covered by the segment rule.
71            if rest.starts_with(char::is_uppercase) {
72                return true;
73            }
74        }
75    }
76    false
77}
78
79/// Whether `path` is test material.
80pub fn is_test_path(path: &Path) -> bool {
81    if has_test_directory(path) {
82        return true;
83    }
84    // `file_stem` strips one extension, so `widget.test.ts` yields
85    // `widget.test` and the marker survives as a segment.
86    let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
87        return false;
88    };
89    has_marker_segment(&stem) || has_camel_marker(&stem)
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use std::path::Path;
96
97    fn is_test(p: &str) -> bool {
98        is_test_path(Path::new(p))
99    }
100
101    #[test]
102    fn every_supported_naming_convention_is_recognised() {
103        for path in [
104            "tests/test_auth.py",
105            "src/auth_test.py",
106            "web/handler_test.go",
107            "ui/widget.test.ts",
108            "ui/widget.spec.tsx",
109            "ui/widget-spec.js",
110            "src/FooTest.java",
111            "src/FooTest.kt",
112            "src/AuthSpec.scala",
113            "src/TestHelpers.kt",
114            // Acronym + marker. Every one of these was misclassified as
115            // ordinary source while the camel rule demanded a lowercase
116            // character before the marker.
117            "src/XMLTest.java",
118            "src/HTTPTest.go",
119            "src/DBTest.kt",
120            "src/UITest.swift",
121            "src/IOTest.scala",
122            "src/MyXMLTest.java",
123            "src/JSONSpec.scala",
124            "crates/x/tests/integration.rs",
125            "src/tests.rs",
126            "app/__tests__/widget.jsx",
127            "spec/models/user_spec.rb",
128        ] {
129            assert!(is_test(path), "not recognised as a test: {path}");
130        }
131    }
132
133    /// The false positive both old implementations shared: a stem that merely
134    /// *ends* in the letters `test`, with no word boundary.
135    #[test]
136    fn an_ordinary_word_ending_in_test_is_not_a_test_file() {
137        for path in [
138            "src/latest.rs",
139            "src/latest.java",
140            "src/latest.kt",
141            "src/greatest.scala",
142            "src/contest.py",
143            "src/attest.go",
144            "src/testing.rs",
145            "src/tester.py",
146            "src/manifest.json",
147        ] {
148            assert!(!is_test(path), "wrongly classified as a test: {path}");
149        }
150    }
151
152    /// `conftest.py` is pytest infrastructure rather than a test module, and
153    /// both previous implementations classified it as non-test. Corpus cases
154    /// depend on that, so it must not change.
155    #[test]
156    fn conftest_is_not_itself_a_test_file() {
157        assert!(!is_test("tests_helpers/conftest.py"));
158        assert!(!is_test("src/conftest.py"));
159    }
160
161    /// A `tests/` directory anywhere in the path wins regardless of filename —
162    /// that is how every ecosystem separates its test tree.
163    #[test]
164    fn a_test_directory_marks_everything_beneath_it() {
165        assert!(is_test("tests/fixtures/data_loader.py"));
166        assert!(is_test("crates/x/tests/common/mod.rs"));
167        assert!(!is_test("src/testdata/loader.py"));
168    }
169}