Skip to content

Commit 465994e

Browse files
committed
New lint: non_canonical_total_float_cmp
1 parent c07baa4 commit 465994e

File tree

9 files changed

+326
-0
lines changed

9 files changed

+326
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6947,6 +6947,7 @@ Released 2018-09-13
69476947
[`non_ascii_literal`]: https://rust-lang.github.io/rust-clippy/master/index.html#non_ascii_literal
69486948
[`non_canonical_clone_impl`]: https://rust-lang.github.io/rust-clippy/master/index.html#non_canonical_clone_impl
69496949
[`non_canonical_partial_ord_impl`]: https://rust-lang.github.io/rust-clippy/master/index.html#non_canonical_partial_ord_impl
6950+
[`non_canonical_total_float_cmp`]: https://rust-lang.github.io/rust-clippy/master/index.html#non_canonical_total_float_cmp
69506951
[`non_minimal_cfg`]: https://rust-lang.github.io/rust-clippy/master/index.html#non_minimal_cfg
69516952
[`non_octal_unix_permissions`]: https://rust-lang.github.io/rust-clippy/master/index.html#non_octal_unix_permissions
69526953
[`non_send_fields_in_send_ty`]: https://rust-lang.github.io/rust-clippy/master/index.html#non_send_fields_in_send_ty

clippy_lints/src/declared_lints.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ pub static LINTS: &[&::declare_clippy_lint::LintInfo] = &[
443443
crate::methods::NEEDLESS_SPLITN_INFO,
444444
crate::methods::NEW_RET_NO_SELF_INFO,
445445
crate::methods::NO_EFFECT_REPLACE_INFO,
446+
crate::methods::NON_CANONICAL_TOTAL_FLOAT_CMP_INFO,
446447
crate::methods::NONSENSICAL_OPEN_OPTIONS_INFO,
447448
crate::methods::OBFUSCATED_IF_ELSE_INFO,
448449
crate::methods::OK_EXPECT_INFO,

clippy_lints/src/methods/mod.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ mod needless_option_as_deref;
8585
mod needless_option_take;
8686
mod new_ret_no_self;
8787
mod no_effect_replace;
88+
mod non_canonical_float_cmp;
8889
mod obfuscated_if_else;
8990
mod ok_expect;
9091
mod open_options;
@@ -2708,6 +2709,72 @@ declare_clippy_lint! {
27082709
"replace with no effect"
27092710
}
27102711

2712+
declare_clippy_lint! {
2713+
/// ### What it does
2714+
///
2715+
/// Checks for manual attempts to force a total ordering on floating-point numbers by
2716+
/// using `.partial_cmp(..).unwrap_or(Ordering)`.
2717+
///
2718+
/// ### Why is this bad?
2719+
///
2720+
/// Floating-point numbers do not have a total order by default because of `NaN` values
2721+
/// and the existence of both `-0.0` and `0.0`. When developers use `.unwrap_or(..)`
2722+
/// with `partial_cmp`, they are usually trying to implement a total order manually,
2723+
/// likely because they do not know about the existence of `total_cmp`.
2724+
///
2725+
/// This is problematic because:
2726+
///
2727+
/// - Inconsistency: Manual unwrapping often fails to handle the nuances of IEEE 754
2728+
/// total ordering (e.g., the sign of `NaN` or the difference between `-0.0` and `0.0`).
2729+
///
2730+
/// - Broken invariants: In contexts like sorting or searching (e.g., `BTreeMap`),
2731+
/// an inconsistent ordering function can lead to non-deterministic results,
2732+
/// infinite loops, or panics.
2733+
///
2734+
/// - Readability: `total_cmp` is the canonical, standard library method for this
2735+
/// exact purpose, making the code more idiomatic and making the intent clear.
2736+
///
2737+
/// While sorting is the most common place this pattern appears, it also affects
2738+
/// any logic relying on a stable comparison, such as finding a minimum/maximum
2739+
/// value in a collection.
2740+
///
2741+
/// ### Known problems
2742+
///
2743+
/// - Performance: On some architectures (like x86), `total_cmp` *may* be
2744+
/// slightly slower than hardware-accelerated float comparisons if the values
2745+
/// are already in floating-point registers.
2746+
///
2747+
/// - Semantic Change: `total_cmp` considers `-0.0` to be strictly less than `0.0`.
2748+
/// If your logic relies on them being equal (the default behavior of `==`),
2749+
/// this lint's suggestion will change your program's behavior.
2750+
///
2751+
/// ### Example
2752+
///
2753+
/// ```rs
2754+
/// use std::cmp;
2755+
///
2756+
/// let x = 0.0;
2757+
/// let y = -0.0;
2758+
/// let is_less = x.partial_cmp(&y).unwrap_or(cmp::Ordering::Greater);
2759+
///
2760+
/// vec.sort_by(|a, b| a.partial_cmp(b).unwrap_or(cmp::Ordering::Equal));
2761+
/// ```
2762+
///
2763+
/// Use instead:
2764+
///
2765+
/// ```rs
2766+
/// let x = 0.0;
2767+
/// let y = -0.0;
2768+
/// let is_less = x.total_cmp(&y);
2769+
///
2770+
/// vec.sort_by(|a, b| a.total_cmp(b));
2771+
/// ```
2772+
#[clippy::version = "1.97.0"]
2773+
pub NON_CANONICAL_TOTAL_FLOAT_CMP,
2774+
suspicious,
2775+
"non-canonical total float comparison which is likely a mistake"
2776+
}
2777+
27112778
declare_clippy_lint! {
27122779
/// ### What it does
27132780
/// Checks for duplicate open options as well as combinations
@@ -4867,6 +4934,7 @@ impl_lint_pass!(Methods => [
48674934
NEEDLESS_SPLITN,
48684935
NEW_RET_NO_SELF,
48694936
NONSENSICAL_OPEN_OPTIONS,
4937+
NON_CANONICAL_TOTAL_FLOAT_CMP,
48704938
NO_EFFECT_REPLACE,
48714939
OBFUSCATED_IF_ELSE,
48724940
OK_EXPECT,
@@ -5685,6 +5753,9 @@ impl Methods {
56855753
Some((sym::map, m_recv, [m_arg], span, _)) => {
56865754
map_unwrap_or::check(cx, expr, m_recv, m_arg, recv, u_arg, span, self.msrv);
56875755
},
5756+
Some((sym::partial_cmp, m_recv, [m_arg], _, _)) => {
5757+
non_canonical_float_cmp::check(cx, expr, m_recv, m_arg, u_arg);
5758+
},
56885759
Some((then_method @ (sym::then | sym::then_some), t_recv, [t_arg], _, _)) => {
56895760
obfuscated_if_else::check(
56905761
cx,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
use clippy_utils::diagnostics::span_lint_and_sugg;
2+
use clippy_utils::{expr_or_init, paths};
3+
use rustc_errors::Applicability;
4+
use rustc_lint::LateContext;
5+
6+
use crate::methods::NON_CANONICAL_TOTAL_FLOAT_CMP;
7+
8+
pub(super) fn check(
9+
cx: &LateContext<'_>,
10+
full_expr: &rustc_hir::Expr<'_>,
11+
partial_cmp_recv: &rustc_hir::Expr<'_>,
12+
partial_cmp_arg: &rustc_hir::Expr<'_>,
13+
unwrap_or_arg: &rustc_hir::Expr<'_>,
14+
) {
15+
// format: <float>.partial_cmp(<float>).unwrap_or(<Ordering>)
16+
17+
if !cx
18+
.typeck_results()
19+
.expr_ty(expr_or_init(cx, partial_cmp_recv))
20+
.peel_refs()
21+
.is_floating_point()
22+
|| !cx
23+
.typeck_results()
24+
.expr_ty(expr_or_init(cx, partial_cmp_arg))
25+
.peel_refs()
26+
.is_floating_point()
27+
{
28+
return;
29+
}
30+
31+
let ordering_variants = [
32+
&paths::CMP_ORDERING_EQUAL,
33+
&paths::CMP_ORDERING_GREATER,
34+
&paths::CMP_ORDERING_LESS,
35+
];
36+
37+
let is_cmp_ordering_variant = ordering_variants
38+
.iter()
39+
.any(|variant| variant.matches_path(cx, expr_or_init(cx, unwrap_or_arg)));
40+
41+
if !is_cmp_ordering_variant {
42+
return;
43+
}
44+
45+
let mut applicability = Applicability::MaybeIncorrect;
46+
47+
let partial_cmp_recv =
48+
clippy_utils::source::snippet_with_applicability(cx.tcx.sess, partial_cmp_recv.span, "..", &mut applicability);
49+
50+
let partial_cmp_arg =
51+
clippy_utils::source::snippet_with_applicability(cx.tcx.sess, partial_cmp_arg.span, "..", &mut applicability);
52+
53+
span_lint_and_sugg(
54+
cx,
55+
NON_CANONICAL_TOTAL_FLOAT_CMP,
56+
full_expr.span,
57+
"non-canonical total float comparison",
58+
"try",
59+
format!("{partial_cmp_recv}.total_cmp({partial_cmp_arg})"),
60+
applicability,
61+
);
62+
}

clippy_utils/src/paths.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ path_macros! {
135135

136136
// Paths in the standard library missing a diagnostic item
137137

138+
pub static CMP_ORDERING_LESS: PathLookup = value_path!(core::cmp::Ordering::Less);
139+
pub static CMP_ORDERING_EQUAL: PathLookup = value_path!(core::cmp::Ordering::Equal);
140+
pub static CMP_ORDERING_GREATER: PathLookup = value_path!(core::cmp::Ordering::Greater);
141+
138142
// Paths in external crates
139143
pub static FUTURES_IO_ASYNCREADEXT: PathLookup = type_path!(futures_util::AsyncReadExt);
140144
pub static FUTURES_IO_ASYNCWRITEEXT: PathLookup = type_path!(futures_util::AsyncWriteExt);

clippy_utils/src/sym.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ generate! {
6666
FileType,
6767
FsOpenOptions,
6868
FsPermissions,
69+
Greater,
6970
HashMapEntry,
7071
Instant,
7172
IntoIter,
@@ -84,6 +85,7 @@ generate! {
8485
LF: "\n",
8586
LateContext,
8687
Lazy,
88+
Less,
8789
LinkedList,
8890
Lint,
8991
LowerExp,
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//@aux-build:proc_macros.rs
2+
#![feature(f16)]
3+
#![feature(f128)]
4+
#![warn(clippy::non_canonical_total_float_cmp)]
5+
use std::cmp;
6+
use std::cmp::Ordering;
7+
use std::cmp::Ordering::{Equal, Greater, Less};
8+
9+
extern crate proc_macros;
10+
11+
fn example() {
12+
93f32.total_cmp(&83f32);
13+
//~^ non_canonical_total_float_cmp
14+
15+
93.0f16.total_cmp(&83.0);
16+
//~^ non_canonical_total_float_cmp
17+
18+
93.0f128.total_cmp(&83.0);
19+
//~^ non_canonical_total_float_cmp
20+
21+
93.0f64.total_cmp(&83.0);
22+
//~^ non_canonical_total_float_cmp
23+
24+
93.0_f32.total_cmp(&83.0);
25+
//~^ non_canonical_total_float_cmp
26+
27+
93f64.total_cmp(&83f64);
28+
//~^ non_canonical_total_float_cmp
29+
30+
let x = cmp::Ordering::Less;
31+
93f32.total_cmp(&83f32);
32+
//~^ non_canonical_total_float_cmp
33+
34+
let x: f32 = 0.0;
35+
x.total_cmp(&83f32);
36+
//~^ non_canonical_total_float_cmp
37+
38+
let x: f32 = 0.0;
39+
93f32.total_cmp(&x);
40+
//~^ non_canonical_total_float_cmp
41+
42+
//
43+
// Tests for tokens that come from expansion.
44+
//
45+
46+
// no lint: can't change to "total_cmp"
47+
proc_macros::external!(93f32.partial_cmp(&83f32).unwrap_or(Equal));
48+
49+
// Ideally, this would lint, but that requires a bigger chance to the
50+
// way that method lints are handled.
51+
proc_macros::external!(93f32).partial_cmp(&83f32).unwrap_or(Equal);
52+
53+
// Ideally, this would lint, but that requires a bigger chance to the
54+
// way that method lints are handled.
55+
93f32.partial_cmp(proc_macros::external!(&83f32)).unwrap_or(Equal);
56+
57+
// no lint: can't change to "total_cmp"
58+
proc_macros::external!(93f32.partial_cmp(&83f32)).unwrap_or(Equal);
59+
60+
// Ideally, this would lint, but that requires a bigger chance to the
61+
// way that method lints are handled.
62+
93f32.partial_cmp(&83f32).unwrap_or(proc_macros::external!(Equal));
63+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//@aux-build:proc_macros.rs
2+
#![feature(f16)]
3+
#![feature(f128)]
4+
#![warn(clippy::non_canonical_total_float_cmp)]
5+
use std::cmp;
6+
use std::cmp::Ordering;
7+
use std::cmp::Ordering::{Equal, Greater, Less};
8+
9+
extern crate proc_macros;
10+
11+
fn example() {
12+
93f32.partial_cmp(&83f32).unwrap_or(Ordering::Less);
13+
//~^ non_canonical_total_float_cmp
14+
15+
93.0f16.partial_cmp(&83.0).unwrap_or(Ordering::Greater);
16+
//~^ non_canonical_total_float_cmp
17+
18+
93.0f128.partial_cmp(&83.0).unwrap_or(Ordering::Equal);
19+
//~^ non_canonical_total_float_cmp
20+
21+
93.0f64.partial_cmp(&83.0).unwrap_or(Less);
22+
//~^ non_canonical_total_float_cmp
23+
24+
93.0_f32.partial_cmp(&83.0).unwrap_or(Greater);
25+
//~^ non_canonical_total_float_cmp
26+
27+
93f64.partial_cmp(&83f64).unwrap_or(Equal);
28+
//~^ non_canonical_total_float_cmp
29+
30+
let x = cmp::Ordering::Less;
31+
93f32.partial_cmp(&83f32).unwrap_or(x);
32+
//~^ non_canonical_total_float_cmp
33+
34+
let x: f32 = 0.0;
35+
x.partial_cmp(&83f32).unwrap_or(core::cmp::Ordering::Greater);
36+
//~^ non_canonical_total_float_cmp
37+
38+
let x: f32 = 0.0;
39+
93f32.partial_cmp(&x).unwrap_or(std::cmp::Ordering::Equal);
40+
//~^ non_canonical_total_float_cmp
41+
42+
//
43+
// Tests for tokens that come from expansion.
44+
//
45+
46+
// no lint: can't change to "total_cmp"
47+
proc_macros::external!(93f32.partial_cmp(&83f32).unwrap_or(Equal));
48+
49+
// Ideally, this would lint, but that requires a bigger chance to the
50+
// way that method lints are handled.
51+
proc_macros::external!(93f32).partial_cmp(&83f32).unwrap_or(Equal);
52+
53+
// Ideally, this would lint, but that requires a bigger chance to the
54+
// way that method lints are handled.
55+
93f32.partial_cmp(proc_macros::external!(&83f32)).unwrap_or(Equal);
56+
57+
// no lint: can't change to "total_cmp"
58+
proc_macros::external!(93f32.partial_cmp(&83f32)).unwrap_or(Equal);
59+
60+
// Ideally, this would lint, but that requires a bigger chance to the
61+
// way that method lints are handled.
62+
93f32.partial_cmp(&83f32).unwrap_or(proc_macros::external!(Equal));
63+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
error: non-canonical total float comparison
2+
--> tests/ui/non_canonical_total_float_cmp.rs:12:5
3+
|
4+
LL | 93f32.partial_cmp(&83f32).unwrap_or(Ordering::Less);
5+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `93f32.total_cmp(&83f32)`
6+
|
7+
= note: `-D clippy::non-canonical-total-float-cmp` implied by `-D warnings`
8+
= help: to override `-D warnings` add `#[allow(clippy::non_canonical_total_float_cmp)]`
9+
10+
error: non-canonical total float comparison
11+
--> tests/ui/non_canonical_total_float_cmp.rs:15:5
12+
|
13+
LL | 93.0f16.partial_cmp(&83.0).unwrap_or(Ordering::Greater);
14+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `93.0f16.total_cmp(&83.0)`
15+
16+
error: non-canonical total float comparison
17+
--> tests/ui/non_canonical_total_float_cmp.rs:18:5
18+
|
19+
LL | 93.0f128.partial_cmp(&83.0).unwrap_or(Ordering::Equal);
20+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `93.0f128.total_cmp(&83.0)`
21+
22+
error: non-canonical total float comparison
23+
--> tests/ui/non_canonical_total_float_cmp.rs:21:5
24+
|
25+
LL | 93.0f64.partial_cmp(&83.0).unwrap_or(Less);
26+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `93.0f64.total_cmp(&83.0)`
27+
28+
error: non-canonical total float comparison
29+
--> tests/ui/non_canonical_total_float_cmp.rs:24:5
30+
|
31+
LL | 93.0_f32.partial_cmp(&83.0).unwrap_or(Greater);
32+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `93.0_f32.total_cmp(&83.0)`
33+
34+
error: non-canonical total float comparison
35+
--> tests/ui/non_canonical_total_float_cmp.rs:27:5
36+
|
37+
LL | 93f64.partial_cmp(&83f64).unwrap_or(Equal);
38+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `93f64.total_cmp(&83f64)`
39+
40+
error: non-canonical total float comparison
41+
--> tests/ui/non_canonical_total_float_cmp.rs:31:5
42+
|
43+
LL | 93f32.partial_cmp(&83f32).unwrap_or(x);
44+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `93f32.total_cmp(&83f32)`
45+
46+
error: non-canonical total float comparison
47+
--> tests/ui/non_canonical_total_float_cmp.rs:35:5
48+
|
49+
LL | x.partial_cmp(&83f32).unwrap_or(core::cmp::Ordering::Greater);
50+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `x.total_cmp(&83f32)`
51+
52+
error: non-canonical total float comparison
53+
--> tests/ui/non_canonical_total_float_cmp.rs:39:5
54+
|
55+
LL | 93f32.partial_cmp(&x).unwrap_or(std::cmp::Ordering::Equal);
56+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `93f32.total_cmp(&x)`
57+
58+
error: aborting due to 9 previous errors
59+

0 commit comments

Comments
 (0)