Skip to content

Commit c8a5072

Browse files
committedMar 10, 2025·
Auto merge of #137899 - notriddle:merged-doctests-stable, r=fmease,GuillaumeGomez
doctests: fix merging on stable Fixes #137898 The generated multi-test harness relies on nightly-only APIs, so the only way to run it on stable is to enable them. To prevent the executing test case from getting at any of the stuff that the harness uses, they're built as two separate crates. The test bundle isn't built with RUSTC_BOOTSTRAP, while the runner harness is.
2 parents 2b4694a + 9cf531d commit c8a5072

File tree

7 files changed

+221
-59
lines changed

7 files changed

+221
-59
lines changed
 

‎src/librustdoc/doctest.rs

+125-45
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ pub(crate) fn generate_args_file(file_path: &Path, options: &RustdocOptions) ->
9696
.map_err(|error| format!("failed to create args file: {error:?}"))?;
9797

9898
// We now put the common arguments into the file we created.
99-
let mut content = vec!["--crate-type=bin".to_string()];
99+
let mut content = vec![];
100100

101101
for cfg in &options.cfgs {
102102
content.push(format!("--cfg={cfg}"));
@@ -513,12 +513,18 @@ pub(crate) struct RunnableDocTest {
513513
line: usize,
514514
edition: Edition,
515515
no_run: bool,
516-
is_multiple_tests: bool,
516+
merged_test_code: Option<String>,
517517
}
518518

519519
impl RunnableDocTest {
520-
fn path_for_merged_doctest(&self) -> PathBuf {
521-
self.test_opts.outdir.path().join(format!("doctest_{}.rs", self.edition))
520+
fn path_for_merged_doctest_bundle(&self) -> PathBuf {
521+
self.test_opts.outdir.path().join(format!("doctest_bundle_{}.rs", self.edition))
522+
}
523+
fn path_for_merged_doctest_runner(&self) -> PathBuf {
524+
self.test_opts.outdir.path().join(format!("doctest_runner_{}.rs", self.edition))
525+
}
526+
fn is_multiple_tests(&self) -> bool {
527+
self.merged_test_code.is_some()
522528
}
523529
}
524530

@@ -537,91 +543,108 @@ fn run_test(
537543
let rust_out = add_exe_suffix("rust_out".to_owned(), &rustdoc_options.target);
538544
let output_file = doctest.test_opts.outdir.path().join(rust_out);
539545

540-
let rustc_binary = rustdoc_options
541-
.test_builder
542-
.as_deref()
543-
.unwrap_or_else(|| rustc_interface::util::rustc_path().expect("found rustc"));
544-
let mut compiler = wrapped_rustc_command(&rustdoc_options.test_builder_wrappers, rustc_binary);
546+
// Common arguments used for compiling the doctest runner.
547+
// On merged doctests, the compiler is invoked twice: once for the test code itself,
548+
// and once for the runner wrapper (which needs to use `#![feature]` on stable).
549+
let mut compiler_args = vec![];
545550

546-
compiler.arg(format!("@{}", doctest.global_opts.args_file.display()));
551+
compiler_args.push(format!("@{}", doctest.global_opts.args_file.display()));
547552

548553
if let Some(sysroot) = &rustdoc_options.maybe_sysroot {
549-
compiler.arg(format!("--sysroot={}", sysroot.display()));
554+
compiler_args.push(format!("--sysroot={}", sysroot.display()));
550555
}
551556

552-
compiler.arg("--edition").arg(doctest.edition.to_string());
553-
if !doctest.is_multiple_tests {
554-
// Setting these environment variables is unneeded if this is a merged doctest.
555-
compiler.env("UNSTABLE_RUSTDOC_TEST_PATH", &doctest.test_opts.path);
556-
compiler.env(
557-
"UNSTABLE_RUSTDOC_TEST_LINE",
558-
format!("{}", doctest.line as isize - doctest.full_test_line_offset as isize),
559-
);
560-
}
561-
compiler.arg("-o").arg(&output_file);
557+
compiler_args.extend_from_slice(&["--edition".to_owned(), doctest.edition.to_string()]);
562558
if langstr.test_harness {
563-
compiler.arg("--test");
559+
compiler_args.push("--test".to_owned());
564560
}
565561
if rustdoc_options.json_unused_externs.is_enabled() && !langstr.compile_fail {
566-
compiler.arg("--error-format=json");
567-
compiler.arg("--json").arg("unused-externs");
568-
compiler.arg("-W").arg("unused_crate_dependencies");
569-
compiler.arg("-Z").arg("unstable-options");
562+
compiler_args.push("--error-format=json".to_owned());
563+
compiler_args.extend_from_slice(&["--json".to_owned(), "unused-externs".to_owned()]);
564+
compiler_args.extend_from_slice(&["-W".to_owned(), "unused_crate_dependencies".to_owned()]);
565+
compiler_args.extend_from_slice(&["-Z".to_owned(), "unstable-options".to_owned()]);
570566
}
571567

572568
if doctest.no_run && !langstr.compile_fail && rustdoc_options.persist_doctests.is_none() {
573569
// FIXME: why does this code check if it *shouldn't* persist doctests
574570
// -- shouldn't it be the negation?
575-
compiler.arg("--emit=metadata");
571+
compiler_args.push("--emit=metadata".to_owned());
576572
}
577-
compiler.arg("--target").arg(match &rustdoc_options.target {
578-
TargetTuple::TargetTuple(s) => s,
579-
TargetTuple::TargetJson { path_for_rustdoc, .. } => {
580-
path_for_rustdoc.to_str().expect("target path must be valid unicode")
581-
}
582-
});
573+
compiler_args.extend_from_slice(&[
574+
"--target".to_owned(),
575+
match &rustdoc_options.target {
576+
TargetTuple::TargetTuple(s) => s.clone(),
577+
TargetTuple::TargetJson { path_for_rustdoc, .. } => {
578+
path_for_rustdoc.to_str().expect("target path must be valid unicode").to_owned()
579+
}
580+
},
581+
]);
583582
if let ErrorOutputType::HumanReadable { kind, color_config } = rustdoc_options.error_format {
584583
let short = kind.short();
585584
let unicode = kind == HumanReadableErrorType::Unicode;
586585

587586
if short {
588-
compiler.arg("--error-format").arg("short");
587+
compiler_args.extend_from_slice(&["--error-format".to_owned(), "short".to_owned()]);
589588
}
590589
if unicode {
591-
compiler.arg("--error-format").arg("human-unicode");
590+
compiler_args
591+
.extend_from_slice(&["--error-format".to_owned(), "human-unicode".to_owned()]);
592592
}
593593

594594
match color_config {
595595
ColorConfig::Never => {
596-
compiler.arg("--color").arg("never");
596+
compiler_args.extend_from_slice(&["--color".to_owned(), "never".to_owned()]);
597597
}
598598
ColorConfig::Always => {
599-
compiler.arg("--color").arg("always");
599+
compiler_args.extend_from_slice(&["--color".to_owned(), "always".to_owned()]);
600600
}
601601
ColorConfig::Auto => {
602-
compiler.arg("--color").arg(if supports_color { "always" } else { "never" });
602+
compiler_args.extend_from_slice(&[
603+
"--color".to_owned(),
604+
if supports_color { "always" } else { "never" }.to_owned(),
605+
]);
603606
}
604607
}
605608
}
606609

610+
let rustc_binary = rustdoc_options
611+
.test_builder
612+
.as_deref()
613+
.unwrap_or_else(|| rustc_interface::util::rustc_path().expect("found rustc"));
614+
let mut compiler = wrapped_rustc_command(&rustdoc_options.test_builder_wrappers, rustc_binary);
615+
616+
compiler.args(&compiler_args);
617+
607618
// If this is a merged doctest, we need to write it into a file instead of using stdin
608619
// because if the size of the merged doctests is too big, it'll simply break stdin.
609-
if doctest.is_multiple_tests {
620+
if doctest.is_multiple_tests() {
610621
// It makes the compilation failure much faster if it is for a combined doctest.
611622
compiler.arg("--error-format=short");
612-
let input_file = doctest.path_for_merged_doctest();
623+
let input_file = doctest.path_for_merged_doctest_bundle();
613624
if std::fs::write(&input_file, &doctest.full_test_code).is_err() {
614625
// If we cannot write this file for any reason, we leave. All combined tests will be
615626
// tested as standalone tests.
616627
return Err(TestFailure::CompileError);
617628
}
618-
compiler.arg(input_file);
619629
if !rustdoc_options.nocapture {
620630
// If `nocapture` is disabled, then we don't display rustc's output when compiling
621631
// the merged doctests.
622632
compiler.stderr(Stdio::null());
623633
}
634+
// bundled tests are an rlib, loaded by a separate runner executable
635+
compiler
636+
.arg("--crate-type=lib")
637+
.arg("--out-dir")
638+
.arg(doctest.test_opts.outdir.path())
639+
.arg(input_file);
624640
} else {
641+
compiler.arg("--crate-type=bin").arg("-o").arg(&output_file);
642+
// Setting these environment variables is unneeded if this is a merged doctest.
643+
compiler.env("UNSTABLE_RUSTDOC_TEST_PATH", &doctest.test_opts.path);
644+
compiler.env(
645+
"UNSTABLE_RUSTDOC_TEST_LINE",
646+
format!("{}", doctest.line as isize - doctest.full_test_line_offset as isize),
647+
);
625648
compiler.arg("-");
626649
compiler.stdin(Stdio::piped());
627650
compiler.stderr(Stdio::piped());
@@ -630,8 +653,65 @@ fn run_test(
630653
debug!("compiler invocation for doctest: {compiler:?}");
631654

632655
let mut child = compiler.spawn().expect("Failed to spawn rustc process");
633-
let output = if doctest.is_multiple_tests {
656+
let output = if let Some(merged_test_code) = &doctest.merged_test_code {
657+
// compile-fail tests never get merged, so this should always pass
634658
let status = child.wait().expect("Failed to wait");
659+
660+
// the actual test runner is a separate component, built with nightly-only features;
661+
// build it now
662+
let runner_input_file = doctest.path_for_merged_doctest_runner();
663+
664+
let mut runner_compiler =
665+
wrapped_rustc_command(&rustdoc_options.test_builder_wrappers, rustc_binary);
666+
// the test runner does not contain any user-written code, so this doesn't allow
667+
// the user to exploit nightly-only features on stable
668+
runner_compiler.env("RUSTC_BOOTSTRAP", "1");
669+
runner_compiler.args(compiler_args);
670+
runner_compiler.args(&["--crate-type=bin", "-o"]).arg(&output_file);
671+
let mut extern_path = std::ffi::OsString::from(format!(
672+
"--extern=doctest_bundle_{edition}=",
673+
edition = doctest.edition
674+
));
675+
for extern_str in &rustdoc_options.extern_strs {
676+
if let Some((_cratename, path)) = extern_str.split_once('=') {
677+
// Direct dependencies of the tests themselves are
678+
// indirect dependencies of the test runner.
679+
// They need to be in the library search path.
680+
let dir = Path::new(path)
681+
.parent()
682+
.filter(|x| x.components().count() > 0)
683+
.unwrap_or(Path::new("."));
684+
runner_compiler.arg("-L").arg(dir);
685+
}
686+
}
687+
let output_bundle_file = doctest
688+
.test_opts
689+
.outdir
690+
.path()
691+
.join(format!("libdoctest_bundle_{edition}.rlib", edition = doctest.edition));
692+
extern_path.push(&output_bundle_file);
693+
runner_compiler.arg(extern_path);
694+
runner_compiler.arg(&runner_input_file);
695+
if std::fs::write(&runner_input_file, &merged_test_code).is_err() {
696+
// If we cannot write this file for any reason, we leave. All combined tests will be
697+
// tested as standalone tests.
698+
return Err(TestFailure::CompileError);
699+
}
700+
if !rustdoc_options.nocapture {
701+
// If `nocapture` is disabled, then we don't display rustc's output when compiling
702+
// the merged doctests.
703+
runner_compiler.stderr(Stdio::null());
704+
}
705+
runner_compiler.arg("--error-format=short");
706+
debug!("compiler invocation for doctest runner: {runner_compiler:?}");
707+
708+
let status = if !status.success() {
709+
status
710+
} else {
711+
let mut child_runner = runner_compiler.spawn().expect("Failed to spawn rustc process");
712+
child_runner.wait().expect("Failed to wait")
713+
};
714+
635715
process::Output { status, stdout: Vec::new(), stderr: Vec::new() }
636716
} else {
637717
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
@@ -708,15 +788,15 @@ fn run_test(
708788
cmd.arg(&output_file);
709789
} else {
710790
cmd = Command::new(&output_file);
711-
if doctest.is_multiple_tests {
791+
if doctest.is_multiple_tests() {
712792
cmd.env("RUSTDOC_DOCTEST_BIN_PATH", &output_file);
713793
}
714794
}
715795
if let Some(run_directory) = &rustdoc_options.test_run_directory {
716796
cmd.current_dir(run_directory);
717797
}
718798

719-
let result = if doctest.is_multiple_tests || rustdoc_options.nocapture {
799+
let result = if doctest.is_multiple_tests() || rustdoc_options.nocapture {
720800
cmd.status().map(|status| process::Output {
721801
status,
722802
stdout: Vec::new(),
@@ -1003,7 +1083,7 @@ fn doctest_run_fn(
10031083
line: scraped_test.line,
10041084
edition: scraped_test.edition(&rustdoc_options),
10051085
no_run: scraped_test.no_run(&rustdoc_options),
1006-
is_multiple_tests: false,
1086+
merged_test_code: None,
10071087
};
10081088
let res =
10091089
run_test(runnable_test, &rustdoc_options, doctest.supports_color, report_unused_externs);

‎src/librustdoc/doctest/runner.rs

+25-12
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub(crate) struct DocTestRunner {
1414
crate_attrs: FxIndexSet<String>,
1515
ids: String,
1616
output: String,
17+
output_merged_tests: String,
1718
supports_color: bool,
1819
nb_tests: usize,
1920
}
@@ -24,6 +25,7 @@ impl DocTestRunner {
2425
crate_attrs: FxIndexSet::default(),
2526
ids: String::new(),
2627
output: String::new(),
28+
output_merged_tests: String::new(),
2729
supports_color: true,
2830
nb_tests: 0,
2931
}
@@ -55,7 +57,8 @@ impl DocTestRunner {
5557
scraped_test,
5658
ignore,
5759
self.nb_tests,
58-
&mut self.output
60+
&mut self.output,
61+
&mut self.output_merged_tests,
5962
),
6063
));
6164
self.supports_color &= doctest.supports_color;
@@ -78,25 +81,28 @@ impl DocTestRunner {
7881
"
7982
.to_string();
8083

84+
let mut code_prefix = String::new();
85+
8186
for crate_attr in &self.crate_attrs {
82-
code.push_str(crate_attr);
83-
code.push('\n');
87+
code_prefix.push_str(crate_attr);
88+
code_prefix.push('\n');
8489
}
8590

8691
if opts.attrs.is_empty() {
8792
// If there aren't any attributes supplied by #![doc(test(attr(...)))], then allow some
8893
// lints that are commonly triggered in doctests. The crate-level test attributes are
8994
// commonly used to make tests fail in case they trigger warnings, so having this there in
9095
// that case may cause some tests to pass when they shouldn't have.
91-
code.push_str("#![allow(unused)]\n");
96+
code_prefix.push_str("#![allow(unused)]\n");
9297
}
9398

9499
// Next, any attributes that came from the crate root via #![doc(test(attr(...)))].
95100
for attr in &opts.attrs {
96-
code.push_str(&format!("#![{attr}]\n"));
101+
code_prefix.push_str(&format!("#![{attr}]\n"));
97102
}
98103

99104
code.push_str("extern crate test;\n");
105+
writeln!(code, "extern crate doctest_bundle_{edition} as doctest_bundle;").unwrap();
100106

101107
let test_args = test_args.iter().fold(String::new(), |mut x, arg| {
102108
write!(x, "{arg:?}.to_string(),").unwrap();
@@ -161,20 +167,20 @@ the same process\");
161167
std::process::Termination::report(test::test_main(test_args, Vec::from(TESTS), None))
162168
}}",
163169
nb_tests = self.nb_tests,
164-
output = self.output,
170+
output = self.output_merged_tests,
165171
ids = self.ids,
166172
)
167173
.expect("failed to generate test code");
168174
let runnable_test = RunnableDocTest {
169-
full_test_code: code,
175+
full_test_code: format!("{code_prefix}{code}", code = self.output),
170176
full_test_line_offset: 0,
171177
test_opts: test_options,
172178
global_opts: opts.clone(),
173179
langstr: LangString::default(),
174180
line: 0,
175181
edition,
176182
no_run: false,
177-
is_multiple_tests: true,
183+
merged_test_code: Some(code),
178184
};
179185
let ret =
180186
run_test(runnable_test, rustdoc_options, self.supports_color, |_: UnusedExterns| {});
@@ -189,14 +195,15 @@ fn generate_mergeable_doctest(
189195
ignore: bool,
190196
id: usize,
191197
output: &mut String,
198+
output_merged_tests: &mut String,
192199
) -> String {
193200
let test_id = format!("__doctest_{id}");
194201

195202
if ignore {
196203
// We generate nothing else.
197-
writeln!(output, "mod {test_id} {{\n").unwrap();
204+
writeln!(output, "pub mod {test_id} {{}}\n").unwrap();
198205
} else {
199-
writeln!(output, "mod {test_id} {{\n{}{}", doctest.crates, doctest.maybe_crate_attrs)
206+
writeln!(output, "pub mod {test_id} {{\n{}{}", doctest.crates, doctest.maybe_crate_attrs)
200207
.unwrap();
201208
if doctest.has_main_fn {
202209
output.push_str(&doctest.everything_else);
@@ -216,11 +223,17 @@ fn main() {returns_result} {{
216223
)
217224
.unwrap();
218225
}
226+
writeln!(
227+
output,
228+
"\npub fn __main_fn() -> impl std::process::Termination {{ main() }} \n}}\n"
229+
)
230+
.unwrap();
219231
}
220232
let not_running = ignore || scraped_test.langstr.no_run;
221233
writeln!(
222-
output,
234+
output_merged_tests,
223235
"
236+
mod {test_id} {{
224237
pub const TEST: test::TestDescAndFn = test::TestDescAndFn::new_doctest(
225238
{test_name:?}, {ignore}, {file:?}, {line}, {no_run}, {should_panic},
226239
test::StaticTestFn(
@@ -242,7 +255,7 @@ test::StaticTestFn(
242255
if let Some(bin_path) = crate::__doctest_mod::doctest_path() {{
243256
test::assert_test_result(crate::__doctest_mod::doctest_runner(bin_path, {id}))
244257
}} else {{
245-
test::assert_test_result(self::main())
258+
test::assert_test_result(doctest_bundle::{test_id}::__main_fn())
246259
}}
247260
",
248261
)

‎tests/run-make/doctests-merge/rmake.rs

-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ fn test_and_compare(input_file: &str, stdout_file: &str, edition: &str, dep: &Pa
88
let output = cmd
99
.input(input_file)
1010
.arg("--test")
11-
.arg("-Zunstable-options")
1211
.edition(edition)
1312
.arg("--test-args=--test-threads=1")
1413
.extern_("foo", dep.display().to_string())

‎tests/rustdoc-ui/doctest/doctest-output.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//@[edition2015]edition:2015
33
//@[edition2015]aux-build:extern_macros.rs
44
//@[edition2015]compile-flags:--test --test-args=--test-threads=1
5-
//@[edition2024]edition:2015
5+
//@[edition2024]edition:2024
66
//@[edition2024]aux-build:extern_macros.rs
77
//@[edition2024]compile-flags:--test --test-args=--test-threads=1
88
//@ normalize-stdout: "tests/rustdoc-ui/doctest" -> "$$DIR"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
2+
running 1 test
3+
test $DIR/failed-doctest-test-crate.rs - m (line 14) ... FAILED
4+
5+
failures:
6+
7+
---- $DIR/failed-doctest-test-crate.rs - m (line 14) stdout ----
8+
error[E0432]: unresolved import `test`
9+
--> $DIR/failed-doctest-test-crate.rs:15:5
10+
|
11+
LL | use test::*;
12+
| ^^^^ use of unresolved module or unlinked crate `test`
13+
|
14+
help: you might be missing a crate named `test`, add it to your project and import it in your code
15+
|
16+
LL + extern crate test;
17+
|
18+
19+
error: aborting due to 1 previous error
20+
21+
For more information about this error, try `rustc --explain E0432`.
22+
Couldn't compile the test.
23+
24+
failures:
25+
$DIR/failed-doctest-test-crate.rs - m (line 14)
26+
27+
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
28+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
2+
running 1 test
3+
test $DIR/failed-doctest-test-crate.rs - m (line 14) ... FAILED
4+
5+
failures:
6+
7+
---- $DIR/failed-doctest-test-crate.rs - m (line 14) stdout ----
8+
error[E0432]: unresolved import `test`
9+
--> $DIR/failed-doctest-test-crate.rs:15:5
10+
|
11+
LL | use test::*;
12+
| ^^^^ use of unresolved module or unlinked crate `test`
13+
|
14+
= help: you might be missing a crate named `test`
15+
16+
error: aborting due to 1 previous error
17+
18+
For more information about this error, try `rustc --explain E0432`.
19+
Couldn't compile the test.
20+
21+
failures:
22+
$DIR/failed-doctest-test-crate.rs - m (line 14)
23+
24+
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
25+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// FIXME: if/when the output of the test harness can be tested on its own, this test should be
2+
// adapted to use that, and that normalize line can go away
3+
4+
//@ revisions: edition2015 edition2024
5+
//@[edition2015]edition:2015
6+
//@[edition2024]edition:2024
7+
//@ compile-flags:--test
8+
//@ normalize-stdout: "tests/rustdoc-ui/doctest" -> "$$DIR"
9+
//@ normalize-stdout: "finished in \d+\.\d+s" -> "finished in $$TIME"
10+
//@ failure-status: 101
11+
12+
/// <https://github.com/rust-lang/rust/pull/137899#discussion_r1976743383>
13+
///
14+
/// ```rust
15+
/// use test::*;
16+
/// ```
17+
pub mod m {}

0 commit comments

Comments
 (0)
Please sign in to comment.