Skip to content

Commit e0fc5b4

Browse files
committed
feat: implement function call follow logic
1 parent 2adaa5f commit e0fc5b4

File tree

3 files changed

+210
-98
lines changed

3 files changed

+210
-98
lines changed

clippy_lints/src/test_without_fail_case.rs

Lines changed: 156 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ use clippy_utils::macros::{is_panic, root_macro_call_first_node};
33
use clippy_utils::ty::is_type_diagnostic_item;
44
use clippy_utils::visitors::Visitable;
55
use clippy_utils::{is_in_test_function, method_chain_args};
6-
use rustc_hir::intravisit::{self, FnKind, Visitor};
7-
use rustc_hir::{AnonConst, Body, Expr, FnDecl, Item, ItemKind};
6+
use rustc_data_structures::fx::FxHashSet;
7+
use rustc_hir::def::Res;
8+
use rustc_hir::def_id::DefId;
9+
use rustc_hir::intravisit::{self, Visitor};
10+
use rustc_hir::{AnonConst, Expr, ExprKind, Item, ItemKind};
811
use rustc_lint::{LateContext, LateLintPass};
912
use rustc_middle::hir::nested_filter;
1013
use rustc_middle::ty;
@@ -13,142 +16,216 @@ use rustc_span::{Span, sym};
1316

1417
declare_clippy_lint! {
1518
/// ### What it does
16-
/// Triggers when a testing function (marked with the `#[test]` attribute) does not have a way to fail.
19+
/// Checks for test functions that cannot fail.
1720
///
18-
/// ### Why restrict this?
19-
/// If a test does not have a way to fail, the developer might be getting false positives from their test suites.
20-
/// The idiomatic way of using test functions should be such that they actually can fail in an erroneous state.
21+
/// ### Why is this bad?
22+
/// A test function that cannot fail might indicate that it does not actually test anything.
23+
/// It could lead to false positives in test suites, giving a false sense of security.
2124
///
2225
/// ### Example
23-
/// ```no_run
26+
/// ```rust
2427
/// #[test]
25-
/// fn my_cool_test() {
26-
/// // [...]
27-
/// }
28-
///
29-
/// #[cfg(test)]
30-
/// mod tests {
31-
/// // [...]
28+
/// fn my_test() {
29+
/// let x = 2;
30+
/// let y = 2;
31+
/// if x + y != 4 {
32+
/// eprintln!("this is not a correct test")
33+
/// }
3234
/// }
33-
///
3435
/// ```
36+
///
3537
/// Use instead:
36-
/// ```no_run
37-
/// #[cfg(test)]
38-
/// mod tests {
39-
/// #[test]
40-
/// fn my_cool_test() {
41-
/// // [...]
42-
/// }
38+
/// ```rust
39+
/// #[test]
40+
/// fn my_test() {
41+
/// let x = 2;
42+
/// let y = 2;
43+
/// assert_eq!(x + y, 4);
4344
/// }
4445
/// ```
45-
#[clippy::version = "1.70.0"]
46+
#[clippy::version = "1.82.0"]
4647
pub TEST_WITHOUT_FAIL_CASE,
4748
restriction,
48-
"A test function is outside the testing module."
49+
"test function cannot fail because it does not panic or assert"
4950
}
5051

5152
declare_lint_pass!(TestWithoutFailCase => [TEST_WITHOUT_FAIL_CASE]);
5253

53-
/*
54-
impl<'tcx> LateLintPass<'tcx> for TestWithoutFailCase {
55-
fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx rustc_hir::Item<'_>) {
56-
if let rustc_hir::ItemKind::Fn(sig, _, body_id) = item.kind {
57-
58-
}
59-
if is_in_test_function(cx.tcx, item.hir_id()) {
60-
let typck = cx.tcx.typeck(item.id.owner_id.def_id);
61-
let find_panic_visitor = FindPanicUnwrap::find_span(cx, typck, item.body)
62-
span_lint(cx, TEST_WITHOUT_FAIL_CASE, item.span, "test function cannot panic");
63-
}
64-
}
65-
}
66-
*/
67-
6854
impl<'tcx> LateLintPass<'tcx> for TestWithoutFailCase {
6955
fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx Item<'tcx>) {
56+
// Only interested in functions that are test functions
7057
if let ItemKind::Fn(_, _, body_id) = item.kind
7158
&& is_in_test_function(cx.tcx, item.hir_id())
7259
{
7360
let body = cx.tcx.hir().body(body_id);
74-
let typ = cx.tcx.typeck(item.owner_id);
75-
let panic_span = FindPanicUnwrap::find_span(cx, typ, body);
61+
let typeck_results = cx.tcx.typeck(item.owner_id);
62+
let panic_span = SearchPanicIntraFunction::find_span(cx, typeck_results, body);
7663
if panic_span.is_none() {
77-
// No way to panic for this test.
78-
#[expect(clippy::collapsible_span_lint_calls, reason = "rust-clippy#7797")]
64+
// No way to panic for this test function
7965
span_lint_and_then(
8066
cx,
8167
TEST_WITHOUT_FAIL_CASE,
8268
item.span,
83-
"this function marked with #[test] has no way to fail",
69+
"this function marked with `#[test]` cannot fail and will always succeed",
8470
|diag| {
85-
diag.note("make sure that something is checked in this test");
71+
diag.note("consider adding assertions or panics to test failure cases");
8672
},
8773
);
8874
}
8975
}
9076
}
9177
}
9278

93-
struct FindPanicUnwrap<'a, 'tcx> {
79+
/// Visitor that searches for expressions that could cause a panic, such as `panic!`,
80+
/// `assert!`, `unwrap()`, or calls to functions that can panic.
81+
struct SearchPanicIntraFunction<'a, 'tcx> {
82+
/// The lint context
9483
cx: &'a LateContext<'tcx>,
84+
/// Whether we are inside a constant context
9585
is_const: bool,
86+
/// The span where a panic was found
9687
panic_span: Option<Span>,
88+
/// Type checking results for the current body
9789
typeck_results: &'tcx ty::TypeckResults<'tcx>,
90+
/// Set of function `DefId`s that have been visited to avoid infinite recursion
91+
visited_functions: FxHashSet<DefId>,
9892
}
9993

100-
impl<'a, 'tcx> FindPanicUnwrap<'a, 'tcx> {
94+
impl<'a, 'tcx> SearchPanicIntraFunction<'a, 'tcx> {
95+
/// Creates a new `FindPanicUnwrap` visitor
96+
pub fn new(cx: &'a LateContext<'tcx>, typeck_results: &'tcx ty::TypeckResults<'tcx>) -> Self {
97+
Self {
98+
cx,
99+
is_const: false,
100+
panic_span: None,
101+
typeck_results,
102+
visited_functions: FxHashSet::default(),
103+
}
104+
}
105+
106+
/// Searches for a way to panic in the given body and returns the span if found
101107
pub fn find_span(
102108
cx: &'a LateContext<'tcx>,
103109
typeck_results: &'tcx ty::TypeckResults<'tcx>,
104110
body: impl Visitable<'tcx>,
105111
) -> Option<(Span, bool)> {
106-
let mut vis = Self {
107-
cx,
108-
is_const: false,
109-
panic_span: None,
110-
typeck_results,
111-
};
112-
body.visit(&mut vis);
113-
vis.panic_span.map(|el| (el, vis.is_const))
112+
let mut visitor = Self::new(cx, typeck_results);
113+
body.visit(&mut visitor);
114+
visitor.panic_span.map(|span| (span, visitor.is_const))
115+
}
116+
117+
/// Checks the called function to see if it contains a panic
118+
fn check_called_function(&mut self, def_id: DefId, span: Span) {
119+
// Avoid infinite recursion by checking if we've already visited this function
120+
if !self.visited_functions.insert(def_id) {
121+
return;
122+
}
123+
124+
if def_id.is_local() {
125+
let hir = self.cx.tcx.hir();
126+
if let Some(local_def_id) = def_id.as_local() {
127+
if let Some(body) = hir.maybe_body_owned_by(local_def_id) {
128+
let typeck_results = self.cx.tcx.typeck(local_def_id);
129+
let mut new_visitor = SearchPanicIntraFunction {
130+
cx: self.cx,
131+
is_const: false,
132+
panic_span: None,
133+
typeck_results,
134+
visited_functions: self.visited_functions.clone(),
135+
};
136+
body.visit(&mut new_visitor);
137+
if let Some(panic_span) = new_visitor.panic_span {
138+
self.panic_span = Some(panic_span);
139+
}
140+
}
141+
}
142+
} else {
143+
// For external functions, assume they can panic
144+
self.panic_span = Some(span);
145+
}
114146
}
115147
}
116148

117-
impl<'a, 'tcx> Visitor<'tcx> for FindPanicUnwrap<'a, 'tcx> {
149+
impl<'a, 'tcx> Visitor<'tcx> for SearchPanicIntraFunction<'a, 'tcx> {
118150
type NestedFilter = nested_filter::OnlyBodies;
119151

120152
fn visit_expr(&mut self, expr: &'tcx Expr<'_>) {
121153
if self.panic_span.is_some() {
154+
// If we've already found a panic, no need to continue
122155
return;
123156
}
124157

125-
if let Some(macro_call) = root_macro_call_first_node(self.cx, expr) {
126-
if is_panic(self.cx, macro_call.def_id)
127-
|| matches!(
128-
self.cx.tcx.item_name(macro_call.def_id).as_str(),
129-
"assert" | "assert_eq" | "assert_ne"
130-
)
131-
{
132-
self.is_const = self.cx.tcx.hir().is_inside_const_context(expr.hir_id);
133-
self.panic_span = Some(macro_call.span);
134-
}
135-
}
158+
match expr.kind {
159+
ExprKind::Call(callee, args) => {
160+
// Function call
161+
if let ExprKind::Path(ref qpath) = callee.kind {
162+
if let Res::Def(_, def_id) = self.cx.qpath_res(qpath, callee.hir_id) {
163+
self.check_called_function(def_id, expr.span);
164+
if self.panic_span.is_some() {
165+
return;
166+
}
167+
}
168+
}
169+
// Visit callee and arguments
170+
self.visit_expr(callee);
171+
for arg in args {
172+
self.visit_expr(arg);
173+
}
174+
},
175+
ExprKind::MethodCall(_, receiver, args, _) => {
176+
// Method call
177+
if let Some(def_id) = self.typeck_results.type_dependent_def_id(expr.hir_id) {
178+
self.check_called_function(def_id, expr.span);
179+
if self.panic_span.is_some() {
180+
return;
181+
}
182+
}
183+
// Visit receiver and arguments
184+
self.visit_expr(receiver);
185+
for arg in args {
186+
self.visit_expr(arg);
187+
}
188+
},
189+
_ => {
190+
if let Some(macro_call) = root_macro_call_first_node(self.cx, expr) {
191+
let macro_name = self.cx.tcx.item_name(macro_call.def_id);
192+
// Skip macros like `println!`, `print!`, `eprintln!`, `eprint!`.
193+
// This is a special case, these macros can panic, but it is very unlikely
194+
// that this is intended. In the name of reducing false positiveness we are
195+
// giving out soundness.
196+
//
197+
// This decision can be justified as it is highly unlikely that the tool is sound
198+
// without this additional check, and with this we are reducing the number of false
199+
// positives.
200+
if matches!(macro_name.as_str(), "println" | "print" | "eprintln" | "eprint" | "dbg") {
201+
return;
202+
}
203+
if is_panic(self.cx, macro_call.def_id)
204+
|| matches!(macro_name.as_str(), "assert" | "assert_eq" | "assert_ne")
205+
{
206+
self.is_const = self.cx.tcx.hir().is_inside_const_context(expr.hir_id);
207+
self.panic_span = Some(macro_call.span);
208+
return;
209+
}
210+
}
136211

137-
// check for `unwrap` and `expect` for both `Option` and `Result`
138-
if let Some(arglists) = method_chain_args(expr, &["unwrap"]).or(method_chain_args(expr, &["expect"])) {
139-
let receiver_ty = self.typeck_results.expr_ty(arglists[0].0).peel_refs();
140-
if is_type_diagnostic_item(self.cx, receiver_ty, sym::Option)
141-
|| is_type_diagnostic_item(self.cx, receiver_ty, sym::Result)
142-
{
143-
self.panic_span = Some(expr.span);
144-
}
145-
}
212+
// Check for `unwrap` and `expect` method calls
213+
if let Some(arglists) = method_chain_args(expr, &["unwrap"]).or(method_chain_args(expr, &["expect"])) {
214+
let receiver_ty = self.typeck_results.expr_ty(arglists[0].0).peel_refs();
215+
if is_type_diagnostic_item(self.cx, receiver_ty, sym::Option)
216+
|| is_type_diagnostic_item(self.cx, receiver_ty, sym::Result)
217+
{
218+
self.panic_span = Some(expr.span);
219+
return;
220+
}
221+
}
146222

147-
// and check sub-expressions
148-
intravisit::walk_expr(self, expr);
223+
intravisit::walk_expr(self, expr);
224+
},
225+
}
149226
}
150227

151-
// Panics in const blocks will cause compilation to fail.
228+
// Do not visit anonymous constants, as panics in const contexts are compile-time errors
152229
fn visit_anon_const(&mut self, _: &'tcx AnonConst) {}
153230

154231
fn nested_visit_map(&mut self) -> Self::Map {

tests/ui/test_without_fail_case.rs

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,50 @@
33
#![allow(clippy::unnecessary_literal_unwrap)]
44
#![warn(clippy::test_without_fail_case)]
55

6-
// Should lint
6+
struct DummyStruct;
7+
8+
impl DummyStruct {
9+
fn panic_in_impl(self) {
10+
panic!("a")
11+
}
12+
13+
fn assert_in_impl(self, a: bool) {
14+
assert!(a)
15+
}
16+
17+
fn unwrap_in_impl(self, a: Option<i32>) {
18+
let _ = a.unwrap();
19+
}
20+
}
21+
722
#[test]
823
fn test_without_fail() {
9-
// This test cannot fail.
1024
let x = 5;
1125
let y = x + 2;
1226
println!("y: {}", y);
1327
}
14-
//~^ ERROR: this function marked with #[test] does not have a way to fail.
15-
//~^ NOTE: Ensure that something is being tested and asserted by this test.
28+
29+
// Should not lint
30+
#[test]
31+
fn impl_panic() {
32+
let dummy_struct = DummyStruct;
33+
dummy_struct.panic_in_impl();
34+
}
35+
36+
// Should not lint
37+
#[test]
38+
fn impl_assert() {
39+
let dummy_struct = DummyStruct;
40+
dummy_struct.assert_in_impl(false);
41+
}
42+
43+
// Should not lint
44+
#[test]
45+
fn impl_unwrap() {
46+
let dummy_struct = DummyStruct;
47+
let a = Some(10i32);
48+
dummy_struct.unwrap_in_impl(a);
49+
}
1650

1751
// Should not lint
1852
#[test]
@@ -27,6 +61,18 @@ fn test_implicit_panic() {
2761
implicit_panic()
2862
}
2963

64+
// Should not lint
65+
#[test]
66+
fn test_implicit_unwrap() {
67+
implicit_unwrap();
68+
}
69+
70+
// Should not lint
71+
#[test]
72+
fn test_implicit_assert() {
73+
implicit_assert();
74+
}
75+
3076
fn implicit_panic() {
3177
panic!("this is an implicit panic");
3278
}

0 commit comments

Comments
 (0)