stern4rust/rules/testing/
arrange_act_assert_rule.rs1use 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
23pub 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 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 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 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 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}