Skip to content

Commit 97e93cb

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 97e93cb

9 files changed

Lines changed: 755 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: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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+
/// ### Example
48+
///
49+
/// ```no_run
50+
/// # let items = vec![1, 2, 3];
51+
/// assert!(items.is_empty());
52+
/// assert!(!items.is_empty());
53+
/// ```
54+
///
55+
/// Use instead:
56+
///
57+
/// ```no_run
58+
/// # let items = vec![1, 2, 3];
59+
/// assert_eq!(items, [] as [i32; 0]);
60+
/// assert_ne!(items, [] as [i32; 0]);
61+
/// ```
62+
#[clippy::version = "1.98.0"]
63+
pub ASSERT_IS_EMPTY,
64+
pedantic,
65+
"asserting on emptiness without showing the asserted value on failure"
66+
}
67+
68+
declare_lint_pass!(AssertIsEmpty => [ASSERT_IS_EMPTY]);
69+
70+
impl<'tcx> LateLintPass<'tcx> for AssertIsEmpty {
71+
/// Finds assertion conditions that only test emptiness.
72+
///
73+
/// Matching assertions are rewritten as equality or inequality assertions against an empty
74+
/// value. The suggestion edits only the macro name and predicate so custom assertion messages
75+
/// remain in place.
76+
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
77+
if !matches!(expr.kind, ExprKind::If(..)) {
78+
return;
79+
}
80+
81+
let Some((macro_name, condition, assert_span)) = assert_call(cx, expr) else {
82+
return;
83+
};
84+
let Some((assertion_kind, receiver)) = emptiness_assertion(condition) else {
85+
return;
86+
};
87+
let Some((receiver_suffix, empty_value)) = assertion_suggestion(cx, receiver) else {
88+
return;
89+
};
90+
91+
emit_assertion_suggestion(
92+
cx,
93+
macro_name,
94+
assert_span,
95+
condition,
96+
assertion_kind,
97+
receiver,
98+
(receiver_suffix, &empty_value),
99+
);
100+
}
101+
}
102+
103+
/// Extracts the source-level condition from `assert!` and `debug_assert!`.
104+
///
105+
/// The returned macro name omits the trailing `!` so the diagnostic can build `assert_eq`,
106+
/// `assert_ne`, `debug_assert_eq`, or `debug_assert_ne` from the original macro. Returns `None`
107+
/// for other macros and for conditions from macro expansions, where rewriting the condition span
108+
/// would produce confusing or invalid suggestions.
109+
fn assert_call<'tcx>(cx: &LateContext<'tcx>, expr: &'tcx Expr<'_>) -> Option<(&'static str, &'tcx Expr<'tcx>, Span)> {
110+
let macro_call = root_macro_call_first_node(cx, expr)?;
111+
let macro_name = match cx.tcx.get_diagnostic_name(macro_call.def_id) {
112+
Some(sym::assert_macro) => "assert",
113+
Some(sym::debug_assert_macro) => "debug_assert",
114+
_ => return None,
115+
};
116+
let (condition, _) = find_assert_args(cx, expr, macro_call.expn)?;
117+
if condition.span.from_expansion() {
118+
return None;
119+
}
120+
121+
Some((macro_name, condition, macro_call.span))
122+
}
123+
124+
/// Returns the assertion kind and receiver for an emptiness predicate.
125+
///
126+
/// `value.is_empty()` maps to an equality assertion against an empty value. `!value.is_empty()`
127+
/// maps to an inequality assertion. Returns `None` when the condition is neither form.
128+
fn emptiness_assertion<'tcx>(condition: &'tcx Expr<'tcx>) -> Option<(AssertionKind, &'tcx Expr<'tcx>)> {
129+
if let Some(receiver) = is_empty_receiver(condition) {
130+
return Some((AssertionKind::Eq, receiver));
131+
}
132+
133+
let ExprKind::Unary(UnOp::Not, inner) = condition.kind else {
134+
return None;
135+
};
136+
137+
is_empty_receiver(inner).map(|receiver| (AssertionKind::Ne, receiver))
138+
}
139+
140+
/// Returns the receiver when `expr` is a direct `value.is_empty()` call.
141+
///
142+
/// Returns `None` for other method calls, negated expressions, and non-method expressions.
143+
fn is_empty_receiver<'tcx>(expr: &'tcx Expr<'tcx>) -> Option<&'tcx Expr<'tcx>> {
144+
let ExprKind::MethodCall(method, receiver, [], _) = expr.kind else {
145+
return None;
146+
};
147+
(method.ident.name == sym::is_empty).then_some(receiver)
148+
}
149+
150+
/// Assertion macro polarity for the replacement assertion.
151+
///
152+
/// The variants are named after the comparison macro suffixes rather than the original predicate
153+
/// shape because suggestions are the only consumer.
154+
#[derive(Clone, Copy, PartialEq, Eq)]
155+
enum AssertionKind {
156+
/// Use an equality assertion against an empty value.
157+
Eq,
158+
159+
/// Use an inequality assertion against an empty value.
160+
Ne,
161+
}
162+
163+
impl AssertionKind {
164+
/// Returns the assertion macro suffix for this emptiness predicate.
165+
///
166+
/// Empty checks become equality assertions. Non-empty checks become inequality assertions.
167+
fn suffix(self) -> &'static str {
168+
match self {
169+
Self::Eq => "_eq",
170+
Self::Ne => "_ne",
171+
}
172+
}
173+
174+
/// Returns the expected state named in the diagnostic.
175+
fn expected_state(self) -> &'static str {
176+
match self {
177+
Self::Eq => "empty",
178+
Self::Ne => "not empty",
179+
}
180+
}
181+
}
182+
183+
/// Builds replacement operands when the resulting assertion is useful.
184+
///
185+
/// The replacement assertion must compile, compare the same value, and print useful failure
186+
/// output. Returns `None` for unsupported collection types and for element types that cannot be
187+
/// printed and compared by the replacement assertion.
188+
fn assertion_suggestion<'tcx>(cx: &LateContext<'tcx>, receiver: &'tcx Expr<'tcx>) -> Option<(&'static str, String)> {
189+
let receiver_ty = cx.typeck_results().expr_ty(receiver);
190+
let suggestion = suggestion_for_type(cx, receiver_ty)?;
191+
if type_is_printable_and_comparable(cx, receiver_ty.peel_refs()) {
192+
Some(suggestion)
193+
} else {
194+
None
195+
}
196+
}
197+
198+
/// Returns the receiver suffix and empty value for this receiver type.
199+
///
200+
/// Arrays and borrowed vectors are compared through slices because direct comparison with `[]` does
201+
/// not compile for those receiver types. Returns `None` when the receiver has no compact
202+
/// empty-literal comparison.
203+
fn suggestion_for_type<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> Option<(&'static str, String)> {
204+
match ty.kind() {
205+
ty::Array(..) => Some((".as_slice()", "[]".to_string())),
206+
ty::Ref(_, inner, _) if matches!(inner.kind(), ty::Array(..)) => Some((".as_slice()", "[]".to_string())),
207+
ty::Ref(_, inner, _) if inner.is_diag_item(cx, sym::Vec) => Some((".as_slice()", "[]".to_string())),
208+
_ => suggestion_for_peeled_type(cx, ty.peel_refs()),
209+
}
210+
}
211+
212+
/// Returns the empty value for receivers that compare directly after peeling references.
213+
///
214+
/// `String` and `str` compare against `""`. Slices compare against `[]`. `Vec<T>` compares
215+
/// against `[] as [T; 0]` so the empty value carries the element type. Returns `None` for other
216+
/// receiver types.
217+
fn suggestion_for_peeled_type<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> Option<(&'static str, String)> {
218+
if ty.is_str() || ty.is_lang_item(cx, LangItem::String) {
219+
Some(("", "\"\"".to_string()))
220+
} else if matches!(ty.kind(), ty::Slice(..)) {
221+
Some(("", "[]".to_string()))
222+
} else if ty.is_diag_item(cx, sym::Vec) {
223+
Some(("", format!("[] as [{}; 0]", element_type(cx, ty)?)))
224+
} else {
225+
None
226+
}
227+
}
228+
229+
/// Returns whether the replacement assertion has useful failure output.
230+
///
231+
/// Suggestions are limited to cases where the replacement assertion can both compare the value and
232+
/// print it on failure. Strings satisfy this directly; sequence-like values require printable,
233+
/// self-comparable elements. Returns `false` when either trait bound is missing.
234+
fn type_is_printable_and_comparable<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> bool {
235+
if ty.is_str() || ty.is_lang_item(cx, LangItem::String) {
236+
return true;
237+
}
238+
239+
if let Some(element_ty) = element_type(cx, ty)
240+
&& let Some(debug_trait) = cx.tcx.get_diagnostic_item(sym::Debug)
241+
&& let Some(partial_eq_trait) = cx.tcx.get_diagnostic_item(sym::PartialEq)
242+
{
243+
implements_trait(cx, element_ty, debug_trait, &[])
244+
&& implements_trait(cx, element_ty, partial_eq_trait, &[element_ty.into()])
245+
} else {
246+
false
247+
}
248+
}
249+
250+
/// Extracts the element type from supported sequence-like values.
251+
///
252+
/// The element type is used both for trait checks and for the typed empty-array suggestion required
253+
/// by `Vec<T>` suggestions. Returns `None` for non-sequence receiver types.
254+
fn element_type<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> Option<Ty<'tcx>> {
255+
match ty.kind() {
256+
ty::Array(ty, _) | ty::Slice(ty) => Some(*ty),
257+
ty::Adt(_, args) if ty.is_diag_item(cx, sym::Vec) => args.types().next(),
258+
_ => None,
259+
}
260+
}
261+
262+
/// Emits the rewrite that preserves custom assertion messages.
263+
///
264+
/// Only the macro suffix and condition expression are replaced. Any message arguments after the
265+
/// condition remain untouched.
266+
fn emit_assertion_suggestion(
267+
cx: &LateContext<'_>,
268+
macro_name: &str,
269+
assert_span: Span,
270+
condition: &Expr<'_>,
271+
assertion_kind: AssertionKind,
272+
receiver: &Expr<'_>,
273+
suggestion: (&str, &str),
274+
) {
275+
let mut applicability = Applicability::MachineApplicable;
276+
let receiver_snip =
277+
Sugg::hir_with_context(cx, receiver, assert_span.ctxt(), "..", &mut applicability).maybe_paren();
278+
let (receiver_suffix, empty_value) = suggestion;
279+
let receiver_snip = format!("{receiver_snip}{receiver_suffix}");
280+
let assertion_suffix = assertion_kind.suffix();
281+
let expected_state = assertion_kind.expected_state();
282+
283+
span_lint_and_then(
284+
cx,
285+
ASSERT_IS_EMPTY,
286+
assert_span,
287+
format!("used `{macro_name}!` to check that a value is {expected_state}"),
288+
|diag| {
289+
let macro_name_span = cx.sess().source_map().span_until_char(assert_span, '!');
290+
let condition_span = walk_span_to_context(condition.span, assert_span.ctxt()).unwrap_or(condition.span);
291+
292+
diag.multipart_suggestion(
293+
format!("use `{macro_name}{assertion_suffix}!` to show the value on failure"),
294+
vec![
295+
(macro_name_span.shrink_to_hi(), assertion_suffix.to_string()),
296+
(condition_span, format!("{receiver_snip}, {empty_value}")),
297+
],
298+
applicability,
299+
);
300+
},
301+
);
302+
}

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)