Skip to main content

wowlab_tidy/languages/rust/rules/hygiene/
tautological_assert.rs

1use ra_ap_syntax::{
2    AstNode, SyntaxElement,
3    ast::{self, LiteralKind, UnaryOp},
4};
5
6use super::support::parse_expr;
7use crate::{AstCtx, Example, Violation};
8
9#[rustfmt::skip]
10const EXAMPLES: &[Example] = &[
11    Example {
12        label: "const vs literal",
13        code: "#[cfg(test)]\nmod tests {\n    #[test]\n    fn t() { assert_eq!(MAX_SIZE, 10); }\n}",
14        pass: false,
15    },
16    Example {
17        label: "literal vs const",
18        code: "#[cfg(test)]\nmod tests {\n    #[test]\n    fn t() { assert_eq!(3, Config::DEFAULT_RETRIES); }\n}",
19        pass: false,
20    },
21    Example {
22        label: "const vs array literal",
23        code: "#[cfg(test)]\nmod tests {\n    #[test]\n    fn t() { assert_eq!(CHECKPOINTS, [0, 90, 180, 270]); }\n}",
24        pass: false,
25    },
26    Example {
27        label: "literal vs literal",
28        code: "#[cfg(test)]\nmod tests {\n    #[test]\n    fn t() { assert_eq!(2, 2); }\n}",
29        pass: false,
30    },
31    Example {
32        label: "assert_ne const vs literal",
33        code: "#[cfg(test)]\nmod tests {\n    #[test]\n    fn t() { assert_ne!(LIMIT, 0); }\n}",
34        pass: false,
35    },
36    Example {
37        label: "test attr without cfg test module",
38        code: "#[test]\nfn t() { assert_eq!(VERSION, \"1.0\"); }",
39        pass: false,
40    },
41    Example {
42        label: "behavior vs literal",
43        code: "#[cfg(test)]\nmod tests {\n    #[test]\n    fn t() { assert_eq!(add(1, 2), 3); }\n}",
44        pass: true,
45    },
46    Example {
47        label: "variable vs const",
48        code: "#[cfg(test)]\nmod tests {\n    #[test]\n    fn t() { let x = grow(); assert_eq!(x, MAX_SIZE); }\n}",
49        pass: true,
50    },
51    Example {
52        label: "method call vs literal",
53        code: "#[cfg(test)]\nmod tests {\n    #[test]\n    fn t() { assert_eq!(result.len(), 4); }\n}",
54        pass: true,
55    },
56    Example {
57        label: "production assert is out of scope",
58        code: "fn f() { assert_eq!(MAX_SIZE, 10); }",
59        pass: true,
60    },
61];
62
63crate::ast_rule!(
64    tautological_assert,
65    "Flag test asserts comparing a constant against a literal (or literal vs literal).",
66    "Asserts that restate a definition pass by construction and add noise instead of verifying behavior; test a property the value must satisfy instead.",
67    Low,
68);
69
70#[derive(Clone, Copy, Eq, PartialEq)]
71enum ArgKind {
72    ConstPath,
73    Literal,
74    Other,
75}
76
77fn is_screaming_snake(ident: &str) -> bool {
78    ident.len() > 1
79        && ident.chars().any(|c| c.is_ascii_uppercase())
80        && ident
81            .chars()
82            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
83}
84
85fn is_plain_literal(literal: &ast::Literal) -> bool {
86    matches!(
87        literal.kind(),
88        LiteralKind::IntNumber(_)
89            | LiteralKind::FloatNumber(_)
90            | LiteralKind::String(_)
91            | LiteralKind::Bool(_)
92    )
93}
94
95fn unwrap_expr(mut expr: ast::Expr) -> ast::Expr {
96    loop {
97        match expr {
98            ast::Expr::ParenExpr(inner) => {
99                let Some(nested) = inner.expr() else {
100                    return ast::Expr::ParenExpr(inner);
101                };
102
103                expr = nested;
104            }
105            ast::Expr::RefExpr(inner) => {
106                let Some(nested) = inner.expr() else {
107                    return ast::Expr::RefExpr(inner);
108                };
109
110                expr = nested;
111            }
112            ast::Expr::PrefixExpr(inner) if inner.op_kind() == Some(UnaryOp::Neg) => {
113                let Some(nested) = inner.expr() else {
114                    return ast::Expr::PrefixExpr(inner);
115                };
116
117                expr = nested;
118            }
119            _ => return expr,
120        }
121    }
122}
123
124fn is_literal_expr(expr: ast::Expr) -> bool {
125    matches!(unwrap_expr(expr), ast::Expr::Literal(literal) if is_plain_literal(&literal))
126}
127
128fn classify(expr: ast::Expr) -> ArgKind {
129    match unwrap_expr(expr) {
130        ast::Expr::Literal(literal) if is_plain_literal(&literal) => ArgKind::Literal,
131        ast::Expr::ArrayExpr(array)
132            if array.semicolon_token().is_none()
133                && array
134                    .syntax()
135                    .children()
136                    .filter_map(ast::Expr::cast)
137                    .all(is_literal_expr) =>
138        {
139            ArgKind::Literal
140        }
141        ast::Expr::TupleExpr(tuple) if tuple.fields().all(is_literal_expr) => ArgKind::Literal,
142        ast::Expr::PathExpr(path) => {
143            let name = path
144                .path()
145                .and_then(|path| path.segment())
146                .and_then(|segment| segment.name_ref());
147
148            name.map_or(ArgKind::Other, |name| {
149                if is_screaming_snake(name.text().as_str()) {
150                    ArgKind::ConstPath
151                } else {
152                    ArgKind::Other
153                }
154            })
155        }
156        _ => ArgKind::Other,
157    }
158}
159
160fn is_tautological(a: ArgKind, b: ArgKind) -> bool {
161    matches!(
162        (a, b),
163        (ArgKind::ConstPath | ArgKind::Literal, ArgKind::Literal)
164            | (ArgKind::Literal, ArgKind::ConstPath)
165    )
166}
167
168fn check_tautological_assert(ctx: &AstCtx<'_>) -> Vec<Violation> {
169    ctx.nodes::<ast::MacroCall>()
170        .filter(|call| ctx.is_in_test(call))
171        .filter_map(|call| {
172            let name = call
173                .path()?
174                .segment()?
175                .name_ref()?
176                .text()
177                .to_string();
178
179            if !matches!(name.as_str(), "assert_eq" | "assert_ne") {
180                return None;
181            }
182
183            let args = macro_arguments(&call)?;
184            let left = parse_expr(args.first()?)?;
185            let right = parse_expr(args.get(1)?)?;
186
187            is_tautological(classify(left), classify(right)).then(|| {
188                ctx.violation(
189                    &call,
190                    "tautological assert restates a definition — test a property the value must satisfy instead",
191                )
192            })
193        })
194        .collect()
195}
196
197const ASSERT_OPERANDS: usize = 2;
198
199// #t(fn: rust_alloc_in_loop) macro arguments are reconstructed as independent parser inputs
200fn macro_arguments(call: &ast::MacroCall) -> Option<Vec<String>> {
201    let tree = call.token_tree()?;
202    let mut elements: Vec<SyntaxElement> = tree.syntax().children_with_tokens().collect();
203
204    if elements.len() < ASSERT_OPERANDS {
205        return None;
206    }
207
208    elements.remove(0);
209    elements.pop();
210    let mut args = vec![String::new()];
211
212    for element in elements {
213        if element.as_token().is_some_and(|token| token.text() == ",") {
214            args.push(String::new());
215        } else if let Some(current) = args.last_mut() {
216            current.push_str(&element.to_string());
217        }
218    }
219
220    Some(args)
221}
222
223crate::tidy_ast_test!(check_tautological_assert, {
224    crate::example_tests!(EXAMPLES, check_tautological_assert);
225});