Skip to content

Commit 8eed350

Browse files
New lint: redundant_test_prefix (rust-lang#13710)
This PR has started as an effort to proceed from the feedback in rust-lang/rust-clippy#12861. - Checks test functions (functions marked with `#[test]` annotation) for redundant "test_" prefix. - Auto-fix is supported (and handles collisions gracefully, see below). - If removing "test_" prefix from, say, `test_foo()` results in a name collision (either because function `foo()` is already defined within the current scope, or because the `foo()` call exists within function -- thus creating an unwanted recursion), lint suggests function rename, warning the user that a simple trimming of `test_` prefix will result in a name collision. - If removing "test_" prefix results in invalid identifier (consider `test_const`, `test_`, `test_42`), then again no auto-fix is suggested, user is asked to rename function, with a note that a simple prefix trimming will result in an invalid function name. (`Applicability::HasPlaceholders` is used and user is suggested to: drop `test_` prefix + add `_works` suffix, i.e. `test_foo` becomes `foo_works` -- but again, user has to apply those changes manually). - If trimmed version of the function name is a valid identifier, doesn't result in name collision or unwanted recursion, then user is able to run auto-fix. fixes rust-lang/rust-clippy#8931 changelog: new lint: [`redundant_test_prefix`]
2 parents 08c78e0 + e2422a6 commit 8eed350

10 files changed

+1151
-1
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -6116,6 +6116,7 @@ Released 2018-09-13
61166116
[`redundant_pub_crate`]: https://rust-lang.github.io/rust-clippy/master/index.html#redundant_pub_crate
61176117
[`redundant_slicing`]: https://rust-lang.github.io/rust-clippy/master/index.html#redundant_slicing
61186118
[`redundant_static_lifetimes`]: https://rust-lang.github.io/rust-clippy/master/index.html#redundant_static_lifetimes
6119+
[`redundant_test_prefix`]: https://rust-lang.github.io/rust-clippy/master/index.html#redundant_test_prefix
61196120
[`redundant_type_annotations`]: https://rust-lang.github.io/rust-clippy/master/index.html#redundant_type_annotations
61206121
[`ref_as_ptr`]: https://rust-lang.github.io/rust-clippy/master/index.html#ref_as_ptr
61216122
[`ref_binding_to_reference`]: https://rust-lang.github.io/rust-clippy/master/index.html#ref_binding_to_reference

clippy_lints/src/declared_lints.rs

+1
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,7 @@ pub static LINTS: &[&crate::LintInfo] = &[
667667
crate::redundant_slicing::DEREF_BY_SLICING_INFO,
668668
crate::redundant_slicing::REDUNDANT_SLICING_INFO,
669669
crate::redundant_static_lifetimes::REDUNDANT_STATIC_LIFETIMES_INFO,
670+
crate::redundant_test_prefix::REDUNDANT_TEST_PREFIX_INFO,
670671
crate::redundant_type_annotations::REDUNDANT_TYPE_ANNOTATIONS_INFO,
671672
crate::ref_option_ref::REF_OPTION_REF_INFO,
672673
crate::ref_patterns::REF_PATTERNS_INFO,

clippy_lints/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ mod redundant_locals;
320320
mod redundant_pub_crate;
321321
mod redundant_slicing;
322322
mod redundant_static_lifetimes;
323+
mod redundant_test_prefix;
323324
mod redundant_type_annotations;
324325
mod ref_option_ref;
325326
mod ref_patterns;
@@ -984,5 +985,6 @@ pub fn register_lints(store: &mut rustc_lint::LintStore, conf: &'static Conf) {
984985
store.register_late_pass(move |_| Box::new(non_std_lazy_statics::NonStdLazyStatic::new(conf)));
985986
store.register_late_pass(|_| Box::new(manual_option_as_slice::ManualOptionAsSlice::new(conf)));
986987
store.register_late_pass(|_| Box::new(single_option_map::SingleOptionMap));
988+
store.register_late_pass(move |_| Box::new(redundant_test_prefix::RedundantTestPrefix));
987989
// add lints here, do not remove this comment, it's used in `new_lint`
988990
}
+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
use clippy_utils::diagnostics::span_lint_and_then;
2+
use clippy_utils::is_test_function;
3+
use clippy_utils::visitors::for_each_expr;
4+
use rustc_errors::Applicability;
5+
use rustc_hir::intravisit::FnKind;
6+
use rustc_hir::{self as hir, Body, ExprKind, FnDecl};
7+
use rustc_lexer::is_ident;
8+
use rustc_lint::{LateContext, LateLintPass};
9+
use rustc_session::declare_lint_pass;
10+
use rustc_span::def_id::LocalDefId;
11+
use rustc_span::{Span, Symbol, edition};
12+
use std::borrow::Cow;
13+
use std::ops::ControlFlow;
14+
15+
declare_clippy_lint! {
16+
/// ### What it does
17+
/// Checks for test functions (functions annotated with `#[test]`) that are prefixed
18+
/// with `test_` which is redundant.
19+
///
20+
/// ### Why is this bad?
21+
/// This is redundant because test functions are already annotated with `#[test]`.
22+
/// Moreover, it clutters the output of `cargo test` since test functions are expanded as
23+
/// `module::tests::test_use_case` in the output. Without the redundant prefix, the output
24+
/// becomes `module::tests::use_case`, which is more readable.
25+
///
26+
/// ### Example
27+
/// ```no_run
28+
/// #[cfg(test)]
29+
/// mod tests {
30+
/// use super::*;
31+
///
32+
/// #[test]
33+
/// fn test_use_case() {
34+
/// // test code
35+
/// }
36+
/// }
37+
/// ```
38+
/// Use instead:
39+
/// ```no_run
40+
/// #[cfg(test)]
41+
/// mod tests {
42+
/// use super::*;
43+
///
44+
/// #[test]
45+
/// fn use_case() {
46+
/// // test code
47+
/// }
48+
/// }
49+
/// ```
50+
#[clippy::version = "1.88.0"]
51+
pub REDUNDANT_TEST_PREFIX,
52+
restriction,
53+
"redundant `test_` prefix in test function name"
54+
}
55+
56+
declare_lint_pass!(RedundantTestPrefix => [REDUNDANT_TEST_PREFIX]);
57+
58+
impl<'tcx> LateLintPass<'tcx> for RedundantTestPrefix {
59+
fn check_fn(
60+
&mut self,
61+
cx: &LateContext<'tcx>,
62+
kind: FnKind<'_>,
63+
_decl: &FnDecl<'_>,
64+
body: &'tcx Body<'_>,
65+
_span: Span,
66+
fn_def_id: LocalDefId,
67+
) {
68+
// Ignore methods and closures.
69+
let FnKind::ItemFn(ref ident, ..) = kind else {
70+
return;
71+
};
72+
73+
// Skip the lint if the function is within a macro expansion.
74+
if ident.span.from_expansion() {
75+
return;
76+
}
77+
78+
// Skip if the function name does not start with `test_`.
79+
if !ident.as_str().starts_with("test_") {
80+
return;
81+
}
82+
83+
// If the function is not a test function, skip the lint.
84+
if !is_test_function(cx.tcx, fn_def_id) {
85+
return;
86+
}
87+
88+
span_lint_and_then(
89+
cx,
90+
REDUNDANT_TEST_PREFIX,
91+
ident.span,
92+
"redundant `test_` prefix in test function name",
93+
|diag| {
94+
let non_prefixed = Symbol::intern(ident.as_str().trim_start_matches("test_"));
95+
if is_invalid_ident(non_prefixed) {
96+
// If the prefix-trimmed name is not a valid function name, do not provide an
97+
// automatic fix, just suggest renaming the function.
98+
diag.help(
99+
"consider function renaming (just removing `test_` prefix will produce invalid function name)",
100+
);
101+
} else {
102+
let (sugg, msg): (Cow<'_, str>, _) = if name_conflicts(cx, body, non_prefixed) {
103+
// If `non_prefixed` conflicts with another function in the same module/scope,
104+
// do not provide an automatic fix, but still emit a fix suggestion.
105+
(
106+
format!("{non_prefixed}_works").into(),
107+
"consider function renaming (just removing `test_` prefix will cause a name conflict)",
108+
)
109+
} else {
110+
// If `non_prefixed` is a valid identifier and does not conflict with another function,
111+
// so we can suggest an auto-fix.
112+
(non_prefixed.as_str().into(), "consider removing the `test_` prefix")
113+
};
114+
diag.span_suggestion(ident.span, msg, sugg, Applicability::MaybeIncorrect);
115+
}
116+
},
117+
);
118+
}
119+
}
120+
121+
/// Checks whether removal of the `_test` prefix from the function name will cause a name conflict.
122+
///
123+
/// There should be no other function with the same name in the same module/scope. Also, there
124+
/// should not be any function call with the same name within the body of the function, to avoid
125+
/// recursion.
126+
fn name_conflicts<'tcx>(cx: &LateContext<'tcx>, body: &'tcx Body<'_>, fn_name: Symbol) -> bool {
127+
let tcx = cx.tcx;
128+
let id = body.id().hir_id;
129+
130+
// Iterate over items in the same module/scope
131+
let (module, _module_span, _module_hir) = tcx.hir_get_module(tcx.parent_module(id));
132+
if module
133+
.item_ids
134+
.iter()
135+
.any(|item| matches!(tcx.hir_item(*item).kind, hir::ItemKind::Fn { ident, .. } if ident.name == fn_name))
136+
{
137+
// Name conflict found
138+
return true;
139+
}
140+
141+
// Also check that within the body of the function there is also no function call
142+
// with the same name (since it will result in recursion)
143+
for_each_expr(cx, body, |expr| {
144+
if let ExprKind::Path(qpath) = &expr.kind
145+
&& let Some(def_id) = cx.qpath_res(qpath, expr.hir_id).opt_def_id()
146+
&& let Some(name) = tcx.opt_item_name(def_id)
147+
&& name == fn_name
148+
{
149+
// Function call with the same name found
150+
ControlFlow::Break(())
151+
} else {
152+
ControlFlow::Continue(())
153+
}
154+
})
155+
.is_some()
156+
}
157+
158+
fn is_invalid_ident(ident: Symbol) -> bool {
159+
// The identifier is either a reserved keyword, or starts with an invalid sequence.
160+
ident.is_reserved(|| edition::LATEST_STABLE_EDITION) || !is_ident(ident.as_str())
161+
}

clippy_utils/src/lib.rs

+22-1
Original file line numberDiff line numberDiff line change
@@ -2641,7 +2641,9 @@ pub fn is_hir_ty_cfg_dependant(cx: &LateContext<'_>, ty: &hir::Ty<'_>) -> bool {
26412641

26422642
static TEST_ITEM_NAMES_CACHE: OnceLock<Mutex<FxHashMap<LocalModDefId, Vec<Symbol>>>> = OnceLock::new();
26432643

2644-
fn with_test_item_names(tcx: TyCtxt<'_>, module: LocalModDefId, f: impl Fn(&[Symbol]) -> bool) -> bool {
2644+
/// Apply `f()` to the set of test item names.
2645+
/// The names are sorted using the default `Symbol` ordering.
2646+
fn with_test_item_names(tcx: TyCtxt<'_>, module: LocalModDefId, f: impl FnOnce(&[Symbol]) -> bool) -> bool {
26452647
let cache = TEST_ITEM_NAMES_CACHE.get_or_init(|| Mutex::new(FxHashMap::default()));
26462648
let mut map: MutexGuard<'_, FxHashMap<LocalModDefId, Vec<Symbol>>> = cache.lock().unwrap();
26472649
let value = map.entry(module);
@@ -2695,6 +2697,25 @@ pub fn is_in_test_function(tcx: TyCtxt<'_>, id: HirId) -> bool {
26952697
})
26962698
}
26972699

2700+
/// Checks if `fn_def_id` has a `#[test]` attribute applied
2701+
///
2702+
/// This only checks directly applied attributes. To see if a node has a parent function marked with
2703+
/// `#[test]` use [`is_in_test_function`].
2704+
///
2705+
/// Note: Add `//@compile-flags: --test` to UI tests with a `#[test]` function
2706+
pub fn is_test_function(tcx: TyCtxt<'_>, fn_def_id: LocalDefId) -> bool {
2707+
let id = tcx.local_def_id_to_hir_id(fn_def_id);
2708+
if let Node::Item(item) = tcx.hir_node(id)
2709+
&& let ItemKind::Fn { ident, .. } = item.kind
2710+
{
2711+
with_test_item_names(tcx, tcx.parent_module(id), |names| {
2712+
names.binary_search(&ident.name).is_ok()
2713+
})
2714+
} else {
2715+
false
2716+
}
2717+
}
2718+
26982719
/// Checks if `id` has a `#[cfg(test)]` attribute applied
26992720
///
27002721
/// This only checks directly applied attributes, to see if a node is inside a `#[cfg(test)]` parent

tests/ui/redundant_test_prefix.fixed

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#![allow(dead_code)]
2+
#![warn(clippy::redundant_test_prefix)]
3+
4+
fn main() {
5+
// Normal function, no redundant prefix.
6+
}
7+
8+
fn f1() {
9+
// Normal function, no redundant prefix.
10+
}
11+
12+
fn test_f2() {
13+
// Has prefix, but no `#[test]` attribute, ignore.
14+
}
15+
16+
#[test]
17+
fn f3() {
18+
//~^ redundant_test_prefix
19+
20+
// Has prefix, has `#[test]` attribute. Not within a `#[cfg(test)]`.
21+
// No collision with other functions, should emit warning.
22+
}
23+
24+
#[cfg(test)]
25+
#[test]
26+
fn f4() {
27+
//~^ redundant_test_prefix
28+
29+
// Has prefix, has `#[test]` attribute, within a `#[cfg(test)]`.
30+
// No collision with other functions, should emit warning.
31+
}
32+
33+
mod m1 {
34+
pub fn f5() {}
35+
}
36+
37+
#[cfg(test)]
38+
#[test]
39+
fn f6() {
40+
//~^ redundant_test_prefix
41+
42+
use m1::f5;
43+
44+
f5();
45+
// Has prefix, has `#[test]` attribute, within a `#[cfg(test)]`.
46+
// No collision, has function call, but it will not result in recursion.
47+
}
48+
49+
#[cfg(test)]
50+
mod tests {
51+
use super::*;
52+
53+
#[test]
54+
fn foo() {
55+
//~^ redundant_test_prefix
56+
}
57+
58+
#[test]
59+
fn foo_with_call() {
60+
//~^ redundant_test_prefix
61+
62+
main();
63+
}
64+
65+
#[test]
66+
fn f1() {
67+
//~^ redundant_test_prefix
68+
}
69+
70+
#[test]
71+
fn f2() {
72+
//~^ redundant_test_prefix
73+
}
74+
75+
#[test]
76+
fn f3() {
77+
//~^ redundant_test_prefix
78+
}
79+
80+
#[test]
81+
fn f4() {
82+
//~^ redundant_test_prefix
83+
}
84+
85+
#[test]
86+
fn f5() {
87+
//~^ redundant_test_prefix
88+
}
89+
90+
#[test]
91+
fn f6() {
92+
//~^ redundant_test_prefix
93+
}
94+
}
95+
96+
mod tests_no_annotations {
97+
use super::*;
98+
99+
#[test]
100+
fn foo() {
101+
//~^ redundant_test_prefix
102+
}
103+
104+
#[test]
105+
fn foo_with_call() {
106+
//~^ redundant_test_prefix
107+
108+
main();
109+
}
110+
111+
#[test]
112+
fn f1() {
113+
//~^ redundant_test_prefix
114+
}
115+
116+
#[test]
117+
fn f2() {
118+
//~^ redundant_test_prefix
119+
}
120+
121+
#[test]
122+
fn f3() {
123+
//~^ redundant_test_prefix
124+
}
125+
126+
#[test]
127+
fn f4() {
128+
//~^ redundant_test_prefix
129+
}
130+
131+
#[test]
132+
fn f5() {
133+
//~^ redundant_test_prefix
134+
}
135+
136+
#[test]
137+
fn f6() {
138+
//~^ redundant_test_prefix
139+
}
140+
}
141+
142+
// This test is inspired by real test in `clippy_utils/src/sugg.rs`.
143+
// The `is_in_test_function()` checks whether any identifier within a given node's parents is
144+
// marked with `#[test]` attribute. Thus flagging false positives when nested functions are
145+
// prefixed with `test_`. Therefore `is_test_function()` has been defined in `clippy_utils`,
146+
// allowing to select only functions that are immediately marked with `#[test]` annotation.
147+
//
148+
// This test case ensures that for such nested functions no error is emitted.
149+
#[test]
150+
fn not_op() {
151+
fn test_not(foo: bool) {
152+
assert!(foo);
153+
}
154+
155+
// Use helper function
156+
test_not(true);
157+
test_not(false);
158+
}

0 commit comments

Comments
 (0)