Skip to main content

testing_conventions/
colocated_test.rs

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