Skip to content

Commit bfb0666

Browse files
committed
feat: configuration support added for test without fail case
1 parent d7f8256 commit bfb0666

File tree

6 files changed

+169
-46
lines changed

6 files changed

+169
-46
lines changed

clippy_config/src/conf.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
use crate::ClippyConfiguration;
22
use crate::msrvs::Msrv;
3-
use crate::types::{DisallowedPath, MacroMatcher, MatchLintBehaviour, PubUnderscoreFieldsBehaviour, Rename};
3+
use crate::types::{
4+
DEFAULT_FALLIBLE_PATHS, DEFAULT_NONFALLIBLE_PATHS, DisallowedPath, MacroMatcher, MatchLintBehaviour,
5+
PubUnderscoreFieldsBehaviour, Rename, TestWithoutFailCaseBehaviour,
6+
};
47
use rustc_errors::Applicability;
58
use rustc_session::Session;
69
use rustc_span::edit_distance::edit_distance;
@@ -628,6 +631,9 @@ define_Conf! {
628631
/// if no suggestion can be made.
629632
#[lints(indexing_slicing)]
630633
suppress_restriction_lint_in_const: bool = false,
634+
/// Lint tests to understand whether it can fail or not.
635+
#[lints(test_without_fail_case)]
636+
test_without_fail_case: TestWithoutFailCaseBehaviour = TestWithoutFailCaseBehaviour::default(),
631637
/// The maximum size of objects (in bytes) that will be linted. Larger objects are ok on the heap
632638
#[lints(boxed_local, useless_vec)]
633639
too_large_for_stack: u64 = 200,
@@ -724,6 +730,14 @@ pub fn lookup_conf_file() -> io::Result<(Option<PathBuf>, Vec<String>)> {
724730
fn deserialize(file: &SourceFile) -> TryConf {
725731
match toml::de::Deserializer::new(file.src.as_ref().unwrap()).deserialize_map(ConfVisitor(file)) {
726732
Ok(mut conf) => {
733+
extend_vec_if_indicator_present(
734+
&mut conf.conf.test_without_fail_case.fallible_paths,
735+
DEFAULT_FALLIBLE_PATHS,
736+
);
737+
extend_vec_if_indicator_present(
738+
&mut conf.conf.test_without_fail_case.non_fallible_paths,
739+
DEFAULT_NONFALLIBLE_PATHS,
740+
);
727741
extend_vec_if_indicator_present(&mut conf.conf.disallowed_names, DEFAULT_DISALLOWED_NAMES);
728742
extend_vec_if_indicator_present(&mut conf.conf.allowed_prefixes, DEFAULT_ALLOWED_PREFIXES);
729743
extend_vec_if_indicator_present(

clippy_config/src/types.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,55 @@ pub enum PubUnderscoreFieldsBehaviour {
130130
PubliclyExported,
131131
AllPubFields,
132132
}
133+
134+
/// Default paths considered as fallible for `test_without_fail_case` lint.
135+
pub(crate) const DEFAULT_FALLIBLE_PATHS: &[&str] =
136+
&["core::panic", "core::assert", "core::assert_eq", "core::assert_ne"];
137+
/// Default paths considered as non-fallible for `test_without_fail_case` lint.
138+
pub(crate) const DEFAULT_NONFALLIBLE_PATHS: &[&str] =
139+
&["std::print", "std::println", "std::dbg", "std::eprint", "std::eprintln"];
140+
141+
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
142+
pub struct TestWithoutFailCaseBehaviour {
143+
/// Indexing into a slice is fallible, and thus the a test that is doing that
144+
/// can fail. If set true, the lint consider indexing into a slice a failable case
145+
/// and won't lint tests that has some sort of indexing. This analysis still done
146+
/// in a interfunctional manner. Meaning that any indexing opeartion done inside of
147+
/// a function that the test calls will still mark the test fallible.
148+
///
149+
/// By default this is set to `false`. That is becuase from a usability perspective,
150+
/// indexing an array is not the intended way to fail a test. So setting this `true`
151+
/// reduces false positives but makes the analysis more focused on possible byproducts
152+
/// of a test. That is the set of operations to get the point we assert something rather
153+
/// than the existance of asserting that thing.
154+
pub include_slice_indexing: bool,
155+
/// List of full paths of macros and functions, that can fail. If a test, or a function
156+
/// that the test calls contains any one of these macros, we will mark the test fallible.
157+
/// By default this macros are defined as `assert!`, `assert_eq!`, `panic!`.
158+
pub fallible_paths: Vec<String>,
159+
/// List of full paths of macros and functions, that we want to mark as not going to fail.
160+
/// This allows us to make the lint more focused on actual short comings of our test suite
161+
/// by marking common routines non-fallible, even though they are fallible.
162+
/// By default this list contains: `println!`, `print!`, `dbg!`.
163+
pub non_fallible_paths: Vec<String>,
164+
}
165+
166+
impl TestWithoutFailCaseBehaviour {
167+
pub fn new(include_slice_indexing: bool, fallible_paths: Vec<String>, non_fallible_paths: Vec<String>) -> Self {
168+
Self {
169+
include_slice_indexing,
170+
fallible_paths,
171+
non_fallible_paths,
172+
}
173+
}
174+
}
175+
176+
impl Default for TestWithoutFailCaseBehaviour {
177+
fn default() -> Self {
178+
Self {
179+
include_slice_indexing: false,
180+
fallible_paths: DEFAULT_FALLIBLE_PATHS.iter().map(|s| s.to_string()).collect(),
181+
non_fallible_paths: DEFAULT_NONFALLIBLE_PATHS.iter().map(|a| a.to_string()).collect(),
182+
}
183+
}
184+
}

clippy_lints/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,6 @@ pub fn register_lints(store: &mut rustc_lint::LintStore, conf: &'static Conf) {
950950
store.register_late_pass(move |_| Box::new(unused_trait_names::UnusedTraitNames::new(conf)));
951951
store.register_late_pass(|_| Box::new(manual_ignore_case_cmp::ManualIgnoreCaseCmp));
952952
store.register_late_pass(|_| Box::new(unnecessary_literal_bound::UnnecessaryLiteralBound));
953-
store.register_late_pass(|_| Box::new(test_without_fail_case::TestWithoutFailCase));
953+
store.register_late_pass(|_| Box::new(test_without_fail_case::TestWithoutFailCase::new(conf)));
954954
// add lints here, do not remove this comment, it's used in `new_lint`
955955
}

clippy_lints/src/test_without_fail_case.rs

Lines changed: 80 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
use clippy_config::Conf;
12
use clippy_utils::diagnostics::span_lint_and_note;
2-
use clippy_utils::macros::{is_panic, root_macro_call_first_node};
3+
use clippy_utils::macros::root_macro_call_first_node;
34
use clippy_utils::ty::is_type_diagnostic_item;
45
use clippy_utils::visitors::Visitable;
56
use clippy_utils::{is_in_test_function, method_chain_args};
@@ -11,8 +12,8 @@ use rustc_hir::{AnonConst, Expr, ExprKind, Item, ItemKind};
1112
use rustc_lint::{LateContext, LateLintPass};
1213
use rustc_middle::hir::nested_filter;
1314
use rustc_middle::ty;
14-
use rustc_session::declare_lint_pass;
15-
use rustc_span::{Span, sym};
15+
use rustc_session::impl_lint_pass;
16+
use rustc_span::sym;
1617

1718
declare_clippy_lint! {
1819
/// ### What it does
@@ -46,10 +47,26 @@ declare_clippy_lint! {
4647
#[clippy::version = "1.82.0"]
4748
pub TEST_WITHOUT_FAIL_CASE,
4849
restriction,
49-
"test function cannot fail because it does not panic or assert"
50+
"test function cannot fail because it does not anyway to panic or assert"
5051
}
5152

52-
declare_lint_pass!(TestWithoutFailCase => [TEST_WITHOUT_FAIL_CASE]);
53+
pub struct TestWithoutFailCase {
54+
config: SearchConfig,
55+
}
56+
57+
impl TestWithoutFailCase {
58+
pub fn new(conf: &Conf) -> Self {
59+
Self {
60+
config: SearchConfig {
61+
indexing_into_slice_fallible: conf.test_without_fail_case.include_slice_indexing,
62+
fallible_paths: conf.test_without_fail_case.fallible_paths.iter().cloned().collect(),
63+
non_fallible_paths: conf.test_without_fail_case.non_fallible_paths.iter().cloned().collect(),
64+
},
65+
}
66+
}
67+
}
68+
69+
impl_lint_pass!(TestWithoutFailCase => [TEST_WITHOUT_FAIL_CASE]);
5370

5471
impl<'tcx> LateLintPass<'tcx> for TestWithoutFailCase {
5572
fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx Item<'tcx>) {
@@ -59,8 +76,8 @@ impl<'tcx> LateLintPass<'tcx> for TestWithoutFailCase {
5976
{
6077
let body = cx.tcx.hir().body(body_id);
6178
let typeck_results = cx.tcx.typeck(item.owner_id);
62-
let panic_span = SearchPanicIntraFunction::find_span(cx, typeck_results, body);
63-
if panic_span.is_none() {
79+
let fail_found = SearchFailIntraFunction::find_fail(cx, typeck_results, &self.config, body);
80+
if !fail_found {
6481
// No way to panic for this test function
6582
span_lint_and_note(
6683
cx,
@@ -75,43 +92,61 @@ impl<'tcx> LateLintPass<'tcx> for TestWithoutFailCase {
7592
}
7693
}
7794

95+
/// Set of options that user provivdes through configs, to modify the lint behaviour
96+
/// according to their repo.
97+
struct SearchConfig {
98+
/// If search should consider indexing into slice as fallible.
99+
indexing_into_slice_fallible: bool,
100+
/// Set of paths that are marked as fallible.
101+
fallible_paths: FxHashSet<String>,
102+
/// Set of paths that are marked as non fallible.
103+
non_fallible_paths: FxHashSet<String>,
104+
}
105+
78106
/// Visitor that searches for expressions that could cause a panic, such as `panic!`,
79107
/// `assert!`, `unwrap()`, or calls to functions that can panic.
80-
struct SearchPanicIntraFunction<'a, 'tcx> {
108+
struct SearchFailIntraFunction<'a, 'tcx> {
81109
/// The lint context
82110
cx: &'a LateContext<'tcx>,
83-
/// The span where a panic was found
84-
panic_span: Option<Span>,
111+
/// Whether a way to fail is found or not.
112+
fail_found: bool,
85113
/// Type checking results for the current body
86114
typeck_results: &'tcx ty::TypeckResults<'tcx>,
87115
/// Set of function `DefId`s that have been visited to avoid infinite recursion
88116
visited_functions: FxHashSet<DefId>,
117+
/// Search configs containing the set of user provided configurations.
118+
search_config: &'a SearchConfig,
89119
}
90120

91-
impl<'a, 'tcx> SearchPanicIntraFunction<'a, 'tcx> {
92-
/// Creates a new `FindPanicUnwrap` visitor
93-
pub fn new(cx: &'a LateContext<'tcx>, typeck_results: &'tcx ty::TypeckResults<'tcx>) -> Self {
121+
impl<'a, 'tcx> SearchFailIntraFunction<'a, 'tcx> {
122+
pub fn new(
123+
cx: &'a LateContext<'tcx>,
124+
typeck_results: &'tcx ty::TypeckResults<'tcx>,
125+
search_config: &'a SearchConfig,
126+
) -> Self {
94127
Self {
95128
cx,
96-
panic_span: None,
129+
fail_found: false,
97130
typeck_results,
98131
visited_functions: FxHashSet::default(),
132+
search_config,
99133
}
100134
}
101135

102136
/// Searches for a way to panic in the given body and returns the span if found
103-
pub fn find_span(
137+
pub fn find_fail(
104138
cx: &'a LateContext<'tcx>,
105139
typeck_results: &'tcx ty::TypeckResults<'tcx>,
140+
search_config: &'a SearchConfig,
106141
body: impl Visitable<'tcx>,
107-
) -> Option<Span> {
108-
let mut visitor = Self::new(cx, typeck_results);
142+
) -> bool {
143+
let mut visitor = Self::new(cx, typeck_results, search_config);
109144
body.visit(&mut visitor);
110-
visitor.panic_span
145+
visitor.fail_found
111146
}
112147

113148
/// Checks the called function to see if it contains a panic
114-
fn check_called_function(&mut self, def_id: DefId, span: Span) {
149+
fn check_called_function(&mut self, def_id: DefId) {
115150
// Avoid infinite recursion by checking if we've already visited this function
116151
if !self.visited_functions.insert(def_id) {
117152
return;
@@ -122,30 +157,31 @@ impl<'a, 'tcx> SearchPanicIntraFunction<'a, 'tcx> {
122157
if let Some(local_def_id) = def_id.as_local() {
123158
if let Some(body) = hir.maybe_body_owned_by(local_def_id) {
124159
let typeck_results = self.cx.tcx.typeck(local_def_id);
125-
let mut new_visitor = SearchPanicIntraFunction {
160+
let mut new_visitor = SearchFailIntraFunction {
126161
cx: self.cx,
127-
panic_span: None,
162+
fail_found: false,
128163
typeck_results,
129164
visited_functions: self.visited_functions.clone(),
165+
search_config: &self.search_config,
130166
};
131167
body.visit(&mut new_visitor);
132-
if let Some(panic_span) = new_visitor.panic_span {
133-
self.panic_span = Some(panic_span);
168+
if new_visitor.fail_found {
169+
self.fail_found = true;
134170
}
135171
}
136172
}
137173
} else {
138174
// For external functions, assume they can panic
139-
self.panic_span = Some(span);
175+
self.fail_found = true;
140176
}
141177
}
142178
}
143179

144-
impl<'tcx> Visitor<'tcx> for SearchPanicIntraFunction<'_, 'tcx> {
180+
impl<'tcx> Visitor<'tcx> for SearchFailIntraFunction<'_, 'tcx> {
145181
type NestedFilter = nested_filter::OnlyBodies;
146182

147183
fn visit_expr(&mut self, expr: &'tcx Expr<'_>) {
148-
if self.panic_span.is_some() {
184+
if self.fail_found {
149185
// If we've already found a panic, no need to continue
150186
return;
151187
}
@@ -154,8 +190,8 @@ impl<'tcx> Visitor<'tcx> for SearchPanicIntraFunction<'_, 'tcx> {
154190
ExprKind::Call(callee, args) => {
155191
if let ExprKind::Path(ref qpath) = callee.kind {
156192
if let Res::Def(_, def_id) = self.cx.qpath_res(qpath, callee.hir_id) {
157-
self.check_called_function(def_id, expr.span);
158-
if self.panic_span.is_some() {
193+
self.check_called_function(def_id);
194+
if self.fail_found {
159195
return;
160196
}
161197
}
@@ -167,8 +203,8 @@ impl<'tcx> Visitor<'tcx> for SearchPanicIntraFunction<'_, 'tcx> {
167203
},
168204
ExprKind::MethodCall(_, receiver, args, _) => {
169205
if let Some(def_id) = self.typeck_results.type_dependent_def_id(expr.hir_id) {
170-
self.check_called_function(def_id, expr.span);
171-
if self.panic_span.is_some() {
206+
self.check_called_function(def_id);
207+
if self.fail_found {
172208
return;
173209
}
174210
}
@@ -179,33 +215,34 @@ impl<'tcx> Visitor<'tcx> for SearchPanicIntraFunction<'_, 'tcx> {
179215
},
180216
_ => {
181217
if let Some(macro_call) = root_macro_call_first_node(self.cx, expr) {
182-
let macro_name = self.cx.tcx.item_name(macro_call.def_id);
183-
// Skip macros like `println!`, `print!`, `eprintln!`, `eprint!`.
184-
// This is a special case, these macros can panic, but it is very unlikely
185-
// that this is intended. In the name of reducing false positiveness we are
186-
// giving out soundness.
218+
let macro_with_path = self.cx.tcx.def_path_str(macro_call.def_id);
219+
// Skip macros that are defined as `non_fallible` in the clippy.toml file.
220+
// Some examples that would fit here can be `println!`, `print!`, `eprintln!`,
221+
// `eprint!`. This is a special case, these macros can panic, but it is very
222+
// unlikely that this is intended as the tests assertion. In the name of
223+
// reducing false negatives we are giving out soundness.
187224
//
188-
// This decision can be justified as it is highly unlikely that the tool is sound
225+
// This decision can be justified as it is highly unlikely that this lint is sound
189226
// without this additional check, and with this we are reducing the number of false
190-
// positives.
191-
if matches!(macro_name.as_str(), "println" | "print" | "eprintln" | "eprint" | "dbg") {
227+
// negatives.
228+
if self.search_config.non_fallible_paths.contains(&macro_with_path) {
192229
return;
193230
}
194-
if is_panic(self.cx, macro_call.def_id)
195-
|| matches!(macro_name.as_str(), "assert" | "assert_eq" | "assert_ne")
196-
{
197-
self.panic_span = Some(macro_call.span);
231+
232+
if self.search_config.fallible_paths.contains(&macro_with_path) {
233+
self.fail_found = true;
198234
return;
199235
}
200236
}
201237

238+
// TODO: also make these two configurable.
202239
// Check for `unwrap` and `expect` method calls
203240
if let Some(arglists) = method_chain_args(expr, &["unwrap"]).or(method_chain_args(expr, &["expect"])) {
204241
let receiver_ty = self.typeck_results.expr_ty(arglists[0].0).peel_refs();
205242
if is_type_diagnostic_item(self.cx, receiver_ty, sym::Option)
206243
|| is_type_diagnostic_item(self.cx, receiver_ty, sym::Result)
207244
{
208-
self.panic_span = Some(expr.span);
245+
self.fail_found = true;
209246
return;
210247
}
211248
}

tests/ui/test_without_fail_case.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ fn test_implicit_assert() {
7373
implicit_assert();
7474
}
7575

76+
// Should lint with default config.
77+
#[test]
78+
fn test_slice_index() {
79+
let a = [1, 2, 3, 4, 5, 6];
80+
// indexing into slice, this can fail but by default check for this is disabled.
81+
let b = a[0];
82+
}
83+
7684
fn implicit_panic() {
7785
panic!("this is an implicit panic");
7886
}

tests/ui/test_without_fail_case.stderr

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,17 @@ LL | | }
1212
= note: `-D clippy::test-without-fail-case` implied by `-D warnings`
1313
= help: to override `-D warnings` add `#[allow(clippy::test_without_fail_case)]`
1414

15-
error: aborting due to 1 previous error
15+
error: this function marked with `#[test]` cannot fail and will always succeed
16+
--> tests/ui/test_without_fail_case.rs:78:1
17+
|
18+
LL | / fn test_slice_index() {
19+
LL | | let a = [1, 2, 3, 4, 5, 6];
20+
LL | | // indexing into slice, this can fail but by default check for this is disabled.
21+
LL | | let b = a[0];
22+
LL | | }
23+
| |_^
24+
|
25+
= note: consider adding assertions or panics to test failure cases
26+
27+
error: aborting due to 2 previous errors
1628

0 commit comments

Comments
 (0)