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
124    // Every tracked path we found, so a subject's expected twin is a lookup
125    // rather than a second pass over the filesystem.
126    let present: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
127
128    let mut orphans: Vec<PathBuf> = Vec::new();
129    for source in &files {
130        // A test file and a support file (Python `conftest.py`) are never subjects.
131        if language.is_test(source) || language.is_support(source) {
132            continue;
133        }
134        if present.contains(language.expected_test_path(source).as_path()) {
135            continue;
136        }
137        // No colocated test. An empty/comment-only file is not a subject; read
138        // only now — for the handful of files that lack a twin — to find out.
139        let contents = std::fs::read_to_string(source)
140            .with_context(|| format!("reading source file `{}`", source.display()))?;
141        if !language.has_code(&contents) {
142            continue;
143        }
144        let relative = source
145            .strip_prefix(root)
146            .unwrap_or(source)
147            .to_string_lossy()
148            .replace('\\', "/");
149        if exempt.contains(&relative) {
150            continue;
151        }
152        orphans.push(source.clone());
153    }
154    orphans.sort();
155    Ok(orphans)
156}
157
158/// Recursively collect every file `language` tracks under `dir` into `out`.
159fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
160    let entries =
161        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
162    for entry in entries {
163        let path = entry
164            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
165            .path();
166        if path.is_dir() {
167            collect_files(&path, language, out)?;
168        } else if language.tracks(&path) {
169            out.push(path);
170        }
171    }
172    Ok(())
173}
174
175/// Walk `root` for Rust source files and return every one that defines testable
176/// behavior — a function with a body, outside any `#[cfg(test)]` module — but
177/// carries no inline `#[cfg(test)]` module, sorted for deterministic output.
178///
179/// The Rust arm of the colocated-test rule: Rust units are inline
180/// `#[cfg(test)]` modules, so "colocated" means a test module in the *same file*,
181/// not a sibling file. A file with no testable function (only `mod` / `use`
182/// declarations, types, or constants) is not a subject; integration crates under
183/// `tests/` (and `benches/` / `examples/`) are not unit sources and are skipped; a
184/// file whose `root`-relative path is in `exempt` is a deliberate, reason-required
185/// omission. Errors if the tree can't be read or a file can't be parsed.
186pub fn missing_inline_tests(
187    root: impl AsRef<Path>,
188    exempt: &BTreeSet<String>,
189) -> Result<Vec<PathBuf>> {
190    let root = root.as_ref();
191    let mut files = Vec::new();
192    collect_rust_source_files(root, &mut files)?;
193    files.sort();
194
195    let mut orphans = Vec::new();
196    for file in &files {
197        let source = std::fs::read_to_string(file)
198            .with_context(|| format!("reading source file `{}`", file.display()))?;
199        let ast = syn::parse_file(&source)
200            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
201        let mut visitor = PresenceVisitor::default();
202        visitor.visit_file(&ast);
203        // No behavior to test → not a subject; an inline `#[cfg(test)]` module → covered.
204        if !visitor.has_testable_fn || visitor.has_test_module {
205            continue;
206        }
207        let relative = file
208            .strip_prefix(root)
209            .unwrap_or(file)
210            .to_string_lossy()
211            .replace('\\', "/");
212        if exempt.contains(&relative) {
213            continue;
214        }
215        orphans.push(file.clone());
216    }
217    // `files` is already sorted, so `orphans` is in order.
218    Ok(orphans)
219}
220
221/// Recursively collect `*.rs` unit-source files under `dir` into `out`, skipping
222/// the non-unit trees — `tests/` (integration crates), `benches/`, `examples/`,
223/// `target/` — and the `build.rs` build script. Inline `#[cfg(test)]` tests live in
224/// the library/binary source, so only those files are presence subjects.
225fn collect_rust_source_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
226    let entries =
227        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
228    for entry in entries {
229        let path = entry
230            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
231            .path();
232        if path.is_dir() {
233            let skip = matches!(
234                path.file_name().and_then(|name| name.to_str()),
235                Some("tests" | "benches" | "examples" | "target")
236            );
237            if !skip {
238                collect_rust_source_files(&path, out)?;
239            }
240        } else if has_extension(&path, &["rs"]) && file_name_of(&path) != "build.rs" {
241            out.push(path);
242        }
243    }
244    Ok(())
245}
246
247/// Walks a parsed Rust file to answer two questions for the inline-`#[cfg(test)]`
248/// presence rule: does the file define testable behavior — a function with a
249/// body outside any `#[cfg(test)]` module — and does it carry an inline
250/// `#[cfg(test)]` module? `test_depth` tracks nesting inside test modules so the
251/// test functions themselves never count as subjects.
252#[derive(Default)]
253struct PresenceVisitor {
254    test_depth: usize,
255    has_testable_fn: bool,
256    has_test_module: bool,
257}
258
259impl<'ast> Visit<'ast> for PresenceVisitor {
260    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
261        let is_test = crate::isolation::has_cfg_test(&node.attrs);
262        if is_test {
263            self.has_test_module = true;
264            self.test_depth += 1;
265        }
266        visit::visit_item_mod(self, node);
267        if is_test {
268            self.test_depth -= 1;
269        }
270    }
271
272    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
273        // A free `fn` with a body is testable behavior — unless it is itself
274        // `#[cfg(test)]`-gated (test-only code, not a shipping subject).
275        if self.test_depth == 0 && !crate::isolation::has_cfg_test(&node.attrs) {
276            self.has_testable_fn = true;
277        }
278        visit::visit_item_fn(self, node);
279    }
280
281    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
282        if self.test_depth == 0 {
283            self.has_testable_fn = true;
284        }
285        visit::visit_impl_item_fn(self, node);
286    }
287
288    fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
289        // Only a default method (with a body) is behavior to test; a bare signature
290        // is not.
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 the file's extension is one of `extensions`.
299fn has_extension(path: &Path, extensions: &[&str]) -> bool {
300    path.extension()
301        .and_then(|ext| ext.to_str())
302        .is_some_and(|ext| extensions.contains(&ext))
303}
304
305/// `true` for a TypeScript declaration file (`*.d.ts` / `*.d.mts` / `*.d.cts`) —
306/// no runtime code, so never a unit-test subject.
307fn is_declaration(path: &Path) -> bool {
308    let name = file_name_of(path);
309    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
310}
311
312/// `true` when any line of Python `source` is neither blank nor a `#` comment. A
313/// module docstring counts as code (it is non-comment content).
314fn python_has_code(source: &str) -> bool {
315    source.lines().any(|line| {
316        let trimmed = line.trim_start();
317        !trimmed.is_empty() && !trimmed.starts_with('#')
318    })
319}
320
321/// `true` when TypeScript `source` holds anything beyond whitespace and comments
322/// (`//` line, `/* … */` block). Any other character — including the start of a
323/// string literal — counts as code.
324fn typescript_has_code(source: &str) -> bool {
325    let mut chars = source.chars().peekable();
326    while let Some(c) = chars.next() {
327        match c {
328            c if c.is_whitespace() => {}
329            '/' if chars.peek() == Some(&'/') => {
330                while chars.peek().is_some_and(|&n| n != '\n') {
331                    chars.next();
332                }
333            }
334            '/' if chars.peek() == Some(&'*') => {
335                chars.next();
336                let mut prev = '\0';
337                for n in chars.by_ref() {
338                    if prev == '*' && n == '/' {
339                        break;
340                    }
341                    prev = n;
342                }
343            }
344            _ => return true,
345        }
346    }
347    false
348}
349
350/// The file extension, lossily decoded (empty if there is none).
351fn extension_of(path: &Path) -> String {
352    path.extension()
353        .map(|ext| ext.to_string_lossy().into_owned())
354        .unwrap_or_default()
355}
356
357/// The file name, lossily decoded.
358fn file_name_of(path: &Path) -> String {
359    path.file_name()
360        .map(|name| name.to_string_lossy().into_owned())
361        .unwrap_or_default()
362}
363
364/// The file stem (the name without its extension), lossily decoded.
365fn stem_of(path: &Path) -> String {
366    path.file_stem()
367        .map(|stem| stem.to_string_lossy().into_owned())
368        .unwrap_or_default()
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn python_tracks_py_files() {
377        assert!(Language::Python.tracks(Path::new("a.py")));
378        assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
379        assert!(!Language::Python.tracks(Path::new("a.pyi")));
380        assert!(!Language::Python.tracks(Path::new("a.txt")));
381        assert!(!Language::Python.tracks(Path::new("README")));
382    }
383
384    #[test]
385    fn python_recognizes_test_files_by_stem_suffix() {
386        assert!(Language::Python.is_test(Path::new("widget_test.py")));
387        assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
388        assert!(!Language::Python.is_test(Path::new("widget.py")));
389    }
390
391    #[test]
392    fn python_conftest_is_support_not_a_subject() {
393        // conftest.py holds pytest fixtures — support, never a subject.
394        assert!(Language::Python.is_support(Path::new("conftest.py")));
395        assert!(Language::Python.is_support(Path::new("pkg/conftest.py")));
396        assert!(!Language::Python.is_support(Path::new("widget.py")));
397        assert!(!Language::Python.is_support(Path::new("widget_test.py")));
398        // Support is Python-only; TypeScript/Rust have no conftest concept.
399        assert!(!Language::TypeScript.is_support(Path::new("conftest.ts")));
400    }
401
402    #[test]
403    fn python_expected_test_path_is_the_colocated_twin() {
404        assert_eq!(
405            Language::Python.expected_test_path(Path::new("pkg/widget.py")),
406            PathBuf::from("pkg/widget_test.py")
407        );
408        assert_eq!(
409            Language::Python.expected_test_path(Path::new("widget.py")),
410            PathBuf::from("widget_test.py")
411        );
412    }
413
414    #[test]
415    fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
416        assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
417        assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
418        assert!(Language::TypeScript.tracks(Path::new("service.mts")));
419        assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
420        assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
421        assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
422        assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
423        assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
424        assert!(!Language::TypeScript.tracks(Path::new("README")));
425    }
426
427    #[test]
428    fn typescript_recognizes_test_files_by_suffix() {
429        assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
430        assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
431        assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
432        assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
433        assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
434        assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
435        assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
436    }
437
438    #[test]
439    fn typescript_expected_test_path_keeps_the_extension() {
440        assert_eq!(
441            Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
442            PathBuf::from("pkg/widget.test.ts")
443        );
444        assert_eq!(
445            Language::TypeScript.expected_test_path(Path::new("button.tsx")),
446            PathBuf::from("button.test.tsx")
447        );
448        assert_eq!(
449            Language::TypeScript.expected_test_path(Path::new("service.mts")),
450            PathBuf::from("service.test.mts")
451        );
452        assert_eq!(
453            Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
454            PathBuf::from("legacy.test.cts")
455        );
456    }
457
458    #[test]
459    fn python_empty_or_comment_only_files_have_no_code() {
460        assert!(!Language::Python.has_code(""));
461        assert!(!Language::Python.has_code("\n   \n"));
462        assert!(!Language::Python.has_code("# just a comment\n   # another\n"));
463    }
464
465    #[test]
466    fn python_real_content_counts_as_code() {
467        assert!(Language::Python.has_code("x = 1\n"));
468        assert!(Language::Python.has_code("# header\nimport os\n"));
469        // A docstring is non-comment content, so it counts.
470        assert!(Language::Python.has_code("\"\"\"Package docstring.\"\"\"\n"));
471    }
472
473    #[test]
474    fn typescript_empty_or_comment_only_files_have_no_code() {
475        assert!(!Language::TypeScript.has_code(""));
476        assert!(!Language::TypeScript.has_code("   \n\t\n"));
477        assert!(!Language::TypeScript.has_code("// a line comment\n"));
478        assert!(!Language::TypeScript.has_code("/* a\n   block\n   comment */\n"));
479    }
480
481    #[test]
482    fn typescript_real_content_counts_as_code() {
483        assert!(Language::TypeScript.has_code("export const x = 1;\n"));
484        assert!(Language::TypeScript.has_code("// note\nexport * from './a';\n"));
485        // A string literal (even one that looks comment-ish) is code.
486        assert!(Language::TypeScript.has_code("const s = '// not a comment';\n"));
487        // A lone division slash is code, not a comment.
488        assert!(Language::TypeScript.has_code("const r = a / b;\n"));
489    }
490
491    #[test]
492    fn rust_has_no_file_based_colocated_convention() {
493        // Rust units are inline `#[cfg(test)]`; the file-based check tracks
494        // nothing and the command guards `--language rust` upstream.
495        assert!(!Language::Rust.tracks(Path::new("lib.rs")));
496        assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
497        assert!(!Language::Rust.has_code("fn main() {}\n"));
498        assert_eq!(
499            Language::Rust.expected_test_path(Path::new("src/lib.rs")),
500            PathBuf::from("src/lib.rs")
501        );
502    }
503
504    /// `(has_testable_fn, has_test_module)` for a Rust source snippet — the two
505    /// signals the inline-`#[cfg(test)]` presence rule decides on.
506    fn presence(src: &str) -> (bool, bool) {
507        let ast = syn::parse_file(src).expect("snippet parses");
508        let mut visitor = PresenceVisitor::default();
509        visitor.visit_file(&ast);
510        (visitor.has_testable_fn, visitor.has_test_module)
511    }
512
513    #[test]
514    fn rust_presence_free_fn_with_test_module_is_covered() {
515        assert_eq!(
516            presence(
517                "pub fn make(n: u8) -> u8 { n + 1 }\n\
518                 #[cfg(test)]\nmod tests { #[test] fn t() {} }\n"
519            ),
520            (true, true)
521        );
522    }
523
524    #[test]
525    fn rust_presence_free_fn_without_test_module_needs_one() {
526        assert_eq!(
527            presence("pub fn make(n: u8) -> u8 { n + 1 }\n"),
528            (true, false)
529        );
530    }
531
532    #[test]
533    fn rust_presence_type_only_file_is_not_a_subject() {
534        assert_eq!(presence("pub struct Point { pub x: u8 }\n"), (false, false));
535    }
536
537    #[test]
538    fn rust_presence_impl_method_is_testable() {
539        assert_eq!(
540            presence("pub struct W;\nimpl W { pub fn go(&self) -> u8 { 1 } }\n"),
541            (true, false)
542        );
543    }
544
545    #[test]
546    fn rust_presence_trait_default_is_testable_but_bare_signature_is_not() {
547        assert_eq!(
548            presence("pub trait T { fn d(&self) -> u8 { 1 } }\n"),
549            (true, false)
550        );
551        assert_eq!(
552            presence("pub trait T { fn s(&self) -> u8; }\n"),
553            (false, false)
554        );
555    }
556
557    #[test]
558    fn rust_presence_test_module_functions_are_not_subjects() {
559        // Only a test module: its functions are at test depth, so the file has no
560        // shipping subject and needs no further inline test.
561        assert_eq!(
562            presence("#[cfg(test)]\nmod tests { fn helper() {} #[test] fn t() {} }\n"),
563            (false, true)
564        );
565    }
566
567    #[test]
568    fn rust_presence_cfg_test_gated_free_fn_is_not_a_subject() {
569        // A directly `#[cfg(test)]`-gated free fn is test-only code, not a subject,
570        // and is not a `#[cfg(test)] mod`.
571        assert_eq!(
572            presence("#[cfg(test)]\nfn only_in_tests() {}\n"),
573            (false, false)
574        );
575    }
576}