Skip to main content

stern4rust/finding/
unit_test_finder.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use proc_macro2::TokenTree;
6use quote::ToTokens;
7use syn::Attribute;
8use syn::Item;
9use syn::parse_file;
10use syn::spanned::Spanned;
11
12use crate::finding::unit_test_site::UnitTestSite;
13use crate::source_file::SourceFile;
14
15// Finds tests, and the machinery of tests, in the production source tree.
16//
17// Three shapes, and all three are about the same thing: code that exists only
18// when the tests are being built. A function carrying a test attribute is the
19// obvious one. `#[cfg(test)]` is the usual one. `#[cfg_attr(test, ...)]` is the
20// third, and it is the one worth spelling out -- a type carrying a derive only
21// under test is a type that means one thing to the tests and another to the
22// shipped build.
23//
24// Only the test-gated spelling counts. `#[cfg_attr(feature = "serde", ...)]` is
25// ordinary library work and is left alone, as is `#[cfg(feature = "...")]`:
26// both gate on something the shipped build can also select.
27pub struct UnitTestFinder;
28
29impl UnitTestFinder {
30    // None means the file does not parse. readable-source reports that.
31    pub fn sites(file: &SourceFile) -> Option<Vec<UnitTestSite>> {
32        let syntax = parse_file(&file.contents()).ok()?;
33        Some(Self::in_items(file, &syntax.items))
34    }
35
36    // Descends into inline modules, because nesting is where a gate is easiest
37    // to miss by eye. An item already reported is not descended into: the
38    // module is the offence, and listing every test inside it would report the
39    // same decision once per test.
40    fn in_items(file: &SourceFile, items: &[Item]) -> Vec<UnitTestSite> {
41        let mut sites = Vec::new();
42        for item in items {
43            if let Some(site) = Self::site_of(file, item) {
44                sites.push(site);
45                continue;
46            }
47            if let Item::Mod(module) = item {
48                if let Some((_, inner)) = &module.content {
49                    sites.extend(Self::in_items(file, inner));
50                }
51            }
52        }
53        sites
54    }
55
56    fn site_of(file: &SourceFile, item: &Item) -> Option<UnitTestSite> {
57        let line = item.span().start().line.max(1);
58        let attrs = Self::attributes(item);
59        let subject = Self::describe(file, item, line);
60        let mirror = Self::mirror_of(file.relative_path());
61
62        if attrs.iter().any(Self::is_cfg_attr_test) {
63            return Some(UnitTestSite::new(
64                line,
65                &format!("the `#[cfg_attr(test, ...)]` on the {subject}"),
66                &format!(
67                    "apply the attribute unconditionally, or move what it guards into {mirror}"
68                ),
69            ));
70        }
71        if attrs.iter().any(Self::is_cfg_test) {
72            return Some(UnitTestSite::new(
73                line,
74                &format!("the `#[cfg(test)]` {subject}"),
75                &format!("move the tests to {mirror} and delete this from the source tree"),
76            ));
77        }
78        if attrs.iter().any(Self::is_test) {
79            return Some(UnitTestSite::new(
80                line,
81                &format!("the test {subject}"),
82                &format!("move the tests to {mirror} and delete this from the source tree"),
83            ));
84        }
85        None
86    }
87
88    // Only when the predicate gates on test. `cfg_attr` is how a crate applies
89    // a derive behind a feature, which is ordinary library work and none of
90    // this rule's business -- what is forbidden is a type that means one thing
91    // to the tests and another to the shipped build.
92    fn is_cfg_attr_test(attr: &Attribute) -> bool {
93        attr.path().is_ident("cfg_attr") && Self::mentions_test(attr)
94    }
95
96    // A predicate rather than the literal text, since `any(test, ...)` and
97    // `not(test)` gate on test just as effectively.
98    fn is_cfg_test(attr: &Attribute) -> bool {
99        attr.path().is_ident("cfg") && Self::mentions_test(attr)
100    }
101
102    // `#[test]`, `#[tokio::test]` and any other harness spelled the same way.
103    fn is_test(attr: &Attribute) -> bool {
104        attr.path()
105            .segments
106            .last()
107            .is_some_and(|segment| segment.ident == "test")
108    }
109
110    // An identifier, not a substring, so `#[cfg(feature = "test")]` is a
111    // feature named test rather than a test gate -- the string literal never
112    // arrives as an Ident.
113    fn mentions_test(attr: &Attribute) -> bool {
114        Self::has_test_ident(attr.meta.to_token_stream())
115    }
116
117    fn has_test_ident(stream: proc_macro2::TokenStream) -> bool {
118        stream.into_iter().any(|tree| match tree {
119            TokenTree::Ident(ident) => ident == "test",
120            TokenTree::Group(group) => Self::has_test_ident(group.stream()),
121            _ => false,
122        })
123    }
124
125    // src/<path>.rs mirrors onto tests/<path>_tests.rs, which is the same
126    // pairing twin4rust enforces, so the correction names the file that should
127    // already exist rather than describing a policy.
128    fn mirror_of(relative_path: &str) -> String {
129        let without_root = relative_path.strip_prefix("src/").unwrap_or(relative_path);
130        let stem = without_root.strip_suffix(".rs").unwrap_or(without_root);
131        format!("tests/{stem}_tests.rs")
132    }
133
134    fn describe(file: &SourceFile, item: &Item, line: usize) -> String {
135        match item {
136            Item::Const(inner) => format!("constant `{}`", inner.ident),
137            Item::Enum(inner) => format!("enum `{}`", inner.ident),
138            Item::Fn(inner) => format!("function `{}`", inner.sig.ident),
139            Item::Mod(inner) => format!("module `{}`", inner.ident),
140            Item::Static(inner) => format!("static `{}`", inner.ident),
141            Item::Struct(inner) => format!("struct `{}`", inner.ident),
142            Item::Trait(inner) => format!("trait `{}`", inner.ident),
143            Item::Type(inner) => format!("type alias `{}`", inner.ident),
144            _ => format!("`{}`", Self::source_line(file, line)),
145        }
146    }
147
148    fn source_line(file: &SourceFile, line: usize) -> String {
149        file.lines()
150            .get(line.saturating_sub(1))
151            .map_or_else(String::new, |text| text.trim().to_string())
152    }
153
154    fn attributes(item: &Item) -> &[Attribute] {
155        match item {
156            Item::Const(inner) => &inner.attrs,
157            Item::Enum(inner) => &inner.attrs,
158            Item::ExternCrate(inner) => &inner.attrs,
159            Item::Fn(inner) => &inner.attrs,
160            Item::ForeignMod(inner) => &inner.attrs,
161            Item::Impl(inner) => &inner.attrs,
162            Item::Macro(inner) => &inner.attrs,
163            Item::Mod(inner) => &inner.attrs,
164            Item::Static(inner) => &inner.attrs,
165            Item::Struct(inner) => &inner.attrs,
166            Item::Trait(inner) => &inner.attrs,
167            Item::TraitAlias(inner) => &inner.attrs,
168            Item::Type(inner) => &inner.attrs,
169            Item::Union(inner) => &inner.attrs,
170            Item::Use(inner) => &inner.attrs,
171            _ => &[],
172        }
173    }
174}