Skip to content

Commit 036738a

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 7112c38 commit 036738a

7 files changed

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

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
@@ -66,6 +66,7 @@ mod arbitrary_source_item_ordering;
6666
mod arc_with_non_send_sync;
6767
mod as_conversions;
6868
mod asm_syntax;
69+
mod assert_is_empty;
6970
mod assertions_on_constants;
7071
mod assertions_on_result_states;
7172
mod assigning_clones;
@@ -871,6 +872,7 @@ pub fn register_lint_passes(store: &mut rustc_lint::LintStore, conf: &'static Co
871872
Box::new(move |_| Box::new(manual_noop_waker::ManualNoopWaker::new(conf))),
872873
Box::new(|_| Box::new(byte_char_slices::ByteCharSlice)),
873874
Box::new(|_| Box::new(manual_assert_eq::ManualAssertEq)),
875+
Box::new(|_| Box::new(assert_is_empty::AssertIsEmpty)),
874876
// add late passes here, used by `cargo dev new_lint`
875877
];
876878
store.late_passes.extend(late_lints);

0 commit comments

Comments
 (0)