Skip to content

Commit dde2ec7

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 843f8ce commit dde2ec7

17 files changed

+227
-33
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,
@@ -1045,3 +1059,20 @@ fn parse_merge(m: &getopts::Matches) -> Result<ShouldMerge, &'static str> {
10451059
Some(_) => Err("argument to --merge must be `none`, `shared`, or `finalize`"),
10461060
}
10471061
}
1062+
1063+
fn parse_merge_doctests(
1064+
m: &getopts::Matches,
1065+
edition: Edition,
1066+
dcx: DiagCtxtHandle<'_>,
1067+
) -> MergeDoctests {
1068+
match m.opt_str("merge-doctests").as_deref() {
1069+
Some("y") | Some("yes") | Some("on") | Some("true") => MergeDoctests::Always,
1070+
Some("n") | Some("no") | Some("off") | Some("false") => MergeDoctests::Never,
1071+
Some("auto") => MergeDoctests::Auto,
1072+
None if edition < Edition::Edition2024 => MergeDoctests::Never,
1073+
None => MergeDoctests::Auto,
1074+
Some(_) => {
1075+
dcx.fatal("argument to --merge-doctests must be a boolean (true/false) or 'auto'")
1076+
}
1077+
}
1078+
}

src/librustdoc/doctest.rs

Lines changed: 34 additions & 16 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

@@ -123,8 +123,13 @@ pub(crate) fn generate_args_file(file_path: &Path, options: &RustdocOptions) ->
123123
Ok(())
124124
}
125125

126-
fn get_doctest_dir() -> io::Result<TempDir> {
127-
TempFileBuilder::new().prefix("rustdoctest").tempdir()
126+
fn get_doctest_dir(opts: &RustdocOptions) -> io::Result<TempDir> {
127+
let mut builder = TempFileBuilder::new();
128+
builder.prefix("rustdoctest");
129+
if opts.codegen_options.save_temps {
130+
builder.disable_cleanup(true);
131+
}
132+
builder.tempdir()
128133
}
129134

130135
pub(crate) fn run(dcx: DiagCtxtHandle<'_>, input: Input, options: RustdocOptions) {
@@ -197,7 +202,7 @@ pub(crate) fn run(dcx: DiagCtxtHandle<'_>, input: Input, options: RustdocOptions
197202
let externs = options.externs.clone();
198203
let json_unused_externs = options.json_unused_externs;
199204

200-
let temp_dir = match get_doctest_dir()
205+
let temp_dir = match get_doctest_dir(&options)
201206
.map_err(|error| format!("failed to create temporary directory: {error:?}"))
202207
{
203208
Ok(temp_dir) => temp_dir,
@@ -207,6 +212,7 @@ pub(crate) fn run(dcx: DiagCtxtHandle<'_>, input: Input, options: RustdocOptions
207212
crate::wrap_return(dcx, generate_args_file(&args_path, &options));
208213

209214
let extract_doctests = options.output_format == OutputFormat::Doctest;
215+
let save_temps = options.codegen_options.save_temps;
210216
let result = interface::run_compiler(config, |compiler| {
211217
let krate = rustc_interface::passes::parse(&compiler.sess);
212218

@@ -259,12 +265,15 @@ pub(crate) fn run(dcx: DiagCtxtHandle<'_>, input: Input, options: RustdocOptions
259265
eprintln!("{error}");
260266
// Since some files in the temporary folder are still owned and alive, we need
261267
// to manually remove the folder.
262-
let _ = std::fs::remove_dir_all(temp_dir.path());
268+
if !save_temps {
269+
let _ = std::fs::remove_dir_all(temp_dir.path());
270+
}
263271
std::process::exit(1);
264272
}
265273
};
266274

267275
run_tests(
276+
dcx,
268277
opts,
269278
&rustdoc_options,
270279
&unused_extern_reports,
@@ -316,6 +325,7 @@ pub(crate) fn run(dcx: DiagCtxtHandle<'_>, input: Input, options: RustdocOptions
316325
}
317326

318327
pub(crate) fn run_tests(
328+
dcx: DiagCtxtHandle<'_>,
319329
opts: GlobalTestOptions,
320330
rustdoc_options: &Arc<RustdocOptions>,
321331
unused_extern_reports: &Arc<Mutex<Vec<UnusedExterns>>>,
@@ -368,6 +378,13 @@ pub(crate) fn run_tests(
368378
}
369379
continue;
370380
}
381+
382+
if rustdoc_options.merge_doctests == MergeDoctests::Always {
383+
let mut diag = dcx.struct_fatal("failed to merge doctests");
384+
diag.note("requested explicitly on the command line with `--merge-doctests=yes`");
385+
diag.emit();
386+
}
387+
371388
// We failed to compile all compatible tests as one so we push them into the
372389
// `standalone_tests` doctests.
373390
debug!("Failed to compile compatible doctests for edition {} all at once", edition);
@@ -645,9 +662,9 @@ fn run_test(
645662
// tested as standalone tests.
646663
return (Duration::default(), Err(TestFailure::CompileError));
647664
}
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.
665+
if !rustdoc_options.no_capture && rustdoc_options.merge_doctests == MergeDoctests::Auto {
666+
// If `no_capture` is disabled, and we might fallback to standalone tests, then we don't
667+
// display rustc's output when compiling the merged doctests.
651668
compiler.stderr(Stdio::null());
652669
}
653670
// bundled tests are an rlib, loaded by a separate runner executable
@@ -728,10 +745,12 @@ fn run_test(
728745
// tested as standalone tests.
729746
return (instant.elapsed(), Err(TestFailure::CompileError));
730747
}
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.
748+
if !rustdoc_options.no_capture && rustdoc_options.merge_doctests == MergeDoctests::Auto {
749+
// If `no_capture` is disabled and we're autodetecting whether to merge,
750+
// we don't display rustc's output when compiling the merged doctests.
734751
runner_compiler.stderr(Stdio::null());
752+
} else {
753+
runner_compiler.stderr(Stdio::inherit());
735754
}
736755
runner_compiler.arg("--error-format=short");
737756
debug!("compiler invocation for doctest runner: {runner_compiler:?}");
@@ -888,7 +907,7 @@ impl IndividualTestOptions {
888907

889908
DirState::Perm(path)
890909
} else {
891-
DirState::Temp(get_doctest_dir().expect("rustdoc needs a tempdir"))
910+
DirState::Temp(get_doctest_dir(options).expect("rustdoc needs a tempdir"))
892911
};
893912

894913
Self { outdir, path: test_path }
@@ -977,21 +996,20 @@ struct CreateRunnableDocTests {
977996
visited_tests: FxHashMap<(String, usize), usize>,
978997
unused_extern_reports: Arc<Mutex<Vec<UnusedExterns>>>,
979998
compiling_test_count: AtomicUsize,
980-
can_merge_doctests: bool,
999+
can_merge_doctests: MergeDoctests,
9811000
}
9821001

9831002
impl CreateRunnableDocTests {
9841003
fn new(rustdoc_options: RustdocOptions, opts: GlobalTestOptions) -> CreateRunnableDocTests {
985-
let can_merge_doctests = rustdoc_options.edition >= Edition::Edition2024;
9861004
CreateRunnableDocTests {
9871005
standalone_tests: Vec::new(),
9881006
mergeable_tests: FxIndexMap::default(),
989-
rustdoc_options: Arc::new(rustdoc_options),
9901007
opts,
9911008
visited_tests: FxHashMap::default(),
9921009
unused_extern_reports: Default::default(),
9931010
compiling_test_count: AtomicUsize::new(0),
994-
can_merge_doctests,
1011+
can_merge_doctests: rustdoc_options.merge_doctests,
1012+
rustdoc_options: Arc::new(rustdoc_options),
9951013
}
9961014
}
9971015

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
@@ -571,6 +571,14 @@ fn opts() -> Vec<RustcOptGroup> {
571571
"[toolchain-shared-resources,invocation-specific,dep-info]",
572572
),
573573
opt(Unstable, FlagMulti, "", "no-run", "Compile doctests without running them", ""),
574+
opt(
575+
Unstable,
576+
Opt,
577+
"",
578+
"merge-doctests",
579+
"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.",
580+
"",
581+
),
574582
opt(
575583
Unstable,
576584
Multi,
@@ -849,7 +857,7 @@ fn main_args(early_dcx: &mut EarlyDiagCtxt, at_args: &[String]) {
849857
options.should_test || output_format == config::OutputFormat::Doctest,
850858
config::markdown_input(&input),
851859
) {
852-
(true, Some(_)) => return wrap_return(dcx, doctest::test_markdown(&input, options)),
860+
(true, Some(_)) => return wrap_return(dcx, doctest::test_markdown(&input, options, dcx)),
853861
(true, None) => return doctest::run(dcx, input, options),
854862
(false, Some(md_input)) => {
855863
let md_input = md_input.to_owned();
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 -C save-temps
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" -> "$$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/doctest/force-merge.rs - Foo (line 14) ... ok
4+
test $DIR/doctest/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)