Skip to content

Commit adac632

Browse files
committed
Add assertion emptiness lint
Warn when assert macros check collection emptiness in a way that hides the collection contents on failure. The lint suggests assert_eq/assert_ne forms for supported collections so failing tests print the unexpected value.
1 parent c05be4b commit adac632

9 files changed

Lines changed: 763 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ document.
88

99
[88f787...master](https://github.com/rust-lang/rust-clippy/compare/88f787...master)
1010

11+
### New Lints
12+
13+
* Added [`assert_is_empty`] to `pedantic`
14+
[#17114](https://github.com/rust-lang/rust-clippy/issues/17114)
15+
1116
## Rust 1.96
1217

1318
Current stable, released 2026-05-28
@@ -6639,6 +6644,7 @@ Released 2018-09-13
66396644
[`as_pointer_underscore`]: https://rust-lang.github.io/rust-clippy/master/index.html#as_pointer_underscore
66406645
[`as_ptr_cast_mut`]: https://rust-lang.github.io/rust-clippy/master/index.html#as_ptr_cast_mut
66416646
[`as_underscore`]: https://rust-lang.github.io/rust-clippy/master/index.html#as_underscore
6647+
[`assert_is_empty`]: https://rust-lang.github.io/rust-clippy/master/index.html#assert_is_empty
66426648
[`assertions_on_constants`]: https://rust-lang.github.io/rust-clippy/master/index.html#assertions_on_constants
66436649
[`assertions_on_result_states`]: https://rust-lang.github.io/rust-clippy/master/index.html#assertions_on_result_states
66446650
[`assign_op_pattern`]: https://rust-lang.github.io/rust-clippy/master/index.html#assign_op_pattern
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
use clippy_utils::diagnostics::span_lint_and_then;
2+
use clippy_utils::macros::{find_assert_args, root_macro_call_first_node};
3+
use clippy_utils::res::MaybeDef;
4+
use clippy_utils::source::walk_span_to_context;
5+
use clippy_utils::sugg::Sugg;
6+
use clippy_utils::sym;
7+
use clippy_utils::ty::implements_trait;
8+
use rustc_errors::Applicability;
9+
use rustc_hir::{Expr, ExprKind, LangItem, UnOp};
10+
use rustc_lint::{LateContext, LateLintPass, LintContext};
11+
use rustc_middle::ty::{self, Ty};
12+
use rustc_session::declare_lint_pass;
13+
use rustc_span::Span;
14+
15+
declare_clippy_lint! {
16+
/// ### What it does
17+
///
18+
/// Checks assertions that only test whether a supported value is empty.
19+
///
20+
/// The lint handles `assert!` and `debug_assert!` calls on strings, slices, arrays, and `Vec`.
21+
///
22+
/// ### Why is this bad?
23+
///
24+
/// A boolean assertion only reports that the emptiness check failed. It does not show what the
25+
/// asserted value contained.
26+
///
27+
/// In CI or another remote test service, the failure output tells you that the value was
28+
/// unexpectedly empty or non-empty, but not which values were present. The next step is often
29+
/// to reproduce the failure locally, add temporary logging, or change the test so it exposes
30+
/// the value. That extra investigation can be much slower than fixing the problem from the
31+
/// original CI failure.
32+
///
33+
/// The emptiness check also commonly appears before a deeper contents assertion:
34+
///
35+
/// ```no_run
36+
/// # let items = vec!["baz"];
37+
/// assert!(!items.is_empty());
38+
/// assert_eq!(items[0], "bar");
39+
/// ```
40+
///
41+
/// If the first assertion fails, the second assertion never runs, so the failure can hide the
42+
/// check that would have shown more useful context.
43+
///
44+
/// Instead, compare the value with an empty value using `assert_eq!`, `assert_ne!`,
45+
/// `debug_assert_eq!`, or `debug_assert_ne!`. These macros print the asserted value on failure.
46+
///
47+
/// ### Known problems
48+
///
49+
/// Printing the asserted value can be undesirable outside tests, especially when the value may
50+
/// be very large or contain sensitive information. If the assertion failure should not reveal
51+
/// the value, keep the boolean assertion and allow this lint at that assertion.
52+
///
53+
/// ### Example
54+
///
55+
/// ```no_run
56+
/// # let items = vec![1, 2, 3];
57+
/// assert!(items.is_empty());
58+
/// assert!(!items.is_empty());
59+
/// ```
60+
///
61+
/// Use instead:
62+
///
63+
/// ```no_run
64+
/// # let items = vec![1, 2, 3];
65+
/// assert_eq!(items, [] as [i32; 0]);
66+
/// assert_ne!(items, [] as [i32; 0]);
67+
/// ```
68+
#[clippy::version = "1.98.0"]
69+
pub ASSERT_IS_EMPTY,
70+
pedantic,
71+
"asserting on emptiness without showing the asserted value on failure"
72+
}
73+
74+
declare_lint_pass!(AssertIsEmpty => [ASSERT_IS_EMPTY]);
75+
76+
impl<'tcx> LateLintPass<'tcx> for AssertIsEmpty {
77+
/// Finds assertion conditions that only test emptiness.
78+
///
79+
/// Matching assertions are rewritten as equality or inequality assertions against an empty
80+
/// value. The suggestion edits only the macro name and predicate so custom assertion messages
81+
/// remain in place.
82+
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
83+
// `assert!` and `debug_assert!` expand to `if`; skip other expressions before walking the
84+
// macro backtrace.
85+
if !matches!(expr.kind, ExprKind::If(..)) {
86+
return;
87+
}
88+
89+
let Some((macro_name, condition, assert_span)) = assert_call(cx, expr) else {
90+
return;
91+
};
92+
let Some((assertion_kind, receiver)) = emptiness_assertion(condition) else {
93+
return;
94+
};
95+
let Some((receiver_suffix, empty_value)) = assertion_suggestion(cx, receiver) else {
96+
return;
97+
};
98+
99+
emit_assertion_suggestion(
100+
cx,
101+
macro_name,
102+
assert_span,
103+
condition,
104+
assertion_kind,
105+
receiver,
106+
(receiver_suffix, &empty_value),
107+
);
108+
}
109+
}
110+
111+
/// Extracts the source-level condition from `assert!` and `debug_assert!`.
112+
///
113+
/// The returned macro name omits the trailing `!` so the diagnostic can build `assert_eq`,
114+
/// `assert_ne`, `debug_assert_eq`, or `debug_assert_ne` from the original macro. Returns `None`
115+
/// for other macros and for conditions from macro expansions, where rewriting the condition span
116+
/// would produce confusing or invalid suggestions.
117+
fn assert_call<'tcx>(cx: &LateContext<'tcx>, expr: &'tcx Expr<'_>) -> Option<(&'static str, &'tcx Expr<'tcx>, Span)> {
118+
let macro_call = root_macro_call_first_node(cx, expr)?;
119+
let macro_name = match cx.tcx.get_diagnostic_name(macro_call.def_id) {
120+
Some(sym::assert_macro) => "assert",
121+
Some(sym::debug_assert_macro) => "debug_assert",
122+
_ => return None,
123+
};
124+
let (condition, _) = find_assert_args(cx, expr, macro_call.expn)?;
125+
if condition.span.from_expansion() {
126+
return None;
127+
}
128+
129+
Some((macro_name, condition, macro_call.span))
130+
}
131+
132+
/// Returns the assertion kind and receiver for an emptiness predicate.
133+
///
134+
/// `value.is_empty()` maps to an equality assertion against an empty value. `!value.is_empty()`
135+
/// maps to an inequality assertion. Returns `None` when the condition is neither form.
136+
fn emptiness_assertion<'tcx>(condition: &'tcx Expr<'tcx>) -> Option<(AssertionKind, &'tcx Expr<'tcx>)> {
137+
if let Some(receiver) = is_empty_receiver(condition) {
138+
return Some((AssertionKind::Eq, receiver));
139+
}
140+
141+
let ExprKind::Unary(UnOp::Not, inner) = condition.kind else {
142+
return None;
143+
};
144+
145+
is_empty_receiver(inner).map(|receiver| (AssertionKind::Ne, receiver))
146+
}
147+
148+
/// Returns the receiver when `expr` is a direct `value.is_empty()` call.
149+
///
150+
/// Returns `None` for other method calls, negated expressions, and non-method expressions.
151+
fn is_empty_receiver<'tcx>(expr: &'tcx Expr<'tcx>) -> Option<&'tcx Expr<'tcx>> {
152+
let ExprKind::MethodCall(method, receiver, [], _) = expr.kind else {
153+
return None;
154+
};
155+
(method.ident.name == sym::is_empty).then_some(receiver)
156+
}
157+
158+
/// Assertion macro polarity for the replacement assertion.
159+
///
160+
/// The variants are named after the comparison macro suffixes rather than the original predicate
161+
/// shape because suggestions are the only consumer.
162+
#[derive(Clone, Copy, PartialEq, Eq)]
163+
enum AssertionKind {
164+
/// Use an equality assertion against an empty value.
165+
Eq,
166+
167+
/// Use an inequality assertion against an empty value.
168+
Ne,
169+
}
170+
171+
impl AssertionKind {
172+
/// Returns the assertion macro suffix for this emptiness predicate.
173+
///
174+
/// Empty checks become equality assertions. Non-empty checks become inequality assertions.
175+
fn suffix(self) -> &'static str {
176+
match self {
177+
Self::Eq => "_eq",
178+
Self::Ne => "_ne",
179+
}
180+
}
181+
182+
/// Returns the expected state named in the diagnostic.
183+
fn expected_state(self) -> &'static str {
184+
match self {
185+
Self::Eq => "empty",
186+
Self::Ne => "not empty",
187+
}
188+
}
189+
}
190+
191+
/// Builds replacement operands when the resulting assertion is useful.
192+
///
193+
/// The replacement assertion must compile, compare the same value, and print useful failure
194+
/// output. Returns `None` for unsupported collection types and for element types that cannot be
195+
/// printed and compared by the replacement assertion.
196+
fn assertion_suggestion<'tcx>(cx: &LateContext<'tcx>, receiver: &'tcx Expr<'tcx>) -> Option<(&'static str, String)> {
197+
let receiver_ty = cx.typeck_results().expr_ty(receiver);
198+
let suggestion = suggestion_for_type(cx, receiver_ty)?;
199+
if type_is_printable_and_comparable(cx, receiver_ty.peel_refs()) {
200+
Some(suggestion)
201+
} else {
202+
None
203+
}
204+
}
205+
206+
/// Returns the receiver suffix and empty value for this receiver type.
207+
///
208+
/// Arrays and borrowed vectors are compared through slices because direct comparison with `[]` does
209+
/// not compile for those receiver types. Returns `None` when the receiver has no compact
210+
/// empty-literal comparison.
211+
fn suggestion_for_type<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> Option<(&'static str, String)> {
212+
match ty.kind() {
213+
ty::Array(..) => Some((".as_slice()", "[]".to_string())),
214+
ty::Ref(_, inner, _) if matches!(inner.kind(), ty::Array(..)) => Some((".as_slice()", "[]".to_string())),
215+
ty::Ref(_, inner, _) if inner.is_diag_item(cx, sym::Vec) => Some((".as_slice()", "[]".to_string())),
216+
_ => suggestion_for_peeled_type(cx, ty.peel_refs()),
217+
}
218+
}
219+
220+
/// Returns the empty value for receivers that compare directly after peeling references.
221+
///
222+
/// `String` and `str` compare against `""`. Slices compare against `[]`. `Vec<T>` compares
223+
/// against `[] as [T; 0]` so the empty value carries the element type. Returns `None` for other
224+
/// receiver types.
225+
fn suggestion_for_peeled_type<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> Option<(&'static str, String)> {
226+
if ty.is_str() || ty.is_lang_item(cx, LangItem::String) {
227+
Some(("", "\"\"".to_string()))
228+
} else if matches!(ty.kind(), ty::Slice(..)) {
229+
Some(("", "[]".to_string()))
230+
} else if ty.is_diag_item(cx, sym::Vec) {
231+
Some(("", format!("[] as [{}; 0]", element_type(cx, ty)?)))
232+
} else {
233+
None
234+
}
235+
}
236+
237+
/// Returns whether the replacement assertion has useful failure output.
238+
///
239+
/// Suggestions are limited to cases where the replacement assertion can both compare the value and
240+
/// print it on failure. Strings satisfy this directly; sequence-like values require printable,
241+
/// self-comparable elements. Returns `false` when either trait bound is missing.
242+
fn type_is_printable_and_comparable<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> bool {
243+
if ty.is_str() || ty.is_lang_item(cx, LangItem::String) {
244+
return true;
245+
}
246+
247+
if let Some(element_ty) = element_type(cx, ty)
248+
&& let Some(debug_trait) = cx.tcx.get_diagnostic_item(sym::Debug)
249+
&& let Some(partial_eq_trait) = cx.tcx.get_diagnostic_item(sym::PartialEq)
250+
{
251+
implements_trait(cx, element_ty, debug_trait, &[])
252+
&& implements_trait(cx, element_ty, partial_eq_trait, &[element_ty.into()])
253+
} else {
254+
false
255+
}
256+
}
257+
258+
/// Extracts the element type from supported sequence-like values.
259+
///
260+
/// The element type is used both for trait checks and for the typed empty-array suggestion required
261+
/// by `Vec<T>` suggestions. Returns `None` for non-sequence receiver types.
262+
fn element_type<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> Option<Ty<'tcx>> {
263+
match ty.kind() {
264+
ty::Array(ty, _) | ty::Slice(ty) => Some(*ty),
265+
ty::Adt(_, args) if ty.is_diag_item(cx, sym::Vec) => args.types().next(),
266+
_ => None,
267+
}
268+
}
269+
270+
/// Emits the rewrite that preserves custom assertion messages.
271+
///
272+
/// Only the macro suffix and condition expression are replaced. Any message arguments after the
273+
/// condition remain untouched.
274+
fn emit_assertion_suggestion(
275+
cx: &LateContext<'_>,
276+
macro_name: &str,
277+
assert_span: Span,
278+
condition: &Expr<'_>,
279+
assertion_kind: AssertionKind,
280+
receiver: &Expr<'_>,
281+
suggestion: (&str, &str),
282+
) {
283+
let mut applicability = Applicability::MachineApplicable;
284+
let receiver_snip =
285+
Sugg::hir_with_context(cx, receiver, assert_span.ctxt(), "..", &mut applicability).maybe_paren();
286+
let (receiver_suffix, empty_value) = suggestion;
287+
let receiver_snip = format!("{receiver_snip}{receiver_suffix}");
288+
let assertion_suffix = assertion_kind.suffix();
289+
let expected_state = assertion_kind.expected_state();
290+
291+
span_lint_and_then(
292+
cx,
293+
ASSERT_IS_EMPTY,
294+
assert_span,
295+
format!("used `{macro_name}!` to check that a value is {expected_state}"),
296+
|diag| {
297+
let macro_name_span = cx.sess().source_map().span_until_char(assert_span, '!');
298+
let condition_span = walk_span_to_context(condition.span, assert_span.ctxt()).unwrap_or(condition.span);
299+
300+
diag.multipart_suggestion(
301+
format!("use `{macro_name}{assertion_suffix}!` to show the value on failure"),
302+
vec![
303+
(macro_name_span.shrink_to_hi(), assertion_suffix.to_string()),
304+
(condition_span, format!("{receiver_snip}, {empty_value}")),
305+
],
306+
applicability,
307+
);
308+
},
309+
);
310+
}

clippy_lints/src/declared_lints.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub static LINTS: &[&::declare_clippy_lint::LintInfo] = &[
1111
crate::as_conversions::AS_CONVERSIONS_INFO,
1212
crate::asm_syntax::INLINE_ASM_X86_ATT_SYNTAX_INFO,
1313
crate::asm_syntax::INLINE_ASM_X86_INTEL_SYNTAX_INFO,
14+
crate::assert_is_empty::ASSERT_IS_EMPTY_INFO,
1415
crate::assertions_on_constants::ASSERTIONS_ON_CONSTANTS_INFO,
1516
crate::assertions_on_result_states::ASSERTIONS_ON_RESULT_STATES_INFO,
1617
crate::assigning_clones::ASSIGNING_CLONES_INFO,

clippy_lints/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ mod arbitrary_source_item_ordering;
6969
mod arc_with_non_send_sync;
7070
mod as_conversions;
7171
mod asm_syntax;
72+
mod assert_is_empty;
7273
mod assertions_on_constants;
7374
mod assertions_on_result_states;
7475
mod assigning_clones;
@@ -571,6 +572,7 @@ rustc_lint::late_lint_methods!(
571572
UnnecessaryMutPassed: unnecessary_mut_passed::UnnecessaryMutPassed = unnecessary_mut_passed::UnnecessaryMutPassed,
572573
SignificantDropTightening: significant_drop_tightening::SignificantDropTightening<'tcx> = <significant_drop_tightening::SignificantDropTightening<'_>>::default(),
573574
LenZero: len_zero::LenZero = len_zero::LenZero::new(conf),
575+
AssertIsEmpty: assert_is_empty::AssertIsEmpty = assert_is_empty::AssertIsEmpty,
574576
LenWithoutIsEmpty: len_without_is_empty::LenWithoutIsEmpty = len_without_is_empty::LenWithoutIsEmpty,
575577
Attributes: attrs::Attributes = attrs::Attributes::new(conf),
576578
BlocksInConditions: blocks_in_conditions::BlocksInConditions = blocks_in_conditions::BlocksInConditions,

tests/compile-test.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,9 @@ fn internal_extern_flags() -> Vec<String> {
8282
.copied()
8383
.filter(|n| !crates.contains_key(n))
8484
.collect();
85-
assert!(
86-
not_found.is_empty(),
85+
assert_eq!(
86+
not_found,
87+
[] as [&str; 0],
8788
"dependencies not found in depinfo: {not_found:?}\n\
8889
help: Make sure the `-Z binary-dep-depinfo` rust flag is enabled\n\
8990
help: Try adding to dev-dependencies in Cargo.toml\n\

tests/dogfood.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ fn dogfood() {
5050
}
5151
}
5252

53-
assert!(
54-
failed_packages.is_empty(),
53+
assert_eq!(
54+
failed_packages,
55+
[] as [&str; 0],
5556
"Dogfood failed for packages `{}`",
5657
failed_packages.iter().join(", "),
5758
);

0 commit comments

Comments
 (0)