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