Skip to main content

testing_conventions/
colocated_test.rs

1//! The unit `colocated-test` check.
2
3use 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/// A language whose colocated unit-test convention can be checked.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
14pub enum Language {
15    /// `foo.py` → colocated `foo_test.py`.
16    #[value(name = "python")]
17    Python,
18    /// `foo-bar.ts` → colocated `foo-bar.test.ts`, across `.ts`/`.tsx`/`.mts`/`.cts`;
19    /// declaration files (`.d.ts`/`.d.mts`/`.d.cts`) are ignored.
20    #[value(name = "typescript")]
21    TypeScript,
22    /// Rust units are inline `#[cfg(test)]` modules, not separate files, so the
23    /// file-pairing walk below does not apply to Rust; its arm of the rule checks
24    /// inline-`#[cfg(test)]` *presence* instead ([`missing_inline_tests`]). The
25    /// variant is also accepted by the other `--language` rules (e.g. `packaging`).
26    #[value(name = "rust")]
27    Rust,
28}
29
30impl Language {
31    /// `true` for a file this language's check tracks (source *or* test).
32    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    /// `true` when `path` is itself a unit test, never a subject.
43    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    /// `true` when `path` is test *support* — Python's `conftest.py`, never a subject.
58    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    /// `true` when `source` at `path` holds a function or control flow anywhere in it.
66    /// Presence, the commit-scoped co-change check, and mutation all decide subjecthood
67    /// here, so none of them can disagree about what has behavior.
68    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    /// `true` when `base` and `head` — the file at `path` before and after an edit — hold
77    /// the same code once comments and formatting whitespace are normalized away. Content
78    /// that fails to parse on either side is **not** equal.
79    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            // Unreachable for Rust; `false` keeps any caller that arrives flagged.
84            Language::Rust => false,
85        }
86    }
87
88    /// The colocated test `source` is expected to have.
89    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            // Unreachable for Rust (nothing is tracked); a harmless identity.
96            Language::Rust => source.to_path_buf(),
97        }
98    }
99}
100
101/// Every source file under `root` (for `language`) with no colocated unit test, sorted.
102/// `exempt` holds the rule's `root`-relative paths resolved from config
103/// ([`crate::config::resolve_exempt`]).
104pub 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    // `<package root>/tests/` belongs to the suite tiers, so nothing under it is a subject.
113    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        // Read only for a file that lacks a twin, so the common case costs no file read.
133        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        let relative = source
139            .strip_prefix(root)
140            .unwrap_or(source)
141            .to_string_lossy()
142            .replace('\\', "/");
143        if exempt.contains(&relative) {
144            continue;
145        }
146        orphans.push(source.clone());
147    }
148    orphans.sort();
149    Ok(orphans)
150}
151
152/// Recursively collect every file `language` tracks under `dir` into `out`.
153pub(crate) fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
154    let entries =
155        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
156    for entry in entries {
157        let path = crate::walk::dir_entry(entry, dir)?.path();
158        if path.is_dir() {
159            collect_files(&path, language, out)?;
160        } else if language.tracks(&path) {
161            out.push(path);
162        }
163    }
164    Ok(())
165}
166
167/// Every Rust source file under `root` that defines testable behavior — a function with a
168/// body, outside any `#[cfg(test)]` module — but carries no inline `#[cfg(test)]` module,
169/// sorted. `exempt` holds the rule's `root`-relative paths resolved from config.
170pub fn missing_inline_tests(
171    root: impl AsRef<Path>,
172    exempt: &BTreeSet<String>,
173) -> Result<Vec<PathBuf>> {
174    let root = root.as_ref();
175    let mut files = Vec::new();
176    collect_rust_source_files(root, &mut files)?;
177    files.sort();
178
179    let mut orphans = Vec::new();
180    for file in &files {
181        let source = std::fs::read_to_string(file)
182            .with_context(|| format!("reading source file `{}`", file.display()))?;
183        let ast = syn::parse_file(&source)
184            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
185        let mut visitor = PresenceVisitor::default();
186        visitor.visit_file(&ast);
187        if !visitor.has_testable_fn || visitor.has_test_module {
188            continue;
189        }
190        let relative = file
191            .strip_prefix(root)
192            .unwrap_or(file)
193            .to_string_lossy()
194            .replace('\\', "/");
195        if exempt.contains(&relative) {
196            continue;
197        }
198        orphans.push(file.clone());
199    }
200    // `files` is already sorted, so `orphans` is in order.
201    Ok(orphans)
202}
203
204/// Recursively collect `*.rs` unit-source files under `dir` into `out`, skipping the
205/// non-unit trees — `tests/`, `benches/`, `examples/`, `target/` — and `build.rs`.
206pub(crate) fn collect_rust_source_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
207    let entries =
208        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
209    for entry in entries {
210        let path = crate::walk::dir_entry(entry, dir)?.path();
211        if path.is_dir() {
212            let skip = matches!(
213                path.file_name().and_then(|name| name.to_str()),
214                Some("tests" | "benches" | "examples" | "target")
215            );
216            if !skip {
217                collect_rust_source_files(&path, out)?;
218            }
219        } else if has_extension(&path, &["rs"]) && file_name_of(&path) != "build.rs" {
220            out.push(path);
221        }
222    }
223    Ok(())
224}
225
226/// Answers, for a parsed Rust file, whether it defines testable behavior outside any
227/// `#[cfg(test)]` module and whether it carries an inline `#[cfg(test)]` module.
228#[derive(Default)]
229struct PresenceVisitor {
230    test_depth: usize,
231    has_testable_fn: bool,
232    has_test_module: bool,
233}
234
235impl<'ast> Visit<'ast> for PresenceVisitor {
236    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
237        let is_test = crate::isolation::has_cfg_test(&node.attrs);
238        if is_test {
239            self.has_test_module = true;
240            self.test_depth += 1;
241        }
242        visit::visit_item_mod(self, node);
243        if is_test {
244            self.test_depth -= 1;
245        }
246    }
247
248    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
249        if self.test_depth == 0
250            && !crate::isolation::has_cfg_test(&node.attrs)
251            && !is_logic_free_main(node)
252        {
253            self.has_testable_fn = true;
254        }
255        visit::visit_item_fn(self, node);
256    }
257
258    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
259        if self.test_depth == 0 {
260            self.has_testable_fn = true;
261        }
262        visit::visit_impl_item_fn(self, node);
263    }
264
265    fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
266        if self.test_depth == 0 && node.default.is_some() {
267            self.has_testable_fn = true;
268        }
269        visit::visit_trait_item_fn(self, node);
270    }
271}
272
273/// `true` when `node` is a logic-free entry point: `fn main` whose body is one argument-free call
274/// and nothing else, as in `fn main() -> ExitCode { mycrate::entrypoint::main() }`.
275///
276/// Rust requires a `main` in a binary root, so that file cannot be emptied the way a library module
277/// can. A `main` of this exact shape holds no decision to test — every one of them, the argv read
278/// included, sits behind the call in the library, where a test reaches it — so it counts as a
279/// declaration. The shape is deliberately narrow: exactly one statement, and that statement a call
280/// through a path taking no arguments. A second statement, an argument, an operator, a `?`, a
281/// `match`, a method call — anything that could encode a decision — makes it a subject again.
282fn is_logic_free_main(node: &syn::ItemFn) -> bool {
283    if node.sig.ident != "main" {
284        return false;
285    }
286    let [syn::Stmt::Expr(syn::Expr::Call(call), _)] = &node.block.stmts[..] else {
287        return false;
288    };
289    call.args.is_empty() && matches!(*call.func, syn::Expr::Path(_))
290}
291
292/// `true` when `source` holds an `fn` or a closure anywhere — free function, method, default
293/// trait method, or closure expression (a `static`'s `Lazy::new(|| ..)` counts, sitting outside
294/// any `fn`). A file that fails to parse is `true`: mutation and colocated-test keep it a subject.
295fn rust_has_behavior(source: &str) -> bool {
296    let Ok(file) = syn::parse_file(source) else {
297        return true;
298    };
299    let mut visitor = BehaviorPresenceVisitor { found: false };
300    visitor.visit_file(&file);
301    visitor.found
302}
303
304/// Records whether the walk reached any function item or closure.
305struct BehaviorPresenceVisitor {
306    found: bool,
307}
308
309impl<'ast> Visit<'ast> for BehaviorPresenceVisitor {
310    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
311        self.found = true;
312        visit::visit_item_fn(self, node);
313    }
314
315    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
316        self.found = true;
317        visit::visit_impl_item_fn(self, node);
318    }
319
320    fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
321        if node.default.is_some() {
322            self.found = true;
323        }
324        visit::visit_trait_item_fn(self, node);
325    }
326
327    fn visit_expr_closure(&mut self, node: &'ast syn::ExprClosure) {
328        self.found = true;
329        visit::visit_expr_closure(self, node);
330    }
331}
332
333/// `true` when the file's extension is one of `extensions`.
334fn has_extension(path: &Path, extensions: &[&str]) -> bool {
335    path.extension()
336        .and_then(|ext| ext.to_str())
337        .is_some_and(|ext| extensions.contains(&ext))
338}
339
340/// `true` for a TypeScript declaration file (`*.d.ts` / `*.d.mts` / `*.d.cts`).
341fn is_declaration(path: &Path) -> bool {
342    let name = file_name_of(path);
343    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
344}
345
346/// `true` when Python `base` and `head` tokenize identically. Comments and blank lines
347/// never reach a token — the parser's `full-lexer` feature is off — while `Indent` /
348/// `Dedent` do, so re-indenting a statement is a code change.
349fn python_same_code(base: &str, head: &str) -> bool {
350    match (python_tokens(base), python_tokens(head)) {
351        (Some(base), Some(head)) => base == head,
352        _ => false,
353    }
354}
355
356/// `true` when Python `source` holds a function or control flow anywhere in it. A module
357/// that fails to parse is `true`: the presence rule keeps a module it couldn't read as a subject.
358fn python_has_behavior(source: &str) -> bool {
359    let Ok(suite) = ast::Suite::parse(source, "<source>") else {
360        return true;
361    };
362    let mut visitor = BehaviorVisitor { found: false };
363    for stmt in suite {
364        visitor.visit_stmt(stmt);
365    }
366    visitor.found
367}
368
369/// Records the first function or control-flow node the walk reaches.
370struct BehaviorVisitor {
371    found: bool,
372}
373
374impl Visitor for BehaviorVisitor {
375    fn visit_stmt_function_def(&mut self, _: ast::StmtFunctionDef) {
376        self.found = true;
377    }
378
379    fn visit_stmt_async_function_def(&mut self, _: ast::StmtAsyncFunctionDef) {
380        self.found = true;
381    }
382
383    fn visit_expr_lambda(&mut self, _: ast::ExprLambda) {
384        self.found = true;
385    }
386
387    fn visit_stmt_if(&mut self, _: ast::StmtIf) {
388        self.found = true;
389    }
390
391    fn visit_stmt_for(&mut self, _: ast::StmtFor) {
392        self.found = true;
393    }
394
395    fn visit_stmt_async_for(&mut self, _: ast::StmtAsyncFor) {
396        self.found = true;
397    }
398
399    fn visit_stmt_while(&mut self, _: ast::StmtWhile) {
400        self.found = true;
401    }
402
403    fn visit_stmt_try(&mut self, _: ast::StmtTry) {
404        self.found = true;
405    }
406
407    fn visit_stmt_try_star(&mut self, _: ast::StmtTryStar) {
408        self.found = true;
409    }
410
411    fn visit_stmt_with(&mut self, _: ast::StmtWith) {
412        self.found = true;
413    }
414
415    fn visit_stmt_async_with(&mut self, _: ast::StmtAsyncWith) {
416        self.found = true;
417    }
418
419    fn visit_stmt_match(&mut self, _: ast::StmtMatch) {
420        self.found = true;
421    }
422
423    fn visit_expr_if_exp(&mut self, _: ast::ExprIfExp) {
424        self.found = true;
425    }
426
427    // The generated `generic_visit_comprehension` and `generic_visit_keyword` are no-ops, so
428    // a lambda in a comprehension's iterable or a call's keyword argument needs a walk here.
429    fn visit_comprehension(&mut self, node: ast::Comprehension) {
430        self.found |= !node.ifs.is_empty();
431        self.visit_expr(node.iter);
432    }
433
434    fn visit_keyword(&mut self, node: ast::Keyword) {
435        self.visit_expr(node.value);
436    }
437}
438
439/// The token stream of Python `source`, or `None` when `source` is not a valid module.
440fn python_tokens(source: &str) -> Option<Vec<Tok>> {
441    let tokens: Vec<Tok> = lex(source, Mode::Module)
442        .map(|token| token.ok().map(|(tok, _)| tok))
443        .collect::<Option<_>>()?;
444    // The lexer accepts token sequences the grammar rejects (`def f() return 1` lexes
445    // cleanly), so the parse decides validity while the tokens carry the comparison.
446    ast::Suite::parse(source, "<source>").ok()?;
447    Some(tokens)
448}
449
450/// The file extension, lossily decoded (empty if there is none).
451fn extension_of(path: &Path) -> String {
452    path.extension()
453        .map(|ext| ext.to_string_lossy().into_owned())
454        .unwrap_or_default()
455}
456
457/// The file name, lossily decoded.
458fn file_name_of(path: &Path) -> String {
459    path.file_name()
460        .map(|name| name.to_string_lossy().into_owned())
461        .unwrap_or_default()
462}
463
464/// The file stem (the name without its extension), lossily decoded.
465fn stem_of(path: &Path) -> String {
466    path.file_stem()
467        .map(|stem| stem.to_string_lossy().into_owned())
468        .unwrap_or_default()
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn python_tracks_py_files() {
477        assert!(Language::Python.tracks(Path::new("a.py")));
478        assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
479        assert!(!Language::Python.tracks(Path::new("a.pyi")));
480        assert!(!Language::Python.tracks(Path::new("a.txt")));
481        assert!(!Language::Python.tracks(Path::new("README")));
482    }
483
484    #[test]
485    fn python_recognizes_test_files_by_stem_suffix() {
486        assert!(Language::Python.is_test(Path::new("widget_test.py")));
487        assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
488        assert!(!Language::Python.is_test(Path::new("widget.py")));
489    }
490
491    #[test]
492    fn python_conftest_is_support_not_a_subject() {
493        assert!(Language::Python.is_support(Path::new("conftest.py")));
494        assert!(Language::Python.is_support(Path::new("pkg/conftest.py")));
495        assert!(!Language::Python.is_support(Path::new("widget.py")));
496        assert!(!Language::Python.is_support(Path::new("widget_test.py")));
497        assert!(!Language::TypeScript.is_support(Path::new("conftest.ts")));
498    }
499
500    #[test]
501    fn python_expected_test_path_is_the_colocated_twin() {
502        assert_eq!(
503            Language::Python.expected_test_path(Path::new("pkg/widget.py")),
504            PathBuf::from("pkg/widget_test.py")
505        );
506        assert_eq!(
507            Language::Python.expected_test_path(Path::new("widget.py")),
508            PathBuf::from("widget_test.py")
509        );
510    }
511
512    #[test]
513    fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
514        assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
515        assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
516        assert!(Language::TypeScript.tracks(Path::new("service.mts")));
517        assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
518        assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
519        assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
520        assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
521        assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
522        assert!(!Language::TypeScript.tracks(Path::new("README")));
523    }
524
525    #[test]
526    fn typescript_recognizes_test_files_by_suffix() {
527        assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
528        assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
529        assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
530        assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
531        assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
532        assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
533        assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
534    }
535
536    #[test]
537    fn typescript_expected_test_path_keeps_the_extension() {
538        assert_eq!(
539            Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
540            PathBuf::from("pkg/widget.test.ts")
541        );
542        assert_eq!(
543            Language::TypeScript.expected_test_path(Path::new("button.tsx")),
544            PathBuf::from("button.test.tsx")
545        );
546        assert_eq!(
547            Language::TypeScript.expected_test_path(Path::new("service.mts")),
548            PathBuf::from("service.test.mts")
549        );
550        assert_eq!(
551            Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
552            PathBuf::from("legacy.test.cts")
553        );
554    }
555
556    #[test]
557    fn typescript_subject_skips_type_only_modules() {
558        let ts = Path::new("aliases.ts");
559        assert!(!Language::TypeScript.is_subject("export type Alias = string;\n", ts));
560        assert!(!Language::TypeScript.is_subject("export interface Shape { kind: string }\n", ts));
561        assert!(!Language::TypeScript.is_subject("import type { A } from './a';\n", ts));
562    }
563
564    #[test]
565    fn typescript_subject_needs_a_function_or_control_flow() {
566        let ts = Path::new("widget.ts");
567        assert!(Language::TypeScript.is_subject("export const x = () => 1;\n", ts));
568        assert!(Language::TypeScript.is_subject(
569            "export type Alias = string;\nexport const x = () => 1;\n",
570            ts
571        ));
572        assert!(!Language::TypeScript.is_subject("export const x = 1;\n", ts));
573        assert!(!Language::TypeScript.is_subject("", ts));
574        assert!(!Language::TypeScript.is_subject("   \n\t\n", ts));
575        assert!(!Language::TypeScript.is_subject("// nothing here\n", ts));
576        assert!(!Language::TypeScript.is_subject("/* a\n   block\n   comment */\n", ts));
577    }
578
579    fn py_subject(source: &str) -> bool {
580        Language::Python.is_subject(source, Path::new("widget.py"))
581    }
582
583    #[test]
584    fn python_declaration_only_module_is_not_a_subject() {
585        assert!(!py_subject("x = 1\n"));
586        assert!(!py_subject("Alias = str\n"));
587        assert!(!py_subject(
588            "\"\"\"pkg.\"\"\"\nfrom ._version import __version__\n__all__ = [\"__version__\"]\n"
589        ));
590        assert!(!py_subject("class Color(Enum):\n    RED = 1\n"));
591        assert!(!py_subject(
592            "@dataclass\nclass Point:\n    x: int\n    y: int\n"
593        ));
594        assert!(!py_subject("logger = getLogger(__name__)\n"));
595        assert!(!py_subject("TIMEOUT = 30 * 60\nNAME = f\"{TIMEOUT}s\"\n"));
596        assert!(!py_subject("\"\"\"Package docstring.\"\"\"\n"));
597        assert!(!py_subject(""));
598        assert!(!py_subject("\n   \n"));
599        assert!(!py_subject("# just a comment\n   # another\n"));
600    }
601
602    #[test]
603    fn python_short_circuit_and_a_plain_comprehension_are_not_control_flow() {
604        assert!(!py_subject("DEBUG = os.environ.get(\"DEBUG\") or \"0\"\n"));
605        assert!(!py_subject("READY = a and b\n"));
606        assert!(!py_subject("NAMES = [c.name for c in Color]\n"));
607        assert!(!py_subject("BY_NAME = {c.name: c for c in Color}\n"));
608    }
609
610    #[test]
611    fn python_every_function_form_is_a_subject() {
612        assert!(py_subject("def f():\n    return 1\n"));
613        assert!(py_subject("async def f():\n    return 1\n"));
614        assert!(py_subject("HANDLERS = {\"id\": lambda x: x}\n"));
615        assert!(py_subject(
616            "class Widget:\n    def run(self):\n        return 1\n"
617        ));
618        assert!(py_subject(
619            "class Runner(Protocol):\n    def run(self) -> int: ...\n"
620        ));
621    }
622
623    #[test]
624    fn python_every_control_flow_form_is_a_subject() {
625        assert!(py_subject("if DEBUG:\n    LEVEL = 10\n"));
626        assert!(py_subject("for i in range(3):\n    pass\n"));
627        assert!(py_subject("async for i in aiter():\n    pass\n"));
628        assert!(py_subject("while True:\n    pass\n"));
629        assert!(py_subject(
630            "try:\n    import x\nexcept ImportError:\n    x = None\n"
631        ));
632        assert!(py_subject(
633            "try:\n    import x\nexcept* ImportError:\n    x = None\n"
634        ));
635        assert!(py_subject("with open(p) as f:\n    DATA = f.read()\n"));
636        assert!(py_subject("async with lock:\n    pass\n"));
637        assert!(py_subject("match cmd:\n    case \"go\":\n        pass\n"));
638        assert!(py_subject("LEVEL = 10 if DEBUG else 20\n"));
639        assert!(py_subject("EVENS = [n for n in range(9) if n % 2 == 0]\n"));
640    }
641
642    #[test]
643    fn python_control_flow_nested_in_an_expression_is_a_subject() {
644        assert!(py_subject("CONFIG = {\"level\": (10 if DEBUG else 20)}\n"));
645        assert!(py_subject("run(callback=lambda: None)\n"));
646        assert!(py_subject(
647            "ORDERED = [x for x in sorted(xs, key=lambda x: x.id)]\n"
648        ));
649    }
650
651    #[test]
652    fn python_unparsable_content_stays_a_subject() {
653        assert!(py_subject("def f(:\n"));
654    }
655
656    const PY_WIDGET: &str = "def widget():\n    return 1\n";
657
658    #[test]
659    fn python_same_code_ignores_comments_and_formatting() {
660        let py = Path::new("widget.py");
661        assert!(Language::Python.same_code(
662            "# widget helpers\ndef widget():\n    return 1\n",
663            "# widget utilities\ndef widget():\n    return 1\n",
664            py
665        ));
666        assert!(Language::Python.same_code(
667            "# widget helpers\ndef widget():\n    return 1\n",
668            PY_WIDGET,
669            py
670        ));
671        assert!(Language::Python.same_code(PY_WIDGET, "def widget():\n\n    return 1\n", py));
672        assert!(Language::Python.same_code("def widget():   \n    return 1   \n", PY_WIDGET, py));
673    }
674
675    #[test]
676    fn python_same_code_sees_every_edit_the_interpreter_sees() {
677        let py = Path::new("widget.py");
678        assert!(!Language::Python.same_code(PY_WIDGET, "def widget():\n    return 2\n", py));
679        assert!(!Language::Python.same_code(
680            "\"\"\"Widget helpers.\"\"\"\ndef widget():\n    return 1\n",
681            "\"\"\"Widget utilities.\"\"\"\ndef widget():\n    return 1\n",
682            py
683        ));
684        assert!(!Language::Python.same_code(
685            "def widget():\n    return \"one\"\n",
686            "def widget():\n    return \"two\"\n",
687            py
688        ));
689        assert!(!Language::Python.same_code(
690            "def widget(flag):\n    if flag:\n        count = 1\n    return count\n",
691            "def widget(flag):\n    if flag:\n        count = 1\n        return count\n",
692            py
693        ));
694    }
695
696    #[test]
697    fn python_same_code_holds_unparseable_content_apart() {
698        let py = Path::new("widget.py");
699        assert!(!Language::Python.same_code(
700            "def widget(:\n    return 1\n",
701            "# note\ndef widget(:\n    return 1\n",
702            py
703        ));
704        assert!(!Language::Python.same_code(
705            "def widget() return 1\n",
706            "# note\ndef widget() return 1\n",
707            py
708        ));
709        assert!(!Language::Python.same_code(PY_WIDGET, "def widget() return 1\n", py));
710        assert!(!Language::Python.same_code("def widget() return 1\n", PY_WIDGET, py));
711    }
712
713    #[test]
714    fn typescript_same_code_reads_the_emitted_module() {
715        let ts = Path::new("widget.ts");
716        assert!(Language::TypeScript.same_code(
717            "// widget factory\nexport const widget = () => 1;\n",
718            "export const widget = () => 1;\n",
719            ts
720        ));
721        assert!(!Language::TypeScript.same_code(
722            "export const widget = () => 1;\n",
723            "export const widget = () => 2;\n",
724            ts
725        ));
726    }
727
728    #[test]
729    fn rust_same_code_never_answers_equal() {
730        assert!(!Language::Rust.same_code("fn f() {}\n", "fn f() {}\n", Path::new("lib.rs")));
731    }
732
733    #[test]
734    fn rust_has_no_file_based_colocated_convention() {
735        assert!(!Language::Rust.tracks(Path::new("lib.rs")));
736        assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
737        assert!(Language::Rust.is_subject("fn main() {}\n", Path::new("main.rs")));
738        assert_eq!(
739            Language::Rust.expected_test_path(Path::new("src/lib.rs")),
740            PathBuf::from("src/lib.rs")
741        );
742    }
743
744    #[test]
745    fn rust_const_only_file_is_not_a_subject() {
746        assert!(!Language::Rust.is_subject(
747            "pub const TIMEOUT: u64 = 30 * 60;\n",
748            Path::new("settings.rs")
749        ));
750    }
751
752    #[test]
753    fn rust_unparseable_file_is_a_subject() {
754        assert!(Language::Rust.is_subject("fn (", Path::new("broken.rs")));
755    }
756
757    #[test]
758    fn rust_impl_method_is_a_subject() {
759        assert!(Language::Rust.is_subject(
760            "struct Widget;\nimpl Widget {\n    fn run(&self) {}\n}\n",
761            Path::new("widget.rs")
762        ));
763    }
764
765    #[test]
766    fn rust_default_trait_method_is_a_subject() {
767        assert!(Language::Rust.is_subject(
768            "trait Greeter {\n    fn greet(&self) {\n        println!(\"hi\");\n    }\n}\n",
769            Path::new("greeter.rs")
770        ));
771    }
772
773    #[test]
774    fn rust_trait_method_signature_without_a_default_is_not_a_subject() {
775        assert!(!Language::Rust.is_subject(
776            "trait Greeter {\n    fn greet(&self);\n}\n",
777            Path::new("greeter.rs")
778        ));
779    }
780
781    #[test]
782    fn rust_closure_outside_any_fn_is_a_subject() {
783        assert!(Language::Rust.is_subject(
784            "static ADDER: fn(i32) -> i32 = |x| x + 1;\n",
785            Path::new("adder.rs")
786        ));
787    }
788
789    /// `(has_testable_fn, has_test_module)` for a Rust source snippet.
790    fn presence(src: &str) -> (bool, bool) {
791        let ast = syn::parse_file(src).expect("snippet parses");
792        let mut visitor = PresenceVisitor::default();
793        visitor.visit_file(&ast);
794        (visitor.has_testable_fn, visitor.has_test_module)
795    }
796
797    #[test]
798    fn rust_presence_free_fn_with_test_module_is_covered() {
799        assert_eq!(
800            presence(
801                "pub fn make(n: u8) -> u8 { n + 1 }\n\
802                 #[cfg(test)]\nmod tests { #[test] fn t() {} }\n"
803            ),
804            (true, true)
805        );
806    }
807
808    #[test]
809    fn rust_presence_free_fn_without_test_module_needs_one() {
810        assert_eq!(
811            presence("pub fn make(n: u8) -> u8 { n + 1 }\n"),
812            (true, false)
813        );
814    }
815
816    #[test]
817    fn rust_presence_type_only_file_is_not_a_subject() {
818        assert_eq!(presence("pub struct Point { pub x: u8 }\n"), (false, false));
819    }
820
821    #[test]
822    fn rust_presence_impl_method_is_testable() {
823        assert_eq!(
824            presence("pub struct W;\nimpl W { pub fn go(&self) -> u8 { 1 } }\n"),
825            (true, false)
826        );
827    }
828
829    #[test]
830    fn rust_presence_logic_free_main_is_a_declaration() {
831        assert_eq!(
832            presence("fn main() -> ExitCode { mycrate::entrypoint::main() }\n"),
833            (false, false)
834        );
835        assert_eq!(presence("fn main() { mycrate::run() }\n"), (false, false));
836        // A trailing semicolon is the same single call.
837        assert_eq!(presence("fn main() { mycrate::run(); }\n"), (false, false));
838    }
839
840    #[test]
841    fn rust_presence_a_main_that_could_hold_a_decision_is_a_subject() {
842        // An argument is somewhere a decision hides — which args, which order, which default.
843        assert_eq!(
844            presence("fn main() { mycrate::run(std::env::args_os()) }\n"),
845            (true, false)
846        );
847        // Two statements: the second is unreviewed behavior.
848        assert_eq!(
849            presence("fn main() { setup(); mycrate::run() }\n"),
850            (true, false)
851        );
852        // `?`, an operator, control flow, and a method call each carry their own branch.
853        assert_eq!(presence("fn main() { mycrate::run()? }\n"), (true, false));
854        assert_eq!(
855            presence("fn main() { mycrate::run().into() }\n"),
856            (true, false)
857        );
858        assert_eq!(
859            presence("fn main() { if x { a() } else { b() } }\n"),
860            (true, false)
861        );
862        assert_eq!(presence("fn main() {}\n"), (true, false));
863    }
864
865    #[test]
866    fn rust_presence_only_main_earns_the_declaration_reading() {
867        assert_eq!(presence("fn run() { mycrate::go() }\n"), (true, false));
868    }
869
870    #[test]
871    fn rust_presence_trait_default_is_testable_but_bare_signature_is_not() {
872        assert_eq!(
873            presence("pub trait T { fn d(&self) -> u8 { 1 } }\n"),
874            (true, false)
875        );
876        assert_eq!(
877            presence("pub trait T { fn s(&self) -> u8; }\n"),
878            (false, false)
879        );
880    }
881
882    #[test]
883    fn rust_presence_test_module_functions_are_not_subjects() {
884        assert_eq!(
885            presence("#[cfg(test)]\nmod tests { fn helper() {} #[test] fn t() {} }\n"),
886            (false, true)
887        );
888    }
889
890    #[test]
891    fn rust_presence_cfg_test_gated_free_fn_is_not_a_subject() {
892        assert_eq!(
893            presence("#[cfg(test)]\nfn only_in_tests() {}\n"),
894            (false, false)
895        );
896    }
897}