rustdoc/doctest/
runner.rs

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