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 let relative = source
156 .strip_prefix(root)
157 .unwrap_or(source)
158 .to_string_lossy()
159 .replace('\\', "/");
160 if exempt.contains(&relative) {
161 continue;
162 }
163 orphans.push(source.clone());
164 }
165 orphans.sort();
166 Ok(orphans)
167}
168
169fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
171 let entries =
172 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
173 for entry in entries {
174 let path = entry
175 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
176 .path();
177 if path.is_dir() {
178 collect_files(&path, language, out)?;
179 } else if language.tracks(&path) {
180 out.push(path);
181 }
182 }
183 Ok(())
184}
185
186pub fn missing_inline_tests(
198 root: impl AsRef<Path>,
199 exempt: &BTreeSet<String>,
200) -> Result<Vec<PathBuf>> {
201 let root = root.as_ref();
202 let mut files = Vec::new();
203 collect_rust_source_files(root, &mut files)?;
204 files.sort();
205
206 let mut orphans = Vec::new();
207 for file in &files {
208 let source = std::fs::read_to_string(file)
209 .with_context(|| format!("reading source file `{}`", file.display()))?;
210 let ast = syn::parse_file(&source)
211 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
212 let mut visitor = PresenceVisitor::default();
213 visitor.visit_file(&ast);
214 if !visitor.has_testable_fn || visitor.has_test_module {
216 continue;
217 }
218 let relative = file
219 .strip_prefix(root)
220 .unwrap_or(file)
221 .to_string_lossy()
222 .replace('\\', "/");
223 if exempt.contains(&relative) {
224 continue;
225 }
226 orphans.push(file.clone());
227 }
228 Ok(orphans)
230}
231
232pub(crate) fn collect_rust_source_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
239 let entries =
240 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
241 for entry in entries {
242 let path = entry
243 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
244 .path();
245 if path.is_dir() {
246 let skip = matches!(
247 path.file_name().and_then(|name| name.to_str()),
248 Some("tests" | "benches" | "examples" | "target")
249 );
250 if !skip {
251 collect_rust_source_files(&path, out)?;
252 }
253 } else if has_extension(&path, &["rs"]) && file_name_of(&path) != "build.rs" {
254 out.push(path);
255 }
256 }
257 Ok(())
258}
259
260#[derive(Default)]
266struct PresenceVisitor {
267 test_depth: usize,
268 has_testable_fn: bool,
269 has_test_module: bool,
270}
271
272impl<'ast> Visit<'ast> for PresenceVisitor {
273 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
274 let is_test = crate::isolation::has_cfg_test(&node.attrs);
275 if is_test {
276 self.has_test_module = true;
277 self.test_depth += 1;
278 }
279 visit::visit_item_mod(self, node);
280 if is_test {
281 self.test_depth -= 1;
282 }
283 }
284
285 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
286 if self.test_depth == 0 && !crate::isolation::has_cfg_test(&node.attrs) {
289 self.has_testable_fn = true;
290 }
291 visit::visit_item_fn(self, node);
292 }
293
294 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
295 if self.test_depth == 0 {
296 self.has_testable_fn = true;
297 }
298 visit::visit_impl_item_fn(self, node);
299 }
300
301 fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
302 if self.test_depth == 0 && node.default.is_some() {
305 self.has_testable_fn = true;
306 }
307 visit::visit_trait_item_fn(self, node);
308 }
309}
310
311fn has_extension(path: &Path, extensions: &[&str]) -> bool {
313 path.extension()
314 .and_then(|ext| ext.to_str())
315 .is_some_and(|ext| extensions.contains(&ext))
316}
317
318fn is_declaration(path: &Path) -> bool {
321 let name = file_name_of(path);
322 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
323}
324
325fn python_has_code(source: &str) -> bool {
328 source.lines().any(|line| {
329 let trimmed = line.trim_start();
330 !trimmed.is_empty() && !trimmed.starts_with('#')
331 })
332}
333
334fn typescript_has_code(source: &str) -> bool {
338 let mut chars = source.chars().peekable();
339 while let Some(c) = chars.next() {
340 match c {
341 c if c.is_whitespace() => {}
342 '/' if chars.peek() == Some(&'/') => {
343 while chars.peek().is_some_and(|&n| n != '\n') {
344 chars.next();
345 }
346 }
347 '/' if chars.peek() == Some(&'*') => {
348 chars.next();
349 let mut prev = '\0';
350 for n in chars.by_ref() {
351 if prev == '*' && n == '/' {
352 break;
353 }
354 prev = n;
355 }
356 }
357 _ => return true,
358 }
359 }
360 false
361}
362
363fn extension_of(path: &Path) -> String {
365 path.extension()
366 .map(|ext| ext.to_string_lossy().into_owned())
367 .unwrap_or_default()
368}
369
370fn file_name_of(path: &Path) -> String {
372 path.file_name()
373 .map(|name| name.to_string_lossy().into_owned())
374 .unwrap_or_default()
375}
376
377fn stem_of(path: &Path) -> String {
379 path.file_stem()
380 .map(|stem| stem.to_string_lossy().into_owned())
381 .unwrap_or_default()
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn python_tracks_py_files() {
390 assert!(Language::Python.tracks(Path::new("a.py")));
391 assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
392 assert!(!Language::Python.tracks(Path::new("a.pyi")));
393 assert!(!Language::Python.tracks(Path::new("a.txt")));
394 assert!(!Language::Python.tracks(Path::new("README")));
395 }
396
397 #[test]
398 fn python_recognizes_test_files_by_stem_suffix() {
399 assert!(Language::Python.is_test(Path::new("widget_test.py")));
400 assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
401 assert!(!Language::Python.is_test(Path::new("widget.py")));
402 }
403
404 #[test]
405 fn python_conftest_is_support_not_a_subject() {
406 assert!(Language::Python.is_support(Path::new("conftest.py")));
408 assert!(Language::Python.is_support(Path::new("pkg/conftest.py")));
409 assert!(!Language::Python.is_support(Path::new("widget.py")));
410 assert!(!Language::Python.is_support(Path::new("widget_test.py")));
411 assert!(!Language::TypeScript.is_support(Path::new("conftest.ts")));
413 }
414
415 #[test]
416 fn python_expected_test_path_is_the_colocated_twin() {
417 assert_eq!(
418 Language::Python.expected_test_path(Path::new("pkg/widget.py")),
419 PathBuf::from("pkg/widget_test.py")
420 );
421 assert_eq!(
422 Language::Python.expected_test_path(Path::new("widget.py")),
423 PathBuf::from("widget_test.py")
424 );
425 }
426
427 #[test]
428 fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
429 assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
430 assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
431 assert!(Language::TypeScript.tracks(Path::new("service.mts")));
432 assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
433 assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
434 assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
435 assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
436 assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
437 assert!(!Language::TypeScript.tracks(Path::new("README")));
438 }
439
440 #[test]
441 fn typescript_recognizes_test_files_by_suffix() {
442 assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
443 assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
444 assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
445 assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
446 assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
447 assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
448 assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
449 }
450
451 #[test]
452 fn typescript_expected_test_path_keeps_the_extension() {
453 assert_eq!(
454 Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
455 PathBuf::from("pkg/widget.test.ts")
456 );
457 assert_eq!(
458 Language::TypeScript.expected_test_path(Path::new("button.tsx")),
459 PathBuf::from("button.test.tsx")
460 );
461 assert_eq!(
462 Language::TypeScript.expected_test_path(Path::new("service.mts")),
463 PathBuf::from("service.test.mts")
464 );
465 assert_eq!(
466 Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
467 PathBuf::from("legacy.test.cts")
468 );
469 }
470
471 #[test]
472 fn python_empty_or_comment_only_files_have_no_code() {
473 assert!(!Language::Python.has_code(""));
474 assert!(!Language::Python.has_code("\n \n"));
475 assert!(!Language::Python.has_code("# just a comment\n # another\n"));
476 }
477
478 #[test]
479 fn python_real_content_counts_as_code() {
480 assert!(Language::Python.has_code("x = 1\n"));
481 assert!(Language::Python.has_code("# header\nimport os\n"));
482 assert!(Language::Python.has_code("\"\"\"Package docstring.\"\"\"\n"));
484 }
485
486 #[test]
487 fn typescript_empty_or_comment_only_files_have_no_code() {
488 assert!(!Language::TypeScript.has_code(""));
489 assert!(!Language::TypeScript.has_code(" \n\t\n"));
490 assert!(!Language::TypeScript.has_code("// a line comment\n"));
491 assert!(!Language::TypeScript.has_code("/* a\n block\n comment */\n"));
492 }
493
494 #[test]
495 fn typescript_real_content_counts_as_code() {
496 assert!(Language::TypeScript.has_code("export const x = 1;\n"));
497 assert!(Language::TypeScript.has_code("// note\nexport * from './a';\n"));
498 assert!(Language::TypeScript.has_code("const s = '// not a comment';\n"));
500 assert!(Language::TypeScript.has_code("const r = a / b;\n"));
502 }
503
504 #[test]
505 fn rust_has_no_file_based_colocated_convention() {
506 assert!(!Language::Rust.tracks(Path::new("lib.rs")));
509 assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
510 assert!(!Language::Rust.has_code("fn main() {}\n"));
511 assert_eq!(
512 Language::Rust.expected_test_path(Path::new("src/lib.rs")),
513 PathBuf::from("src/lib.rs")
514 );
515 }
516
517 fn presence(src: &str) -> (bool, bool) {
520 let ast = syn::parse_file(src).expect("snippet parses");
521 let mut visitor = PresenceVisitor::default();
522 visitor.visit_file(&ast);
523 (visitor.has_testable_fn, visitor.has_test_module)
524 }
525
526 #[test]
527 fn rust_presence_free_fn_with_test_module_is_covered() {
528 assert_eq!(
529 presence(
530 "pub fn make(n: u8) -> u8 { n + 1 }\n\
531 #[cfg(test)]\nmod tests { #[test] fn t() {} }\n"
532 ),
533 (true, true)
534 );
535 }
536
537 #[test]
538 fn rust_presence_free_fn_without_test_module_needs_one() {
539 assert_eq!(
540 presence("pub fn make(n: u8) -> u8 { n + 1 }\n"),
541 (true, false)
542 );
543 }
544
545 #[test]
546 fn rust_presence_type_only_file_is_not_a_subject() {
547 assert_eq!(presence("pub struct Point { pub x: u8 }\n"), (false, false));
548 }
549
550 #[test]
551 fn rust_presence_impl_method_is_testable() {
552 assert_eq!(
553 presence("pub struct W;\nimpl W { pub fn go(&self) -> u8 { 1 } }\n"),
554 (true, false)
555 );
556 }
557
558 #[test]
559 fn rust_presence_trait_default_is_testable_but_bare_signature_is_not() {
560 assert_eq!(
561 presence("pub trait T { fn d(&self) -> u8 { 1 } }\n"),
562 (true, false)
563 );
564 assert_eq!(
565 presence("pub trait T { fn s(&self) -> u8; }\n"),
566 (false, false)
567 );
568 }
569
570 #[test]
571 fn rust_presence_test_module_functions_are_not_subjects() {
572 assert_eq!(
575 presence("#[cfg(test)]\nmod tests { fn helper() {} #[test] fn t() {} }\n"),
576 (false, true)
577 );
578 }
579
580 #[test]
581 fn rust_presence_cfg_test_gated_free_fn_is_not_a_subject() {
582 assert_eq!(
585 presence("#[cfg(test)]\nfn only_in_tests() {}\n"),
586 (false, false)
587 );
588 }
589}