1use std::collections::{BTreeSet, HashSet};
17use std::path::{Path, PathBuf};
18
19use anyhow::{anyhow, Context, Result};
20use syn::visit::{self, Visit};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
24pub enum Language {
25 #[value(name = "python")]
27 Python,
28 #[value(name = "typescript")]
31 TypeScript,
32 #[value(name = "rust")]
37 Rust,
38}
39
40impl Language {
41 pub(crate) fn tracks(self, path: &Path) -> bool {
43 match self {
44 Language::Python => has_extension(path, &["py"]),
45 Language::TypeScript => {
46 has_extension(path, &["ts", "tsx", "mts", "cts"]) && !is_declaration(path)
47 }
48 Language::Rust => false,
52 }
53 }
54
55 pub(crate) fn is_test(self, path: &Path) -> bool {
57 match self {
58 Language::Python => stem_of(path).ends_with("_test"),
59 Language::TypeScript => {
60 let name = file_name_of(path);
61 name.ends_with(".test.ts")
62 || name.ends_with(".test.tsx")
63 || name.ends_with(".test.mts")
64 || name.ends_with(".test.cts")
65 }
66 Language::Rust => false,
67 }
68 }
69
70 pub(crate) fn is_support(self, path: &Path) -> bool {
74 match self {
75 Language::Python => file_name_of(path) == "conftest.py",
76 Language::TypeScript | Language::Rust => false,
77 }
78 }
79
80 pub(crate) fn has_code(self, source: &str) -> bool {
85 match self {
86 Language::Python => python_has_code(source),
87 Language::TypeScript => typescript_has_code(source),
88 Language::Rust => false,
89 }
90 }
91
92 pub(crate) fn expected_test_path(self, source: &Path) -> PathBuf {
94 match self {
95 Language::Python => source.with_file_name(format!("{}_test.py", stem_of(source))),
96 Language::TypeScript => {
97 source.with_file_name(format!("{}.test.{}", stem_of(source), extension_of(source)))
98 }
99 Language::Rust => source.to_path_buf(),
101 }
102 }
103}
104
105pub fn missing_unit_tests(
116 root: impl AsRef<Path>,
117 language: Language,
118 exempt: &BTreeSet<String>,
119) -> Result<Vec<PathBuf>> {
120 let root = root.as_ref();
121 let mut files = Vec::new();
122 collect_files(root, language, &mut files)?;
123 let manifest = match language {
127 Language::Python => Some("pyproject.toml"),
128 Language::TypeScript => Some("package.json"),
129 Language::Rust => None,
130 };
131 if let Some(tests) = manifest.and_then(|m| crate::tiers::suite_tests_dir(root, m)) {
132 files.retain(|file| !file.starts_with(&tests));
133 }
134
135 let present: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
138
139 let mut orphans: Vec<PathBuf> = Vec::new();
140 for source in &files {
141 if language.is_test(source) || language.is_support(source) {
143 continue;
144 }
145 if present.contains(language.expected_test_path(source).as_path()) {
146 continue;
147 }
148 let contents = std::fs::read_to_string(source)
151 .with_context(|| format!("reading source file `{}`", source.display()))?;
152 if !language.has_code(&contents) {
153 continue;
154 }
155 if language == Language::TypeScript && crate::ts::is_type_only_module(&contents, source) {
159 continue;
160 }
161 let relative = source
162 .strip_prefix(root)
163 .unwrap_or(source)
164 .to_string_lossy()
165 .replace('\\', "/");
166 if exempt.contains(&relative) {
167 continue;
168 }
169 orphans.push(source.clone());
170 }
171 orphans.sort();
172 Ok(orphans)
173}
174
175fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
177 let entries =
178 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
179 for entry in entries {
180 let path = entry
181 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
182 .path();
183 if path.is_dir() {
184 collect_files(&path, language, out)?;
185 } else if language.tracks(&path) {
186 out.push(path);
187 }
188 }
189 Ok(())
190}
191
192pub fn missing_inline_tests(
204 root: impl AsRef<Path>,
205 exempt: &BTreeSet<String>,
206) -> Result<Vec<PathBuf>> {
207 let root = root.as_ref();
208 let mut files = Vec::new();
209 collect_rust_source_files(root, &mut files)?;
210 files.sort();
211
212 let mut orphans = Vec::new();
213 for file in &files {
214 let source = std::fs::read_to_string(file)
215 .with_context(|| format!("reading source file `{}`", file.display()))?;
216 let ast = syn::parse_file(&source)
217 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
218 let mut visitor = PresenceVisitor::default();
219 visitor.visit_file(&ast);
220 if !visitor.has_testable_fn || visitor.has_test_module {
222 continue;
223 }
224 let relative = file
225 .strip_prefix(root)
226 .unwrap_or(file)
227 .to_string_lossy()
228 .replace('\\', "/");
229 if exempt.contains(&relative) {
230 continue;
231 }
232 orphans.push(file.clone());
233 }
234 Ok(orphans)
236}
237
238pub(crate) fn collect_rust_source_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
245 let entries =
246 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
247 for entry in entries {
248 let path = entry
249 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
250 .path();
251 if path.is_dir() {
252 let skip = matches!(
253 path.file_name().and_then(|name| name.to_str()),
254 Some("tests" | "benches" | "examples" | "target")
255 );
256 if !skip {
257 collect_rust_source_files(&path, out)?;
258 }
259 } else if has_extension(&path, &["rs"]) && file_name_of(&path) != "build.rs" {
260 out.push(path);
261 }
262 }
263 Ok(())
264}
265
266#[derive(Default)]
272struct PresenceVisitor {
273 test_depth: usize,
274 has_testable_fn: bool,
275 has_test_module: bool,
276}
277
278impl<'ast> Visit<'ast> for PresenceVisitor {
279 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
280 let is_test = crate::isolation::has_cfg_test(&node.attrs);
281 if is_test {
282 self.has_test_module = true;
283 self.test_depth += 1;
284 }
285 visit::visit_item_mod(self, node);
286 if is_test {
287 self.test_depth -= 1;
288 }
289 }
290
291 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
292 if self.test_depth == 0 && !crate::isolation::has_cfg_test(&node.attrs) {
295 self.has_testable_fn = true;
296 }
297 visit::visit_item_fn(self, node);
298 }
299
300 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
301 if self.test_depth == 0 {
302 self.has_testable_fn = true;
303 }
304 visit::visit_impl_item_fn(self, node);
305 }
306
307 fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
308 if self.test_depth == 0 && node.default.is_some() {
311 self.has_testable_fn = true;
312 }
313 visit::visit_trait_item_fn(self, node);
314 }
315}
316
317fn has_extension(path: &Path, extensions: &[&str]) -> bool {
319 path.extension()
320 .and_then(|ext| ext.to_str())
321 .is_some_and(|ext| extensions.contains(&ext))
322}
323
324fn is_declaration(path: &Path) -> bool {
327 let name = file_name_of(path);
328 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
329}
330
331fn python_has_code(source: &str) -> bool {
334 source.lines().any(|line| {
335 let trimmed = line.trim_start();
336 !trimmed.is_empty() && !trimmed.starts_with('#')
337 })
338}
339
340fn typescript_has_code(source: &str) -> bool {
344 let mut chars = source.chars().peekable();
345 while let Some(c) = chars.next() {
346 match c {
347 c if c.is_whitespace() => {}
348 '/' if chars.peek() == Some(&'/') => {
349 while chars.peek().is_some_and(|&n| n != '\n') {
350 chars.next();
351 }
352 }
353 '/' if chars.peek() == Some(&'*') => {
354 chars.next();
355 let mut prev = '\0';
356 for n in chars.by_ref() {
357 if prev == '*' && n == '/' {
358 break;
359 }
360 prev = n;
361 }
362 }
363 _ => return true,
364 }
365 }
366 false
367}
368
369fn extension_of(path: &Path) -> String {
371 path.extension()
372 .map(|ext| ext.to_string_lossy().into_owned())
373 .unwrap_or_default()
374}
375
376fn file_name_of(path: &Path) -> String {
378 path.file_name()
379 .map(|name| name.to_string_lossy().into_owned())
380 .unwrap_or_default()
381}
382
383fn stem_of(path: &Path) -> String {
385 path.file_stem()
386 .map(|stem| stem.to_string_lossy().into_owned())
387 .unwrap_or_default()
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn python_tracks_py_files() {
396 assert!(Language::Python.tracks(Path::new("a.py")));
397 assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
398 assert!(!Language::Python.tracks(Path::new("a.pyi")));
399 assert!(!Language::Python.tracks(Path::new("a.txt")));
400 assert!(!Language::Python.tracks(Path::new("README")));
401 }
402
403 #[test]
404 fn python_recognizes_test_files_by_stem_suffix() {
405 assert!(Language::Python.is_test(Path::new("widget_test.py")));
406 assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
407 assert!(!Language::Python.is_test(Path::new("widget.py")));
408 }
409
410 #[test]
411 fn python_conftest_is_support_not_a_subject() {
412 assert!(Language::Python.is_support(Path::new("conftest.py")));
414 assert!(Language::Python.is_support(Path::new("pkg/conftest.py")));
415 assert!(!Language::Python.is_support(Path::new("widget.py")));
416 assert!(!Language::Python.is_support(Path::new("widget_test.py")));
417 assert!(!Language::TypeScript.is_support(Path::new("conftest.ts")));
419 }
420
421 #[test]
422 fn python_expected_test_path_is_the_colocated_twin() {
423 assert_eq!(
424 Language::Python.expected_test_path(Path::new("pkg/widget.py")),
425 PathBuf::from("pkg/widget_test.py")
426 );
427 assert_eq!(
428 Language::Python.expected_test_path(Path::new("widget.py")),
429 PathBuf::from("widget_test.py")
430 );
431 }
432
433 #[test]
434 fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
435 assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
436 assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
437 assert!(Language::TypeScript.tracks(Path::new("service.mts")));
438 assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
439 assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
440 assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
441 assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
442 assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
443 assert!(!Language::TypeScript.tracks(Path::new("README")));
444 }
445
446 #[test]
447 fn typescript_recognizes_test_files_by_suffix() {
448 assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
449 assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
450 assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
451 assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
452 assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
453 assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
454 assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
455 }
456
457 #[test]
458 fn typescript_expected_test_path_keeps_the_extension() {
459 assert_eq!(
460 Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
461 PathBuf::from("pkg/widget.test.ts")
462 );
463 assert_eq!(
464 Language::TypeScript.expected_test_path(Path::new("button.tsx")),
465 PathBuf::from("button.test.tsx")
466 );
467 assert_eq!(
468 Language::TypeScript.expected_test_path(Path::new("service.mts")),
469 PathBuf::from("service.test.mts")
470 );
471 assert_eq!(
472 Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
473 PathBuf::from("legacy.test.cts")
474 );
475 }
476
477 #[test]
478 fn python_empty_or_comment_only_files_have_no_code() {
479 assert!(!Language::Python.has_code(""));
480 assert!(!Language::Python.has_code("\n \n"));
481 assert!(!Language::Python.has_code("# just a comment\n # another\n"));
482 }
483
484 #[test]
485 fn python_real_content_counts_as_code() {
486 assert!(Language::Python.has_code("x = 1\n"));
487 assert!(Language::Python.has_code("# header\nimport os\n"));
488 assert!(Language::Python.has_code("\"\"\"Package docstring.\"\"\"\n"));
490 }
491
492 #[test]
493 fn typescript_empty_or_comment_only_files_have_no_code() {
494 assert!(!Language::TypeScript.has_code(""));
495 assert!(!Language::TypeScript.has_code(" \n\t\n"));
496 assert!(!Language::TypeScript.has_code("// a line comment\n"));
497 assert!(!Language::TypeScript.has_code("/* a\n block\n comment */\n"));
498 }
499
500 #[test]
501 fn typescript_real_content_counts_as_code() {
502 assert!(Language::TypeScript.has_code("export const x = 1;\n"));
503 assert!(Language::TypeScript.has_code("// note\nexport * from './a';\n"));
504 assert!(Language::TypeScript.has_code("const s = '// not a comment';\n"));
506 assert!(Language::TypeScript.has_code("const r = a / b;\n"));
508 }
509
510 #[test]
511 fn rust_has_no_file_based_colocated_convention() {
512 assert!(!Language::Rust.tracks(Path::new("lib.rs")));
515 assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
516 assert!(!Language::Rust.has_code("fn main() {}\n"));
517 assert_eq!(
518 Language::Rust.expected_test_path(Path::new("src/lib.rs")),
519 PathBuf::from("src/lib.rs")
520 );
521 }
522
523 fn presence(src: &str) -> (bool, bool) {
526 let ast = syn::parse_file(src).expect("snippet parses");
527 let mut visitor = PresenceVisitor::default();
528 visitor.visit_file(&ast);
529 (visitor.has_testable_fn, visitor.has_test_module)
530 }
531
532 #[test]
533 fn rust_presence_free_fn_with_test_module_is_covered() {
534 assert_eq!(
535 presence(
536 "pub fn make(n: u8) -> u8 { n + 1 }\n\
537 #[cfg(test)]\nmod tests { #[test] fn t() {} }\n"
538 ),
539 (true, true)
540 );
541 }
542
543 #[test]
544 fn rust_presence_free_fn_without_test_module_needs_one() {
545 assert_eq!(
546 presence("pub fn make(n: u8) -> u8 { n + 1 }\n"),
547 (true, false)
548 );
549 }
550
551 #[test]
552 fn rust_presence_type_only_file_is_not_a_subject() {
553 assert_eq!(presence("pub struct Point { pub x: u8 }\n"), (false, false));
554 }
555
556 #[test]
557 fn rust_presence_impl_method_is_testable() {
558 assert_eq!(
559 presence("pub struct W;\nimpl W { pub fn go(&self) -> u8 { 1 } }\n"),
560 (true, false)
561 );
562 }
563
564 #[test]
565 fn rust_presence_trait_default_is_testable_but_bare_signature_is_not() {
566 assert_eq!(
567 presence("pub trait T { fn d(&self) -> u8 { 1 } }\n"),
568 (true, false)
569 );
570 assert_eq!(
571 presence("pub trait T { fn s(&self) -> u8; }\n"),
572 (false, false)
573 );
574 }
575
576 #[test]
577 fn rust_presence_test_module_functions_are_not_subjects() {
578 assert_eq!(
581 presence("#[cfg(test)]\nmod tests { fn helper() {} #[test] fn t() {} }\n"),
582 (false, true)
583 );
584 }
585
586 #[test]
587 fn rust_presence_cfg_test_gated_free_fn_is_not_a_subject() {
588 assert_eq!(
591 presence("#[cfg(test)]\nfn only_in_tests() {}\n"),
592 (false, false)
593 );
594 }
595}