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    /// `true` when `head` differs from `base` only by imports that moved off a module in
89    /// `deleted` — the same code and bound names otherwise.
90    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    /// The colocated test `source` is expected to have.
109    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            // Unreachable for Rust (nothing is tracked); a harmless identity.
116            Language::Rust => source.to_path_buf(),
117        }
118    }
119}
120
121/// Every source file under `root` (for `language`) with no colocated unit test, sorted.
122/// `exempt` holds the rule's `root`-relative paths resolved from config
123/// ([`crate::config::resolve_exempt`]).
124pub 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    // `<package root>/tests/` belongs to the suite tiers, so nothing under it is a subject.
133    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        // Read only for a file that lacks a twin, so the common case costs no file read.
153        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
167/// The key `exempt` sets are indexed by: `file` relative to `root`, with Windows separators
168/// normalized to `/` so a config written on either platform matches. The path itself when it
169/// does not sit under `root`.
170fn exempt_key(root: &Path, file: &Path) -> String {
171    file.strip_prefix(root)
172        .unwrap_or(file)
173        .to_string_lossy()
174        .replace('\\', "/")
175}
176
177/// `true` when `file` defines testable behavior — a function with a body outside any
178/// `#[cfg(test)]` module — but carries no inline `#[cfg(test)]` module of its own.
179fn 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
189/// Recursively collect every file `language` tracks under `dir` into `out`.
190pub(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
204/// Every Rust source file under `root` that defines testable behavior — a function with a
205/// body, outside any `#[cfg(test)]` module — but carries no inline `#[cfg(test)]` module,
206/// sorted. `exempt` holds the rule's `root`-relative paths resolved from config.
207pub 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    // `files` is already sorted, so `orphans` is in order.
226    Ok(orphans)
227}
228
229/// Recursively collect `*.rs` unit-source files under `dir` into `out`, skipping the
230/// non-unit trees — `tests/`, `benches/`, `examples/`, `target/` — and `build.rs`.
231pub(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/// Answers, for a parsed Rust file, whether it defines testable behavior outside any
252/// `#[cfg(test)]` module and whether it carries an inline `#[cfg(test)]` module.
253#[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
298/// `true` when `node` is a logic-free entry point: `fn main` whose body is one argument-free call
299/// and nothing else, as in `fn main() -> ExitCode { mycrate::entrypoint::main() }`.
300///
301/// Rust requires a `main` in a binary root, so that file cannot be emptied the way a library module
302/// can. A `main` of this exact shape holds no decision to test — every one of them, the argv read
303/// included, sits behind the call in the library, where a test reaches it — so it counts as a
304/// declaration. The shape is deliberately narrow: exactly one statement, and that statement a call
305/// through a path taking no arguments. A second statement, an argument, an operator, a `?`, a
306/// `match`, a method call — anything that could encode a decision — makes it a subject again.
307fn 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
317/// `true` when `source` holds an `fn` or a closure anywhere — free function, method, default
318/// trait method, or closure expression (a `static`'s `Lazy::new(|| ..)` counts, sitting outside
319/// any `fn`). A file that fails to parse is `true`: mutation and colocated-test keep it a subject.
320fn 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
329/// Records whether the walk reached any function item or closure.
330struct 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
358/// `true` when the file's extension is one of `extensions`.
359fn 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
365/// `true` for a TypeScript declaration file (`*.d.ts` / `*.d.mts` / `*.d.cts`).
366fn 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
371/// `true` when Python `base` and `head` tokenize identically. Comments and blank lines
372/// never reach a token — the parser's `full-lexer` feature is off — while `Indent` /
373/// `Dedent` do, so re-indenting a statement is a code change.
374fn 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
381/// `true` when Python `source` holds a function or control flow anywhere in it. A module
382/// that fails to parse is `true`: the presence rule keeps a module it couldn't read as a subject.
383fn 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
394/// Records the first function or control-flow node the walk reaches.
395struct 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    // The generated `generic_visit_comprehension` and `generic_visit_keyword` are no-ops, so
453    // a lambda in a comprehension's iterable or a call's keyword argument needs a walk here.
454    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
464/// The token stream of Python `source`, or `None` when `source` is not a valid module.
465fn 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    // The lexer accepts token sequences the grammar rejects (`def f() return 1` lexes
470    // cleanly), so the parse decides validity while the tokens carry the comparison.
471    ast::Suite::parse(source, "<source>").ok()?;
472    Some(tokens)
473}
474
475/// The file extension, lossily decoded (empty if there is none).
476fn extension_of(path: &Path) -> String {
477    path.extension()
478        .map(|ext| ext.to_string_lossy().into_owned())
479        .unwrap_or_default()
480}
481
482/// The file name, lossily decoded.
483fn 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
489/// The file stem (the name without its extension), lossily decoded.
490fn 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    /// A process-unique scratch tree, rebuilt from scratch on every call.
501    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    /// `(has_testable_fn, has_test_module)` for a Rust source snippet.
952    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        // A trailing semicolon is the same single call.
999        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        // An argument is somewhere a decision hides — which args, which order, which default.
1005        assert_eq!(
1006            presence("fn main() { mycrate::run(std::env::args_os()) }\n"),
1007            (true, false)
1008        );
1009        // Two statements: the second is unreviewed behavior.
1010        assert_eq!(
1011            presence("fn main() { setup(); mycrate::run() }\n"),
1012            (true, false)
1013        );
1014        // `?`, an operator, control flow, and a method call each carry their own branch.
1015        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}