Skip to content

Commit 3af5d85

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 3af5d85

9 files changed

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

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)