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