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, ...)` gates on
98 // 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(), false)
116 }
117
118 // Negation flips what a mention means, and reading the ident without the
119 // polarity around it gets the answer exactly backwards. `not(test)` gates an
120 // item OUT of the test build, which makes it production-only code -- the
121 // opposite of what this rule looks for.
122 //
123 // This used to count a `test` ident anywhere in the predicate, and said in
124 // as many words that `not(test)` gated on test "just as effectively".
125 // `etheram-embassy` guards its arm-only allocator with
126 // `#[cfg(all(not(test), target_arch = "arm"))]`, and every item behind one
127 // was reported as test code living in the source tree: eleven offences,
128 // none of them real, against code that cannot appear in a test build at all.
129 //
130 // A `not` applies to the group that follows it, so that group is walked with
131 // the polarity flipped while everything beside it keeps the polarity it
132 // inherited. Double negation therefore lands back on gating.
133 fn has_test_ident(stream: proc_macro2::TokenStream, negated: bool) -> bool {
134 let mut trees = stream.into_iter().peekable();
135 while let Some(tree) = trees.next() {
136 match tree {
137 TokenTree::Ident(ident) if ident == "not" => {
138 if let Some(TokenTree::Group(group)) =
139 trees.next_if(|next| matches!(next, TokenTree::Group(_)))
140 && Self::has_test_ident(group.stream(), !negated)
141 {
142 return true;
143 }
144 }
145 TokenTree::Ident(ident) => {
146 if ident == "test" && !negated {
147 return true;
148 }
149 }
150 TokenTree::Group(group) => {
151 if Self::has_test_ident(group.stream(), negated) {
152 return true;
153 }
154 }
155 _ => {}
156 }
157 }
158 false
159 }
160
161 // src/<path>.rs mirrors onto tests/<path>_tests.rs, which is the same
162 // pairing twin4rust enforces, so the correction names the file that should
163 // already exist rather than describing a policy.
164 fn mirror_of(relative_path: &str) -> String {
165 let without_root = relative_path.strip_prefix("src/").unwrap_or(relative_path);
166 let stem = without_root.strip_suffix(".rs").unwrap_or(without_root);
167 format!("tests/{stem}_tests.rs")
168 }
169
170 // The kind words stay here for the same reason they stay in
171 // `RegistryParser`: they sit inside offence descriptions, and this one says
172 // "module" where that one says "inline module".
173 fn describe(file: &SourceFile, item: &Item, line: usize) -> String {
174 match (Self::kind(item), ItemNaming::identifier(item)) {
175 (Some(kind), Some(subject)) => format!("{kind} `{subject}`"),
176 _ => format!("`{}`", ItemNaming::source_line(file, line)),
177 }
178 }
179
180 fn kind(item: &Item) -> Option<&'static str> {
181 match item {
182 Item::Const(_) => Some("constant"),
183 Item::Enum(_) => Some("enum"),
184 Item::Fn(_) => Some("function"),
185 Item::Mod(_) => Some("module"),
186 Item::Static(_) => Some("static"),
187 Item::Struct(_) => Some("struct"),
188 Item::Trait(_) => Some("trait"),
189 Item::Type(_) => Some("type alias"),
190 _ => None,
191 }
192 }
193
194 fn attributes(item: &Item) -> &[Attribute] {
195 match item {
196 Item::Const(inner) => &inner.attrs,
197 Item::Enum(inner) => &inner.attrs,
198 Item::ExternCrate(inner) => &inner.attrs,
199 Item::Fn(inner) => &inner.attrs,
200 Item::ForeignMod(inner) => &inner.attrs,
201 Item::Impl(inner) => &inner.attrs,
202 Item::Macro(inner) => &inner.attrs,
203 Item::Mod(inner) => &inner.attrs,
204 Item::Static(inner) => &inner.attrs,
205 Item::Struct(inner) => &inner.attrs,
206 Item::Trait(inner) => &inner.attrs,
207 Item::TraitAlias(inner) => &inner.attrs,
208 Item::Type(inner) => &inner.attrs,
209 Item::Union(inner) => &inner.attrs,
210 Item::Use(inner) => &inner.attrs,
211 _ => &[],
212 }
213 }
214}