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