Skip to content

Commit 4a8cdc6

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 4a8cdc6

9 files changed

Lines changed: 742 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: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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+
171+
/// Builds replacement operands when the resulting assertion is useful.
172+
///
173+
/// The replacement assertion must compile, compare the same value, and print useful failure
174+
/// output. Returns `None` for unsupported collection types and for element types that cannot be
175+
/// printed and compared by the replacement assertion.
176+
fn assertion_suggestion<'tcx>(cx: &LateContext<'tcx>, receiver: &'tcx Expr<'tcx>) -> Option<(&'static str, String)> {
177+
let receiver_ty = cx.typeck_results().expr_ty(receiver);
178+
let suggestion = suggestion_for_type(cx, receiver_ty)?;
179+
if type_is_printable_and_comparable(cx, receiver_ty.peel_refs()) {
180+
Some(suggestion)
181+
} else {
182+
None
183+
}
184+
}
185+
186+
/// Returns the receiver suffix and empty value for this receiver type.
187+
///
188+
/// Arrays and borrowed vectors are compared through slices because direct comparison with `[]` does
189+
/// not compile for those receiver types. Returns `None` when the receiver has no compact
190+
/// empty-literal comparison.
191+
fn suggestion_for_type<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> Option<(&'static str, String)> {
192+
match ty.kind() {
193+
ty::Array(..) => Some((".as_slice()", "[]".to_string())),
194+
ty::Ref(_, inner, _) if matches!(inner.kind(), ty::Array(..)) => Some((".as_slice()", "[]".to_string())),
195+
ty::Ref(_, inner, _) if inner.is_diag_item(cx, sym::Vec) => Some((".as_slice()", "[]".to_string())),
196+
_ => suggestion_for_peeled_type(cx, ty.peel_refs()),
197+
}
198+
}
199+
200+
/// Returns the empty value for receivers that compare directly after peeling references.
201+
///
202+
/// `String` and `str` compare against `""`. Slices compare against `[]`. `Vec<T>` compares
203+
/// against `[] as [T; 0]` so the empty value carries the element type. Returns `None` for other
204+
/// receiver types.
205+
fn suggestion_for_peeled_type<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> Option<(&'static str, String)> {
206+
if ty.is_str() || ty.is_lang_item(cx, LangItem::String) {
207+
Some(("", "\"\"".to_string()))
208+
} else if matches!(ty.kind(), ty::Slice(..)) {
209+
Some(("", "[]".to_string()))
210+
} else if ty.is_diag_item(cx, sym::Vec) {
211+
Some(("", format!("[] as [{}; 0]", element_type(cx, ty)?)))
212+
} else {
213+
None
214+
}
215+
}
216+
217+
/// Returns whether the replacement assertion has useful failure output.
218+
///
219+
/// Suggestions are limited to cases where the replacement assertion can both compare the value and
220+
/// print it on failure. Strings satisfy this directly; sequence-like values require printable,
221+
/// self-comparable elements. Returns `false` when either trait bound is missing.
222+
fn type_is_printable_and_comparable<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> bool {
223+
if ty.is_str() || ty.is_lang_item(cx, LangItem::String) {
224+
return true;
225+
}
226+
227+
if let Some(element_ty) = element_type(cx, ty)
228+
&& let Some(debug_trait) = cx.tcx.get_diagnostic_item(sym::Debug)
229+
&& let Some(partial_eq_trait) = cx.tcx.get_diagnostic_item(sym::PartialEq)
230+
{
231+
implements_trait(cx, element_ty, debug_trait, &[])
232+
&& implements_trait(cx, element_ty, partial_eq_trait, &[element_ty.into()])
233+
} else {
234+
false
235+
}
236+
}
237+
238+
/// Extracts the element type from supported sequence-like values.
239+
///
240+
/// The element type is used both for trait checks and for the typed empty-array suggestion required
241+
/// by `Vec<T>` suggestions. Returns `None` for non-sequence receiver types.
242+
fn element_type<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> Option<Ty<'tcx>> {
243+
match ty.kind() {
244+
ty::Array(ty, _) | ty::Slice(ty) => Some(*ty),
245+
ty::Adt(_, args) if ty.is_diag_item(cx, sym::Vec) => args.types().next(),
246+
_ => None,
247+
}
248+
}
249+
250+
/// Emits the rewrite that preserves custom assertion messages.
251+
///
252+
/// Only the macro suffix and condition expression are replaced. Any message arguments after the
253+
/// condition remain untouched.
254+
fn emit_assertion_suggestion(
255+
cx: &LateContext<'_>,
256+
macro_name: &str,
257+
assert_span: Span,
258+
condition: &Expr<'_>,
259+
assertion_kind: AssertionKind,
260+
receiver: &Expr<'_>,
261+
suggestion: (&str, &str),
262+
) {
263+
let mut applicability = Applicability::MachineApplicable;
264+
let receiver_snip =
265+
Sugg::hir_with_context(cx, receiver, assert_span.ctxt(), "..", &mut applicability).maybe_paren();
266+
let (receiver_suffix, empty_value) = suggestion;
267+
let receiver_snip = format!("{receiver_snip}{receiver_suffix}");
268+
let assertion_suffix = assertion_kind.suffix();
269+
270+
span_lint_and_then(
271+
cx,
272+
ASSERT_IS_EMPTY,
273+
assert_span,
274+
format!("used `{macro_name}!` to check if a value is empty"),
275+
|diag| {
276+
let macro_name_span = cx.sess().source_map().span_until_char(assert_span, '!');
277+
let condition_span = walk_span_to_context(condition.span, assert_span.ctxt()).unwrap_or(condition.span);
278+
279+
diag.multipart_suggestion(
280+
format!("use `{macro_name}{assertion_suffix}!` to show the value on failure"),
281+
vec![
282+
(macro_name_span.shrink_to_hi(), assertion_suffix.to_string()),
283+
(condition_span, format!("{receiver_snip}, {empty_value}")),
284+
],
285+
applicability,
286+
);
287+
},
288+
);
289+
}

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)