Skip to main content

testing_conventions/
colocated_test.rs

1//! The unit `colocated-test` check.
2//!
3//! Convention (README "Colocated Test"; `internals/*/testing.md`): a source
4//! file is unit-tested by a *colocated* test named after it — `foo.py` →
5//! `foo_test.py` (Python), `foo-bar.ts` → `foo-bar.test.ts` (TypeScript).
6//! [`missing_unit_tests`] walks a tree for a [`Language`] and returns every
7//! source file with no such sibling — an "orphan". Test files are what the
8//! check looks *for*, never subjects.
9//!
10//! Two things are not orphans even without a colocated test: a file
11//! that holds no code (empty or comment-only — e.g. a bare `__init__.py`), which
12//! is not a subject at all, and a file listed in the config `exempt` table,
13//! which is a deliberate, reason-required omission. Everything else must be
14//! tested — there is no automatic name- or shape-based exemption.
15
16use std::collections::{BTreeSet, HashSet};
17use std::path::{Path, PathBuf};
18
19use anyhow::{anyhow, Context, Result};
20use syn::visit::{self, Visit};
21
22/// A language whose colocated unit-test convention can be checked.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
24pub enum Language {
25    /// `foo.py` → colocated `foo_test.py`.
26    #[value(name = "python")]
27    Python,
28    /// `foo-bar.ts` → colocated `foo-bar.test.ts`, across `.ts`/`.tsx`/`.mts`/`.cts`;
29    /// declaration files (`.d.ts`/`.d.mts`/`.d.cts`) are ignored.
30    #[value(name = "typescript")]
31    TypeScript,
32    /// Rust units are inline `#[cfg(test)]` modules, not separate files, so the
33    /// file-pairing walk below does not apply to Rust; its arm of the rule checks
34    /// inline-`#[cfg(test)]` *presence* instead ([`missing_inline_tests`]). The
35    /// variant is also accepted by the other `--language` rules (e.g. `packaging`).
36    #[value(name = "rust")]
37    Rust,
38}
39
40impl Language {
41    /// `true` for a file this language's check tracks (source *or* test).
42    pub(crate) fn tracks(self, path: &Path) -> bool {
43        match self {
44            Language::Python => has_extension(path, &["py"]),
45            Language::TypeScript => {
46                has_extension(path, &["ts", "tsx", "mts", "cts"]) && !is_declaration(path)
47            }
48            // Rust uses [`missing_inline_tests`] (inline `#[cfg(test)]` presence),
49            // not this file-pairing walk, so nothing is tracked here and `is_test`
50            // / `has_code` / `expected_test_path` are never reached for Rust.
51            Language::Rust => false,
52        }
53    }
54
55    /// `true` when `path` is itself a unit test, never a subject.
56    pub(crate) fn is_test(self, path: &Path) -> bool {
57        match self {
58            Language::Python => stem_of(path).ends_with("_test"),
59            Language::TypeScript => {
60                let name = file_name_of(path);
61                name.ends_with(".test.ts")
62                    || name.ends_with(".test.tsx")
63                    || name.ends_with(".test.mts")
64                    || name.ends_with(".test.cts")
65            }
66            Language::Rust => false,
67        }
68    }
69
70    /// `true` when `path` is test *support* — not a unit under test, but not a
71    /// subject either. Python's `conftest.py` (pytest fixtures) is the only such
72    /// file: there is no `conftest_test.py`, and it is never a coverage subject.
73    pub(crate) fn is_support(self, path: &Path) -> bool {
74        match self {
75            Language::Python => file_name_of(path) == "conftest.py",
76            Language::TypeScript | Language::Rust => false,
77        }
78    }
79
80    /// `true` when `source` (the file's contents) holds at least one line of
81    /// code — anything beyond blank lines and comments. An empty or comment-only
82    /// file (e.g. a bare `__init__.py`) carries no logic, so it is never a
83    /// unit-test subject and needs no exemption.
84    pub(crate) fn has_code(self, source: &str) -> bool {
85        match self {
86            Language::Python => python_has_code(source),
87            Language::TypeScript => typescript_has_code(source),
88            Language::Rust => false,
89        }
90    }
91
92    /// The colocated test `source` is expected to have.
93    pub(crate) fn expected_test_path(self, source: &Path) -> PathBuf {
94        match self {
95            Language::Python => source.with_file_name(format!("{}_test.py", stem_of(source))),
96            Language::TypeScript => {
97                source.with_file_name(format!("{}.test.{}", stem_of(source), extension_of(source)))
98            }
99            // Unreachable for Rust (nothing is tracked); a harmless identity.
100            Language::Rust => source.to_path_buf(),
101        }
102    }
103}
104
105/// Walk `root` recursively and return every source file (for `language`) that
106/// has no colocated unit test, sorted for deterministic output.
107///
108/// A file that is itself a test is never a subject; an empty/comment-only file
109/// holds no logic and is never a subject; a file whose `root`-relative path is
110/// in `exempt` is a deliberate, reason-required omission. Every other source
111/// file must have its colocated test sibling. `exempt` holds the
112/// `colocated-test`-rule paths resolved from config
113/// ([`crate::config::resolve_exempt`]). Returns an
114/// error if the tree under `root` cannot be read.
115pub fn missing_unit_tests(
116    root: impl AsRef<Path>,
117    language: Language,
118    exempt: &BTreeSet<String>,
119) -> Result<Vec<PathBuf>> {
120    let root = root.as_ref();
121    let mut files = Vec::new();
122    collect_files(root, language, &mut files)?;
123    // `<package root>/tests/` belongs to the suite tiers (integration / e2e),
124    // so nothing under it is a colocated-unit subject. Rust's walk already
125    // skips `tests/` (an integration-crate directory in cargo's own layout).
126    let manifest = match language {
127        Language::Python => Some("pyproject.toml"),
128        Language::TypeScript => Some("package.json"),
129        Language::Rust => None,
130    };
131    if let Some(tests) = manifest.and_then(|m| crate::tiers::suite_tests_dir(root, m)) {
132        files.retain(|file| !file.starts_with(&tests));
133    }
134
135    // Every tracked path we found, so a subject's expected twin is a lookup
136    // rather than a second pass over the filesystem.
137    let present: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
138
139    let mut orphans: Vec<PathBuf> = Vec::new();
140    for source in &files {
141        // A test file and a support file (Python `conftest.py`) are never subjects.
142        if language.is_test(source) || language.is_support(source) {
143            continue;
144        }
145        if present.contains(language.expected_test_path(source).as_path()) {
146            continue;
147        }
148        // No colocated test. An empty/comment-only file is not a subject; read
149        // only now — for the handful of files that lack a twin — to find out.
150        let contents = std::fs::read_to_string(source)
151            .with_context(|| format!("reading source file `{}`", source.display()))?;
152        if !language.has_code(&contents) {
153            continue;
154        }
155        // A type-only TypeScript module (only `type` / `interface` / `import type` /
156        // `export type`) erases to zero runtime JavaScript, so it has no behavior to test —
157        // a non-subject like a `.d.ts` file, needing no colocated test or exemption.
158        if language == Language::TypeScript && crate::ts::is_type_only_module(&contents, source) {
159            continue;
160        }
161        let relative = source
162            .strip_prefix(root)
163            .unwrap_or(source)
164            .to_string_lossy()
165            .replace('\\', "/");
166        if exempt.contains(&relative) {
167            continue;
168        }
169        orphans.push(source.clone());
170    }
171    orphans.sort();
172    Ok(orphans)
173}
174
175/// Recursively collect every file `language` tracks under `dir` into `out`.
176fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
177    let entries =
178        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
179    for entry in entries {
180        let path = entry
181            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
182            .path();
183        if path.is_dir() {
184            collect_files(&path, language, out)?;
185        } else if language.tracks(&path) {
186            out.push(path);
187        }
188    }
189    Ok(())
190}
191
192/// Walk `root` for Rust source files and return every one that defines testable
193/// behavior — a function with a body, outside any `#[cfg(test)]` module — but
194/// carries no inline `#[cfg(test)]` module, sorted for deterministic output.
195///
196/// The Rust arm of the colocated-test rule: Rust units are inline
197/// `#[cfg(test)]` modules, so "colocated" means a test module in the *same file*,
198/// not a sibling file. A file with no testable function (only `mod` / `use`
199/// declarations, types, or constants) is not a subject; integration crates under
200/// `tests/` (and `benches/` / `examples/`) are not unit sources and are skipped; a
201/// file whose `root`-relative path is in `exempt` is a deliberate, reason-required
202/// omission. Errors if the tree can't be read or a file can't be parsed.
203pub fn missing_inline_tests(
204    root: impl AsRef<Path>,
205    exempt: &BTreeSet<String>,
206) -> Result<Vec<PathBuf>> {
207    let root = root.as_ref();
208    let mut files = Vec::new();
209    collect_rust_source_files(root, &mut files)?;
210    files.sort();
211
212    let mut orphans = Vec::new();
213    for file in &files {
214        let source = std::fs::read_to_string(file)
215            .with_context(|| format!("reading source file `{}`", file.display()))?;
216        let ast = syn::parse_file(&source)
217            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
218        let mut visitor = PresenceVisitor::default();
219        visitor.visit_file(&ast);
220        // No behavior to test → not a subject; an inline `#[cfg(test)]` module → covered.
221        if !visitor.has_testable_fn || visitor.has_test_module {
222            continue;
223        }
224        let relative = file
225            .strip_prefix(root)
226            .unwrap_or(file)
227            .to_string_lossy()
228            .replace('\\', "/");
229        if exempt.contains(&relative) {
230            continue;
231        }
232        orphans.push(file.clone());
233    }
234    // `files` is already sorted, so `orphans` is in order.
235    Ok(orphans)
236}
237
238/// Recursively collect `*.rs` unit-source files under `dir` into `out`, skipping
239/// the non-unit trees — `tests/` (integration crates), `benches/`, `examples/`,
240/// `target/` — and the `build.rs` build script. Inline `#[cfg(test)]` tests live in
241/// the library/binary source, so only those files are presence subjects. Shared with
242/// the unit-isolation scan ([`crate::isolation::find_violations`]), which walks the
243/// same unit source.
244pub(crate) fn collect_rust_source_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
245    let entries =
246        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
247    for entry in entries {
248        let path = entry
249            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
250            .path();
251        if path.is_dir() {
252            let skip = matches!(
253                path.file_name().and_then(|name| name.to_str()),
254                Some("tests" | "benches" | "examples" | "target")
255            );
256            if !skip {
257                collect_rust_source_files(&path, out)?;
258            }
259        } else if has_extension(&path, &["rs"]) && file_name_of(&path) != "build.rs" {
260            out.push(path);
261        }
262    }
263    Ok(())
264}
265
266/// Walks a parsed Rust file to answer two questions for the inline-`#[cfg(test)]`
267/// presence rule: does the file define testable behavior — a function with a
268/// body outside any `#[cfg(test)]` module — and does it carry an inline
269/// `#[cfg(test)]` module? `test_depth` tracks nesting inside test modules so the
270/// test functions themselves never count as subjects.
271#[derive(Default)]
272struct PresenceVisitor {
273    test_depth: usize,
274    has_testable_fn: bool,
275    has_test_module: bool,
276}
277
278impl<'ast> Visit<'ast> for PresenceVisitor {
279    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
280        let is_test = crate::isolation::has_cfg_test(&node.attrs);
281        if is_test {
282            self.has_test_module = true;
283            self.test_depth += 1;
284        }
285        visit::visit_item_mod(self, node);
286        if is_test {
287            self.test_depth -= 1;
288        }
289    }
290
291    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
292        // A free `fn` with a body is testable behavior — unless it is itself
293        // `#[cfg(test)]`-gated (test-only code, not a shipping subject).
294        if self.test_depth == 0 && !crate::isolation::has_cfg_test(&node.attrs) {
295            self.has_testable_fn = true;
296        }
297        visit::visit_item_fn(self, node);
298    }
299
300    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
301        if self.test_depth == 0 {
302            self.has_testable_fn = true;
303        }
304        visit::visit_impl_item_fn(self, node);
305    }
306
307    fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
308        // Only a default method (with a body) is behavior to test; a bare signature
309        // is not.
310        if self.test_depth == 0 && node.default.is_some() {
311            self.has_testable_fn = true;
312        }
313        visit::visit_trait_item_fn(self, node);
314    }
315}
316
317/// `true` when the file's extension is one of `extensions`.
318fn has_extension(path: &Path, extensions: &[&str]) -> bool {
319    path.extension()
320        .and_then(|ext| ext.to_str())
321        .is_some_and(|ext| extensions.contains(&ext))
322}
323
324/// `true` for a TypeScript declaration file (`*.d.ts` / `*.d.mts` / `*.d.cts`) —
325/// no runtime code, so never a unit-test subject.
326fn is_declaration(path: &Path) -> bool {
327    let name = file_name_of(path);
328    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
329}
330
331/// `true` when any line of Python `source` is neither blank nor a `#` comment. A
332/// module docstring counts as code (it is non-comment content).
333fn python_has_code(source: &str) -> bool {
334    source.lines().any(|line| {
335        let trimmed = line.trim_start();
336        !trimmed.is_empty() && !trimmed.starts_with('#')
337    })
338}
339
340/// `true` when TypeScript `source` holds anything beyond whitespace and comments
341/// (`//` line, `/* … */` block). Any other character — including the start of a
342/// string literal — counts as code.
343fn typescript_has_code(source: &str) -> bool {
344    let mut chars = source.chars().peekable();
345    while let Some(c) = chars.next() {
346        match c {
347            c if c.is_whitespace() => {}
348            '/' if chars.peek() == Some(&'/') => {
349                while chars.peek().is_some_and(|&n| n != '\n') {
350                    chars.next();
351                }
352            }
353            '/' if chars.peek() == Some(&'*') => {
354                chars.next();
355                let mut prev = '\0';
356                for n in chars.by_ref() {
357                    if prev == '*' && n == '/' {
358                        break;
359                    }
360                    prev = n;
361                }
362            }
363            _ => return true,
364        }
365    }
366    false
367}
368
369/// The file extension, lossily decoded (empty if there is none).
370fn extension_of(path: &Path) -> String {
371    path.extension()
372        .map(|ext| ext.to_string_lossy().into_owned())
373        .unwrap_or_default()
374}
375
376/// The file name, lossily decoded.
377fn file_name_of(path: &Path) -> String {
378    path.file_name()
379        .map(|name| name.to_string_lossy().into_owned())
380        .unwrap_or_default()
381}
382
383/// The file stem (the name without its extension), lossily decoded.
384fn stem_of(path: &Path) -> String {
385    path.file_stem()
386        .map(|stem| stem.to_string_lossy().into_owned())
387        .unwrap_or_default()
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn python_tracks_py_files() {
396        assert!(Language::Python.tracks(Path::new("a.py")));
397        assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
398        assert!(!Language::Python.tracks(Path::new("a.pyi")));
399        assert!(!Language::Python.tracks(Path::new("a.txt")));
400        assert!(!Language::Python.tracks(Path::new("README")));
401    }
402
403    #[test]
404    fn python_recognizes_test_files_by_stem_suffix() {
405        assert!(Language::Python.is_test(Path::new("widget_test.py")));
406        assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
407        assert!(!Language::Python.is_test(Path::new("widget.py")));
408    }
409
410    #[test]
411    fn python_conftest_is_support_not_a_subject() {
412        // conftest.py holds pytest fixtures — support, never a subject.
413        assert!(Language::Python.is_support(Path::new("conftest.py")));
414        assert!(Language::Python.is_support(Path::new("pkg/conftest.py")));
415        assert!(!Language::Python.is_support(Path::new("widget.py")));
416        assert!(!Language::Python.is_support(Path::new("widget_test.py")));
417        // Support is Python-only; TypeScript/Rust have no conftest concept.
418        assert!(!Language::TypeScript.is_support(Path::new("conftest.ts")));
419    }
420
421    #[test]
422    fn python_expected_test_path_is_the_colocated_twin() {
423        assert_eq!(
424            Language::Python.expected_test_path(Path::new("pkg/widget.py")),
425            PathBuf::from("pkg/widget_test.py")
426        );
427        assert_eq!(
428            Language::Python.expected_test_path(Path::new("widget.py")),
429            PathBuf::from("widget_test.py")
430        );
431    }
432
433    #[test]
434    fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
435        assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
436        assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
437        assert!(Language::TypeScript.tracks(Path::new("service.mts")));
438        assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
439        assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
440        assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
441        assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
442        assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
443        assert!(!Language::TypeScript.tracks(Path::new("README")));
444    }
445
446    #[test]
447    fn typescript_recognizes_test_files_by_suffix() {
448        assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
449        assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
450        assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
451        assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
452        assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
453        assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
454        assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
455    }
456
457    #[test]
458    fn typescript_expected_test_path_keeps_the_extension() {
459        assert_eq!(
460            Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
461            PathBuf::from("pkg/widget.test.ts")
462        );
463        assert_eq!(
464            Language::TypeScript.expected_test_path(Path::new("button.tsx")),
465            PathBuf::from("button.test.tsx")
466        );
467        assert_eq!(
468            Language::TypeScript.expected_test_path(Path::new("service.mts")),
469            PathBuf::from("service.test.mts")
470        );
471        assert_eq!(
472            Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
473            PathBuf::from("legacy.test.cts")
474        );
475    }
476
477    #[test]
478    fn python_empty_or_comment_only_files_have_no_code() {
479        assert!(!Language::Python.has_code(""));
480        assert!(!Language::Python.has_code("\n   \n"));
481        assert!(!Language::Python.has_code("# just a comment\n   # another\n"));
482    }
483
484    #[test]
485    fn python_real_content_counts_as_code() {
486        assert!(Language::Python.has_code("x = 1\n"));
487        assert!(Language::Python.has_code("# header\nimport os\n"));
488        // A docstring is non-comment content, so it counts.
489        assert!(Language::Python.has_code("\"\"\"Package docstring.\"\"\"\n"));
490    }
491
492    #[test]
493    fn typescript_empty_or_comment_only_files_have_no_code() {
494        assert!(!Language::TypeScript.has_code(""));
495        assert!(!Language::TypeScript.has_code("   \n\t\n"));
496        assert!(!Language::TypeScript.has_code("// a line comment\n"));
497        assert!(!Language::TypeScript.has_code("/* a\n   block\n   comment */\n"));
498    }
499
500    #[test]
501    fn typescript_real_content_counts_as_code() {
502        assert!(Language::TypeScript.has_code("export const x = 1;\n"));
503        assert!(Language::TypeScript.has_code("// note\nexport * from './a';\n"));
504        // A string literal (even one that looks comment-ish) is code.
505        assert!(Language::TypeScript.has_code("const s = '// not a comment';\n"));
506        // A lone division slash is code, not a comment.
507        assert!(Language::TypeScript.has_code("const r = a / b;\n"));
508    }
509
510    #[test]
511    fn rust_has_no_file_based_colocated_convention() {
512        // Rust units are inline `#[cfg(test)]`; the file-based check tracks
513        // nothing and the command guards `--language rust` upstream.
514        assert!(!Language::Rust.tracks(Path::new("lib.rs")));
515        assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
516        assert!(!Language::Rust.has_code("fn main() {}\n"));
517        assert_eq!(
518            Language::Rust.expected_test_path(Path::new("src/lib.rs")),
519            PathBuf::from("src/lib.rs")
520        );
521    }
522
523    /// `(has_testable_fn, has_test_module)` for a Rust source snippet — the two
524    /// signals the inline-`#[cfg(test)]` presence rule decides on.
525    fn presence(src: &str) -> (bool, bool) {
526        let ast = syn::parse_file(src).expect("snippet parses");
527        let mut visitor = PresenceVisitor::default();
528        visitor.visit_file(&ast);
529        (visitor.has_testable_fn, visitor.has_test_module)
530    }
531
532    #[test]
533    fn rust_presence_free_fn_with_test_module_is_covered() {
534        assert_eq!(
535            presence(
536                "pub fn make(n: u8) -> u8 { n + 1 }\n\
537                 #[cfg(test)]\nmod tests { #[test] fn t() {} }\n"
538            ),
539            (true, true)
540        );
541    }
542
543    #[test]
544    fn rust_presence_free_fn_without_test_module_needs_one() {
545        assert_eq!(
546            presence("pub fn make(n: u8) -> u8 { n + 1 }\n"),
547            (true, false)
548        );
549    }
550
551    #[test]
552    fn rust_presence_type_only_file_is_not_a_subject() {
553        assert_eq!(presence("pub struct Point { pub x: u8 }\n"), (false, false));
554    }
555
556    #[test]
557    fn rust_presence_impl_method_is_testable() {
558        assert_eq!(
559            presence("pub struct W;\nimpl W { pub fn go(&self) -> u8 { 1 } }\n"),
560            (true, false)
561        );
562    }
563
564    #[test]
565    fn rust_presence_trait_default_is_testable_but_bare_signature_is_not() {
566        assert_eq!(
567            presence("pub trait T { fn d(&self) -> u8 { 1 } }\n"),
568            (true, false)
569        );
570        assert_eq!(
571            presence("pub trait T { fn s(&self) -> u8; }\n"),
572            (false, false)
573        );
574    }
575
576    #[test]
577    fn rust_presence_test_module_functions_are_not_subjects() {
578        // Only a test module: its functions are at test depth, so the file has no
579        // shipping subject and needs no further inline test.
580        assert_eq!(
581            presence("#[cfg(test)]\nmod tests { fn helper() {} #[test] fn t() {} }\n"),
582            (false, true)
583        );
584    }
585
586    #[test]
587    fn rust_presence_cfg_test_gated_free_fn_is_not_a_subject() {
588        // A directly `#[cfg(test)]`-gated free fn is test-only code, not a subject,
589        // and is not a `#[cfg(test)] mod`.
590        assert_eq!(
591            presence("#[cfg(test)]\nfn only_in_tests() {}\n"),
592            (false, false)
593        );
594    }
595}