1use std::collections::{BTreeSet, HashSet};
4use std::path::{Path, PathBuf};
5
6use anyhow::{anyhow, Context, Result};
7use rustpython_ast::Visitor;
8use rustpython_parser::lexer::lex;
9use rustpython_parser::{ast, Mode, Parse, Tok};
10use syn::visit::{self, Visit};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
14pub enum Language {
15 #[value(name = "python")]
17 Python,
18 #[value(name = "typescript")]
21 TypeScript,
22 #[value(name = "rust")]
27 Rust,
28}
29
30impl Language {
31 pub(crate) fn tracks(self, path: &Path) -> bool {
33 match self {
34 Language::Python => has_extension(path, &["py"]),
35 Language::TypeScript => {
36 has_extension(path, &["ts", "tsx", "mts", "cts"]) && !is_declaration(path)
37 }
38 Language::Rust => false,
39 }
40 }
41
42 pub(crate) fn is_test(self, path: &Path) -> bool {
44 match self {
45 Language::Python => stem_of(path).ends_with("_test"),
46 Language::TypeScript => {
47 let name = file_name_of(path);
48 name.ends_with(".test.ts")
49 || name.ends_with(".test.tsx")
50 || name.ends_with(".test.mts")
51 || name.ends_with(".test.cts")
52 }
53 Language::Rust => false,
54 }
55 }
56
57 pub(crate) fn is_support(self, path: &Path) -> bool {
59 match self {
60 Language::Python => file_name_of(path) == "conftest.py",
61 Language::TypeScript | Language::Rust => false,
62 }
63 }
64
65 pub(crate) fn is_subject(self, source: &str, path: &Path) -> bool {
69 match self {
70 Language::Python => python_has_behavior(source),
71 Language::TypeScript => crate::ts::has_behavior(source, path),
72 Language::Rust => rust_has_behavior(source),
73 }
74 }
75
76 pub(crate) fn same_code(self, base: &str, head: &str, path: &Path) -> bool {
80 match self {
81 Language::Python => python_same_code(base, head),
82 Language::TypeScript => crate::ts::same_code(base, head, path),
83 Language::Rust => false,
85 }
86 }
87
88 pub(crate) fn relocates_imports(
91 self,
92 base: &str,
93 head: &str,
94 path: &Path,
95 deleted: &BTreeSet<String>,
96 ) -> bool {
97 match self {
98 Language::Python => crate::python_imports::split(base, path)
99 .zip(crate::python_imports::split(head, path))
100 .is_some_and(|(b, h)| crate::relocation::is_relocation(&b, &h, deleted)),
101 Language::TypeScript => crate::ts_imports::split(base, path)
102 .zip(crate::ts_imports::split(head, path))
103 .is_some_and(|(b, h)| crate::relocation::is_relocation(&b, &h, deleted)),
104 Language::Rust => false,
105 }
106 }
107
108 pub(crate) fn expected_test_path(self, source: &Path) -> PathBuf {
110 match self {
111 Language::Python => source.with_file_name(format!("{}_test.py", stem_of(source))),
112 Language::TypeScript => {
113 source.with_file_name(format!("{}.test.{}", stem_of(source), extension_of(source)))
114 }
115 Language::Rust => source.to_path_buf(),
117 }
118 }
119}
120
121pub fn missing_unit_tests(
125 root: impl AsRef<Path>,
126 language: Language,
127 exempt: &BTreeSet<String>,
128) -> Result<Vec<PathBuf>> {
129 let root = root.as_ref();
130 let mut files = Vec::new();
131 collect_files(root, language, &mut files)?;
132 let manifest = match language {
134 Language::Python => Some("pyproject.toml"),
135 Language::TypeScript => Some("package.json"),
136 Language::Rust => None,
137 };
138 if let Some(tests) = manifest.and_then(|m| crate::tiers::suite_tests_dir(root, m)) {
139 files.retain(|file| !file.starts_with(&tests));
140 }
141
142 let present: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
143
144 let mut orphans: Vec<PathBuf> = Vec::new();
145 for source in &files {
146 if language.is_test(source) || language.is_support(source) {
147 continue;
148 }
149 if present.contains(language.expected_test_path(source).as_path()) {
150 continue;
151 }
152 let contents = std::fs::read_to_string(source)
154 .with_context(|| format!("reading source file `{}`", source.display()))?;
155 if !language.is_subject(&contents, source) {
156 continue;
157 }
158 if exempt.contains(&exempt_key(root, source)) {
159 continue;
160 }
161 orphans.push(source.clone());
162 }
163 orphans.sort();
164 Ok(orphans)
165}
166
167fn exempt_key(root: &Path, file: &Path) -> String {
171 file.strip_prefix(root)
172 .unwrap_or(file)
173 .to_string_lossy()
174 .replace('\\', "/")
175}
176
177fn lacks_inline_tests(file: &Path) -> Result<bool> {
180 let source = std::fs::read_to_string(file)
181 .with_context(|| format!("reading source file `{}`", file.display()))?;
182 let ast =
183 syn::parse_file(&source).map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
184 let mut visitor = PresenceVisitor::default();
185 visitor.visit_file(&ast);
186 Ok(visitor.has_testable_fn && !visitor.has_test_module)
187}
188
189pub(crate) fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
191 let entries =
192 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
193 for entry in entries {
194 let path = crate::walk::dir_entry(entry, dir)?.path();
195 if path.is_dir() {
196 collect_files(&path, language, out)?;
197 } else if language.tracks(&path) {
198 out.push(path);
199 }
200 }
201 Ok(())
202}
203
204pub fn missing_inline_tests(
208 root: impl AsRef<Path>,
209 exempt: &BTreeSet<String>,
210) -> Result<Vec<PathBuf>> {
211 let root = root.as_ref();
212 let mut files = Vec::new();
213 collect_rust_source_files(root, &mut files)?;
214 files.sort();
215
216 let mut orphans = Vec::new();
217 for file in &files {
218 if exempt.contains(&exempt_key(root, file)) {
219 continue;
220 }
221 if lacks_inline_tests(file)? {
222 orphans.push(file.clone());
223 }
224 }
225 Ok(orphans)
227}
228
229pub(crate) fn collect_rust_source_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
232 let entries =
233 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
234 for entry in entries {
235 let path = crate::walk::dir_entry(entry, dir)?.path();
236 if path.is_dir() {
237 let skip = matches!(
238 path.file_name().and_then(|name| name.to_str()),
239 Some("tests" | "benches" | "examples" | "target")
240 );
241 if !skip {
242 collect_rust_source_files(&path, out)?;
243 }
244 } else if has_extension(&path, &["rs"]) && file_name_of(&path) != "build.rs" {
245 out.push(path);
246 }
247 }
248 Ok(())
249}
250
251#[derive(Default)]
254struct PresenceVisitor {
255 test_depth: usize,
256 has_testable_fn: bool,
257 has_test_module: bool,
258}
259
260impl<'ast> Visit<'ast> for PresenceVisitor {
261 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
262 let is_test = crate::isolation::has_cfg_test(&node.attrs);
263 if is_test {
264 self.has_test_module = true;
265 self.test_depth += 1;
266 }
267 visit::visit_item_mod(self, node);
268 if is_test {
269 self.test_depth -= 1;
270 }
271 }
272
273 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
274 if self.test_depth == 0
275 && !crate::isolation::has_cfg_test(&node.attrs)
276 && !is_logic_free_main(node)
277 {
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() {
292 self.has_testable_fn = true;
293 }
294 visit::visit_trait_item_fn(self, node);
295 }
296}
297
298fn is_logic_free_main(node: &syn::ItemFn) -> bool {
308 if node.sig.ident != "main" {
309 return false;
310 }
311 let [syn::Stmt::Expr(syn::Expr::Call(call), _)] = &node.block.stmts[..] else {
312 return false;
313 };
314 call.args.is_empty() && matches!(*call.func, syn::Expr::Path(_))
315}
316
317fn rust_has_behavior(source: &str) -> bool {
321 let Ok(file) = syn::parse_file(source) else {
322 return true;
323 };
324 let mut visitor = BehaviorPresenceVisitor { found: false };
325 visitor.visit_file(&file);
326 visitor.found
327}
328
329struct BehaviorPresenceVisitor {
331 found: bool,
332}
333
334impl<'ast> Visit<'ast> for BehaviorPresenceVisitor {
335 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
336 self.found = true;
337 visit::visit_item_fn(self, node);
338 }
339
340 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
341 self.found = true;
342 visit::visit_impl_item_fn(self, node);
343 }
344
345 fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
346 if node.default.is_some() {
347 self.found = true;
348 }
349 visit::visit_trait_item_fn(self, node);
350 }
351
352 fn visit_expr_closure(&mut self, node: &'ast syn::ExprClosure) {
353 self.found = true;
354 visit::visit_expr_closure(self, node);
355 }
356}
357
358fn has_extension(path: &Path, extensions: &[&str]) -> bool {
360 path.extension()
361 .and_then(|ext| ext.to_str())
362 .is_some_and(|ext| extensions.contains(&ext))
363}
364
365fn is_declaration(path: &Path) -> bool {
367 let name = file_name_of(path);
368 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
369}
370
371fn python_same_code(base: &str, head: &str) -> bool {
375 match (python_tokens(base), python_tokens(head)) {
376 (Some(base), Some(head)) => base == head,
377 _ => false,
378 }
379}
380
381fn python_has_behavior(source: &str) -> bool {
384 let Ok(suite) = ast::Suite::parse(source, "<source>") else {
385 return true;
386 };
387 let mut visitor = BehaviorVisitor { found: false };
388 for stmt in suite {
389 visitor.visit_stmt(stmt);
390 }
391 visitor.found
392}
393
394struct BehaviorVisitor {
396 found: bool,
397}
398
399impl Visitor for BehaviorVisitor {
400 fn visit_stmt_function_def(&mut self, _: ast::StmtFunctionDef) {
401 self.found = true;
402 }
403
404 fn visit_stmt_async_function_def(&mut self, _: ast::StmtAsyncFunctionDef) {
405 self.found = true;
406 }
407
408 fn visit_expr_lambda(&mut self, _: ast::ExprLambda) {
409 self.found = true;
410 }
411
412 fn visit_stmt_if(&mut self, _: ast::StmtIf) {
413 self.found = true;
414 }
415
416 fn visit_stmt_for(&mut self, _: ast::StmtFor) {
417 self.found = true;
418 }
419
420 fn visit_stmt_async_for(&mut self, _: ast::StmtAsyncFor) {
421 self.found = true;
422 }
423
424 fn visit_stmt_while(&mut self, _: ast::StmtWhile) {
425 self.found = true;
426 }
427
428 fn visit_stmt_try(&mut self, _: ast::StmtTry) {
429 self.found = true;
430 }
431
432 fn visit_stmt_try_star(&mut self, _: ast::StmtTryStar) {
433 self.found = true;
434 }
435
436 fn visit_stmt_with(&mut self, _: ast::StmtWith) {
437 self.found = true;
438 }
439
440 fn visit_stmt_async_with(&mut self, _: ast::StmtAsyncWith) {
441 self.found = true;
442 }
443
444 fn visit_stmt_match(&mut self, _: ast::StmtMatch) {
445 self.found = true;
446 }
447
448 fn visit_expr_if_exp(&mut self, _: ast::ExprIfExp) {
449 self.found = true;
450 }
451
452 fn visit_comprehension(&mut self, node: ast::Comprehension) {
455 self.found |= !node.ifs.is_empty();
456 self.visit_expr(node.iter);
457 }
458
459 fn visit_keyword(&mut self, node: ast::Keyword) {
460 self.visit_expr(node.value);
461 }
462}
463
464fn python_tokens(source: &str) -> Option<Vec<Tok>> {
466 let tokens: Vec<Tok> = lex(source, Mode::Module)
467 .map(|token| token.ok().map(|(tok, _)| tok))
468 .collect::<Option<_>>()?;
469 ast::Suite::parse(source, "<source>").ok()?;
472 Some(tokens)
473}
474
475fn extension_of(path: &Path) -> String {
477 path.extension()
478 .map(|ext| ext.to_string_lossy().into_owned())
479 .unwrap_or_default()
480}
481
482fn file_name_of(path: &Path) -> String {
484 path.file_name()
485 .map(|name| name.to_string_lossy().into_owned())
486 .unwrap_or_default()
487}
488
489fn stem_of(path: &Path) -> String {
491 path.file_stem()
492 .map(|stem| stem.to_string_lossy().into_owned())
493 .unwrap_or_default()
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 fn scratch(name: &str, files: &[(&str, &str)]) -> PathBuf {
502 let root = std::env::temp_dir().join(format!(
503 "testing-conventions-colocated-{name}-{}",
504 std::process::id()
505 ));
506 let _ = std::fs::remove_dir_all(&root);
507 for (path, contents) in files {
508 let full = root.join(path);
509 std::fs::create_dir_all(full.parent().unwrap()).unwrap();
510 std::fs::write(&full, contents).unwrap();
511 }
512 root
513 }
514
515 #[test]
516 fn a_rust_file_with_a_body_and_no_test_module_lacks_inline_tests() {
517 let root = scratch(
518 "lacks-yes",
519 &[("src/widget.rs", "pub fn f() -> u8 { 1 }\n")],
520 );
521 assert!(lacks_inline_tests(&root.join("src/widget.rs")).unwrap());
522 }
523
524 #[test]
525 fn a_rust_file_carrying_its_own_test_module_does_not() {
526 let root = scratch(
527 "lacks-no",
528 &[(
529 "src/widget.rs",
530 "pub fn f() -> u8 { 1 }\n#[cfg(test)]\nmod tests { #[test] fn t() {} }\n",
531 )],
532 );
533 assert!(!lacks_inline_tests(&root.join("src/widget.rs")).unwrap());
534 }
535
536 #[test]
537 fn a_rust_file_with_no_testable_body_does_not_want_inline_tests() {
538 let root = scratch("lacks-decl", &[("src/types.rs", "pub struct Widget;\n")]);
539 assert!(!lacks_inline_tests(&root.join("src/types.rs")).unwrap());
540 }
541
542 #[test]
543 fn missing_inline_tests_names_the_orphan_and_only_the_orphan() {
544 let root = scratch(
545 "inline-orphans",
546 &[
547 ("src/orphan.rs", "pub fn f() -> u8 { 1 }\n"),
548 (
549 "src/tested.rs",
550 "pub fn g() -> u8 { 2 }\n#[cfg(test)]\nmod tests { #[test] fn t() {} }\n",
551 ),
552 ],
553 );
554 let orphans = missing_inline_tests(&root, &BTreeSet::new()).unwrap();
555 assert_eq!(orphans, vec![root.join("src/orphan.rs")]);
556 }
557
558 #[test]
559 fn an_exempt_rust_file_is_never_an_orphan() {
560 let root = scratch(
561 "inline-exempt",
562 &[("src/orphan.rs", "pub fn f() -> u8 { 1 }\n")],
563 );
564 let exempt: BTreeSet<String> = ["src/orphan.rs".to_string()].into_iter().collect();
565 assert!(missing_inline_tests(&root, &exempt).unwrap().is_empty());
566 }
567
568 #[test]
569 fn missing_unit_tests_names_the_source_with_no_colocated_twin() {
570 let root = scratch(
571 "unit-orphans",
572 &[
573 ("pkg/orphan.py", "def f():\n return 1\n"),
574 ("pkg/tested.py", "def g():\n return 2\n"),
575 ("pkg/tested_test.py", "def test_g():\n assert True\n"),
576 ],
577 );
578 let orphans = missing_unit_tests(&root, Language::Python, &BTreeSet::new()).unwrap();
579 assert_eq!(orphans, vec![root.join("pkg/orphan.py")]);
580 }
581
582 #[test]
583 fn an_exempt_source_has_no_missing_unit_test() {
584 let root = scratch(
585 "unit-exempt",
586 &[("pkg/orphan.py", "def f():\n return 1\n")],
587 );
588 let exempt: BTreeSet<String> = ["pkg/orphan.py".to_string()].into_iter().collect();
589 assert!(missing_unit_tests(&root, Language::Python, &exempt)
590 .unwrap()
591 .is_empty());
592 }
593
594 #[test]
595 fn the_exempt_key_is_root_relative_with_forward_slashes() {
596 assert_eq!(
597 exempt_key(Path::new("/repo"), Path::new("/repo/src/lib.rs")),
598 "src/lib.rs"
599 );
600 }
601
602 #[test]
603 fn a_file_outside_the_root_keys_on_its_own_path() {
604 assert_eq!(
605 exempt_key(Path::new("/repo"), Path::new("/elsewhere/lib.rs")),
606 "/elsewhere/lib.rs"
607 );
608 }
609
610 #[test]
611 fn python_tracks_py_files() {
612 assert!(Language::Python.tracks(Path::new("a.py")));
613 assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
614 assert!(!Language::Python.tracks(Path::new("a.pyi")));
615 assert!(!Language::Python.tracks(Path::new("a.txt")));
616 assert!(!Language::Python.tracks(Path::new("README")));
617 }
618
619 #[test]
620 fn python_recognizes_test_files_by_stem_suffix() {
621 assert!(Language::Python.is_test(Path::new("widget_test.py")));
622 assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
623 assert!(!Language::Python.is_test(Path::new("widget.py")));
624 }
625
626 #[test]
627 fn python_conftest_is_support_not_a_subject() {
628 assert!(Language::Python.is_support(Path::new("conftest.py")));
629 assert!(Language::Python.is_support(Path::new("pkg/conftest.py")));
630 assert!(!Language::Python.is_support(Path::new("widget.py")));
631 assert!(!Language::Python.is_support(Path::new("widget_test.py")));
632 assert!(!Language::TypeScript.is_support(Path::new("conftest.ts")));
633 }
634
635 #[test]
636 fn python_expected_test_path_is_the_colocated_twin() {
637 assert_eq!(
638 Language::Python.expected_test_path(Path::new("pkg/widget.py")),
639 PathBuf::from("pkg/widget_test.py")
640 );
641 assert_eq!(
642 Language::Python.expected_test_path(Path::new("widget.py")),
643 PathBuf::from("widget_test.py")
644 );
645 }
646
647 #[test]
648 fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
649 assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
650 assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
651 assert!(Language::TypeScript.tracks(Path::new("service.mts")));
652 assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
653 assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
654 assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
655 assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
656 assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
657 assert!(!Language::TypeScript.tracks(Path::new("README")));
658 }
659
660 #[test]
661 fn typescript_recognizes_test_files_by_suffix() {
662 assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
663 assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
664 assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
665 assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
666 assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
667 assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
668 assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
669 }
670
671 #[test]
672 fn typescript_expected_test_path_keeps_the_extension() {
673 assert_eq!(
674 Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
675 PathBuf::from("pkg/widget.test.ts")
676 );
677 assert_eq!(
678 Language::TypeScript.expected_test_path(Path::new("button.tsx")),
679 PathBuf::from("button.test.tsx")
680 );
681 assert_eq!(
682 Language::TypeScript.expected_test_path(Path::new("service.mts")),
683 PathBuf::from("service.test.mts")
684 );
685 assert_eq!(
686 Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
687 PathBuf::from("legacy.test.cts")
688 );
689 }
690
691 #[test]
692 fn typescript_subject_skips_type_only_modules() {
693 let ts = Path::new("aliases.ts");
694 assert!(!Language::TypeScript.is_subject("export type Alias = string;\n", ts));
695 assert!(!Language::TypeScript.is_subject("export interface Shape { kind: string }\n", ts));
696 assert!(!Language::TypeScript.is_subject("import type { A } from './a';\n", ts));
697 }
698
699 #[test]
700 fn typescript_subject_needs_a_function_or_control_flow() {
701 let ts = Path::new("widget.ts");
702 assert!(Language::TypeScript.is_subject("export const x = () => 1;\n", ts));
703 assert!(Language::TypeScript.is_subject(
704 "export type Alias = string;\nexport const x = () => 1;\n",
705 ts
706 ));
707 assert!(!Language::TypeScript.is_subject("export const x = 1;\n", ts));
708 assert!(!Language::TypeScript.is_subject("", ts));
709 assert!(!Language::TypeScript.is_subject(" \n\t\n", ts));
710 assert!(!Language::TypeScript.is_subject("// nothing here\n", ts));
711 assert!(!Language::TypeScript.is_subject("/* a\n block\n comment */\n", ts));
712 }
713
714 fn py_subject(source: &str) -> bool {
715 Language::Python.is_subject(source, Path::new("widget.py"))
716 }
717
718 #[test]
719 fn python_declaration_only_module_is_not_a_subject() {
720 assert!(!py_subject("x = 1\n"));
721 assert!(!py_subject("Alias = str\n"));
722 assert!(!py_subject(
723 "\"\"\"pkg.\"\"\"\nfrom ._version import __version__\n__all__ = [\"__version__\"]\n"
724 ));
725 assert!(!py_subject("class Color(Enum):\n RED = 1\n"));
726 assert!(!py_subject(
727 "@dataclass\nclass Point:\n x: int\n y: int\n"
728 ));
729 assert!(!py_subject("logger = getLogger(__name__)\n"));
730 assert!(!py_subject("TIMEOUT = 30 * 60\nNAME = f\"{TIMEOUT}s\"\n"));
731 assert!(!py_subject("\"\"\"Package docstring.\"\"\"\n"));
732 assert!(!py_subject(""));
733 assert!(!py_subject("\n \n"));
734 assert!(!py_subject("# just a comment\n # another\n"));
735 }
736
737 #[test]
738 fn python_short_circuit_and_a_plain_comprehension_are_not_control_flow() {
739 assert!(!py_subject("DEBUG = os.environ.get(\"DEBUG\") or \"0\"\n"));
740 assert!(!py_subject("READY = a and b\n"));
741 assert!(!py_subject("NAMES = [c.name for c in Color]\n"));
742 assert!(!py_subject("BY_NAME = {c.name: c for c in Color}\n"));
743 }
744
745 #[test]
746 fn python_every_function_form_is_a_subject() {
747 assert!(py_subject("def f():\n return 1\n"));
748 assert!(py_subject("async def f():\n return 1\n"));
749 assert!(py_subject("HANDLERS = {\"id\": lambda x: x}\n"));
750 assert!(py_subject(
751 "class Widget:\n def run(self):\n return 1\n"
752 ));
753 assert!(py_subject(
754 "class Runner(Protocol):\n def run(self) -> int: ...\n"
755 ));
756 }
757
758 #[test]
759 fn python_every_control_flow_form_is_a_subject() {
760 assert!(py_subject("if DEBUG:\n LEVEL = 10\n"));
761 assert!(py_subject("for i in range(3):\n pass\n"));
762 assert!(py_subject("async for i in aiter():\n pass\n"));
763 assert!(py_subject("while True:\n pass\n"));
764 assert!(py_subject(
765 "try:\n import x\nexcept ImportError:\n x = None\n"
766 ));
767 assert!(py_subject(
768 "try:\n import x\nexcept* ImportError:\n x = None\n"
769 ));
770 assert!(py_subject("with open(p) as f:\n DATA = f.read()\n"));
771 assert!(py_subject("async with lock:\n pass\n"));
772 assert!(py_subject("match cmd:\n case \"go\":\n pass\n"));
773 assert!(py_subject("LEVEL = 10 if DEBUG else 20\n"));
774 assert!(py_subject("EVENS = [n for n in range(9) if n % 2 == 0]\n"));
775 }
776
777 #[test]
778 fn python_control_flow_nested_in_an_expression_is_a_subject() {
779 assert!(py_subject("CONFIG = {\"level\": (10 if DEBUG else 20)}\n"));
780 assert!(py_subject("run(callback=lambda: None)\n"));
781 assert!(py_subject(
782 "ORDERED = [x for x in sorted(xs, key=lambda x: x.id)]\n"
783 ));
784 }
785
786 #[test]
787 fn python_unparsable_content_stays_a_subject() {
788 assert!(py_subject("def f(:\n"));
789 }
790
791 const PY_WIDGET: &str = "def widget():\n return 1\n";
792
793 #[test]
794 fn python_same_code_ignores_comments_and_formatting() {
795 let py = Path::new("widget.py");
796 assert!(Language::Python.same_code(
797 "# widget helpers\ndef widget():\n return 1\n",
798 "# widget utilities\ndef widget():\n return 1\n",
799 py
800 ));
801 assert!(Language::Python.same_code(
802 "# widget helpers\ndef widget():\n return 1\n",
803 PY_WIDGET,
804 py
805 ));
806 assert!(Language::Python.same_code(PY_WIDGET, "def widget():\n\n return 1\n", py));
807 assert!(Language::Python.same_code("def widget(): \n return 1 \n", PY_WIDGET, py));
808 }
809
810 #[test]
811 fn python_same_code_sees_every_edit_the_interpreter_sees() {
812 let py = Path::new("widget.py");
813 assert!(!Language::Python.same_code(PY_WIDGET, "def widget():\n return 2\n", py));
814 assert!(!Language::Python.same_code(
815 "\"\"\"Widget helpers.\"\"\"\ndef widget():\n return 1\n",
816 "\"\"\"Widget utilities.\"\"\"\ndef widget():\n return 1\n",
817 py
818 ));
819 assert!(!Language::Python.same_code(
820 "def widget():\n return \"one\"\n",
821 "def widget():\n return \"two\"\n",
822 py
823 ));
824 assert!(!Language::Python.same_code(
825 "def widget(flag):\n if flag:\n count = 1\n return count\n",
826 "def widget(flag):\n if flag:\n count = 1\n return count\n",
827 py
828 ));
829 }
830
831 #[test]
832 fn relocates_imports_reads_each_language_and_never_rust() {
833 let gone: BTreeSet<String> = ["pkg/old.py", "src/old.ts"]
834 .iter()
835 .map(|p| p.to_string())
836 .collect();
837 assert!(Language::Python.relocates_imports(
838 "from pkg.old import f\nx = f()\n",
839 "from pkg.new import f\nx = f()\n",
840 Path::new("pkg/user.py"),
841 &gone,
842 ));
843 assert!(!Language::Python.relocates_imports(
844 "from pkg.old import f\nx = f()\n",
845 "def f() return 1\n",
846 Path::new("pkg/user.py"),
847 &gone,
848 ));
849 assert!(Language::TypeScript.relocates_imports(
850 "import { f } from './old.js';\nexport const x = f();\n",
851 "import { f } from './new.js';\nexport const x = f();\n",
852 Path::new("src/user.ts"),
853 &gone,
854 ));
855 assert!(!Language::Rust.relocates_imports("", "", Path::new("src/lib.rs"), &gone));
856 }
857
858 #[test]
859 fn python_same_code_holds_unparseable_content_apart() {
860 let py = Path::new("widget.py");
861 assert!(!Language::Python.same_code(
862 "def widget(:\n return 1\n",
863 "# note\ndef widget(:\n return 1\n",
864 py
865 ));
866 assert!(!Language::Python.same_code(
867 "def widget() return 1\n",
868 "# note\ndef widget() return 1\n",
869 py
870 ));
871 assert!(!Language::Python.same_code(PY_WIDGET, "def widget() return 1\n", py));
872 assert!(!Language::Python.same_code("def widget() return 1\n", PY_WIDGET, py));
873 }
874
875 #[test]
876 fn typescript_same_code_reads_the_emitted_module() {
877 let ts = Path::new("widget.ts");
878 assert!(Language::TypeScript.same_code(
879 "// widget factory\nexport const widget = () => 1;\n",
880 "export const widget = () => 1;\n",
881 ts
882 ));
883 assert!(!Language::TypeScript.same_code(
884 "export const widget = () => 1;\n",
885 "export const widget = () => 2;\n",
886 ts
887 ));
888 }
889
890 #[test]
891 fn rust_same_code_never_answers_equal() {
892 assert!(!Language::Rust.same_code("fn f() {}\n", "fn f() {}\n", Path::new("lib.rs")));
893 }
894
895 #[test]
896 fn rust_has_no_file_based_colocated_convention() {
897 assert!(!Language::Rust.tracks(Path::new("lib.rs")));
898 assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
899 assert!(Language::Rust.is_subject("fn main() {}\n", Path::new("main.rs")));
900 assert_eq!(
901 Language::Rust.expected_test_path(Path::new("src/lib.rs")),
902 PathBuf::from("src/lib.rs")
903 );
904 }
905
906 #[test]
907 fn rust_const_only_file_is_not_a_subject() {
908 assert!(!Language::Rust.is_subject(
909 "pub const TIMEOUT: u64 = 30 * 60;\n",
910 Path::new("settings.rs")
911 ));
912 }
913
914 #[test]
915 fn rust_unparseable_file_is_a_subject() {
916 assert!(Language::Rust.is_subject("fn (", Path::new("broken.rs")));
917 }
918
919 #[test]
920 fn rust_impl_method_is_a_subject() {
921 assert!(Language::Rust.is_subject(
922 "struct Widget;\nimpl Widget {\n fn run(&self) {}\n}\n",
923 Path::new("widget.rs")
924 ));
925 }
926
927 #[test]
928 fn rust_default_trait_method_is_a_subject() {
929 assert!(Language::Rust.is_subject(
930 "trait Greeter {\n fn greet(&self) {\n println!(\"hi\");\n }\n}\n",
931 Path::new("greeter.rs")
932 ));
933 }
934
935 #[test]
936 fn rust_trait_method_signature_without_a_default_is_not_a_subject() {
937 assert!(!Language::Rust.is_subject(
938 "trait Greeter {\n fn greet(&self);\n}\n",
939 Path::new("greeter.rs")
940 ));
941 }
942
943 #[test]
944 fn rust_closure_outside_any_fn_is_a_subject() {
945 assert!(Language::Rust.is_subject(
946 "static ADDER: fn(i32) -> i32 = |x| x + 1;\n",
947 Path::new("adder.rs")
948 ));
949 }
950
951 fn presence(src: &str) -> (bool, bool) {
953 let ast = syn::parse_file(src).expect("snippet parses");
954 let mut visitor = PresenceVisitor::default();
955 visitor.visit_file(&ast);
956 (visitor.has_testable_fn, visitor.has_test_module)
957 }
958
959 #[test]
960 fn rust_presence_free_fn_with_test_module_is_covered() {
961 assert_eq!(
962 presence(
963 "pub fn make(n: u8) -> u8 { n + 1 }\n\
964 #[cfg(test)]\nmod tests { #[test] fn t() {} }\n"
965 ),
966 (true, true)
967 );
968 }
969
970 #[test]
971 fn rust_presence_free_fn_without_test_module_needs_one() {
972 assert_eq!(
973 presence("pub fn make(n: u8) -> u8 { n + 1 }\n"),
974 (true, false)
975 );
976 }
977
978 #[test]
979 fn rust_presence_type_only_file_is_not_a_subject() {
980 assert_eq!(presence("pub struct Point { pub x: u8 }\n"), (false, false));
981 }
982
983 #[test]
984 fn rust_presence_impl_method_is_testable() {
985 assert_eq!(
986 presence("pub struct W;\nimpl W { pub fn go(&self) -> u8 { 1 } }\n"),
987 (true, false)
988 );
989 }
990
991 #[test]
992 fn rust_presence_logic_free_main_is_a_declaration() {
993 assert_eq!(
994 presence("fn main() -> ExitCode { mycrate::entrypoint::main() }\n"),
995 (false, false)
996 );
997 assert_eq!(presence("fn main() { mycrate::run() }\n"), (false, false));
998 assert_eq!(presence("fn main() { mycrate::run(); }\n"), (false, false));
1000 }
1001
1002 #[test]
1003 fn rust_presence_a_main_that_could_hold_a_decision_is_a_subject() {
1004 assert_eq!(
1006 presence("fn main() { mycrate::run(std::env::args_os()) }\n"),
1007 (true, false)
1008 );
1009 assert_eq!(
1011 presence("fn main() { setup(); mycrate::run() }\n"),
1012 (true, false)
1013 );
1014 assert_eq!(presence("fn main() { mycrate::run()? }\n"), (true, false));
1016 assert_eq!(
1017 presence("fn main() { mycrate::run().into() }\n"),
1018 (true, false)
1019 );
1020 assert_eq!(
1021 presence("fn main() { if x { a() } else { b() } }\n"),
1022 (true, false)
1023 );
1024 assert_eq!(presence("fn main() {}\n"), (true, false));
1025 }
1026
1027 #[test]
1028 fn rust_presence_only_main_earns_the_declaration_reading() {
1029 assert_eq!(presence("fn run() { mycrate::go() }\n"), (true, false));
1030 }
1031
1032 #[test]
1033 fn rust_presence_trait_default_is_testable_but_bare_signature_is_not() {
1034 assert_eq!(
1035 presence("pub trait T { fn d(&self) -> u8 { 1 } }\n"),
1036 (true, false)
1037 );
1038 assert_eq!(
1039 presence("pub trait T { fn s(&self) -> u8; }\n"),
1040 (false, false)
1041 );
1042 }
1043
1044 #[test]
1045 fn rust_presence_test_module_functions_are_not_subjects() {
1046 assert_eq!(
1047 presence("#[cfg(test)]\nmod tests { fn helper() {} #[test] fn t() {} }\n"),
1048 (false, true)
1049 );
1050 }
1051
1052 #[test]
1053 fn rust_presence_cfg_test_gated_free_fn_is_not_a_subject() {
1054 assert_eq!(
1055 presence("#[cfg(test)]\nfn only_in_tests() {}\n"),
1056 (false, false)
1057 );
1058 }
1059}