Skip to content

Commit 33cdb19

Browse files
committed
feat: add manual_max and manual_min lints
Add two `complexity` lints that detect a single-sided clamp written as an `if`/`else` (or a guarding `if`) and suggest `Ord::max` / `Ord::min`: let _ = if a < b { b } else { a }; // -> a.max(b) if cores < b { cores = b; } // -> cores = cores.max(b); This is the sound, generalizable form of the manual clamp simplified by hand in uutils/coreutils#12753 (`nproc`). Unlike `manual_clamp`, which only fires when both a lower and an upper bound are applied, these catch the common single-bound floor/ceiling case. Restricted to `Ord` types so float `NaN` semantics are not changed, and emitted as `MaybeIncorrect` since the branching form re-evaluates the selected operand.
1 parent 9116cc8 commit 33cdb19

17 files changed

Lines changed: 716 additions & 60 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7007,8 +7007,10 @@ Released 2018-09-13
70077007
[`manual_let_else`]: https://rust-lang.github.io/rust-clippy/master/index.html#manual_let_else
70087008
[`manual_main_separator_str`]: https://rust-lang.github.io/rust-clippy/master/index.html#manual_main_separator_str
70097009
[`manual_map`]: https://rust-lang.github.io/rust-clippy/master/index.html#manual_map
7010+
[`manual_max`]: https://rust-lang.github.io/rust-clippy/master/index.html#manual_max
70107011
[`manual_memcpy`]: https://rust-lang.github.io/rust-clippy/master/index.html#manual_memcpy
70117012
[`manual_midpoint`]: https://rust-lang.github.io/rust-clippy/master/index.html#manual_midpoint
7013+
[`manual_min`]: https://rust-lang.github.io/rust-clippy/master/index.html#manual_min
70127014
[`manual_next_back`]: https://rust-lang.github.io/rust-clippy/master/index.html#manual_next_back
70137015
[`manual_non_exhaustive`]: https://rust-lang.github.io/rust-clippy/master/index.html#manual_non_exhaustive
70147016
[`manual_noop_waker`]: https://rust-lang.github.io/rust-clippy/master/index.html#manual_noop_waker

clippy_lints/src/declared_lints.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,8 @@ pub static LINTS: &[&::declare_clippy_lint::LintInfo] = &[
314314
crate::manual_is_power_of_two::MANUAL_IS_POWER_OF_TWO_INFO,
315315
crate::manual_let_else::MANUAL_LET_ELSE_INFO,
316316
crate::manual_main_separator_str::MANUAL_MAIN_SEPARATOR_STR_INFO,
317+
crate::manual_max::MANUAL_MAX_INFO,
318+
crate::manual_max::MANUAL_MIN_INFO,
317319
crate::manual_non_exhaustive::MANUAL_NON_EXHAUSTIVE_INFO,
318320
crate::manual_noop_waker::MANUAL_NOOP_WAKER_INFO,
319321
crate::manual_option_as_slice::MANUAL_OPTION_AS_SLICE_INFO,

clippy_lints/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ mod manual_is_ascii_check;
213213
mod manual_is_power_of_two;
214214
mod manual_let_else;
215215
mod manual_main_separator_str;
216+
mod manual_max;
216217
mod manual_non_exhaustive;
217218
mod manual_noop_waker;
218219
mod manual_option_as_slice;
@@ -758,6 +759,7 @@ rustc_lint::late_lint_methods!(
758759
PartialeqToNone: partialeq_to_none::PartialeqToNone = partialeq_to_none::PartialeqToNone,
759760
ManualAbsDiff: manual_abs_diff::ManualAbsDiff = manual_abs_diff::ManualAbsDiff::new(conf),
760761
ManualClamp: manual_clamp::ManualClamp = manual_clamp::ManualClamp::new(conf),
762+
ManualMax: manual_max::ManualMax = manual_max::ManualMax::new(conf),
761763
ManualStringNew: manual_string_new::ManualStringNew = manual_string_new::ManualStringNew,
762764
UnusedPeekable: unused_peekable::UnusedPeekable = unused_peekable::UnusedPeekable,
763765
BoolToIntWithIf: bool_to_int_with_if::BoolToIntWithIf = bool_to_int_with_if::BoolToIntWithIf,

clippy_lints/src/manual_max.rs

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
use clippy_config::Conf;
2+
use clippy_utils::diagnostics::span_lint_and_sugg;
3+
use clippy_utils::higher::If;
4+
use clippy_utils::msrvs::{self, Msrv};
5+
use clippy_utils::sugg::Sugg;
6+
use clippy_utils::ty::implements_trait;
7+
use clippy_utils::{eq_expr_value, is_in_const_context, peel_blocks, peel_blocks_with_stmt, sym};
8+
use rustc_errors::Applicability;
9+
use rustc_hir::{BinOpKind, Expr, ExprKind};
10+
use rustc_lint::{LateContext, LateLintPass};
11+
use rustc_session::impl_lint_pass;
12+
13+
declare_clippy_lint! {
14+
/// ### What it does
15+
/// Checks for a single `if`/`else` (or guarding `if`) that picks the greater of
16+
/// two values, where [`Ord::max`] would be clearer.
17+
///
18+
/// ### Why is this bad?
19+
/// `a.max(b)` is shorter, has no branch, and states the intent directly. Unlike
20+
/// [`MANUAL_CLAMP`](https://rust-lang.github.io/rust-clippy/master/index.html#manual_clamp),
21+
/// which only triggers when both a lower *and* an upper bound are applied, this lint
22+
/// catches the common single-sided "floor" case.
23+
///
24+
/// ### Known problems
25+
/// On a tie the two forms can return different operands: `Ord::max` returns the
26+
/// *second* argument when the operands compare equal, whereas `if a < b { b } else { a }`
27+
/// returns the *first* (`a`). For types whose `Eq`-equal values are observationally
28+
/// distinct (e.g. ordered by a key field), the rewrite changes which value is selected,
29+
/// so the suggestion is `MaybeIncorrect`.
30+
///
31+
/// ### Example
32+
/// ```no_run
33+
/// # let (a, b) = (1, 2);
34+
/// let _ = if a < b { b } else { a };
35+
///
36+
/// let mut cores = a;
37+
/// if cores < b {
38+
/// cores = b;
39+
/// }
40+
/// ```
41+
/// Use instead:
42+
/// ```no_run
43+
/// # let (a, b) = (1, 2);
44+
/// let _ = a.max(b);
45+
///
46+
/// let mut cores = a;
47+
/// cores = cores.max(b);
48+
/// ```
49+
#[clippy::version = "1.98.0"]
50+
pub MANUAL_MAX,
51+
complexity,
52+
"an `if`/`else` that could be written as a call to `Ord::max`"
53+
}
54+
55+
declare_clippy_lint! {
56+
/// ### What it does
57+
/// Checks for a single `if`/`else` (or guarding `if`) that picks the lesser of
58+
/// two values, where [`Ord::min`] would be clearer.
59+
///
60+
/// ### Why is this bad?
61+
/// `a.min(b)` is shorter, has no branch, and states the intent directly. Unlike
62+
/// [`MANUAL_CLAMP`](https://rust-lang.github.io/rust-clippy/master/index.html#manual_clamp),
63+
/// which only triggers when both a lower *and* an upper bound are applied, this lint
64+
/// catches the common single-sided "ceiling" case.
65+
///
66+
/// ### Example
67+
/// ```no_run
68+
/// # let (a, b) = (1, 2);
69+
/// let _ = if a > b { b } else { a };
70+
///
71+
/// let mut cores = a;
72+
/// if cores > b {
73+
/// cores = b;
74+
/// }
75+
/// ```
76+
/// Use instead:
77+
/// ```no_run
78+
/// # let (a, b) = (1, 2);
79+
/// let _ = a.min(b);
80+
///
81+
/// let mut cores = a;
82+
/// cores = cores.min(b);
83+
/// ```
84+
#[clippy::version = "1.98.0"]
85+
pub MANUAL_MIN,
86+
complexity,
87+
"an `if`/`else` that could be written as a call to `Ord::min`"
88+
}
89+
90+
impl_lint_pass!(ManualMax => [MANUAL_MAX, MANUAL_MIN]);
91+
92+
pub struct ManualMax {
93+
msrv: Msrv,
94+
}
95+
96+
impl ManualMax {
97+
pub fn new(conf: &'static Conf) -> Self {
98+
Self { msrv: conf.msrv }
99+
}
100+
}
101+
102+
#[derive(Clone, Copy)]
103+
enum MinMax {
104+
Max,
105+
Min,
106+
}
107+
108+
impl MinMax {
109+
fn method(self) -> &'static str {
110+
match self {
111+
MinMax::Max => "max",
112+
MinMax::Min => "min",
113+
}
114+
}
115+
116+
fn lint(self) -> &'static rustc_lint::Lint {
117+
match self {
118+
MinMax::Max => MANUAL_MAX,
119+
MinMax::Min => MANUAL_MIN,
120+
}
121+
}
122+
}
123+
124+
/// `lhs OP rhs` where `OP` is a comparison; used to reason about which operand the
125+
/// condition selects when it holds.
126+
struct Cmp<'tcx> {
127+
op: BinOpKind,
128+
lhs: &'tcx Expr<'tcx>,
129+
rhs: &'tcx Expr<'tcx>,
130+
}
131+
132+
impl<'tcx> Cmp<'tcx> {
133+
fn new(cond: &'tcx Expr<'tcx>) -> Option<Self> {
134+
if let ExprKind::Binary(op, lhs, rhs) = peel_blocks(cond).kind
135+
&& matches!(op.node, BinOpKind::Lt | BinOpKind::Le | BinOpKind::Gt | BinOpKind::Ge)
136+
{
137+
Some(Self { op: op.node, lhs, rhs })
138+
} else {
139+
None
140+
}
141+
}
142+
143+
/// Returns the operator rewritten so that `var` sits on the left-hand side, i.e. the
144+
/// direction of the comparison as seen from `var`. Returns `None` if `var` is not one
145+
/// of the operands.
146+
fn orient(&self, cx: &LateContext<'tcx>, var: &Expr<'tcx>, ctxt: rustc_span::SyntaxContext) -> Option<BinOpKind> {
147+
if eq_expr_value(cx, ctxt, var, self.lhs) {
148+
Some(self.op)
149+
} else if eq_expr_value(cx, ctxt, var, self.rhs) {
150+
Some(flip(self.op))
151+
} else {
152+
None
153+
}
154+
}
155+
}
156+
157+
fn flip(op: BinOpKind) -> BinOpKind {
158+
match op {
159+
BinOpKind::Lt => BinOpKind::Gt,
160+
BinOpKind::Le => BinOpKind::Ge,
161+
BinOpKind::Gt => BinOpKind::Lt,
162+
BinOpKind::Ge => BinOpKind::Le,
163+
other => other,
164+
}
165+
}
166+
167+
impl<'tcx> LateLintPass<'tcx> for ManualMax {
168+
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
169+
// `Ord::max`/`Ord::min` were stabilized in 1.21.0.
170+
if expr.span.from_expansion() || is_in_const_context(cx) || !self.msrv.meets(cx, msrvs::ORD_MAX_MIN) {
171+
return;
172+
}
173+
if let Some(If { cond, then, r#else }) = If::hir(expr)
174+
&& let Some(cmp) = Cmp::new(cond)
175+
// Only `Ord` types: `f32`/`f64` implement `PartialOrd` only, and `a.max(b)`
176+
// differs from the branching form when an operand is `NaN`. Both operands must be
177+
// `Ord` (and hence the same type) for `lhs.max(rhs)` to type-check.
178+
&& is_ord(cx, cmp.lhs)
179+
&& is_ord(cx, cmp.rhs)
180+
{
181+
let ctxt = expr.span.ctxt();
182+
let found = match r#else {
183+
Some(r#else) => match_select(cx, &cmp, peel_blocks(then), peel_blocks(r#else), ctxt),
184+
None => match_guard(cx, &cmp, peel_blocks_with_stmt(then), ctxt),
185+
};
186+
if let Some((kind, recv, arg, assign_to)) = found {
187+
emit(cx, kind, expr, recv, arg, assign_to);
188+
}
189+
}
190+
}
191+
}
192+
193+
/// Matches the value-returning form `if lhs OP rhs { x } else { y }`, where `{x, y}` are
194+
/// the two operands in either order.
195+
fn match_select<'tcx>(
196+
cx: &LateContext<'tcx>,
197+
cmp: &Cmp<'tcx>,
198+
then: &'tcx Expr<'tcx>,
199+
r#else: &'tcx Expr<'tcx>,
200+
ctxt: rustc_span::SyntaxContext,
201+
) -> Option<(MinMax, &'tcx Expr<'tcx>, &'tcx Expr<'tcx>, Option<&'tcx Expr<'tcx>>)> {
202+
let picks_lhs = eq_expr_value(cx, ctxt, then, cmp.lhs) && eq_expr_value(cx, ctxt, r#else, cmp.rhs);
203+
let picks_rhs = eq_expr_value(cx, ctxt, then, cmp.rhs) && eq_expr_value(cx, ctxt, r#else, cmp.lhs);
204+
// When the condition holds: `Lt`/`Le` means `lhs` is the smaller operand, `Gt`/`Ge`
205+
// means `lhs` is the larger one. Picking the larger operand => `max`, else `min`.
206+
let lhs_is_greater_when_true = matches!(cmp.op, BinOpKind::Gt | BinOpKind::Ge);
207+
let kind = match (picks_lhs, picks_rhs) {
208+
(true, false) if lhs_is_greater_when_true => MinMax::Max,
209+
(true, false) => MinMax::Min,
210+
(false, true) if lhs_is_greater_when_true => MinMax::Min,
211+
(false, true) => MinMax::Max,
212+
_ => return None,
213+
};
214+
Some((kind, cmp.lhs, cmp.rhs, None))
215+
}
216+
217+
/// Matches the guarding form `if x OP bound { x = bound; }` (no `else`), equivalent to
218+
/// `x = x.max(bound)` / `x = x.min(bound)`.
219+
fn match_guard<'tcx>(
220+
cx: &LateContext<'tcx>,
221+
cmp: &Cmp<'tcx>,
222+
then: &'tcx Expr<'tcx>,
223+
ctxt: rustc_span::SyntaxContext,
224+
) -> Option<(MinMax, &'tcx Expr<'tcx>, &'tcx Expr<'tcx>, Option<&'tcx Expr<'tcx>>)> {
225+
if let ExprKind::Assign(target, value, _) = then.kind
226+
// The assigned value must be the bound that is compared against, and the assignment
227+
// target the clamped variable.
228+
&& let Some(oriented) = cmp.orient(cx, target, ctxt)
229+
&& (eq_expr_value(cx, ctxt, value, cmp.lhs) || eq_expr_value(cx, ctxt, value, cmp.rhs))
230+
&& !eq_expr_value(cx, ctxt, value, target)
231+
{
232+
// `if x < bound { x = bound }` raises `x` to a floor => `max`;
233+
// `if x > bound { x = bound }` lowers `x` to a ceiling => `min`.
234+
let kind = match oriented {
235+
BinOpKind::Lt | BinOpKind::Le => MinMax::Max,
236+
BinOpKind::Gt | BinOpKind::Ge => MinMax::Min,
237+
_ => return None,
238+
};
239+
return Some((kind, target, value, Some(target)));
240+
}
241+
None
242+
}
243+
244+
fn is_ord<'tcx>(cx: &LateContext<'tcx>, expr: &Expr<'tcx>) -> bool {
245+
let ty = cx.typeck_results().expr_ty(expr);
246+
cx.tcx
247+
.get_diagnostic_item(sym::Ord)
248+
.is_some_and(|ord| implements_trait(cx, ty, ord, &[]))
249+
}
250+
251+
fn emit<'tcx>(
252+
cx: &LateContext<'tcx>,
253+
kind: MinMax,
254+
expr: &Expr<'tcx>,
255+
recv: &Expr<'tcx>,
256+
arg: &Expr<'tcx>,
257+
assign_to: Option<&Expr<'tcx>>,
258+
) {
259+
let recv = Sugg::hir(cx, recv, "..").maybe_paren();
260+
let arg = Sugg::hir(cx, arg, "..");
261+
let call = format!("{recv}.{}({arg})", kind.method());
262+
let sugg = match assign_to {
263+
// The guard form replaces a whole `if` *statement*, so the assignment needs its
264+
// own terminating `;`; the value-returning form is an expression and must not.
265+
Some(target) => format!("{} = {call};", Sugg::hir(cx, target, "..")),
266+
None => call,
267+
};
268+
span_lint_and_sugg(
269+
cx,
270+
kind.lint(),
271+
expr.span,
272+
format!("this `if` expression is a manual `{}`", kind.method()),
273+
"replace with",
274+
sugg,
275+
// The branching form re-evaluates the selected operand, so operands with side
276+
// effects could behave differently.
277+
Applicability::MaybeIncorrect,
278+
);
279+
}

clippy_utils/src/msrvs.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ msrv_aliases! {
8282
1,27,0 { ITERATOR_TRY_FOLD, DOUBLE_ENDED_ITERATOR_RFIND, DURATION_FROM_NANOS_MICROS }
8383
1,26,0 { RANGE_INCLUSIVE, STRING_RETAIN, POINTER_ADD_SUB_METHODS }
8484
1,24,0 { IS_ASCII_DIGIT, PTR_NULL }
85+
1,21,0 { ORD_MAX_MIN }
8586
1,18,0 { HASH_MAP_RETAIN, HASH_SET_RETAIN }
8687
1,17,0 { FIELD_INIT_SHORTHAND, STATIC_IN_CONST, EXPECT_ERR }
8788
1,16,0 { STR_REPEAT, RESULT_UNWRAP_OR_DEFAULT }

tests/ui/manual_clamp.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#![warn(clippy::manual_clamp)]
2+
#![allow(clippy::manual_max, clippy::manual_min)]
23
#![expect(clippy::if_same_then_else, clippy::needless_match)]
34

45
use std::cmp::{max as cmp_max, min as cmp_min};

tests/ui/manual_clamp.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#![warn(clippy::manual_clamp)]
2+
#![allow(clippy::manual_max, clippy::manual_min)]
23
#![expect(clippy::if_same_then_else, clippy::needless_match)]
34

45
use std::cmp::{max as cmp_max, min as cmp_min};

0 commit comments

Comments
 (0)