Skip to content

Commit 665fd52

Browse files
committed
Auto merge of rust-lang#11872 - llogiq:test-attr-in-doctest, r=xFrednet
add lint against unit tests in doctests During RustLab, Alice Ryhl brought to my attention that the Andoid team stumbled over the fact that if one attempts to write a unit test within a doctest, it will be summarily ignored. So this lint should help people wondering why their tests won't run. --- changelog: New lint: [`test_attr_in_doctest`] [rust-lang#11872](rust-lang/rust-clippy#11872)
2 parents 8b0bf64 + 0ba9bf9 commit 665fd52

6 files changed

+172
-17
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5545,6 +5545,7 @@ Released 2018-09-13
55455545
[`tabs_in_doc_comments`]: https://rust-lang.github.io/rust-clippy/master/index.html#tabs_in_doc_comments
55465546
[`temporary_assignment`]: https://rust-lang.github.io/rust-clippy/master/index.html#temporary_assignment
55475547
[`temporary_cstring_as_ptr`]: https://rust-lang.github.io/rust-clippy/master/index.html#temporary_cstring_as_ptr
5548+
[`test_attr_in_doctest`]: https://rust-lang.github.io/rust-clippy/master/index.html#test_attr_in_doctest
55485549
[`tests_outside_test_module`]: https://rust-lang.github.io/rust-clippy/master/index.html#tests_outside_test_module
55495550
[`to_digit_is_some`]: https://rust-lang.github.io/rust-clippy/master/index.html#to_digit_is_some
55505551
[`to_string_in_display`]: https://rust-lang.github.io/rust-clippy/master/index.html#to_string_in_display

clippy_lints/src/declared_lints.rs

+1
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ pub(crate) static LINTS: &[&crate::LintInfo] = &[
140140
crate::doc::MISSING_SAFETY_DOC_INFO,
141141
crate::doc::NEEDLESS_DOCTEST_MAIN_INFO,
142142
crate::doc::SUSPICIOUS_DOC_COMMENTS_INFO,
143+
crate::doc::TEST_ATTR_IN_DOCTEST_INFO,
143144
crate::doc::UNNECESSARY_SAFETY_DOC_INFO,
144145
crate::double_parens::DOUBLE_PARENS_INFO,
145146
crate::drop_forget_ref::DROP_NON_DROP_INFO,

clippy_lints/src/doc/mod.rs

+39-1
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,39 @@ declare_clippy_lint! {
199199
"presence of `fn main() {` in code examples"
200200
}
201201

202+
declare_clippy_lint! {
203+
/// ### What it does
204+
/// Checks for `#[test]` in doctests unless they are marked with
205+
/// either `ignore`, `no_run` or `compile_fail`.
206+
///
207+
/// ### Why is this bad?
208+
/// Code in examples marked as `#[test]` will somewhat
209+
/// surprisingly not be run by `cargo test`. If you really want
210+
/// to show how to test stuff in an example, mark it `no_run` to
211+
/// make the intent clear.
212+
///
213+
/// ### Examples
214+
/// ```no_run
215+
/// /// An example of a doctest with a `main()` function
216+
/// ///
217+
/// /// # Examples
218+
/// ///
219+
/// /// ```
220+
/// /// #[test]
221+
/// /// fn equality_works() {
222+
/// /// assert_eq!(1_u8, 1);
223+
/// /// }
224+
/// /// ```
225+
/// fn test_attr_in_doctest() {
226+
/// unimplemented!();
227+
/// }
228+
/// ```
229+
#[clippy::version = "1.40.0"]
230+
pub TEST_ATTR_IN_DOCTEST,
231+
suspicious,
232+
"presence of `#[test]` in code examples"
233+
}
234+
202235
declare_clippy_lint! {
203236
/// ### What it does
204237
/// Detects the syntax `['foo']` in documentation comments (notice quotes instead of backticks)
@@ -329,6 +362,7 @@ impl_lint_pass!(Documentation => [
329362
MISSING_ERRORS_DOC,
330363
MISSING_PANICS_DOC,
331364
NEEDLESS_DOCTEST_MAIN,
365+
TEST_ATTR_IN_DOCTEST,
332366
UNNECESSARY_SAFETY_DOC,
333367
SUSPICIOUS_DOC_COMMENTS
334368
]);
@@ -515,6 +549,7 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
515549
let mut in_heading = false;
516550
let mut is_rust = false;
517551
let mut no_test = false;
552+
let mut ignore = false;
518553
let mut edition = None;
519554
let mut ticks_unbalanced = false;
520555
let mut text_to_check: Vec<(CowStr<'_>, Range<usize>)> = Vec::new();
@@ -530,6 +565,8 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
530565
break;
531566
} else if item == "no_test" {
532567
no_test = true;
568+
} else if item == "no_run" || item == "compile_fail" {
569+
ignore = true;
533570
}
534571
if let Some(stripped) = item.strip_prefix("edition") {
535572
is_rust = true;
@@ -543,6 +580,7 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
543580
End(CodeBlock(_)) => {
544581
in_code = false;
545582
is_rust = false;
583+
ignore = false;
546584
},
547585
Start(Link(_, url, _)) => in_link = Some(url),
548586
End(Link(..)) => in_link = None,
@@ -596,7 +634,7 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
596634
if in_code {
597635
if is_rust && !no_test {
598636
let edition = edition.unwrap_or_else(|| cx.tcx.sess.edition());
599-
needless_doctest_main::check(cx, &text, edition, range.clone(), fragments);
637+
needless_doctest_main::check(cx, &text, edition, range.clone(), fragments, ignore);
600638
}
601639
} else {
602640
if in_link.is_some() {

clippy_lints/src/doc/needless_doctest_main.rs

+51-16
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use std::ops::Range;
22
use std::{io, thread};
33

4-
use crate::doc::NEEDLESS_DOCTEST_MAIN;
4+
use crate::doc::{NEEDLESS_DOCTEST_MAIN, TEST_ATTR_IN_DOCTEST};
55
use clippy_utils::diagnostics::span_lint;
6-
use rustc_ast::{Async, Fn, FnRetTy, ItemKind};
6+
use rustc_ast::{Async, Fn, FnRetTy, Item, ItemKind};
77
use rustc_data_structures::sync::Lrc;
88
use rustc_errors::emitter::EmitterWriter;
99
use rustc_errors::Handler;
@@ -13,14 +13,33 @@ use rustc_parse::parser::ForceCollect;
1313
use rustc_session::parse::ParseSess;
1414
use rustc_span::edition::Edition;
1515
use rustc_span::source_map::{FilePathMapping, SourceMap};
16-
use rustc_span::{sym, FileName};
16+
use rustc_span::{sym, FileName, Pos};
1717

1818
use super::Fragments;
1919

20-
pub fn check(cx: &LateContext<'_>, text: &str, edition: Edition, range: Range<usize>, fragments: Fragments<'_>) {
21-
fn has_needless_main(code: String, edition: Edition) -> bool {
20+
fn get_test_spans(item: &Item, test_attr_spans: &mut Vec<Range<usize>>) {
21+
test_attr_spans.extend(
22+
item.attrs
23+
.iter()
24+
.find(|attr| attr.has_name(sym::test))
25+
.map(|attr| attr.span.lo().to_usize()..item.ident.span.hi().to_usize()),
26+
);
27+
}
28+
29+
pub fn check(
30+
cx: &LateContext<'_>,
31+
text: &str,
32+
edition: Edition,
33+
range: Range<usize>,
34+
fragments: Fragments<'_>,
35+
ignore: bool,
36+
) {
37+
// return whether the code contains a needless `fn main` plus a vector of byte position ranges
38+
// of all `#[test]` attributes in not ignored code examples
39+
fn check_code_sample(code: String, edition: Edition, ignore: bool) -> (bool, Vec<Range<usize>>) {
2240
rustc_driver::catch_fatal_errors(|| {
2341
rustc_span::create_session_globals_then(edition, || {
42+
let mut test_attr_spans = vec![];
2443
let filename = FileName::anon_source_code(&code);
2544

2645
let fallback_bundle =
@@ -35,17 +54,21 @@ pub fn check(cx: &LateContext<'_>, text: &str, edition: Edition, range: Range<us
3554
Ok(p) => p,
3655
Err(errs) => {
3756
drop(errs);
38-
return false;
57+
return (false, test_attr_spans);
3958
},
4059
};
4160

4261
let mut relevant_main_found = false;
62+
let mut eligible = true;
4363
loop {
4464
match parser.parse_item(ForceCollect::No) {
4565
Ok(Some(item)) => match &item.kind {
4666
ItemKind::Fn(box Fn {
4767
sig, body: Some(block), ..
4868
}) if item.ident.name == sym::main => {
69+
if !ignore {
70+
get_test_spans(&item, &mut test_attr_spans);
71+
}
4972
let is_async = matches!(sig.header.asyncness, Async::Yes { .. });
5073
let returns_nothing = match &sig.decl.output {
5174
FnRetTy::Default(..) => true,
@@ -58,27 +81,34 @@ pub fn check(cx: &LateContext<'_>, text: &str, edition: Edition, range: Range<us
5881
relevant_main_found = true;
5982
} else {
6083
// This main function should not be linted, we're done
61-
return false;
84+
eligible = false;
85+
}
86+
},
87+
// Another function was found; this case is ignored for needless_doctest_main
88+
ItemKind::Fn(box Fn { .. }) => {
89+
eligible = false;
90+
if !ignore {
91+
get_test_spans(&item, &mut test_attr_spans);
6292
}
6393
},
6494
// Tests with one of these items are ignored
6595
ItemKind::Static(..)
6696
| ItemKind::Const(..)
6797
| ItemKind::ExternCrate(..)
68-
| ItemKind::ForeignMod(..)
69-
// Another function was found; this case is ignored
70-
| ItemKind::Fn(..) => return false,
98+
| ItemKind::ForeignMod(..) => {
99+
eligible = false;
100+
},
71101
_ => {},
72102
},
73103
Ok(None) => break,
74104
Err(e) => {
75105
e.cancel();
76-
return false;
106+
return (false, test_attr_spans);
77107
},
78108
}
79109
}
80110

81-
relevant_main_found
111+
(relevant_main_found & eligible, test_attr_spans)
82112
})
83113
})
84114
.ok()
@@ -90,11 +120,16 @@ pub fn check(cx: &LateContext<'_>, text: &str, edition: Edition, range: Range<us
90120
// Because of the global session, we need to create a new session in a different thread with
91121
// the edition we need.
92122
let text = text.to_owned();
93-
if thread::spawn(move || has_needless_main(text, edition))
123+
let (has_main, test_attr_spans) = thread::spawn(move || check_code_sample(text, edition, ignore))
94124
.join()
95-
.expect("thread::spawn failed")
96-
&& let Some(span) = fragments.span(cx, range.start..range.end - trailing_whitespace)
97-
{
125+
.expect("thread::spawn failed");
126+
if has_main && let Some(span) = fragments.span(cx, range.start..range.end - trailing_whitespace) {
98127
span_lint(cx, NEEDLESS_DOCTEST_MAIN, span, "needless `fn main` in doctest");
99128
}
129+
for span in test_attr_spans {
130+
let span = (range.start + span.start)..(range.start + span.end);
131+
if let Some(span) = fragments.span(cx, span) {
132+
span_lint(cx, TEST_ATTR_IN_DOCTEST, span, "unit tests in doctest are not executed");
133+
}
134+
}
100135
}

tests/ui/test_attr_in_doctest.rs

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/// This is a test for `#[test]` in doctests
2+
///
3+
/// # Examples
4+
///
5+
/// ```
6+
/// #[test]
7+
/// fn should_be_linted() {
8+
/// assert_eq!(1, 1);
9+
/// }
10+
/// ```
11+
///
12+
/// Make sure we catch multiple tests in one example,
13+
/// and show that we really parse the attr:
14+
///
15+
/// ```
16+
/// #[test]
17+
/// fn should_also_be_linted() {
18+
/// #[cfg(test)]
19+
/// assert!(true);
20+
/// }
21+
///
22+
/// #[test]
23+
/// fn should_be_linted_too() {
24+
/// assert_eq!("#[test]", "
25+
/// #[test]
26+
/// ");
27+
/// }
28+
/// ```
29+
///
30+
/// We don't catch examples that aren't run:
31+
///
32+
/// ```ignore
33+
/// #[test]
34+
/// fn ignored() { todo!() }
35+
/// ```
36+
///
37+
/// ```no_run
38+
/// #[test]
39+
/// fn ignored() { todo!() }
40+
/// ```
41+
///
42+
/// ```compile_fail
43+
/// #[test]
44+
/// fn ignored() { Err(()) }
45+
/// ```
46+
///
47+
/// ```txt
48+
/// #[test]
49+
/// fn not_even_rust() { panic!("Ouch") }
50+
/// ```
51+
fn test_attr_in_doctests() {}

tests/ui/test_attr_in_doctest.stderr

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
error: unit tests in doctest are not executed
2+
--> $DIR/test_attr_in_doctest.rs:6:5
3+
|
4+
LL | /// #[test]
5+
| _____^
6+
LL | | /// fn should_be_linted() {
7+
| |_______________________^
8+
|
9+
= note: `-D clippy::test-attr-in-doctest` implied by `-D warnings`
10+
= help: to override `-D warnings` add `#[allow(clippy::test_attr_in_doctest)]`
11+
12+
error: unit tests in doctest are not executed
13+
--> $DIR/test_attr_in_doctest.rs:16:5
14+
|
15+
LL | /// #[test]
16+
| _____^
17+
LL | | /// fn should_also_be_linted() {
18+
| |____________________________^
19+
20+
error: unit tests in doctest are not executed
21+
--> $DIR/test_attr_in_doctest.rs:22:5
22+
|
23+
LL | /// #[test]
24+
| _____^
25+
LL | | /// fn should_be_linted_too() {
26+
| |___________________________^
27+
28+
error: aborting due to 3 previous errors
29+

0 commit comments

Comments
 (0)