wowlab_tidy/languages/rust/rules/correctness/
floating_point_eq.rs1use ra_ap_syntax::{
2 AstNode,
3 ast::{self, BinaryOp, CmpOp, HasName, LiteralKind},
4};
5use wowlab_types::sim::FastSet;
6
7use crate::{AstCtx, Example, Violation};
8
9#[rustfmt::skip]
10const EXAMPLES: &[Example] = &[
11 Example {
12 label: "direct f64 equality",
13 code: "fn f(a: f64, b: f64) -> bool { a == b }",
14 pass: false,
15 },
16 Example {
17 label: "direct f32 equality",
18 code: "fn f(a: f32, b: f32) -> bool { a == b }",
19 pass: false,
20 },
21 Example {
22 label: "f64 not-equal",
23 code: "fn f(a: f64, b: f64) -> bool { a != b }",
24 pass: false,
25 },
26 Example {
27 label: "integer equality is fine",
28 code: "fn f(a: i32, b: i32) -> bool { a == b }",
29 pass: true,
30 },
31 Example {
32 label: "float comparison with epsilon",
33 code: "fn f(a: f64, b: f64) -> bool { (a - b).abs() < f64::EPSILON }",
34 pass: true,
35 },
36 Example {
37 label: "float eq in test module",
38 code: "#[cfg(test)]\nmod tests {\n fn t(a: f64, b: f64) -> bool { a == b }\n}",
39 pass: true,
40 },
41 Example {
42 label: "compare float to literal zero",
43 code: "fn f(a: f64) -> bool { a == 0.0 }",
44 pass: false,
45 },
46 Example {
47 label: "local float binding equality",
48 code: "fn f() -> bool { let x: f64 = 1.0; x == 0.0 }",
49 pass: false,
50 },
51 Example {
52 label: "local float literal equality",
53 code: "fn f() -> bool { let x = 1.0; x == 0.0 }",
54 pass: false,
55 },
56 Example {
57 label: "left-hand-side float literal",
58 code: "fn f(a: f64) -> bool { 0.0 == a }",
59 pass: false,
60 },
61 Example {
62 label: "cast-to-float comparison",
63 code: "fn f(b: i32) -> bool { b as f64 == 0 }",
64 pass: false,
65 },
66 Example {
67 label: "float equality in impl method",
68 code: "struct S; impl S { fn m(&self, a: f64, b: f64) -> bool { a == b } }",
69 pass: false,
70 },
71 Example {
72 label: "integer not-equal is fine",
73 code: "fn f(a: u32, b: u32) -> bool { a != b }",
74 pass: true,
75 },
76];
77
78crate::ast_rule!(
79 floating_point_eq,
80 "Flag direct `==`/`!=` comparison on `f32`/`f64` values.",
81 "Floating-point equality is unreliable due to rounding. Use an epsilon comparison or relative tolerance instead.",
82 High,
83);
84
85fn check_floating_point_eq(ctx: &AstCtx<'_>) -> Vec<Violation> {
86 ctx.nodes::<ast::BinExpr>()
87 .filter(|expr| {
88 !ctx.is_in_test(expr)
89 && matches!(expr.op_kind(), Some(BinaryOp::CmpOp(CmpOp::Eq { .. })))
90 })
91 .filter_map(|expr| {
92 let function = expr.syntax().ancestors().find_map(ast::Fn::cast)?;
93 let float_names = float_names_before(&function, &expr);
94 let (left, right) = expr.sub_exprs();
95 let (left, right) = (left?, right?);
96
97 (expr_uses_float_name(&left, &float_names)
98 || expr_uses_float_name(&right, &float_names))
99 .then(|| {
100 ctx.violation(
101 &expr,
102 "direct float equality comparison — use epsilon-based comparison instead",
103 )
104 })
105 })
106 .collect()
107}
108
109fn expr_is_float(expr: &ast::Expr) -> bool {
110 match expr {
111 ast::Expr::Literal(literal) => matches!(literal.kind(), LiteralKind::FloatNumber(_)),
112 ast::Expr::CastExpr(cast) => cast.ty().is_some_and(|ty| type_is_float(&ty)),
113 _ => false,
114 }
115}
116
117fn expr_uses_float_name(expr: &ast::Expr, float_names: &FastSet<String>) -> bool {
118 match expr {
119 ast::Expr::PathExpr(path) => {
120 let name = path
121 .path()
122 .and_then(|path| path.segment())
123 .and_then(|segment| segment.name_ref());
124
125 name.is_some_and(|name| float_names.contains(name.text().as_str()))
126 }
127 _ => expr_is_float(expr),
128 }
129}
130
131fn float_names_before(function: &ast::Fn, expr: &ast::BinExpr) -> FastSet<String> {
133 let mut names = FastSet::default();
134
135 if let Some(parameters) = function.param_list() {
136 for parameter in parameters.params() {
137 if parameter.ty().is_some_and(|ty| type_is_float(&ty))
138 && let Some(ast::Pat::IdentPat(pattern)) = parameter.pat()
139 && let Some(name) = pattern.name()
140 {
141 names.insert(name.text().to_string());
142 }
143 }
144 }
145
146 let expression_start = expr.syntax().text_range().start();
147
148 for local in function
149 .syntax()
150 .descendants()
151 .filter_map(ast::LetStmt::cast)
152 .filter(|local| {
153 local.syntax().text_range().start() < expression_start
154 && local.syntax().ancestors().find_map(ast::Fn::cast).as_ref() == Some(function)
155 })
156 {
157 let Some(ast::Pat::IdentPat(pattern)) = local.pat() else {
158 continue;
159 };
160 let is_float = local.ty().is_some_and(|ty| type_is_float(&ty))
161 || local.initializer().is_some_and(|expr| expr_is_float(&expr));
162
163 if is_float && let Some(name) = pattern.name() {
164 names.insert(name.text().to_string());
165 }
166 }
167
168 names
169}
170
171fn type_is_float(ty: &ast::Type) -> bool {
172 let ast::Type::PathType(path) = ty else {
173 return false;
174 };
175
176 let name = path
177 .path()
178 .and_then(|path| path.segment())
179 .and_then(|segment| segment.name_ref());
180
181 name.is_some_and(|name| matches!(name.text().as_str(), "f32" | "f64"))
182}
183
184crate::tidy_ast_test!(check_floating_point_eq, {
185 crate::example_tests!(EXAMPLES, check_floating_point_eq);
186});