Skip to main content

cpd_semantic/
test_code.rs

1//! Which functions are tests, so that `--compare` measures tests and code
2//! apart and pairs a test only with a test.
3//!
4//! Most languages keep tests in files of their own, named by convention
5//! ([`is_test_path`]): `*_test.go`, `test_*.py`, `*.test.ts`, `*Test.java`,
6//! `*Tests.swift`, `*_spec.rb`, or folders such as `tests/`, `__tests__/`,
7//! `src/test/` and `MyAppTests/`. Two kinds of test live among the code
8//! ([`inline_test`]): Rust tests, in a `#[cfg(test)]` module or under a
9//! `#[test]` attribute, and JavaScript and TypeScript test cases,
10//! `it('title', () => …)`, which Vitest runs from source files too.
11
12use cpd_core::models::Token;
13use cpd_tokenizer::functions::{NOT_TEST_CASES, TEST_CASE_CALLS};
14use std::path::{Component, Path};
15
16/// Folders that hold tests, compared without case.
17const TEST_DIRS: &[&str] = &["test", "tests", "__tests__", "spec", "specs"];
18
19/// Whether `path` names a test file by the conventions of the languages
20/// jscpd compares. `path` starts at the compared folder itself
21/// (`tests/copy.rs` when the folder is `tests/`), so a folder given on the
22/// command line counts as well as the folders below it.
23pub fn is_test_path(path: &Path) -> bool {
24    let parts: Vec<&str> = path
25        .components()
26        .filter_map(|c| match c {
27            Component::Normal(part) => part.to_str(),
28            _ => None,
29        })
30        .collect();
31    let Some((file, dirs)) = parts.split_last() else {
32        return false;
33    };
34    dirs.iter().any(|dir| is_test_dir(dir)) || is_test_file(file)
35}
36
37/// `tests`, `__tests__`, `src/test`, Android's `androidTest`, and the test
38/// targets of Xcode and .NET: `MyAppTests`, `MyApp.UITests`, `MyApp.Tests`.
39fn is_test_dir(dir: &str) -> bool {
40    let lower = dir.to_ascii_lowercase();
41    TEST_DIRS.contains(&lower.as_str())
42        || ["Tests", "Test"]
43            .iter()
44            .any(|suffix| dir.len() > suffix.len() && dir.ends_with(suffix))
45}
46
47fn is_test_file(file: &str) -> bool {
48    let lower = file.to_ascii_lowercase();
49    let stem = file.split('.').next().unwrap_or(file);
50    let lower_stem = stem.to_ascii_lowercase();
51    // app.test.ts, app.spec.jsx, app_test.go, test_app.py, app_spec.rb,
52    // and the `tests.rs` a `#[cfg(test)] mod tests;` declares.
53    lower.contains(".test.")
54        || lower.contains(".spec.")
55        || lower_stem.starts_with("test_")
56        || lower_stem.ends_with("_test")
57        || lower_stem.ends_with("_tests")
58        || lower_stem.ends_with("_spec")
59        || lower_stem == "tests"
60        // CartTests.swift, CartTests.cs: a capitalized plural after another
61        // word. The singular `CartTest.java` and `CartSpec.scala` are left
62        // to their folders (`src/test/`): as a name alone they would take
63        // `ABTest.java` and `OpenApiSpec.ts` for tests.
64        || (stem.len() > 5 && stem.ends_with("Tests"))
65}
66
67/// Whether the function of `grammar` found at `head..` in `code` is a test
68/// that lives among the code: a Rust function in a `#[cfg(test)]` module
69/// (see [`rust_test_modules`]) or under a test attribute, or a JavaScript
70/// test case. `start` is where the Rust item's first keyword is, after its
71/// attributes.
72pub(crate) fn inline_test(
73    grammar: &str,
74    code: &str,
75    head: usize,
76    start: usize,
77    rust_modules: &[(usize, usize)],
78) -> bool {
79    match grammar {
80        "rust" => {
81            rust_modules
82                .iter()
83                .any(|&(from, to)| from <= start && start < to)
84                || has_test_attribute(code, start)
85        }
86        "oxc" => code.get(head..).is_some_and(starts_test_case),
87        _ => false,
88    }
89}
90
91/// Whether `code` starts with a test-case call: a name from
92/// `TEST_CASE_CALLS`, members and calls after it (`.only`, `.each(table)`),
93/// then `(` and a string title, as in `it('rounds cents', …)`. A method
94/// named `test` (`test(input) { … }`) has no title and is code, and
95/// Playwright's `test.describe(…)` and `test.step(…)` are not test cases.
96fn starts_test_case(code: &str) -> bool {
97    let ident = |text: &str| -> usize {
98        text.find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
99            .unwrap_or(text.len())
100    };
101    let callee_len = ident(code);
102    if !TEST_CASE_CALLS.contains(&&code[..callee_len]) {
103        return false;
104    }
105    let mut rest = &code[callee_len..];
106    loop {
107        rest = rest.trim_start();
108        if let Some(member) = rest.strip_prefix('.') {
109            let member = member.trim_start();
110            let len = ident(member);
111            if len == 0 || NOT_TEST_CASES.contains(&&member[..len]) {
112                return false;
113            }
114            rest = &member[len..];
115        } else if let Some(args) = rest.strip_prefix('(') {
116            if args.trim_start().starts_with(['\'', '"', '`']) {
117                return true;
118            }
119            // A call inside the chain, such as `.each(table)`: skip it.
120            let Some(end) = closing_paren(rest) else {
121                return false;
122            };
123            rest = &rest[end..];
124        } else {
125            return false;
126        }
127    }
128}
129
130/// The offset just after the `)` that closes the `(` `text` starts with.
131fn closing_paren(text: &str) -> Option<usize> {
132    let mut depth = 0usize;
133    for (at, c) in text.char_indices() {
134        match c {
135            '(' => depth += 1,
136            ')' => {
137                depth = depth.checked_sub(1)?;
138                if depth == 0 {
139                    return Some(at + 1);
140                }
141            }
142            _ => {}
143        }
144    }
145    None
146}
147
148/// Whether the attributes of the Rust item at `start` include a test
149/// attribute: `#[test]`, or one whose path ends in `test` (`#[tokio::test]`,
150/// `#[sqlx::test]`), or `#[rstest]` and `#[test_case(…)]`. They stand before
151/// the item's first keyword on its own line (`#[test] fn adds()`) or on the
152/// lines above it, among doc comments.
153fn has_test_attribute(code: &str, start: usize) -> bool {
154    let before = code.get(..start).unwrap_or_default();
155    let mut lines = before.rsplit('\n');
156    if lines
157        .next()
158        .is_some_and(|own| attributes_are_test(own.trim()))
159    {
160        return true;
161    }
162    for line in lines {
163        let line = line.trim();
164        if line.is_empty() || line.starts_with("//") {
165            continue;
166        }
167        if !line.starts_with("#[") {
168            return false;
169        }
170        if attributes_are_test(line) {
171            return true;
172        }
173    }
174    false
175}
176
177/// Whether one of the `#[…]` attributes in `text` is a test attribute.
178fn attributes_are_test(text: &str) -> bool {
179    text.split("#[").skip(1).any(|attribute| {
180        let path = attribute
181            .split(['(', ']'])
182            .next()
183            .unwrap_or_default()
184            .trim();
185        let last = path.rsplit("::").next().unwrap_or(path);
186        last == "test" || last.starts_with("test_") || last == "rstest"
187    })
188}
189
190/// Whether a `cfg` condition holds in tests only: it names `test` as a
191/// predicate, and not under `not(…)`. `feature = "test-util"` names a
192/// feature, and `not(test)` is the opposite of a test.
193fn cfg_is_test(condition: &str) -> bool {
194    // Values are strings: `feature = "test"` names no predicate.
195    let mut bare = String::with_capacity(condition.len());
196    let mut in_string = false;
197    for c in condition.chars() {
198        match c {
199            '"' => in_string = !in_string,
200            _ if in_string => {}
201            _ => bare.push(c),
202        }
203    }
204    let mut at = 0;
205    while let Some(found) = bare[at..].find("test") {
206        let start = at + found;
207        let end = start + 4;
208        at = end;
209        let word = |c: char| c.is_alphanumeric() || c == '_';
210        let whole = !bare[..start].ends_with(word) && !bare[end..].starts_with(word);
211        if whole && !bare[..start].trim_end().ends_with("not(") {
212            return true;
213        }
214    }
215    false
216}
217
218/// The byte ranges of the bodies of `#[cfg(test)]` modules in Rust `code`
219/// (`#[cfg(all(test, feature = "x"))]` included), found by matching the
220/// brace tokens after `mod name`. A file that says `#![cfg(test)]` is one
221/// range from start to end.
222pub(crate) fn rust_test_modules(code: &str, tokens: &[Token]) -> Vec<(usize, usize)> {
223    let inner = code.match_indices("#![cfg(").any(|(at, _)| {
224        code[at + 7..]
225            .find(")]")
226            .is_some_and(|close| cfg_is_test(&code[at + 7..at + 7 + close]))
227    });
228    if inner {
229        return vec![(0, code.len())];
230    }
231    let mut ranges = Vec::new();
232    let mut from = 0;
233    while let Some(at) = code[from..].find("#[cfg(") {
234        let attr_start = from + at;
235        from = attr_start + 6;
236        let Some(close) = code[attr_start..].find(")]") else {
237            break;
238        };
239        if !cfg_is_test(&code[attr_start + 6..attr_start + close]) {
240            continue;
241        }
242        // Other attributes and `pub`/`pub(crate)` may stand between the
243        // attribute and `mod name {`.
244        let after = attr_start + close + 2;
245        let Some(brace) = module_brace(&code[after..]).map(|b| after + b) else {
246            continue;
247        };
248        if let Some(end) = matching_brace(tokens, brace) {
249            ranges.push((brace, end));
250            from = end;
251        }
252    }
253    ranges
254}
255
256/// The offset of the `{` of `mod name {` at the start of `rest`, after
257/// attributes and visibility; `None` when `rest` starts with something else
258/// (`mod tests;`, a function).
259fn module_brace(rest: &str) -> Option<usize> {
260    let mut at = 0;
261    loop {
262        let trimmed = rest[at..].trim_start();
263        at = rest.len() - trimmed.len();
264        if trimmed.starts_with("#[") {
265            at += trimmed.find(']')? + 1;
266        } else if let Some(after) = trimmed.strip_prefix("pub(") {
267            at += 4 + after.find(')')? + 1;
268        } else if trimmed.starts_with("pub ") {
269            at += 4;
270        } else {
271            break;
272        }
273    }
274    let trimmed = rest[at..].strip_prefix("mod ")?;
275    let brace = trimmed.find(['{', ';'])?;
276    let name = trimmed[..brace].trim();
277    (trimmed.as_bytes()[brace] == b'{' && !name.is_empty() && !name.contains(char::is_whitespace))
278        .then_some(at + 4 + brace)
279}
280
281/// The end offset of the brace token that closes the `{` at `open`.
282fn matching_brace(tokens: &[Token], open: usize) -> Option<usize> {
283    let first = tokens
284        .iter()
285        .position(|t| t.start.offset as usize == open)?;
286    let mut depth = 0usize;
287    for token in &tokens[first..] {
288        match token.value.as_str() {
289            "{" => depth += 1,
290            "}" => {
291                depth = depth.checked_sub(1)?;
292                if depth == 0 {
293                    return Some(token.end.offset as usize);
294                }
295            }
296            _ => {}
297        }
298    }
299    None
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use cpd_tokenizer::tokenizer::{Mode, tokenize};
306
307    #[test]
308    fn test_files_by_the_conventions_of_each_language() {
309        for path in [
310            "billing/app_test.go",
311            "billing/test_app.py",
312            "billing/app_test.py",
313            "src/app.test.ts",
314            "src/Cart.spec.tsx",
315            "src/test/java/CartTest.java",
316            "src/test/scala/CartSpec.scala",
317            "app/src/androidTest/kotlin/CartTests.kt",
318            "MyAppTests/CartTests.swift",
319            "MyApp.Tests/CartTests.cs",
320            "spec/cart_spec.rb",
321            "lib/__tests__/copy.js",
322            "tests/copy.rs",
323            "src/cart/tests.rs",
324        ]
325        .iter()
326        .map(|p| format!("side/{p}"))
327        {
328            assert!(is_test_path(Path::new(&path)), "{path}");
329        }
330        for path in [
331            "side/src/contest.rs",
332            "side/src/Request.java",
333            "side/src/Contest.kt",
334            "side/src/testing_utils.py",
335            "side/fixtures/app.py",
336            "side/src/latest.ts",
337            "side/src/OpenApiSpec.ts",
338            "side/src/ABTest.java",
339            "side/src/LoadTest.kt",
340        ] {
341            assert!(!is_test_path(Path::new(path)), "{path}");
342        }
343        // The compared folder's own name counts.
344        assert!(is_test_path(Path::new("tests/copy.rs")));
345    }
346
347    fn rust(code: &str) -> (Vec<Token>, Vec<(usize, usize)>) {
348        let tokens = tokenize("rust", code, Mode::Weak);
349        let modules = rust_test_modules(code, &tokens);
350        (tokens, modules)
351    }
352
353    #[test]
354    fn rust_tests_in_cfg_test_modules_and_under_test_attributes() {
355        let code = "pub fn add(a: u32, b: u32) -> u32 {\n    a + b\n}\n\n#[tokio::test]\nasync fn adds_async() {\n    assert_eq!(add(1, 2), 3);\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn helper() -> u32 { 1 }\n\n    #[test]\n    fn adds() {\n        let s = \"{ not a brace }\";\n        assert_eq!(add(helper(), 2), 3);\n    }\n}\n\npub fn after() {}\n";
356        let (_, modules) = rust(code);
357        assert_eq!(modules.len(), 1);
358        let at = |needle: &str| code.find(needle).unwrap();
359        let test = |needle: &str| {
360            let start = at(needle);
361            inline_test("rust", code, start, start, &modules)
362        };
363        assert!(!test("pub fn add"));
364        assert!(test("async fn adds_async"));
365        assert!(test("fn helper"), "a helper inside the test module");
366        assert!(test("fn adds()"));
367        assert!(
368            !test("pub fn after"),
369            "the module ends at its closing brace"
370        );
371    }
372
373    #[test]
374    fn cfg_conditions_hold_in_tests_only_when_they_name_test() {
375        assert!(cfg_is_test("test"));
376        assert!(cfg_is_test("all(test, unix)"));
377        assert!(!cfg_is_test("not(test)"));
378        assert!(!cfg_is_test("all(not(test), unix)"));
379        assert!(!cfg_is_test("feature = \"test-util\""));
380        assert!(!cfg_is_test("feature = \"test\""));
381        assert!(!cfg_is_test("testing"));
382        let (_, none) = rust("#[cfg(not(test))]\nmod platform {\n    fn open() {}\n}\n");
383        assert!(none.is_empty());
384        let code = "#![cfg(test)]\nfn helper() {}\n";
385        let (_, whole) = rust(code);
386        assert_eq!(whole, vec![(0, code.len())]);
387    }
388
389    #[test]
390    fn a_test_attribute_on_the_function_line_counts() {
391        let code = "#[test] fn adds() {\n    assert!(true);\n}\n#[inline] fn fast() {}\n";
392        let at = |needle: &str| code.find(needle).unwrap();
393        assert!(inline_test("rust", code, at("fn adds"), at("fn adds"), &[]));
394        assert!(!inline_test(
395            "rust",
396            code,
397            at("fn fast"),
398            at("fn fast"),
399            &[]
400        ));
401    }
402
403    #[test]
404    fn a_cfg_test_module_declared_elsewhere_or_cfg_without_test_is_no_range() {
405        let (_, none) = rust(
406            "#[cfg(test)]\nmod tests;\n\n#[cfg(feature = \"x\")]\nmod extra {\n    fn f() {}\n}\n",
407        );
408        assert!(none.is_empty());
409        let (_, some) =
410            rust("#[cfg(all(test, unix))]\npub(crate) mod unix_tests {\n    fn f() {}\n}\n");
411        assert_eq!(some.len(), 1);
412    }
413
414    #[test]
415    fn javascript_test_cases_are_tests_wherever_they_live() {
416        let code = "export function add(a, b) { return a + b; }\nit('adds', () => { expect(add(1, 2)).toBe(3); });\ntest.each([[1]])('t %i', (a) => {});\nconst item = { it: 1 };\nclass Matcher { test(input) { return this.re.test(input); } }\ntest.describe('suite', () => {});\n";
417        let test = |needle: &str| {
418            let at = code.find(needle).unwrap();
419            inline_test("oxc", code, at, at, &[])
420        };
421        assert!(!test("export function add"));
422        assert!(test("it('adds'"));
423        assert!(test("test.each"));
424        assert!(!test("item = "));
425        assert!(!test("test(input)"), "a method named test is code");
426        assert!(!test("test.describe"), "a suite is not a test case");
427    }
428}