1use std::collections::{BTreeSet, HashSet};
4use std::path::{Path, PathBuf};
5
6use anyhow::{anyhow, Context, Result};
7use rustpython_parser::lexer::lex;
8use rustpython_parser::{ast, Mode, Parse, Tok};
9use syn::visit::{self, Visit};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
13pub enum Language {
14 #[value(name = "python")]
16 Python,
17 #[value(name = "typescript")]
20 TypeScript,
21 #[value(name = "rust")]
26 Rust,
27}
28
29impl Language {
30 pub(crate) fn tracks(self, path: &Path) -> bool {
32 match self {
33 Language::Python => has_extension(path, &["py"]),
34 Language::TypeScript => {
35 has_extension(path, &["ts", "tsx", "mts", "cts"]) && !is_declaration(path)
36 }
37 Language::Rust => false,
38 }
39 }
40
41 pub(crate) fn is_test(self, path: &Path) -> bool {
43 match self {
44 Language::Python => stem_of(path).ends_with("_test"),
45 Language::TypeScript => {
46 let name = file_name_of(path);
47 name.ends_with(".test.ts")
48 || name.ends_with(".test.tsx")
49 || name.ends_with(".test.mts")
50 || name.ends_with(".test.cts")
51 }
52 Language::Rust => false,
53 }
54 }
55
56 pub(crate) fn is_support(self, path: &Path) -> bool {
58 match self {
59 Language::Python => file_name_of(path) == "conftest.py",
60 Language::TypeScript | Language::Rust => false,
61 }
62 }
63
64 pub(crate) fn has_code(self, source: &str) -> bool {
67 match self {
68 Language::Python => python_has_code(source),
69 Language::TypeScript => typescript_has_code(source),
70 Language::Rust => false,
71 }
72 }
73
74 pub(crate) fn is_subject(self, source: &str, path: &Path) -> bool {
78 if !self.has_code(source) {
79 return false;
80 }
81 match self {
82 Language::TypeScript => !crate::ts::is_type_only_module(source, path),
83 Language::Python | Language::Rust => true,
84 }
85 }
86
87 pub(crate) fn same_code(self, base: &str, head: &str, path: &Path) -> bool {
91 match self {
92 Language::Python => python_same_code(base, head),
93 Language::TypeScript => crate::ts::same_code(base, head, path),
94 Language::Rust => false,
96 }
97 }
98
99 pub(crate) fn expected_test_path(self, source: &Path) -> PathBuf {
101 match self {
102 Language::Python => source.with_file_name(format!("{}_test.py", stem_of(source))),
103 Language::TypeScript => {
104 source.with_file_name(format!("{}.test.{}", stem_of(source), extension_of(source)))
105 }
106 Language::Rust => source.to_path_buf(),
108 }
109 }
110}
111
112pub 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 {
125 Language::Python => Some("pyproject.toml"),
126 Language::TypeScript => Some("package.json"),
127 Language::Rust => None,
128 };
129 if let Some(tests) = manifest.and_then(|m| crate::tiers::suite_tests_dir(root, m)) {
130 files.retain(|file| !file.starts_with(&tests));
131 }
132
133 let present: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
134
135 let mut orphans: Vec<PathBuf> = Vec::new();
136 for source in &files {
137 if language.is_test(source) || language.is_support(source) {
138 continue;
139 }
140 if present.contains(language.expected_test_path(source).as_path()) {
141 continue;
142 }
143 let contents = std::fs::read_to_string(source)
145 .with_context(|| format!("reading source file `{}`", source.display()))?;
146 if !language.is_subject(&contents, source) {
147 continue;
148 }
149 let relative = source
150 .strip_prefix(root)
151 .unwrap_or(source)
152 .to_string_lossy()
153 .replace('\\', "/");
154 if exempt.contains(&relative) {
155 continue;
156 }
157 orphans.push(source.clone());
158 }
159 orphans.sort();
160 Ok(orphans)
161}
162
163pub(crate) fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
165 let entries =
166 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
167 for entry in entries {
168 let path = entry
169 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
170 .path();
171 if path.is_dir() {
172 collect_files(&path, language, out)?;
173 } else if language.tracks(&path) {
174 out.push(path);
175 }
176 }
177 Ok(())
178}
179
180pub fn missing_inline_tests(
184 root: impl AsRef<Path>,
185 exempt: &BTreeSet<String>,
186) -> Result<Vec<PathBuf>> {
187 let root = root.as_ref();
188 let mut files = Vec::new();
189 collect_rust_source_files(root, &mut files)?;
190 files.sort();
191
192 let mut orphans = Vec::new();
193 for file in &files {
194 let source = std::fs::read_to_string(file)
195 .with_context(|| format!("reading source file `{}`", file.display()))?;
196 let ast = syn::parse_file(&source)
197 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
198 let mut visitor = PresenceVisitor::default();
199 visitor.visit_file(&ast);
200 if !visitor.has_testable_fn || visitor.has_test_module {
201 continue;
202 }
203 let relative = file
204 .strip_prefix(root)
205 .unwrap_or(file)
206 .to_string_lossy()
207 .replace('\\', "/");
208 if exempt.contains(&relative) {
209 continue;
210 }
211 orphans.push(file.clone());
212 }
213 Ok(orphans)
215}
216
217pub(crate) fn collect_rust_source_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
220 let entries =
221 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
222 for entry in entries {
223 let path = entry
224 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
225 .path();
226 if path.is_dir() {
227 let skip = matches!(
228 path.file_name().and_then(|name| name.to_str()),
229 Some("tests" | "benches" | "examples" | "target")
230 );
231 if !skip {
232 collect_rust_source_files(&path, out)?;
233 }
234 } else if has_extension(&path, &["rs"]) && file_name_of(&path) != "build.rs" {
235 out.push(path);
236 }
237 }
238 Ok(())
239}
240
241#[derive(Default)]
244struct PresenceVisitor {
245 test_depth: usize,
246 has_testable_fn: bool,
247 has_test_module: bool,
248}
249
250impl<'ast> Visit<'ast> for PresenceVisitor {
251 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
252 let is_test = crate::isolation::has_cfg_test(&node.attrs);
253 if is_test {
254 self.has_test_module = true;
255 self.test_depth += 1;
256 }
257 visit::visit_item_mod(self, node);
258 if is_test {
259 self.test_depth -= 1;
260 }
261 }
262
263 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
264 if self.test_depth == 0 && !crate::isolation::has_cfg_test(&node.attrs) {
265 self.has_testable_fn = true;
266 }
267 visit::visit_item_fn(self, node);
268 }
269
270 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
271 if self.test_depth == 0 {
272 self.has_testable_fn = true;
273 }
274 visit::visit_impl_item_fn(self, node);
275 }
276
277 fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
278 if self.test_depth == 0 && node.default.is_some() {
279 self.has_testable_fn = true;
280 }
281 visit::visit_trait_item_fn(self, node);
282 }
283}
284
285fn has_extension(path: &Path, extensions: &[&str]) -> bool {
287 path.extension()
288 .and_then(|ext| ext.to_str())
289 .is_some_and(|ext| extensions.contains(&ext))
290}
291
292fn is_declaration(path: &Path) -> bool {
294 let name = file_name_of(path);
295 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
296}
297
298fn python_has_code(source: &str) -> bool {
300 source.lines().any(|line| {
301 let trimmed = line.trim_start();
302 !trimmed.is_empty() && !trimmed.starts_with('#')
303 })
304}
305
306fn python_same_code(base: &str, head: &str) -> bool {
310 match (python_tokens(base), python_tokens(head)) {
311 (Some(base), Some(head)) => base == head,
312 _ => false,
313 }
314}
315
316fn python_tokens(source: &str) -> Option<Vec<Tok>> {
318 let tokens: Vec<Tok> = lex(source, Mode::Module)
319 .map(|token| token.ok().map(|(tok, _)| tok))
320 .collect::<Option<_>>()?;
321 ast::Suite::parse(source, "<source>").ok()?;
324 Some(tokens)
325}
326
327fn typescript_has_code(source: &str) -> bool {
330 let mut chars = source.chars().peekable();
331 while let Some(c) = chars.next() {
332 match c {
333 c if c.is_whitespace() => {}
334 '/' if chars.peek() == Some(&'/') => {
335 while chars.peek().is_some_and(|&n| n != '\n') {
336 chars.next();
337 }
338 }
339 '/' if chars.peek() == Some(&'*') => {
340 chars.next();
341 let mut prev = '\0';
342 for n in chars.by_ref() {
343 if prev == '*' && n == '/' {
344 break;
345 }
346 prev = n;
347 }
348 }
349 _ => return true,
350 }
351 }
352 false
353}
354
355fn extension_of(path: &Path) -> String {
357 path.extension()
358 .map(|ext| ext.to_string_lossy().into_owned())
359 .unwrap_or_default()
360}
361
362fn file_name_of(path: &Path) -> String {
364 path.file_name()
365 .map(|name| name.to_string_lossy().into_owned())
366 .unwrap_or_default()
367}
368
369fn stem_of(path: &Path) -> String {
371 path.file_stem()
372 .map(|stem| stem.to_string_lossy().into_owned())
373 .unwrap_or_default()
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn python_tracks_py_files() {
382 assert!(Language::Python.tracks(Path::new("a.py")));
383 assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
384 assert!(!Language::Python.tracks(Path::new("a.pyi")));
385 assert!(!Language::Python.tracks(Path::new("a.txt")));
386 assert!(!Language::Python.tracks(Path::new("README")));
387 }
388
389 #[test]
390 fn python_recognizes_test_files_by_stem_suffix() {
391 assert!(Language::Python.is_test(Path::new("widget_test.py")));
392 assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
393 assert!(!Language::Python.is_test(Path::new("widget.py")));
394 }
395
396 #[test]
397 fn python_conftest_is_support_not_a_subject() {
398 assert!(Language::Python.is_support(Path::new("conftest.py")));
399 assert!(Language::Python.is_support(Path::new("pkg/conftest.py")));
400 assert!(!Language::Python.is_support(Path::new("widget.py")));
401 assert!(!Language::Python.is_support(Path::new("widget_test.py")));
402 assert!(!Language::TypeScript.is_support(Path::new("conftest.ts")));
403 }
404
405 #[test]
406 fn python_expected_test_path_is_the_colocated_twin() {
407 assert_eq!(
408 Language::Python.expected_test_path(Path::new("pkg/widget.py")),
409 PathBuf::from("pkg/widget_test.py")
410 );
411 assert_eq!(
412 Language::Python.expected_test_path(Path::new("widget.py")),
413 PathBuf::from("widget_test.py")
414 );
415 }
416
417 #[test]
418 fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
419 assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
420 assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
421 assert!(Language::TypeScript.tracks(Path::new("service.mts")));
422 assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
423 assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
424 assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
425 assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
426 assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
427 assert!(!Language::TypeScript.tracks(Path::new("README")));
428 }
429
430 #[test]
431 fn typescript_recognizes_test_files_by_suffix() {
432 assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
433 assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
434 assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
435 assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
436 assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
437 assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
438 assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
439 }
440
441 #[test]
442 fn typescript_expected_test_path_keeps_the_extension() {
443 assert_eq!(
444 Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
445 PathBuf::from("pkg/widget.test.ts")
446 );
447 assert_eq!(
448 Language::TypeScript.expected_test_path(Path::new("button.tsx")),
449 PathBuf::from("button.test.tsx")
450 );
451 assert_eq!(
452 Language::TypeScript.expected_test_path(Path::new("service.mts")),
453 PathBuf::from("service.test.mts")
454 );
455 assert_eq!(
456 Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
457 PathBuf::from("legacy.test.cts")
458 );
459 }
460
461 #[test]
462 fn python_empty_or_comment_only_files_have_no_code() {
463 assert!(!Language::Python.has_code(""));
464 assert!(!Language::Python.has_code("\n \n"));
465 assert!(!Language::Python.has_code("# just a comment\n # another\n"));
466 }
467
468 #[test]
469 fn python_real_content_counts_as_code() {
470 assert!(Language::Python.has_code("x = 1\n"));
471 assert!(Language::Python.has_code("# header\nimport os\n"));
472 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"));
488 assert!(Language::TypeScript.has_code("const r = a / b;\n"));
489 }
490
491 #[test]
492 fn typescript_subject_skips_type_only_modules() {
493 let ts = Path::new("aliases.ts");
494 assert!(!Language::TypeScript.is_subject("export type Alias = string;\n", ts));
495 assert!(!Language::TypeScript.is_subject("export interface Shape { kind: string }\n", ts));
496 assert!(!Language::TypeScript.is_subject("import type { A } from './a';\n", ts));
497 }
498
499 #[test]
500 fn typescript_subject_keeps_anything_with_runtime_behavior() {
501 let ts = Path::new("widget.ts");
502 assert!(Language::TypeScript.is_subject("export const x = 1;\n", ts));
503 assert!(Language::TypeScript
504 .is_subject("export type Alias = string;\nexport const x = 1;\n", ts));
505 assert!(!Language::TypeScript.is_subject("", ts));
506 assert!(!Language::TypeScript.is_subject("// nothing here\n", ts));
507 }
508
509 #[test]
510 fn python_subject_is_decided_by_code_alone() {
511 let py = Path::new("widget.py");
512 assert!(Language::Python.is_subject("x = 1\n", py));
513 assert!(Language::Python.is_subject("Alias = str\n", py));
514 assert!(!Language::Python.is_subject("# just a comment\n", py));
515 }
516
517 const PY_WIDGET: &str = "def widget():\n return 1\n";
518
519 #[test]
520 fn python_same_code_ignores_comments_and_formatting() {
521 let py = Path::new("widget.py");
522 assert!(Language::Python.same_code(
523 "# widget helpers\ndef widget():\n return 1\n",
524 "# widget utilities\ndef widget():\n return 1\n",
525 py
526 ));
527 assert!(Language::Python.same_code(
528 "# widget helpers\ndef widget():\n return 1\n",
529 PY_WIDGET,
530 py
531 ));
532 assert!(Language::Python.same_code(PY_WIDGET, "def widget():\n\n return 1\n", py));
533 assert!(Language::Python.same_code("def widget(): \n return 1 \n", PY_WIDGET, py));
534 }
535
536 #[test]
537 fn python_same_code_sees_every_edit_the_interpreter_sees() {
538 let py = Path::new("widget.py");
539 assert!(!Language::Python.same_code(PY_WIDGET, "def widget():\n return 2\n", py));
540 assert!(!Language::Python.same_code(
541 "\"\"\"Widget helpers.\"\"\"\ndef widget():\n return 1\n",
542 "\"\"\"Widget utilities.\"\"\"\ndef widget():\n return 1\n",
543 py
544 ));
545 assert!(!Language::Python.same_code(
546 "def widget():\n return \"one\"\n",
547 "def widget():\n return \"two\"\n",
548 py
549 ));
550 assert!(!Language::Python.same_code(
551 "def widget(flag):\n if flag:\n count = 1\n return count\n",
552 "def widget(flag):\n if flag:\n count = 1\n return count\n",
553 py
554 ));
555 }
556
557 #[test]
558 fn python_same_code_holds_unparseable_content_apart() {
559 let py = Path::new("widget.py");
560 assert!(!Language::Python.same_code(
561 "def widget(:\n return 1\n",
562 "# note\ndef widget(:\n return 1\n",
563 py
564 ));
565 assert!(!Language::Python.same_code(
566 "def widget() return 1\n",
567 "# note\ndef widget() return 1\n",
568 py
569 ));
570 assert!(!Language::Python.same_code(PY_WIDGET, "def widget() return 1\n", py));
571 assert!(!Language::Python.same_code("def widget() return 1\n", PY_WIDGET, py));
572 }
573
574 #[test]
575 fn typescript_same_code_reads_the_emitted_module() {
576 let ts = Path::new("widget.ts");
577 assert!(Language::TypeScript.same_code(
578 "// widget factory\nexport const widget = () => 1;\n",
579 "export const widget = () => 1;\n",
580 ts
581 ));
582 assert!(!Language::TypeScript.same_code(
583 "export const widget = () => 1;\n",
584 "export const widget = () => 2;\n",
585 ts
586 ));
587 }
588
589 #[test]
590 fn rust_same_code_never_answers_equal() {
591 assert!(!Language::Rust.same_code("fn f() {}\n", "fn f() {}\n", Path::new("lib.rs")));
592 }
593
594 #[test]
595 fn rust_has_no_file_based_colocated_convention() {
596 assert!(!Language::Rust.tracks(Path::new("lib.rs")));
597 assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
598 assert!(!Language::Rust.has_code("fn main() {}\n"));
599 assert_eq!(
600 Language::Rust.expected_test_path(Path::new("src/lib.rs")),
601 PathBuf::from("src/lib.rs")
602 );
603 }
604
605 fn presence(src: &str) -> (bool, bool) {
607 let ast = syn::parse_file(src).expect("snippet parses");
608 let mut visitor = PresenceVisitor::default();
609 visitor.visit_file(&ast);
610 (visitor.has_testable_fn, visitor.has_test_module)
611 }
612
613 #[test]
614 fn rust_presence_free_fn_with_test_module_is_covered() {
615 assert_eq!(
616 presence(
617 "pub fn make(n: u8) -> u8 { n + 1 }\n\
618 #[cfg(test)]\nmod tests { #[test] fn t() {} }\n"
619 ),
620 (true, true)
621 );
622 }
623
624 #[test]
625 fn rust_presence_free_fn_without_test_module_needs_one() {
626 assert_eq!(
627 presence("pub fn make(n: u8) -> u8 { n + 1 }\n"),
628 (true, false)
629 );
630 }
631
632 #[test]
633 fn rust_presence_type_only_file_is_not_a_subject() {
634 assert_eq!(presence("pub struct Point { pub x: u8 }\n"), (false, false));
635 }
636
637 #[test]
638 fn rust_presence_impl_method_is_testable() {
639 assert_eq!(
640 presence("pub struct W;\nimpl W { pub fn go(&self) -> u8 { 1 } }\n"),
641 (true, false)
642 );
643 }
644
645 #[test]
646 fn rust_presence_trait_default_is_testable_but_bare_signature_is_not() {
647 assert_eq!(
648 presence("pub trait T { fn d(&self) -> u8 { 1 } }\n"),
649 (true, false)
650 );
651 assert_eq!(
652 presence("pub trait T { fn s(&self) -> u8; }\n"),
653 (false, false)
654 );
655 }
656
657 #[test]
658 fn rust_presence_test_module_functions_are_not_subjects() {
659 assert_eq!(
660 presence("#[cfg(test)]\nmod tests { fn helper() {} #[test] fn t() {} }\n"),
661 (false, true)
662 );
663 }
664
665 #[test]
666 fn rust_presence_cfg_test_gated_free_fn_is_not_a_subject() {
667 assert_eq!(
668 presence("#[cfg(test)]\nfn only_in_tests() {}\n"),
669 (false, false)
670 );
671 }
672}