Skip to main content

stern4rust/rules/testing/
arrange_act_assert_rule.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use std::collections::BTreeSet;
6
7use proc_macro2::TokenStream;
8use proc_macro2::TokenTree;
9use quote::ToTokens;
10use syn::Attribute;
11use syn::Item;
12use syn::ItemFn;
13use syn::parse_file;
14use syn::spanned::Spanned;
15
16use crate::finding::model::test_marker::MarkerPhase;
17use crate::finding::model::test_marker::TestMarker;
18use crate::reporting::offence::Offence;
19use crate::reporting::rule_explanation::RuleExplanation;
20use crate::rule::Rule;
21use crate::source_file::SourceFile;
22
23// A test reads `Arrange`, then one or more `Act`/`Assert` pairs.
24//
25// The name of a test says what it claims; this says whether the body is laid out
26// so a reader can check the claim. It is the oldest of this tool's rules by
27// intent -- the original motivating example -- and the last to be built, because
28// of one problem that has nothing to do with AAA.
29//
30// **The markers are comments, and comments never reach the syntax tree.** `syn`
31// discards them, so this rule reads lines. And a line scanner cannot tell code
32// from a string that contains code -- which matters here more than anywhere,
33// because this repository's own tests are built from Rust source embedded in raw
34// strings. Scanning lines naively reports the fixtures rather than the tests: it
35// finds seven offences in this crate that are all string literals, and the rule
36// fails the gate it exists to pass.
37//
38// So the lines a literal occupies are taken from the token stream and skipped.
39// Comments are not tokens and literals are, which is exactly the distinction
40// needed. Walking the tokens rather than visiting the syntax tree also reaches
41// inside macros, where `assert_eq!("// Act", x)` would otherwise hide one.
42//
43// The grammar is deliberately small. Every marker expands to the phases it
44// names, and the expansion must read `Arrange` followed by one or more
45// `Act`/`Assert` pairs. That single check covers every legal shape --
46// `// Arrange & Act`, `// Act & Assert` and `// Arrange & Act & Assert` all
47// expand into the same sequence as the three separate markers do -- and rejects
48// a test whose Act has no Assert, whose Assert has no Act, or whose Arrange was
49// dropped instead of merged.
50pub struct ArrangeActAssertRule;
51
52impl ArrangeActAssertRule {
53    pub const REGISTRIES: [&'static str; 2] = ["all_tests.rs", "mod.rs"];
54    pub const TESTS_ROOT: &'static str = "tests/";
55
56    pub fn new() -> Self {
57        Self
58    }
59
60    fn applies_to(file: &SourceFile) -> bool {
61        let path = file.relative_path();
62        path.starts_with(Self::TESTS_ROOT)
63            && !path
64                .rsplit('/')
65                .next()
66                .is_some_and(|name| Self::REGISTRIES.contains(&name))
67    }
68
69    fn is_test(attrs: &[Attribute]) -> bool {
70        attrs.iter().any(|attr| {
71            attr.path()
72                .segments
73                .last()
74                .is_some_and(|segment| segment.ident == "test")
75        })
76    }
77
78    // Every line any literal occupies. Comments are not tokens, so nothing a
79    // marker could sit on is lost, and every line of a multi-line raw string is
80    // covered.
81    fn literal_lines(tokens: TokenStream) -> BTreeSet<usize> {
82        tokens
83            .into_iter()
84            .flat_map(|tree| match tree {
85                TokenTree::Group(group) => Self::literal_lines(group.stream()),
86                TokenTree::Literal(literal) => {
87                    let span = literal.span();
88                    (span.start().line..=span.end().line).collect()
89                }
90                _ => BTreeSet::new(),
91            })
92            .collect()
93    }
94
95    fn markers_of(file: &SourceFile, function: &ItemFn) -> Vec<TestMarker> {
96        let body = function.block.span();
97        let skipped = Self::literal_lines(function.block.to_token_stream());
98        (body.start().line..=body.end().line)
99            .filter(|line| !skipped.contains(line))
100            .filter_map(|line| {
101                let text = file.lines().get(line - 1)?;
102                TestMarker::parse(text, line)
103            })
104            .collect()
105    }
106
107    // Arrange, then one or more Act/Assert pairs. An odd length with Arrange
108    // first and clean pairs after is the whole grammar.
109    fn is_legal(phases: &[MarkerPhase]) -> bool {
110        if phases.len() < 3 || phases.len() % 2 == 0 || phases[0] != MarkerPhase::Arrange {
111            return false;
112        }
113        phases[1..]
114            .chunks(2)
115            .all(|pair| pair == [MarkerPhase::Act, MarkerPhase::Assert])
116    }
117
118    fn named(phases: &[MarkerPhase]) -> String {
119        if phases.is_empty() {
120            return "no AAA markers".to_string();
121        }
122        phases
123            .iter()
124            .map(|phase| match phase {
125                MarkerPhase::Arrange => "Arrange",
126                MarkerPhase::Act => "Act",
127                MarkerPhase::Assert => "Assert",
128            })
129            .collect::<Vec<&str>>()
130            .join(", ")
131    }
132
133    fn sequence_offence(&self, file: &SourceFile, function: &ItemFn, found: &str) -> Offence {
134        let name = function.sig.ident.to_string();
135        Offence::new(
136            file.relative_path(),
137            function.sig.ident.span().start().line,
138            self.name(),
139            format!(
140                "`{name}` reads {found}; a test is `Arrange` followed by one or more \
141                 `Act`/`Assert` pairs"
142            ),
143            "label the sections `// Arrange`, `// Act` and `// Assert`, merging adjacent ones \
144             as `// Arrange & Act`, `// Act & Assert` or `// Arrange & Act & Assert`"
145                .to_string(),
146        )
147        .with_subject(&name)
148    }
149
150    // Every marker after the first opens a section, and a section that does not
151    // start on its own is one a reader has to find rather than see.
152    fn spacing_offences(
153        &self,
154        file: &SourceFile,
155        function: &ItemFn,
156        markers: &[TestMarker],
157    ) -> Vec<Offence> {
158        let name = function.sig.ident.to_string();
159        markers
160            .iter()
161            .skip(1)
162            .filter(|marker| !Self::is_blank_above(file, marker.line))
163            .map(|marker| {
164                Offence::new(
165                    file.relative_path(),
166                    marker.line,
167                    self.name(),
168                    format!(
169                        "`{}` in `{name}` is not preceded by a blank line",
170                        marker.label
171                    ),
172                    format!("put a blank line before `{}`", marker.label),
173                )
174                .with_subject(&name)
175            })
176            .collect()
177    }
178
179    // Comment lines above a marker are folded into it, the same way
180    // `TestFileParser` folds them into the item they document. Without that, a
181    // marker that explains itself over two lines reads as a spacing offence --
182    // and the explanation is the thing worth keeping.
183    fn is_blank_above(file: &SourceFile, line: usize) -> bool {
184        let lines = file.lines();
185        (1..line)
186            .rev()
187            .map(|above| lines[above - 1].trim())
188            .find(|text| !text.starts_with("//"))
189            .is_none_or(str::is_empty)
190    }
191
192    fn offences_of(&self, file: &SourceFile, function: &ItemFn) -> Vec<Offence> {
193        let markers = Self::markers_of(file, function);
194        let phases: Vec<MarkerPhase> = markers
195            .iter()
196            .flat_map(|marker| marker.phases.clone())
197            .collect();
198        if !Self::is_legal(&phases) {
199            return vec![self.sequence_offence(file, function, &Self::named(&phases))];
200        }
201        self.spacing_offences(file, function, &markers)
202    }
203}
204
205impl Default for ArrangeActAssertRule {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211impl Rule for ArrangeActAssertRule {
212    fn name(&self) -> &'static str {
213        "arrange-act-assert"
214    }
215
216    fn check(&self, file: &SourceFile) -> Vec<Offence> {
217        if !Self::applies_to(file) {
218            return Vec::new();
219        }
220        let Ok(syntax) = parse_file(&file.contents()) else {
221            return Vec::new();
222        };
223        syntax
224            .items
225            .iter()
226            .filter_map(|item| match item {
227                Item::Fn(function) if Self::is_test(&function.attrs) => Some(function),
228                _ => None,
229            })
230            .flat_map(|function| self.offences_of(file, function))
231            .collect()
232    }
233
234    fn check_workspace(&self, _files: &[SourceFile]) -> Vec<Offence> {
235        Vec::new()
236    }
237
238    fn requirement(&self) -> Option<&'static str> {
239        None
240    }
241
242    fn is_configured(&self) -> bool {
243        true
244    }
245
246    fn explanation(&self) -> RuleExplanation {
247        RuleExplanation::new(
248            self.name(),
249            "A test reads Arrange, then one or more Act/Assert pairs.",
250            "#[test]\nfn adds_two_numbers() {\n    assert_eq!(add(1, 1), 2);\n}",
251            "#[test]\nfn adds_two_numbers() {\n    // Arrange & Act & Assert\n    assert_eq!(add(1, 1), 2);\n}",
252        )
253    }
254}