1use cpd_core::models::Token;
13use cpd_tokenizer::functions::{NOT_TEST_CASES, TEST_CASE_CALLS};
14use std::path::{Component, Path};
15
16const TEST_DIRS: &[&str] = &["test", "tests", "__tests__", "spec", "specs"];
18
19pub 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
37fn 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 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 || (stem.len() > 5 && stem.ends_with("Tests"))
65}
66
67pub(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
91fn 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 let Some(end) = closing_paren(rest) else {
121 return false;
122 };
123 rest = &rest[end..];
124 } else {
125 return false;
126 }
127 }
128}
129
130fn 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
148fn 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
177fn 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
190fn cfg_is_test(condition: &str) -> bool {
194 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
218pub(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 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
256fn 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
281fn 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 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}