Skip to main content

wowlab_tidy/languages/rust/rules/correctness/
assert_side_effects.rs

1use ra_ap_syntax::{
2    AstNode,
3    ast::{self},
4};
5
6use crate::{AstCtx, Example, Violation};
7
8#[rustfmt::skip]
9const EXAMPLES: &[Example] = &[
10    Example {
11        label: "compound assign in debug_assert",
12        code: "fn f() { let mut x = 0; debug_assert!({ x += 1; x > 0 }); }",
13        pass: false,
14    },
15    Example {
16        label: "simple comparison",
17        code: "fn f() { debug_assert!(x > 0); }",
18        pass: true,
19    },
20    Example {
21        label: "debug_assert_eq passes",
22        code: "fn f() { debug_assert_eq!(a, b); }",
23        pass: true,
24    },
25    Example {
26        label: "subtract assign in debug_assert",
27        code: "fn f() { let mut x = 5; debug_assert!({ x -= 1; x > 0 }); }",
28        pass: false,
29    },
30    Example {
31        label: "compound assign in test module",
32        code: "#[cfg(test)]\nmod tests {\n    fn t() { let mut x = 0; debug_assert!({ x += 1; x > 0 }); }\n}",
33        pass: true,
34    },
35];
36
37crate::ast_rule!(
38    assert_side_effects,
39    "Ban compound assignments (`+=`, `-=`) inside `debug_assert!` macros.",
40    "Compound assignments inside debug_assert! are side effects that vanish in release builds, causing silent behavior changes.",
41    High,
42);
43
44const DEBUG_ASSERT_MACROS: &[&str] = &["debug_assert", "debug_assert_eq", "debug_assert_ne"];
45const MAX_COMPOUND_OPERATOR_LEN: usize = 3;
46
47fn check_assert_side_effects(ctx: &AstCtx<'_>) -> Vec<Violation> {
48    ctx.nodes::<ast::MacroCall>()
49        .filter(|call| !ctx.is_in_test(call))
50        .filter_map(|call| {
51            let name = macro_name(&call)?;
52
53            if !DEBUG_ASSERT_MACROS.contains(&name.as_str()) {
54                return None;
55            }
56
57            let op = has_compound_assign(&call)?;
58
59            Some(ctx.violation(
60                &call,
61                format!(
62                    "{name}!() contains compound assignment `{op}` — side effect lost in release builds"
63                ),
64            ))
65        })
66        .collect()
67}
68
69fn macro_name(call: &ast::MacroCall) -> Option<String> {
70    call.path()?
71        .segment()?
72        .name_ref()
73        .map(|name| name.text().to_string())
74}
75
76// #t(fn: rust_alloc_in_loop) the punctuation window is built incrementally from syntax tokens
77fn has_compound_assign(call: &ast::MacroCall) -> Option<&'static str> {
78    const OPERATORS: &[&str] = &["+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "<<=", ">>="];
79    let mut punctuation = String::new();
80
81    for token in call
82        .token_tree()?
83        .syntax()
84        .descendants_with_tokens()
85        .filter_map(ra_ap_syntax::NodeOrToken::into_token)
86        .filter(|token| !token.kind().is_trivia())
87    {
88        if !token.kind().is_punct()
89            || matches!(token.text(), "(" | ")" | "[" | "]" | "{" | "}" | "," | ";")
90        {
91            punctuation.clear();
92            continue;
93        }
94
95        punctuation.push_str(token.text());
96
97        if let Some(operator) = OPERATORS
98            .iter()
99            .copied()
100            .find(|operator| punctuation.ends_with(operator))
101        {
102            return Some(operator);
103        }
104
105        if punctuation.len() > MAX_COMPOUND_OPERATOR_LEN {
106            punctuation.remove(0);
107        }
108    }
109
110    None
111}
112
113crate::tidy_ast_test!(check_assert_side_effects, {
114    crate::example_tests!(EXAMPLES, check_assert_side_effects);
115});