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        if exempt.contains(&exempt_key(root, source)) {
139            continue;
140        }
141        orphans.push(source.clone());
142    }
143    orphans.sort();
144    Ok(orphans)
145}
146
147/// The key `exempt` sets are indexed by: `file` relative to `root`, with Windows separators
148/// normalized to `/` so a config written on either platform matches. The path itself when it
149/// does not sit under `root`.
150fn exempt_key(root: &Path, file: &Path) -> String {
151    file.strip_prefix(root)
152        .unwrap_or(file)
153        .to_string_lossy()
154        .replace('\\', "/")
155}
156
157/// `true` when `file` defines testable behavior — a function with a body outside any
158/// `#[cfg(test)]` module — but carries no inline `#[cfg(test)]` module of its own.
159fn 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
169/// Recursively collect every file `language` tracks under `dir` into `out`.
170pub(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
184/// Every Rust source file under `root` that defines testable behavior — a function with a
185/// body, outside any `#[cfg(test)]` module — but carries no inline `#[cfg(test)]` module,
186/// sorted. `exempt` holds the rule's `root`-relative paths resolved from config.
187pub 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    // `files` is already sorted, so `orphans` is in order.
206    Ok(orphans)
207}
208
209/// Recursively collect `*.rs` unit-source files under `dir` into `out`, skipping the
210/// non-unit trees — `tests/`, `benches/`, `examples/`, `target/` — and `build.rs`.
211pub(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/// Answers, for a parsed Rust file, whether it defines testable behavior outside any
232/// `#[cfg(test)]` module and whether it carries an inline `#[cfg(test)]` module.
233#[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
278/// `true` when `node` is a logic-free entry point: `fn main` whose body is one argument-free call
279/// and nothing else, as in `fn main() -> ExitCode { mycrate::entrypoint::main() }`.
280///
281/// Rust requires a `main` in a binary root, so that file cannot be emptied the way a library module
282/// can. A `main` of this exact shape holds no decision to test — every one of them, the argv read
283/// included, sits behind the call in the library, where a test reaches it — so it counts as a
284/// declaration. The shape is deliberately narrow: exactly one statement, and that statement a call
285/// through a path taking no arguments. A second statement, an argument, an operator, a `?`, a
286/// `match`, a method call — anything that could encode a decision — makes it a subject again.
287fn 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
297/// `true` when `source` holds an `fn` or a closure anywhere — free function, method, default
298/// trait method, or closure expression (a `static`'s `Lazy::new(|| ..)` counts, sitting outside
299/// any `fn`). A file that fails to parse is `true`: mutation and colocated-test keep it a subject.
300fn 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
309/// Records whether the walk reached any function item or closure.
310struct 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
338/// `true` when the file's extension is one of `extensions`.
339fn 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
345/// `true` for a TypeScript declaration file (`*.d.ts` / `*.d.mts` / `*.d.cts`).
346fn 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
351/// `true` when Python `base` and `head` tokenize identically. Comments and blank lines
352/// never reach a token — the parser's `full-lexer` feature is off — while `Indent` /
353/// `Dedent` do, so re-indenting a statement is a code change.
354fn 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
361/// `true` when Python `source` holds a function or control flow anywhere in it. A module
362/// that fails to parse is `true`: the presence rule keeps a module it couldn't read as a subject.
363fn 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
374/// Records the first function or control-flow node the walk reaches.
375struct 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    // The generated `generic_visit_comprehension` and `generic_visit_keyword` are no-ops, so
433    // a lambda in a comprehension's iterable or a call's keyword argument needs a walk here.
434    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
444/// The token stream of Python `source`, or `None` when `source` is not a valid module.
445fn 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    // The lexer accepts token sequences the grammar rejects (`def f() return 1` lexes
450    // cleanly), so the parse decides validity while the tokens carry the comparison.
451    ast::Suite::parse(source, "<source>").ok()?;
452    Some(tokens)
453}
454
455/// The file extension, lossily decoded (empty if there is none).
456fn extension_of(path: &Path) -> String {
457    path.extension()
458        .map(|ext| ext.to_string_lossy().into_owned())
459        .unwrap_or_default()
460}
461
462/// The file name, lossily decoded.
463fn 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
469/// The file stem (the name without its extension), lossily decoded.
470fn 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    /// A process-unique scratch tree, rebuilt from scratch on every call.
481    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    /// `(has_testable_fn, has_test_module)` for a Rust source snippet.
905    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        // A trailing semicolon is the same single call.
952        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        // An argument is somewhere a decision hides — which args, which order, which default.
958        assert_eq!(
959            presence("fn main() { mycrate::run(std::env::args_os()) }\n"),
960            (true, false)
961        );
962        // Two statements: the second is unreviewed behavior.
963        assert_eq!(
964            presence("fn main() { setup(); mycrate::run() }\n"),
965            (true, false)
966        );
967        // `?`, an operator, control flow, and a method call each carry their own branch.
968        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}