rustdoc/doctest/
runner.rs

1use std::fmt::Write;
2
3use rustc_data_structures::fx::FxIndexSet;
4use rustc_span::edition::Edition;
5
6use crate::doctest::{
7    DocTestBuilder, GlobalTestOptions, IndividualTestOptions, RunnableDocTest, RustdocOptions,
8    ScrapedDocTest, TestFailure, UnusedExterns, run_test,
9};
10use crate::html::markdown::{Ignore, LangString};
11
12/// Convenient type to merge compatible doctests into one.
13pub(crate) struct DocTestRunner {
14    crate_attrs: FxIndexSet<String>,
15    ids: String,
16    output: String,
17    output_merged_tests: String,
18    supports_color: bool,
19    nb_tests: usize,
20}
21
22impl DocTestRunner {
23    pub(crate) fn new() -> Self {
24        Self {
25            crate_attrs: FxIndexSet::default(),
26            ids: String::new(),
27            output: String::new(),
28            output_merged_tests: String::new(),
29            supports_color: true,
30            nb_tests: 0,
31        }
32    }
33
34    pub(crate) fn add_test(
35        &mut self,
36        doctest: &DocTestBuilder,
37        scraped_test: &ScrapedDocTest,
38        target_str: &str,
39    ) {
40        let ignore = match scraped_test.langstr.ignore {
41            Ignore::All => true,
42            Ignore::None => false,
43            Ignore::Some(ref ignores) => ignores.iter().any(|s| target_str.contains(s)),
44        };
45        if !ignore {
46            for line in doctest.crate_attrs.split('\n') {
47                self.crate_attrs.insert(line.to_string());
48            }
49        }
50        self.ids.push_str(&format!(
51            "tests.push({}::TEST);\n",
52            generate_mergeable_doctest(
53                doctest,
54                scraped_test,
55                ignore,
56                self.nb_tests,
57                &mut self.output,
58                &mut self.output_merged_tests,
59            ),
60        ));
61        self.supports_color &= doctest.supports_color;
62        self.nb_tests += 1;
63    }
64
65    pub(crate) fn run_merged_tests(
66        &mut self,
67        test_options: IndividualTestOptions,
68        edition: Edition,
69        opts: &GlobalTestOptions,
70        test_args: &[String],
71        rustdoc_options: &RustdocOptions,
72    ) -> Result<bool, ()> {
73        let mut code = "\
74#![allow(unused_extern_crates)]
75#![allow(internal_features)]
76#![feature(test)]
77#![feature(rustc_attrs)]
78"
79        .to_string();
80
81        let mut code_prefix = String::new();
82
83        for crate_attr in &self.crate_attrs {
84            code_prefix.push_str(crate_attr);
85            code_prefix.push('\n');
86        }
87
88        if opts.attrs.is_empty() {
89            // If there aren't any attributes supplied by #![doc(test(attr(...)))], then allow some
90            // lints that are commonly triggered in doctests. The crate-level test attributes are
91            // commonly used to make tests fail in case they trigger warnings, so having this there in
92            // that case may cause some tests to pass when they shouldn't have.
93            code_prefix.push_str("#![allow(unused)]\n");
94        }
95
96        // Next, any attributes that came from the crate root via #![doc(test(attr(...)))].
97        for attr in &opts.attrs {
98            code_prefix.push_str(&format!("#![{attr}]\n"));
99        }
100
101        code.push_str("extern crate test;\n");
102        writeln!(code, "extern crate doctest_bundle_{edition} as doctest_bundle;").unwrap();
103
104        let test_args = test_args.iter().fold(String::new(), |mut x, arg| {
105            write!(x, "{arg:?}.to_string(),").unwrap();
106            x
107        });
108        write!(
109            code,
110            "\
111{output}
112
113mod __doctest_mod {{
114    use std::sync::OnceLock;
115    use std::path::PathBuf;
116    use std::process::ExitCode;
117
118    pub static BINARY_PATH: OnceLock<PathBuf> = OnceLock::new();
119    pub const RUN_OPTION: &str = \"RUSTDOC_DOCTEST_RUN_NB_TEST\";
120
121    #[allow(unused)]
122    pub fn doctest_path() -> Option<&'static PathBuf> {{
123        self::BINARY_PATH.get()
124    }}
125
126    #[allow(unused)]
127    pub fn doctest_runner(bin: &std::path::Path, test_nb: usize) -> ExitCode {{
128        let out = std::process::Command::new(bin)
129            .env(self::RUN_OPTION, test_nb.to_string())
130            .args(std::env::args().skip(1).collect::<Vec<_>>())
131            .output()
132            .expect(\"failed to run command\");
133        if !out.status.success() {{
134            eprint!(\"{{}}\", String::from_utf8_lossy(&out.stderr));
135            ExitCode::FAILURE
136        }} else {{
137            ExitCode::SUCCESS
138        }}
139    }}
140}}
141
142#[rustc_main]
143fn main() -> std::process::ExitCode {{
144let tests = {{
145    let mut tests = Vec::with_capacity({nb_tests});
146    {ids}
147    tests
148}};
149let test_marker = std::ffi::OsStr::new(__doctest_mod::RUN_OPTION);
150let test_args = &[{test_args}];
151const ENV_BIN: &'static str = \"RUSTDOC_DOCTEST_BIN_PATH\";
152
153if let Ok(binary) = std::env::var(ENV_BIN) {{
154    let _ = crate::__doctest_mod::BINARY_PATH.set(binary.into());
155    unsafe {{ std::env::remove_var(ENV_BIN); }}
156    return std::process::Termination::report(test::test_main(test_args, tests, None));
157}} else if let Ok(nb_test) = std::env::var(__doctest_mod::RUN_OPTION) {{
158    if let Ok(nb_test) = nb_test.parse::<usize>() {{
159        if let Some(test) = tests.get(nb_test) {{
160            if let test::StaticTestFn(f) = &test.testfn {{
161                return std::process::Termination::report(f());
162            }}
163        }}
164    }}
165    panic!(\"Unexpected value for `{{}}`\", __doctest_mod::RUN_OPTION);
166}}
167
168eprintln!(\"WARNING: No rustdoc doctest environment variable provided so doctests will be run in \
169the same process\");
170std::process::Termination::report(test::test_main(test_args, tests, None))
171}}",
172            nb_tests = self.nb_tests,
173            output = self.output_merged_tests,
174            ids = self.ids,
175        )
176        .expect("failed to generate test code");
177        let runnable_test = RunnableDocTest {
178            full_test_code: format!("{code_prefix}{code}", code = self.output),
179            full_test_line_offset: 0,
180            test_opts: test_options,
181            global_opts: opts.clone(),
182            langstr: LangString::default(),
183            line: 0,
184            edition,
185            no_run: false,
186            merged_test_code: Some(code),
187        };
188        let ret =
189            run_test(runnable_test, rustdoc_options, self.supports_color, |_: UnusedExterns| {});
190        if let Err(TestFailure::CompileError) = ret { Err(()) } else { Ok(ret.is_ok()) }
191    }
192}
193
194/// Push new doctest content into `output`. Returns the test ID for this doctest.
195fn generate_mergeable_doctest(
196    doctest: &DocTestBuilder,
197    scraped_test: &ScrapedDocTest,
198    ignore: bool,
199    id: usize,
200    output: &mut String,
201    output_merged_tests: &mut String,
202) -> String {
203    let test_id = format!("__doctest_{id}");
204
205    if ignore {
206        // We generate nothing else.
207        writeln!(output, "pub mod {test_id} {{}}\n").unwrap();
208    } else {
209        writeln!(output, "pub mod {test_id} {{\n{}{}", doctest.crates, doctest.maybe_crate_attrs)
210            .unwrap();
211        if doctest.has_main_fn {
212            output.push_str(&doctest.everything_else);
213        } else {
214            let returns_result = if doctest.everything_else.trim_end().ends_with("(())") {
215                "-> Result<(), impl core::fmt::Debug>"
216            } else {
217                ""
218            };
219            write!(
220                output,
221                "\
222fn main() {returns_result} {{
223{}
224}}",
225                doctest.everything_else
226            )
227            .unwrap();
228        }
229        writeln!(
230            output,
231            "\npub fn __main_fn() -> impl std::process::Termination {{ main() }} \n}}\n"
232        )
233        .unwrap();
234    }
235    let not_running = ignore || scraped_test.langstr.no_run;
236    writeln!(
237        output_merged_tests,
238        "
239mod {test_id} {{
240pub const TEST: test::TestDescAndFn = test::TestDescAndFn::new_doctest(
241{test_name:?}, {ignore}, {file:?}, {line}, {no_run}, {should_panic},
242test::StaticTestFn(
243    || {{{runner}}},
244));
245}}",
246        test_name = scraped_test.name,
247        file = scraped_test.path(),
248        line = scraped_test.line,
249        no_run = scraped_test.langstr.no_run,
250        should_panic = !scraped_test.langstr.no_run && scraped_test.langstr.should_panic,
251        // Setting `no_run` to `true` in `TestDesc` still makes the test run, so we simply
252        // don't give it the function to run.
253        runner = if not_running {
254            "test::assert_test_result(Ok::<(), String>(()))".to_string()
255        } else {
256            format!(
257                "
258if let Some(bin_path) = crate::__doctest_mod::doctest_path() {{
259    test::assert_test_result(crate::__doctest_mod::doctest_runner(bin_path, {id}))
260}} else {{
261    test::assert_test_result(doctest_bundle::{test_id}::__main_fn())
262}}
263",
264            )
265        },
266    )
267    .unwrap();
268    test_id
269}