1use std::collections::BTreeMap;
6use std::io::Write;
7use std::process::Stdio;
8
9use camino::Utf8PathBuf;
10use clap::Parser;
11
12#[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 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}