rust_doctest_builder/
main.rs

1//! This is a helper binary for building rustdoc tests
2//! It essentially does what rustdoc test does except it allows for the full feature support
3//! of libtest so that it can be integrated to work with our nextest test_runner library.
4
5use std::collections::BTreeMap;
6use std::io::Write;
7use std::process::Stdio;
8
9use camino::Utf8PathBuf;
10use clap::Parser;
11
12/// Simple program to greet a person
13#[derive(Parser, Debug)]
14#[command(version, about, long_about = None)]
15struct Args {
16    #[arg(long)]
17    rustc: Utf8PathBuf,
18
19    #[arg(long)]
20    out_dir: Utf8PathBuf,
21
22    #[arg(long)]
23    extracted_tests: Utf8PathBuf,
24
25    #[arg(long)]
26    edition: String,
27
28    #[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)]
29    extra: Vec<String>,
30}
31
32#[derive(Debug, serde_derive::Deserialize)]
33struct RustDocExtract {
34    format_version: i32,
35    doctests: Vec<ExtractedDocTest>,
36}
37
38#[derive(Debug, serde_derive::Deserialize)]
39struct ExtractedDocTest {
40    file: String,
41    line: i32,
42    doctest_attributes: DoctestAttributes,
43    doctest_code: DocTestCode,
44    name: String,
45}
46
47#[derive(Debug, Clone, serde_derive::Deserialize)]
48struct DocTestCode {
49    crate_level: String,
50    code: String,
51    wrapper: Option<DocTestCodeWrapper>,
52}
53
54#[derive(Debug, Clone, serde_derive::Deserialize)]
55struct DocTestCodeWrapper {
56    before: String,
57    after: String,
58}
59
60#[derive(Debug, serde_derive::Deserialize)]
61struct DoctestAttributes {
62    should_panic: bool,
63    no_run: bool,
64    ignore: String,
65    rust: bool,
66    test_harness: bool,
67    compile_fail: bool,
68    standalone_crate: bool,
69    error_codes: Vec<String>,
70    edition: Option<String>,
71}
72
73fn make_test_case(name: &str, ignore: bool, file: &str, line: usize, should_panic: bool, func_path: &str) -> String {
74    format!(
75        r#"&const {{
76    test::TestDescAndFn::new_doctest(
77        "{name}",
78        {ignore},
79        "{file}",
80        {line},
81        false,
82        {should_panic},
83        test::StaticTestFn(
84            #[coverage(off)]
85            || test::assert_test_result({func_path}()),
86        ),
87    )
88}}"#
89    )
90}
91
92fn test_binary(extern_crates: &str, test_cases: &[String]) -> String {
93    let test_cases = test_cases.join(",");
94    format!(
95        "
96#![feature(test)]
97#![feature(coverage_attribute)]
98
99extern crate test;
100{extern_crates}
101
102#[doc(hidden)]
103#[coverage(off)]
104fn main() {{
105    test::test_main_static(&[{test_cases}])
106}}"
107    )
108}
109
110fn test_function(test_ident: &str, code: &str) -> String {
111    format!(
112        "pub mod {test_ident} {{
113    pub fn __main_fn() -> impl std::process::Termination {{
114        {code}
115    }}
116}}"
117    )
118}
119
120struct Dirs {
121    src: Utf8PathBuf,
122    rlib: Utf8PathBuf,
123    compile_fail: Utf8PathBuf,
124    bin: Utf8PathBuf,
125}
126
127fn compile_merged(
128    args: &Args,
129    all_tests: &[ExtractedDocTest],
130    dirs: &Dirs,
131    edition_merged_tests: BTreeMap<&str, Vec<(usize, String)>>,
132) -> Utf8PathBuf {
133    struct MergedLib {
134        edition: String,
135        filename: Utf8PathBuf,
136        crate_ident: String,
137    }
138
139    let mut all_test_cases = Vec::new();
140    let mut externs = String::new();
141    let merged_files: Vec<_> = edition_merged_tests
142        .iter()
143        .map(|(edition, tests)| {
144            let crate_ident = format!("doctest_merged_{edition}");
145            let tokens = tests
146                .iter()
147                .map(|(idx, content)| test_function(&format!("test_{idx}"), content))
148                .collect::<Vec<_>>();
149            all_test_cases.extend(tests.iter().filter_map(|(idx, _)| {
150                let test: &ExtractedDocTest = &all_tests[*idx];
151                if !test.doctest_attributes.no_run {
152                    Some(make_test_case(
153                        &test.name,
154                        test.doctest_attributes.ignore != "None",
155                        &test.file,
156                        test.line as usize,
157                        test.doctest_attributes.should_panic,
158                        &format!("{crate_ident}::test_{idx}::__main_fn"),
159                    ))
160                } else {
161                    None
162                }
163            }));
164
165            let filename = dirs.src.join(format!("{crate_ident}.rs"));
166            std::fs::write(&filename, tokens.join("\n\n")).expect("failed to write merged");
167
168            externs.push_str("extern crate ");
169            externs.push_str(&crate_ident);
170            externs.push_str(";\n");
171
172            MergedLib {
173                edition: edition.to_string(),
174                filename,
175                crate_ident,
176            }
177        })
178        .collect();
179
180    let merged_binary = test_binary(&externs, &all_test_cases);
181    let rustdoc_merged_file = dirs.src.join("rustdoc_merged.rs");
182    std::fs::write(&rustdoc_merged_file, merged_binary).expect("failed to write merged");
183
184    for file in &merged_files {
185        let status = std::process::Command::new(&args.rustc)
186            .args(&args.extra)
187            .arg("--crate-type=rlib")
188            .arg("--crate-name")
189            .arg(&file.crate_ident)
190            .arg("--edition")
191            .arg(&file.edition)
192            .arg(&file.filename)
193            .arg("--out-dir")
194            .arg(&dirs.rlib)
195            .status()
196            .expect("failed to compile");
197
198        if !status.success() {
199            std::process::exit(status.code().unwrap_or(127))
200        }
201    }
202
203    let mut binary_out = dirs.bin.join("rustdoc_merged");
204    if cfg!(windows) {
205        binary_out.set_extension("exe");
206    }
207
208    let status = std::process::Command::new(&args.rustc)
209        .args(&args.extra)
210        .arg(format!("-L{}", dirs.rlib))
211        .arg("--crate-type=bin")
212        .arg("--crate-name")
213        .arg("rustdoc_merged")
214        .arg("--edition")
215        .arg("2024")
216        .arg(&rustdoc_merged_file)
217        .arg("-o")
218        .arg(&binary_out)
219        .env("RUSTC_BOOTSTRAP", "1")
220        .status()
221        .expect("failed to compile");
222
223    if !status.success() {
224        std::process::exit(status.code().unwrap_or(127))
225    }
226
227    binary_out
228}
229
230fn standalone_test_runner(env_var: &str) -> String {
231    format!(
232        r#"if let Some(binary) = std::env::var_os("{env_var}") {{
233        let status = ::std::process::Command::new(binary)
234            .status()
235            .expect("failed to run test binary");
236        if !status.success() {{
237            panic!("test exited with {{status:?}}")
238        }}
239    }} else {{
240        panic!("{env_var} is not set");
241    }}"#
242    )
243}
244
245fn compile_fail_test_result(error: Option<&str>) -> String {
246    let Some(error) = error else {
247        return String::new();
248    };
249
250    format!(r#"panic!("{error}");"#)
251}
252
253fn doctest_to_code(doctest: &DocTestCode) -> String {
254    format!(
255        "{crate_level}{before}{code}{after}",
256        crate_level = doctest.crate_level,
257        before = doctest
258            .wrapper
259            .as_ref()
260            .map(|wrapper| wrapper.before.as_str())
261            .unwrap_or_default(),
262        code = doctest.code,
263        after = doctest
264            .wrapper
265            .as_ref()
266            .map(|wrapper| wrapper.after.as_str())
267            .unwrap_or_default(),
268    )
269}
270
271fn main() {
272    let args = Args::parse();
273
274    let extracted_tests = std::fs::read_to_string(&args.extracted_tests).expect("failed to read extracted tests");
275
276    let mut all_tests = Vec::new();
277
278    for line in extracted_tests.lines() {
279        let extract: RustDocExtract = serde_json::from_str(line)
280            .map_err(|err| format!("invalid rustdoc line: {line} - {err}"))
281            .unwrap();
282        if extract.format_version != 2 {
283            panic!("format version mismatch: 1 != {}", extract.format_version);
284        }
285
286        all_tests.extend(extract.doctests);
287    }
288
289    let mut standalone_tests = Vec::new();
290    let mut edition_merged_tests: BTreeMap<_, Vec<_>> = BTreeMap::new();
291    let mut compile_fail_tests = Vec::new();
292    let mut test_harness_tests = Vec::new();
293
294    let dirs = Dirs {
295        bin: args.out_dir.join("bin"),
296        src: args.out_dir.join("src"),
297        compile_fail: args.out_dir.join("compile_fail"),
298        rlib: args.out_dir.join("rlib"),
299    };
300
301    std::fs::create_dir_all(&dirs.bin).expect("failed to create bin dir");
302    std::fs::create_dir_all(&dirs.src).expect("failed to create src dir");
303    std::fs::create_dir_all(&dirs.compile_fail).expect("failed to create compile_fail dir");
304    std::fs::create_dir_all(&dirs.rlib).expect("failed to create rlib dir");
305
306    for (idx, test) in all_tests.iter().enumerate() {
307        if !test.doctest_attributes.rust {
308            continue;
309        }
310
311        if !test.doctest_attributes.standalone_crate
312            && !test.doctest_attributes.compile_fail
313            && !test.doctest_attributes.test_harness
314        {
315            edition_merged_tests
316                .entry(test.doctest_attributes.edition.as_ref().unwrap_or(&args.edition).as_str())
317                .or_default()
318                .push((idx, doctest_to_code(&test.doctest_code)));
319        } else {
320            match (test.doctest_attributes.compile_fail, test.doctest_attributes.test_harness) {
321                (true, _) => compile_fail_tests.push(idx),
322                (false, true) => test_harness_tests.push(idx),
323                (false, false) => standalone_tests.push(idx),
324            }
325        }
326    }
327
328    for idx in &compile_fail_tests {
329        let crate_name = format!("compile_fail_{idx}");
330        let test = &all_tests[*idx];
331        let mut child = std::process::Command::new(&args.rustc)
332            .args(&args.extra)
333            .arg("--test")
334            .arg("--crate-type=rlib")
335            .arg("--crate-name")
336            .arg(crate_name)
337            .arg("--edition")
338            .arg(test.doctest_attributes.edition.as_ref().unwrap_or(&args.edition))
339            .arg("-")
340            .arg("--out-dir")
341            .arg(&dirs.compile_fail)
342            .env("UNSTABLE_RUSTDOC_TEST_PATH", &test.file)
343            .env("UNSTABLE_RUSTDOC_TEST_LINE", test.line.to_string())
344            .stdin(Stdio::piped())
345            .stderr(Stdio::piped())
346            .stdout(Stdio::piped())
347            .spawn()
348            .expect("failed to compile");
349
350        let mut stdin = child.stdin.take().unwrap();
351
352        stdin
353            .write_all(doctest_to_code(&test.doctest_code).as_bytes())
354            .expect("failed to write stdin");
355        drop(stdin);
356
357        let output = child.wait_with_output().expect("failed to wait for output");
358
359        if output.status.success() {
360            edition_merged_tests.entry("2024").or_default().push((
361                *idx,
362                compile_fail_test_result(Some("test marked as compile_fail but successfully compiled")),
363            ))
364        } else {
365            let stderr = String::from_utf8(output.stderr).expect("bad stderr");
366            let missing_ecs: Vec<_> = test
367                .doctest_attributes
368                .error_codes
369                .iter()
370                .filter(|ec| !stderr.contains(*ec))
371                .collect();
372            if missing_ecs.is_empty() {
373                edition_merged_tests
374                    .entry("2024")
375                    .or_default()
376                    .push((*idx, compile_fail_test_result(None)))
377            } else {
378                edition_merged_tests.entry("2024").or_default().push((
379                    *idx,
380                    compile_fail_test_result(Some(&format!("Some expected error codes were not found: {missing_ecs:?}"))),
381                ))
382            }
383        }
384    }
385
386    let mut standalone_binary_envs = Vec::new();
387
388    for idx in &standalone_tests {
389        let test = &all_tests[*idx];
390        let crate_name = format!("standalone_{idx}");
391        let mut binary_out = dirs.bin.join(&crate_name);
392        if cfg!(windows) {
393            binary_out.set_extension("exe");
394        }
395
396        let mut child = std::process::Command::new(&args.rustc)
397            .args(&args.extra)
398            .arg("--crate-type=bin")
399            .arg("--crate-name")
400            .arg(crate_name)
401            .arg("--edition")
402            .arg(all_tests[*idx].doctest_attributes.edition.as_ref().unwrap_or(&args.edition))
403            .arg("-")
404            .arg("-o")
405            .arg(&binary_out)
406            .stdin(Stdio::piped())
407            .stderr(Stdio::inherit())
408            .stdout(Stdio::inherit())
409            .env("UNSTABLE_RUSTDOC_TEST_PATH", &test.file)
410            .env("UNSTABLE_RUSTDOC_TEST_LINE", test.line.to_string())
411            .spawn()
412            .expect("failed to compile");
413
414        let mut stdin = child.stdin.take().unwrap();
415
416        stdin
417            .write_all(doctest_to_code(&test.doctest_code).as_bytes())
418            .expect("failed to write stdin");
419        drop(stdin);
420
421        let status = child.wait().expect("failed to wait for output");
422
423        if !status.success() {
424            std::process::exit(status.code().unwrap_or(127))
425        }
426
427        let env_var = format!("RUSTDOC_TEST_{idx}_BINARY");
428        edition_merged_tests
429            .entry("2024")
430            .or_default()
431            .push((*idx, standalone_test_runner(&env_var)));
432        standalone_binary_envs.push((env_var, binary_out.strip_prefix(&args.out_dir).unwrap().to_owned()));
433    }
434
435    let mut test_binaries = Vec::new();
436
437    // Compile all tests which are merged.
438    if !edition_merged_tests.is_empty() {
439        test_binaries.push(rust_doctest_common::TestBinary {
440            name: "Merged Doctests".to_string(),
441            path: compile_merged(&args, &all_tests, &dirs, edition_merged_tests)
442                .strip_prefix(&args.out_dir)
443                .unwrap()
444                .to_owned(),
445        });
446    }
447
448    for idx in &test_harness_tests {
449        let test = &all_tests[*idx];
450        let crate_name = format!("test_harness_{idx}");
451        let mut binary_out = dirs.bin.join(&crate_name);
452        if cfg!(windows) {
453            binary_out.set_extension("exe");
454        }
455
456        let mut child = std::process::Command::new(&args.rustc)
457            .args(&args.extra)
458            .arg("--test")
459            .arg("--crate-type=bin")
460            .arg("--crate-name")
461            .arg(crate_name)
462            .arg("--edition")
463            .arg(all_tests[*idx].doctest_attributes.edition.as_ref().unwrap_or(&args.edition))
464            .arg("-")
465            .arg("-o")
466            .arg(&binary_out)
467            .env("UNSTABLE_RUSTDOC_TEST_PATH", &test.file)
468            .env("UNSTABLE_RUSTDOC_TEST_LINE", test.line.to_string())
469            .stdin(Stdio::piped())
470            .stderr(Stdio::inherit())
471            .stdout(Stdio::inherit())
472            .spawn()
473            .expect("failed to compile");
474
475        let mut stdin = child.stdin.take().unwrap();
476
477        stdin
478            .write_all(doctest_to_code(&test.doctest_code).as_bytes())
479            .expect("failed to write stdin");
480        drop(stdin);
481
482        let status = child.wait().expect("failed to wait for output");
483
484        if !status.success() {
485            std::process::exit(status.code().unwrap_or(127))
486        }
487
488        test_binaries.push(rust_doctest_common::TestBinary {
489            name: test.name.clone(),
490            path: binary_out.strip_prefix(&args.out_dir).unwrap().to_owned(),
491        });
492    }
493
494    let manifest = serde_json::to_string_pretty(&rust_doctest_common::Manifest {
495        test_binaries,
496        standalone_binary_envs,
497    })
498    .unwrap();
499
500    std::fs::write(args.out_dir.join("manifest.json"), manifest).expect("failed to write manifest");
501}