Skip to main content

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

1use 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
131// #t(fn: rust_alloc_in_loop) float binding names must be owned beyond each syntax-node iteration
132fn 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});