Skip to content

Commit 29f31f1

Browse files
committed
rustdoc: Add unstable --merge-doctests=yes/no/auto flag
This is useful for changing the *default* for whether doctests are merged or not. Currently, that default is solely controlled by `edition = 2024`, which adds a high switching cost to get doctest merging. This flag allows opt-ing in even on earlier additions. Unlike the `edition = 2024` default, `--merge-doctests=yes` gives a hard error if merging fails instead of falling back to running standalone tests. The user has explicitly said they want merging, so we shouldn't silently do something else. `--merge-doctests=auto` is equivalent to the current 2024 edition behavior, but available on earlier editions.
1 parent 568b117 commit 29f31f1

18 files changed

+225
-29
lines changed

src/librustdoc/config.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ pub(crate) enum InputMode {
6363
HasFile(Input),
6464
}
6565

66+
/// Whether to run multiple doctests in the same binary.
67+
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
68+
pub(crate) enum MergeDoctests {
69+
#[default]
70+
Never,
71+
Always,
72+
Auto,
73+
}
74+
6675
/// Configuration options for rustdoc.
6776
#[derive(Clone)]
6877
pub(crate) struct Options {
@@ -121,6 +130,8 @@ pub(crate) struct Options {
121130
/// Optional path to persist the doctest executables to, defaults to a
122131
/// temporary directory if not set.
123132
pub(crate) persist_doctests: Option<PathBuf>,
133+
/// Whether to merge
134+
pub(crate) merge_doctests: MergeDoctests,
124135
/// Runtool to run doctests with
125136
pub(crate) test_runtool: Option<String>,
126137
/// Arguments to pass to the runtool
@@ -801,6 +812,8 @@ impl Options {
801812
Ok(result) => result,
802813
Err(e) => dcx.fatal(format!("--merge option error: {e}")),
803814
};
815+
let merge_doctests = parse_merge_doctests(matches, edition, dcx);
816+
tracing::debug!("merge_doctests: {merge_doctests:?}");
804817

805818
if generate_link_to_definition && (show_coverage || output_format != OutputFormat::Html) {
806819
dcx.struct_warn(
@@ -852,6 +865,7 @@ impl Options {
852865
crate_version,
853866
test_run_directory,
854867
persist_doctests,
868+
merge_doctests,
855869
test_runtool,
856870
test_runtool_args,
857871
test_builder,
@@ -1048,3 +1062,20 @@ fn parse_merge(m: &getopts::Matches) -> Result<ShouldMerge, &'static str> {
10481062
Some(_) => Err("argument to --merge must be `none`, `shared`, or `finalize`"),
10491063
}
10501064
}
1065+
1066+
fn parse_merge_doctests(
1067+
m: &getopts::Matches,
1068+
edition: Edition,
1069+
dcx: DiagCtxtHandle<'_>,
1070+
) -> MergeDoctests {
1071+
match m.opt_str("merge-doctests").as_deref() {
1072+
Some("y") | Some("yes") | Some("on") | Some("true") => MergeDoctests::Always,
1073+
Some("n") | Some("no") | Some("off") | Some("false") => MergeDoctests::Never,
1074+
Some("auto") => MergeDoctests::Auto,
1075+
None if edition < Edition::Edition2024 => MergeDoctests::Never,
1076+
None => MergeDoctests::Auto,
1077+
Some(_) => {
1078+
dcx.fatal("argument to --merge-doctests must be a boolean (true/false) or 'auto'")
1079+
}
1080+
}
1081+
}

src/librustdoc/doctest.rs

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ use tempfile::{Builder as TempFileBuilder, TempDir};
3333
use tracing::debug;
3434

3535
use self::rust::HirCollector;
36-
use crate::config::{Options as RustdocOptions, OutputFormat};
36+
use crate::config::{MergeDoctests, Options as RustdocOptions, OutputFormat};
3737
use crate::html::markdown::{ErrorCodes, Ignore, LangString, MdRelLine};
3838
use crate::lint::init_lints;
3939

@@ -265,6 +265,7 @@ pub(crate) fn run(dcx: DiagCtxtHandle<'_>, input: Input, options: RustdocOptions
265265
};
266266

267267
run_tests(
268+
dcx,
268269
opts,
269270
&rustdoc_options,
270271
&unused_extern_reports,
@@ -316,6 +317,7 @@ pub(crate) fn run(dcx: DiagCtxtHandle<'_>, input: Input, options: RustdocOptions
316317
}
317318

318319
pub(crate) fn run_tests(
320+
dcx: DiagCtxtHandle<'_>,
319321
opts: GlobalTestOptions,
320322
rustdoc_options: &Arc<RustdocOptions>,
321323
unused_extern_reports: &Arc<Mutex<Vec<UnusedExterns>>>,
@@ -368,6 +370,13 @@ pub(crate) fn run_tests(
368370
}
369371
continue;
370372
}
373+
374+
if rustdoc_options.merge_doctests == MergeDoctests::Always {
375+
let mut diag = dcx.struct_fatal("failed to merge doctests");
376+
diag.note("requested explicitly on the command line with `--merge-doctests=yes`");
377+
diag.emit();
378+
}
379+
371380
// We failed to compile all compatible tests as one so we push them into the
372381
// `standalone_tests` doctests.
373382
debug!("Failed to compile compatible doctests for edition {} all at once", edition);
@@ -645,9 +654,9 @@ fn run_test(
645654
// tested as standalone tests.
646655
return (Duration::default(), Err(TestFailure::CompileError));
647656
}
648-
if !rustdoc_options.no_capture {
649-
// If `no_capture` is disabled, then we don't display rustc's output when compiling
650-
// the merged doctests.
657+
if !rustdoc_options.no_capture && rustdoc_options.merge_doctests == MergeDoctests::Auto {
658+
// If `no_capture` is disabled, and we might fallback to standalone tests, then we don't
659+
// display rustc's output when compiling the merged doctests.
651660
compiler.stderr(Stdio::null());
652661
}
653662
// bundled tests are an rlib, loaded by a separate runner executable
@@ -728,10 +737,12 @@ fn run_test(
728737
// tested as standalone tests.
729738
return (instant.elapsed(), Err(TestFailure::CompileError));
730739
}
731-
if !rustdoc_options.no_capture {
732-
// If `no_capture` is disabled, then we don't display rustc's output when compiling
733-
// the merged doctests.
740+
if !rustdoc_options.no_capture && rustdoc_options.merge_doctests == MergeDoctests::Auto {
741+
// If `no_capture` is disabled and we're autodetecting whether to merge,
742+
// we don't display rustc's output when compiling the merged doctests.
734743
runner_compiler.stderr(Stdio::null());
744+
} else {
745+
runner_compiler.stderr(Stdio::inherit());
735746
}
736747
runner_compiler.arg("--error-format=short");
737748
debug!("compiler invocation for doctest runner: {runner_compiler:?}");
@@ -888,7 +899,7 @@ impl IndividualTestOptions {
888899

889900
DirState::Perm(path)
890901
} else {
891-
DirState::Temp(get_doctest_dir().expect("rustdoc needs a tempdir"))
902+
DirState::Temp(get_doctest_dir(options).expect("rustdoc needs a tempdir"))
892903
};
893904

894905
Self { outdir, path: test_path }
@@ -977,21 +988,20 @@ struct CreateRunnableDocTests {
977988
visited_tests: FxHashMap<(String, usize), usize>,
978989
unused_extern_reports: Arc<Mutex<Vec<UnusedExterns>>>,
979990
compiling_test_count: AtomicUsize,
980-
can_merge_doctests: bool,
991+
can_merge_doctests: MergeDoctests,
981992
}
982993

983994
impl CreateRunnableDocTests {
984995
fn new(rustdoc_options: RustdocOptions, opts: GlobalTestOptions) -> CreateRunnableDocTests {
985-
let can_merge_doctests = rustdoc_options.edition >= Edition::Edition2024;
986996
CreateRunnableDocTests {
987997
standalone_tests: Vec::new(),
988998
mergeable_tests: FxIndexMap::default(),
989-
rustdoc_options: Arc::new(rustdoc_options),
990999
opts,
9911000
visited_tests: FxHashMap::default(),
9921001
unused_extern_reports: Default::default(),
9931002
compiling_test_count: AtomicUsize::new(0),
994-
can_merge_doctests,
1003+
can_merge_doctests: rustdoc_options.merge_doctests,
1004+
rustdoc_options: Arc::new(rustdoc_options),
9951005
}
9961006
}
9971007

src/librustdoc/doctest/make.rs

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use rustc_span::{DUMMY_SP, FileName, Span, kw};
2020
use tracing::debug;
2121

2222
use super::GlobalTestOptions;
23+
use crate::config::MergeDoctests;
2324
use crate::display::Joined as _;
2425
use crate::html::markdown::LangString;
2526

@@ -41,7 +42,7 @@ pub(crate) struct BuildDocTestBuilder<'a> {
4142
source: &'a str,
4243
crate_name: Option<&'a str>,
4344
edition: Edition,
44-
can_merge_doctests: bool,
45+
can_merge_doctests: MergeDoctests,
4546
// If `test_id` is `None`, it means we're generating code for a code example "run" link.
4647
test_id: Option<String>,
4748
lang_str: Option<&'a LangString>,
@@ -55,7 +56,7 @@ impl<'a> BuildDocTestBuilder<'a> {
5556
source,
5657
crate_name: None,
5758
edition: DEFAULT_EDITION,
58-
can_merge_doctests: false,
59+
can_merge_doctests: MergeDoctests::Never,
5960
test_id: None,
6061
lang_str: None,
6162
span: DUMMY_SP,
@@ -70,7 +71,7 @@ impl<'a> BuildDocTestBuilder<'a> {
7071
}
7172

7273
#[inline]
73-
pub(crate) fn can_merge_doctests(mut self, can_merge_doctests: bool) -> Self {
74+
pub(crate) fn can_merge_doctests(mut self, can_merge_doctests: MergeDoctests) -> Self {
7475
self.can_merge_doctests = can_merge_doctests;
7576
self
7677
}
@@ -117,10 +118,6 @@ impl<'a> BuildDocTestBuilder<'a> {
117118
span,
118119
global_crate_attrs,
119120
} = self;
120-
let can_merge_doctests = can_merge_doctests
121-
&& lang_str.is_some_and(|lang_str| {
122-
!lang_str.compile_fail && !lang_str.test_harness && !lang_str.standalone_crate
123-
});
124121

125122
let result = rustc_driver::catch_fatal_errors(|| {
126123
rustc_span::create_session_if_not_set_then(edition, |_| {
@@ -155,14 +152,26 @@ impl<'a> BuildDocTestBuilder<'a> {
155152
debug!("crate_attrs:\n{crate_attrs}{maybe_crate_attrs}");
156153
debug!("crates:\n{crates}");
157154
debug!("after:\n{everything_else}");
158-
159-
// If it contains `#[feature]` or `#[no_std]`, we don't want it to be merged either.
160-
let can_be_merged = can_merge_doctests
161-
&& !has_global_allocator
162-
&& crate_attrs.is_empty()
163-
// If this is a merged doctest and a defined macro uses `$crate`, then the path will
164-
// not work, so better not put it into merged doctests.
165-
&& !(has_macro_def && everything_else.contains("$crate"));
155+
debug!("merge-doctests: {can_merge_doctests:?}");
156+
157+
// Up until now, we've been dealing with settings for the whole crate.
158+
// Now, infer settings for this particular test.
159+
let can_be_merged = if can_merge_doctests == MergeDoctests::Auto {
160+
let mut can_merge = false;
161+
// Avoid tests with incompatible attributes.
162+
can_merge |= lang_str.is_some_and(|lang_str| {
163+
!lang_str.compile_fail && !lang_str.test_harness && !lang_str.standalone_crate
164+
});
165+
// If it contains `#[feature]` or `#[no_std]`, we don't want it to be merged either.
166+
can_merge &= !has_global_allocator
167+
&& crate_attrs.is_empty()
168+
// If this is a merged doctest and a defined macro uses `$crate`, then the path will
169+
// not work, so better not put it into merged doctests.
170+
&& !(has_macro_def && everything_else.contains("$crate"));
171+
can_merge
172+
} else {
173+
can_merge_doctests != MergeDoctests::Never
174+
};
166175
DocTestBuilder {
167176
supports_color,
168177
has_main_fn,

src/librustdoc/doctest/markdown.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use std::fs::read_to_string;
44
use std::sync::{Arc, Mutex};
55

6+
use rustc_errors::DiagCtxtHandle;
67
use rustc_session::config::Input;
78
use rustc_span::{DUMMY_SP, FileName};
89
use tempfile::tempdir;
@@ -78,7 +79,7 @@ impl DocTestVisitor for MdCollector {
7879
}
7980

8081
/// Runs any tests/code examples in the markdown file `options.input`.
81-
pub(crate) fn test(input: &Input, options: Options) -> Result<(), String> {
82+
pub(crate) fn test(input: &Input, options: Options, dcx: DiagCtxtHandle<'_>) -> Result<(), String> {
8283
let input_str = match input {
8384
Input::File(path) => {
8485
read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?
@@ -118,6 +119,7 @@ pub(crate) fn test(input: &Input, options: Options) -> Result<(), String> {
118119
let CreateRunnableDocTests { opts, rustdoc_options, standalone_tests, mergeable_tests, .. } =
119120
collector;
120121
crate::doctest::run_tests(
122+
dcx,
121123
opts,
122124
&rustdoc_options,
123125
&Arc::new(Mutex::new(Vec::new())),

src/librustdoc/lib.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,14 @@ fn opts() -> Vec<RustcOptGroup> {
544544
"[toolchain-shared-resources,invocation-specific,dep-info]",
545545
),
546546
opt(Unstable, FlagMulti, "", "no-run", "Compile doctests without running them", ""),
547+
opt(
548+
Unstable,
549+
Opt,
550+
"",
551+
"merge-doctests",
552+
"Force all doctests to be compiled as a single binary, instead of one binary per test. If merging fails, rustdoc will emit a hard error.",
553+
"yes|no|auto",
554+
),
547555
opt(
548556
Unstable,
549557
Multi,
@@ -822,7 +830,7 @@ fn main_args(early_dcx: &mut EarlyDiagCtxt, at_args: &[String]) {
822830
options.should_test || output_format == config::OutputFormat::Doctest,
823831
config::markdown_input(&input),
824832
) {
825-
(true, Some(_)) => return wrap_return(dcx, doctest::test_markdown(&input, options)),
833+
(true, Some(_)) => return wrap_return(dcx, doctest::test_markdown(&input, options, dcx)),
826834
(true, None) => return doctest::run(dcx, input, options),
827835
(false, Some(md_input)) => {
828836
let md_input = md_input.to_owned();

tests/run-make/rustdoc-default-output/output-default.stdout

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ Options:
154154
Comma separated list of types of output for rustdoc to
155155
emit
156156
--no-run Compile doctests without running them
157+
--merge-doctests yes|no|auto
158+
Force all doctests to be compiled as a single binary,
159+
instead of one binary per test. If merging fails,
160+
rustdoc will emit a hard error.
157161
--remap-path-prefix FROM=TO
158162
Remap source names in compiler messages
159163
--show-type-layout
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//@ edition: 2018
2+
//@ compile-flags: --test --test-args=--test-threads=1 --merge-doctests=yes -Z unstable-options
3+
//@ normalize-stderr: ".*doctest_bundle_2018.rs:\d+:\d+" -> "doctest_bundle_2018.rs:$$LINE:$$COL"
4+
5+
//~? ERROR failed to merge doctests
6+
7+
/// These two doctests will fail to force-merge, and should give a hard error as a result.
8+
///
9+
/// ```
10+
/// #![deny(clashing_extern_declarations)]
11+
/// unsafe extern "C" { fn unmangled_name() -> u8; }
12+
/// ```
13+
///
14+
/// ```
15+
/// #![deny(clashing_extern_declarations)]
16+
/// unsafe extern "C" { fn unmangled_name(); }
17+
/// ```
18+
pub struct Foo;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
doctest_bundle_2018.rs:$LINE:$COL: error: `unmangled_name` redeclared with a different signature: this signature doesn't match the previous declaration
2+
error: aborting due to 1 previous error
3+
error: failed to merge doctests
4+
|
5+
= note: requested explicitly on the command line with `--merge-doctests=yes`
6+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//@ check-pass
2+
//@ edition: 2018
3+
//@ compile-flags: --test --test-args=--test-threads=1 --merge-doctests=yes -Z unstable-options
4+
//@ normalize-stdout: "tests/rustdoc-ui/doctest" -> "$$DIR"
5+
//@ normalize-stdout: "finished in \d+\.\d+s" -> "finished in $$TIME"
6+
//@ normalize-stdout: "ran in \d+\.\d+s" -> "ran in $$TIME"
7+
//@ normalize-stdout: "compilation took \d+\.\d+s" -> "compilation took $$TIME"
8+
//@ normalize-stdout: ".rs:\d+:\d+" -> ".rs:$$LINE:$$COL"
9+
10+
// FIXME: compiletest doesn't support `// RAW` for doctests because the progress messages aren't
11+
// emitted as JSON. Instead the .stderr file tests that this contains a
12+
// "merged compilation took ..." message.
13+
14+
/// ```
15+
/// let x = 12;
16+
/// ```
17+
///
18+
/// These two doctests should be force-merged, even though this uses edition 2018.
19+
///
20+
/// ```
21+
/// fn main() {
22+
/// println!("owo");
23+
/// }
24+
/// ```
25+
pub struct Foo;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
2+
running 2 tests
3+
test $DIR/force-merge.rs - Foo (line 14) ... ok
4+
test $DIR/force-merge.rs - Foo (line 20) ... ok
5+
6+
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
7+
8+
all doctests ran in $TIME; merged doctests compilation took $TIME

0 commit comments

Comments
 (0)