wowlab_tidy/languages/rust/rules/hygiene/
tautological_assert.rs1use 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
199fn 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});