wowlab_tidy/languages/rust/rules/style/
padding.rs1#[cfg(test)]
2use googletest::prelude::*;
3use ra_ap_syntax::{
4 AstNode, SyntaxKind, SyntaxNode, SyntaxToken, ast, syntax_editor::SyntaxEditor,
5};
6
7use crate::{AstCtx, Example, Violation};
8
9const BLANK_LINE_NEWLINES: usize = 2;
10
11#[rustfmt::skip]
12const EXAMPLES: &[Example] = &[
13 Example {
14 label: "return has padding",
15 code: "fn value(flag: bool) -> usize {\n let value = 1;\n\n return value;\n}",
16 pass: true,
17 },
18 Example {
19 label: "return needs padding",
20 code: "fn value() -> usize {\n let value = 1;\n return value;\n}",
21 pass: false,
22 },
23 Example {
24 label: "first return is exempt",
25 code: "fn value() -> usize {\n return 1;\n}",
26 pass: true,
27 },
28 Example {
29 label: "tail expression has padding",
30 code: "fn value() -> usize {\n let value = 1;\n\n value\n}",
31 pass: true,
32 },
33 Example {
34 label: "tail expression needs padding",
35 code: "fn value() -> usize {\n let value = 1;\n value\n}",
36 pass: false,
37 },
38 Example {
39 label: "let run needs following padding",
40 code: "fn run() {\n let one = 1;\n let two = 2;\n consume(one, two);\n}",
41 pass: false,
42 },
43 Example {
44 label: "let run followed by padding",
45 code: "fn run() {\n let one = 1;\n let two = 2;\n\n consume(one, two);\n}",
46 pass: true,
47 },
48 Example {
49 label: "multiline control expression is padded",
50 code: "fn run(flag: bool) {\n prepare();\n\n if flag {\n work();\n }\n\n finish();\n}",
51 pass: true,
52 },
53 Example {
54 label: "multiline control expression needs padding before and after",
55 code: "fn run(flag: bool) {\n prepare();\n if flag {\n work();\n }\n finish();\n}",
56 pass: false,
57 },
58 Example {
59 label: "first and last multiline controls need only interior padding",
60 code: "fn run(flag: bool) {\n if flag {\n work();\n }\n\n loop {\n break;\n }\n}",
61 pass: true,
62 },
63 Example {
64 label: "single-expression closure is exempt",
65 code: "fn run() {\n consume(|| { 1 });\n}",
66 pass: true,
67 },
68 Example {
69 label: "docref region keeps contiguous statements",
70 code: "fn value() -> usize {\n // docref:start padding-region\n let value = 1;\n return value;\n // docref:end padding-region\n}",
71 pass: true,
72 },
73 Example {
74 label: "fix pads outside but not inside docref region",
75 code: "fn value() -> usize {\n let outside = 1;\n consume(outside);\n // docref:start padding-region\n let value = 1;\n return value;\n // docref:end padding-region\n}",
76 pass: false,
77 },
78 Example {
79 label: "directive stays attached to multiline control",
80 code: "fn run(flag: bool) {\n let value = String::new();\n // #t(rust_clone_in_loop) bounded control path\n if flag {\n consume(value.clone());\n }\n}",
81 pass: false,
82 },
83 Example {
84 label: "directive stays attached to tail expression",
85 code: "fn value() -> usize {\n let value = 1;\n // #t(rust_clone_in_loop) representative fixture\n value\n}",
86 pass: false,
87 },
88 Example {
89 label: "block directive stays attached to loop",
90 code: "fn run(values: &[String]) {\n let mut copies = Vec::new();\n // #t(block: rust_clone_in_loop) bounded fixture\n for value in values {\n copies.push(value.clone());\n }\n}",
91 pass: false,
92 },
93];
94
95crate::ast_tree_rule!(
96 padding,
97 "Require blank-line padding between distinct statement groups.",
98 "Consistent vertical separation makes control flow, setup runs, and tail values easier to scan.",
99 Low,
100 fix_padding,
101);
102
103#[derive(Clone)]
104#[expect(
105 clippy::struct_excessive_bools,
106 reason = "padding decisions combine four independent syntax classifications"
107)]
108struct Entry {
109 syntax: SyntaxNode,
110 is_let: bool,
111 is_return: bool,
112 is_multiline_control: bool,
113 is_tail: bool,
114}
115
116fn check_padding(ctx: &AstCtx<'_>) -> Vec<Violation> {
117 let mut violations = Vec::new();
118 let docref_regions = docref_regions(ctx);
119
120 for list in ctx.nodes::<ast::StmtList>() {
121 for entry in missing_gaps(ctx, &docref_regions, &list) {
122 violations.push(ctx.violation(
123 &list,
124 format!("blank line required before {}", entry_label(&entry)),
125 ));
126 }
127 }
128
129 violations
130}
131
132fn entries(list: &ast::StmtList) -> Vec<Entry> {
133 let mut entries: Vec<Entry> = list.statements().map(entry_from_stmt).collect();
134
135 if let Some(tail) = list.tail_expr() {
136 entries.push(Entry {
137 is_multiline_control: is_multiline_control(&tail),
138 syntax: tail.syntax().clone(),
139 is_let: false,
140 is_return: matches!(tail, ast::Expr::ReturnExpr(_)),
141 is_tail: true,
142 });
143 }
144
145 entries
146}
147
148#[expect(
149 clippy::needless_pass_by_value,
150 reason = "statement iterators yield owned facade nodes and this helper is used directly by Iterator::map"
151)]
152fn entry_from_stmt(statement: ast::Stmt) -> Entry {
153 let is_let = matches!(statement, ast::Stmt::LetStmt(_));
154 let expression = match &statement {
155 ast::Stmt::ExprStmt(statement) => statement.expr(),
156 _ => None,
157 };
158
159 Entry {
160 syntax: statement.syntax().clone(),
161 is_let,
162 is_return: expression
163 .as_ref()
164 .is_some_and(|expr| matches!(expr, ast::Expr::ReturnExpr(_))),
165 is_multiline_control: expression.as_ref().is_some_and(is_multiline_control),
166 is_tail: false,
167 }
168}
169
170fn is_multiline_control(expression: &ast::Expr) -> bool {
171 matches!(
172 expression,
173 ast::Expr::IfExpr(_)
174 | ast::Expr::MatchExpr(_)
175 | ast::Expr::ForExpr(_)
176 | ast::Expr::WhileExpr(_)
177 | ast::Expr::LoopExpr(_)
178 ) && expression.syntax().text().contains_char('\n')
179}
180
181fn docref_regions(ctx: &AstCtx<'_>) -> Vec<(usize, usize)> {
182 let mut regions = Vec::new();
183 let mut open = None;
184
185 for (index, line) in ctx.file.lines.iter().enumerate() {
186 let Some(content) = crate::infra::parse::prose_comment_content(line) else {
187 continue;
188 };
189 let marker = content.trim();
190
191 if let Some(id) = marker.strip_prefix("docref:start ") {
192 open = Some((id.trim(), index));
193 } else if let Some(id) = marker.strip_prefix("docref:end ")
194 && let Some((open_id, start)) = open.take()
195 && open_id == id.trim()
196 {
197 regions.push((start, index));
198 }
199 }
200
201 regions
202}
203
204fn gap_is_inside_docref(ctx: &AstCtx<'_>, regions: &[(usize, usize)], token: &SyntaxToken) -> bool {
205 let line = ctx.line_index.line_col(token.text_range().start()).line as usize;
206
207 regions
208 .iter()
209 .any(|&(start, end)| (start..end).contains(&line))
210}
211
212fn is_attached_directive(token: &SyntaxToken) -> bool {
213 token.kind() == SyntaxKind::COMMENT
214 && matches!(
215 crate::infra::parse::directive(token.text()),
216 Some(crate::infra::parse::DirectiveResult::Valid(directive))
217 if matches!(
218 directive.scope,
219 crate::infra::parse::Scope::NextLine | crate::infra::parse::Scope::Block
220 )
221 )
222}
223
224fn padding_gap_token(current: &SyntaxNode) -> Option<SyntaxToken> {
225 let mut gap = gap_token(current)?;
226
227 while !has_blank_line(&gap) {
228 let Some(comment) = gap.prev_token().filter(is_attached_directive) else {
229 break;
230 };
231 let Some(previous_gap) = comment
232 .prev_token()
233 .filter(|token| token.kind() == SyntaxKind::WHITESPACE)
234 else {
235 break;
236 };
237
238 gap = previous_gap;
239 }
240
241 Some(gap)
242}
243
244fn missing_gaps(
246 ctx: &AstCtx<'_>,
247 docref_regions: &[(usize, usize)],
248 list: &ast::StmtList,
249) -> Vec<Entry> {
250 let entries = entries(list);
251
252 entries
253 .iter()
254 .enumerate()
255 .skip(1)
256 .filter(|(index, current)| {
257 let previous = &entries[index - 1];
258 let required = current.is_return
259 || current.is_tail
260 || (previous.is_let && !current.is_let)
261 || current.is_multiline_control
262 || previous.is_multiline_control;
263
264 required
265 && gap_token(¤t.syntax).is_some_and(|immediate_gap| {
266 !gap_is_inside_docref(ctx, docref_regions, &immediate_gap)
267 && padding_gap_token(¤t.syntax)
268 .is_some_and(|padding_gap| !has_blank_line(&padding_gap))
269 })
270 })
271 .map(|(_, entry)| entry.clone())
272 .collect()
273}
274
275fn gap_token(current: &SyntaxNode) -> Option<SyntaxToken> {
276 current
277 .prev_sibling_or_token()
278 .and_then(ra_ap_syntax::NodeOrToken::into_token)
279 .filter(|token| token.kind() == SyntaxKind::WHITESPACE)
280}
281
282fn has_blank_line(token: &SyntaxToken) -> bool {
283 token.text().matches('\n').count() >= BLANK_LINE_NEWLINES
284}
285
286const fn entry_label(entry: &Entry) -> &'static str {
287 if entry.is_return {
288 "return statement"
289 } else if entry.is_tail {
290 "tail expression"
291 } else if entry.is_multiline_control {
292 "multi-line control-flow statement"
293 } else {
294 "statement following a let run"
295 }
296}
297
298fn fix_padding(ctx: &AstCtx<'_>, _violations: &[Violation]) -> Option<String> {
300 let (editor, root) = SyntaxEditor::with_ast_node(ctx.root);
301 let docref_regions = docref_regions(ctx);
302 let mut changed = false;
303
304 for list in root.syntax().descendants().filter_map(ast::StmtList::cast) {
305 for entry in missing_gaps(ctx, &docref_regions, &list) {
306 let token = padding_gap_token(&entry.syntax)?;
307
308 editor.replace(
309 token.clone(),
310 editor.make().whitespace(&format!("\n{}", token.text())),
311 );
312 changed = true;
313 }
314 }
315
316 changed.then(|| editor.finish().new_root().to_string())
317}
318
319crate::tidy_ast_test!(check_padding, {
320 crate::example_tests!(EXAMPLES, check_padding);
321 crate::fix_tests!(ast_tree, check_padding, fix_padding);
322
323 #[gtest]
324 fn fix_preserves_contiguous_docref_region() -> Result<()> {
325 let source = "fn value() -> usize {\n let outside = 1;\n consume(outside);\n // docref:start padding-region\n let value = 1;\n return value;\n // docref:end padding-region\n}";
326 let expected = "fn value() -> usize {\n let outside = 1;\n\n consume(outside);\n // docref:start padding-region\n let value = 1;\n return value;\n // docref:end padding-region\n}";
327
328 verify_eq!(
329 crate::apply_ast_tree_fix(source, check_padding, fix_padding),
330 expected
331 )?;
332
333 Ok(())
334 }
335
336 #[gtest]
337 fn fix_places_padding_before_attached_directives() -> Result<()> {
338 let cases = [
339 (
340 "fn run(flag: bool) {\n let value = String::new();\n // #t(rust_clone_in_loop) bounded control path\n if flag {\n consume(value.clone());\n }\n}",
341 "fn run(flag: bool) {\n let value = String::new();\n\n // #t(rust_clone_in_loop) bounded control path\n if flag {\n consume(value.clone());\n }\n}",
342 ),
343 (
344 "fn value() -> usize {\n let value = 1;\n // #t(rust_clone_in_loop) representative fixture\n value\n}",
345 "fn value() -> usize {\n let value = 1;\n\n // #t(rust_clone_in_loop) representative fixture\n value\n}",
346 ),
347 (
348 "fn run(values: &[String]) {\n let mut copies = Vec::new();\n // #t(block: rust_clone_in_loop) bounded fixture\n for value in values {\n copies.push(value.clone());\n }\n}",
349 "fn run(values: &[String]) {\n let mut copies = Vec::new();\n\n // #t(block: rust_clone_in_loop) bounded fixture\n for value in values {\n copies.push(value.clone());\n }\n}",
350 ),
351 ];
352
353 for (source, expected) in cases {
354 verify_eq!(
355 crate::apply_ast_tree_fix(source, check_padding, fix_padding),
356 expected
357 )?;
358 }
359
360 Ok(())
361 }
362});