Skip to main content

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