use std::fmt::Write;
use rustc_data_structures::fx::FxIndexSet;
use rustc_span::edition::Edition;
use crate::doctest::{
DocTestBuilder, GlobalTestOptions, IndividualTestOptions, RunnableDocTest, RustdocOptions,
ScrapedDocTest, TestFailure, UnusedExterns, run_test,
};
use crate::html::markdown::{Ignore, LangString};
pub(crate) struct DocTestRunner {
crate_attrs: FxIndexSet<String>,
ids: String,
output: String,
supports_color: bool,
nb_tests: usize,
}
impl DocTestRunner {
pub(crate) fn new() -> Self {
Self {
crate_attrs: FxIndexSet::default(),
ids: String::new(),
output: String::new(),
supports_color: true,
nb_tests: 0,
}
}
pub(crate) fn add_test(
&mut self,
doctest: &DocTestBuilder,
scraped_test: &ScrapedDocTest,
target_str: &str,
) {
let ignore = match scraped_test.langstr.ignore {
Ignore::All => true,
Ignore::None => false,
Ignore::Some(ref ignores) => ignores.iter().any(|s| target_str.contains(s)),
};
if !ignore {
for line in doctest.crate_attrs.split('\n') {
self.crate_attrs.insert(line.to_string());
}
}
if !self.ids.is_empty() {
self.ids.push(',');
}
self.ids.push_str(&format!(
"{}::TEST",
generate_mergeable_doctest(
doctest,
scraped_test,
ignore,
self.nb_tests,
&mut self.output
),
));
self.supports_color &= doctest.supports_color;
self.nb_tests += 1;
}
pub(crate) fn run_merged_tests(
&mut self,
test_options: IndividualTestOptions,
edition: Edition,
opts: &GlobalTestOptions,
test_args: &[String],
rustdoc_options: &RustdocOptions,
) -> Result<bool, ()> {
let mut code = "\
#![allow(unused_extern_crates)]
#![allow(internal_features)]
#![feature(test)]
#![feature(rustc_attrs)]
"
.to_string();
for crate_attr in &self.crate_attrs {
code.push_str(crate_attr);
code.push('\n');
}
if opts.attrs.is_empty() {
code.push_str("#![allow(unused)]\n");
}
for attr in &opts.attrs {
code.push_str(&format!("#![{attr}]\n"));
}
code.push_str("extern crate test;\n");
let test_args = test_args.iter().fold(String::new(), |mut x, arg| {
write!(x, "{arg:?}.to_string(),").unwrap();
x
});
write!(
code,
"\
{output}
mod __doctest_mod {{
use std::sync::OnceLock;
use std::path::PathBuf;
pub static BINARY_PATH: OnceLock<PathBuf> = OnceLock::new();
pub const RUN_OPTION: &str = \"RUSTDOC_DOCTEST_RUN_NB_TEST\";
#[allow(unused)]
pub fn doctest_path() -> Option<&'static PathBuf> {{
self::BINARY_PATH.get()
}}
#[allow(unused)]
pub fn doctest_runner(bin: &std::path::Path, test_nb: usize) -> Result<(), String> {{
let out = std::process::Command::new(bin)
.env(self::RUN_OPTION, test_nb.to_string())
.args(std::env::args().skip(1).collect::<Vec<_>>())
.output()
.expect(\"failed to run command\");
if !out.status.success() {{
Err(String::from_utf8_lossy(&out.stderr).to_string())
}} else {{
Ok(())
}}
}}
}}
#[rustc_main]
fn main() -> std::process::ExitCode {{
const TESTS: [test::TestDescAndFn; {nb_tests}] = [{ids}];
let test_marker = std::ffi::OsStr::new(__doctest_mod::RUN_OPTION);
let test_args = &[{test_args}];
const ENV_BIN: &'static str = \"RUSTDOC_DOCTEST_BIN_PATH\";
if let Ok(binary) = std::env::var(ENV_BIN) {{
let _ = crate::__doctest_mod::BINARY_PATH.set(binary.into());
unsafe {{ std::env::remove_var(ENV_BIN); }}
return std::process::Termination::report(test::test_main(test_args, Vec::from(TESTS), None));
}} else if let Ok(nb_test) = std::env::var(__doctest_mod::RUN_OPTION) {{
if let Ok(nb_test) = nb_test.parse::<usize>() {{
if let Some(test) = TESTS.get(nb_test) {{
if let test::StaticTestFn(f) = test.testfn {{
return std::process::Termination::report(f());
}}
}}
}}
panic!(\"Unexpected value for `{{}}`\", __doctest_mod::RUN_OPTION);
}}
eprintln!(\"WARNING: No rustdoc doctest environment variable provided so doctests will be run in \
the same process\");
std::process::Termination::report(test::test_main(test_args, Vec::from(TESTS), None))
}}",
nb_tests = self.nb_tests,
output = self.output,
ids = self.ids,
)
.expect("failed to generate test code");
let runnable_test = RunnableDocTest {
full_test_code: code,
full_test_line_offset: 0,
test_opts: test_options,
global_opts: opts.clone(),
langstr: LangString::default(),
line: 0,
edition,
no_run: false,
is_multiple_tests: true,
};
let ret =
run_test(runnable_test, rustdoc_options, self.supports_color, |_: UnusedExterns| {});
if let Err(TestFailure::CompileError) = ret { Err(()) } else { Ok(ret.is_ok()) }
}
}
fn generate_mergeable_doctest(
doctest: &DocTestBuilder,
scraped_test: &ScrapedDocTest,
ignore: bool,
id: usize,
output: &mut String,
) -> String {
let test_id = format!("__doctest_{id}");
if ignore {
writeln!(output, "mod {test_id} {{\n").unwrap();
} else {
writeln!(output, "mod {test_id} {{\n{}{}", doctest.crates, doctest.maybe_crate_attrs)
.unwrap();
if scraped_test.langstr.no_run {
writeln!(output, "#![allow(unused)]").unwrap();
}
if doctest.has_main_fn {
output.push_str(&doctest.everything_else);
} else {
let returns_result = if doctest.everything_else.trim_end().ends_with("(())") {
"-> Result<(), impl core::fmt::Debug>"
} else {
""
};
write!(
output,
"\
fn main() {returns_result} {{
{}
}}",
doctest.everything_else
)
.unwrap();
}
}
let not_running = ignore || scraped_test.langstr.no_run;
writeln!(
output,
"
pub const TEST: test::TestDescAndFn = test::TestDescAndFn::new_doctest(
{test_name:?}, {ignore}, {file:?}, {line}, {no_run}, {should_panic},
test::StaticTestFn(
|| {{{runner}}},
));
}}",
test_name = scraped_test.name,
file = scraped_test.path(),
line = scraped_test.line,
no_run = scraped_test.langstr.no_run,
should_panic = !scraped_test.langstr.no_run && scraped_test.langstr.should_panic,
runner = if not_running {
"test::assert_test_result(Ok::<(), String>(()))".to_string()
} else {
format!(
"
if let Some(bin_path) = crate::__doctest_mod::doctest_path() {{
test::assert_test_result(crate::__doctest_mod::doctest_runner(bin_path, {id}))
}} else {{
test::assert_test_result(self::main())
}}
",
)
},
)
.unwrap();
test_id
}