Skip to main content

wowlab_tidy/languages/rust/rules/style/
padding.rs

1#[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
244// #t(fn: rust_unchecked_indexing) enumeration skips index zero before reading the preceding entry
245fn 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(&current.syntax).is_some_and(|immediate_gap| {
266                    !gap_is_inside_docref(ctx, docref_regions, &immediate_gap)
267                        && padding_gap_token(&current.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
298// #t(fn: rust_alloc_in_loop, rust_clone_in_loop) each missing whitespace boundary needs an owned replacement token
299fn 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});