Skip to content

Commit 4fccde5

Browse files
teryrorRalfJung
authored andcommitted
make cargo-miri run doc-tests
1 parent 28f813f commit 4fccde5

File tree

8 files changed

+239
-28
lines changed

8 files changed

+239
-28
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,10 @@ different Miri binaries, and as such worth documenting:
290290
that the compiled `rlib`s are compatible with Miri.
291291
When set while running `cargo-miri`, it indicates that we are part of a sysroot
292292
build (for which some crates need special treatment).
293+
* `MIRI_CALLED_FROM_RUSTDOC` when set to any value tells `cargo-miri` that it is
294+
running as a child process of `rustdoc`, which invokes it twice for each doc-test
295+
and requires special treatment, most notably a check-only build before interpretation.
296+
This is set by `cargo-miri` itself when running as a `rustdoc`-wrapper.
293297
* `MIRI_CWD` when set to any value tells the Miri driver to change to the given
294298
directory after loading all the source files, but before commencing
295299
interpretation. This is useful if the interpreted program wants a different

cargo-miri/bin.rs

Lines changed: 191 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use std::env;
22
use std::ffi::OsString;
33
use std::fs::{self, File};
4-
use std::io::{self, BufRead, BufReader, BufWriter, Write};
54
use std::iter::TakeWhile;
5+
use std::io::{self, BufRead, BufReader, BufWriter, Read, Write};
66
use std::ops::Not;
77
use std::path::{Path, PathBuf};
88
use std::process::Command;
@@ -46,6 +46,8 @@ struct CrateRunEnv {
4646
env: Vec<(OsString, OsString)>,
4747
/// The current working directory.
4848
current_dir: OsString,
49+
/// The contents passed via standard input.
50+
stdin: Vec<u8>,
4951
}
5052

5153
/// The information Miri needs to run a crate. Stored as JSON when the crate is "compiled".
@@ -63,7 +65,13 @@ impl CrateRunInfo {
6365
let args = args.collect();
6466
let env = env::vars_os().collect();
6567
let current_dir = env::current_dir().unwrap().into_os_string();
66-
Self::RunWith(CrateRunEnv { args, env, current_dir })
68+
69+
let mut stdin = Vec::new();
70+
if env::var_os("MIRI_CALLED_FROM_RUSTDOC").is_some() {
71+
std::io::stdin().lock().read_to_end(&mut stdin).expect("cannot read stdin");
72+
}
73+
74+
Self::RunWith(CrateRunEnv { args, env, current_dir, stdin })
6775
}
6876

6977
fn store(&self, filename: &Path) {
@@ -190,6 +198,22 @@ fn exec(mut cmd: Command) {
190198
}
191199
}
192200

201+
/// Execute the command and pipe `input` into its stdin.
202+
/// If it fails, fail this process with the same exit code.
203+
/// Otherwise, continue.
204+
fn exec_with_pipe(mut cmd: Command, input: &[u8]) {
205+
cmd.stdin(std::process::Stdio::piped());
206+
let mut child = cmd.spawn().expect("failed to spawn process");
207+
{
208+
let stdin = child.stdin.as_mut().expect("failed to open stdin");
209+
stdin.write_all(input).expect("failed to write out test source");
210+
}
211+
let exit_status = child.wait().expect("failed to run command");
212+
if exit_status.success().not() {
213+
std::process::exit(exit_status.code().unwrap_or(-1))
214+
}
215+
}
216+
193217
fn xargo_version() -> Option<(u32, u32, u32)> {
194218
let out = xargo_check().arg("--version").output().ok()?;
195219
if !out.status.success() {
@@ -591,24 +615,29 @@ fn phase_cargo_rustc(mut args: env::Args) {
591615
}
592616

593617
fn out_filename(prefix: &str, suffix: &str) -> PathBuf {
594-
let mut path = PathBuf::from(get_arg_flag_value("--out-dir").unwrap());
595-
path.push(format!(
596-
"{}{}{}{}",
597-
prefix,
598-
get_arg_flag_value("--crate-name").unwrap(),
599-
// This is technically a `-C` flag but the prefix seems unique enough...
600-
// (and cargo passes this before the filename so it should be unique)
601-
get_arg_flag_value("extra-filename").unwrap_or(String::new()),
602-
suffix,
603-
));
604-
path
618+
if let Some(out_dir) = get_arg_flag_value("--out-dir") {
619+
let mut path = PathBuf::from(out_dir);
620+
path.push(format!(
621+
"{}{}{}{}",
622+
prefix,
623+
get_arg_flag_value("--crate-name").unwrap(),
624+
// This is technically a `-C` flag but the prefix seems unique enough...
625+
// (and cargo passes this before the filename so it should be unique)
626+
get_arg_flag_value("extra-filename").unwrap_or(String::new()),
627+
suffix,
628+
));
629+
path
630+
} else {
631+
let out_file = get_arg_flag_value("-o").unwrap();
632+
PathBuf::from(out_file)
633+
}
605634
}
606635

607636
let verbose = std::env::var_os("MIRI_VERBOSE").is_some();
608637
let target_crate = is_target_crate();
609638
let print = get_arg_flag_value("--print").is_some(); // whether this is cargo passing `--print` to get some infos
610639

611-
let store_json = |info: CrateRunInfo| {
640+
let store_json = |info: &CrateRunInfo| {
612641
// Create a stub .d file to stop Cargo from "rebuilding" the crate:
613642
// https://github.com/rust-lang/miri/issues/1724#issuecomment-787115693
614643
// As we store a JSON file instead of building the crate here, an empty file is fine.
@@ -636,15 +665,55 @@ fn phase_cargo_rustc(mut args: env::Args) {
636665
// like we want them.
637666
// Instead of compiling, we write JSON into the output file with all the relevant command-line flags
638667
// and environment variables; this is used when cargo calls us again in the CARGO_TARGET_RUNNER phase.
639-
store_json(CrateRunInfo::collect(args));
668+
let info = CrateRunInfo::collect(args);
669+
store_json(&info);
670+
671+
// Rustdoc expects us to exit with an error code if the test is marked as `compile_fail`,
672+
// just creating the JSON file is not enough: we need to detect syntax errors,
673+
// so we need to run Miri with `MIRI_BE_RUSTC` for a check-only build.
674+
if std::env::var_os("MIRI_CALLED_FROM_RUSTDOC").is_some() {
675+
let mut cmd = miri();
676+
let env = if let CrateRunInfo::RunWith(env) = info {
677+
env
678+
} else {
679+
return;
680+
};
681+
682+
// use our own sysroot
683+
if !has_arg_flag("--sysroot") {
684+
let sysroot = env::var_os("MIRI_SYSROOT")
685+
.expect("the wrapper should have set MIRI_SYSROOT");
686+
cmd.arg("--sysroot").arg(sysroot);
687+
}
688+
689+
// ensure --emit argument for a check-only build is present
690+
if let Some(i) = env.args.iter().position(|arg| arg.starts_with("--emit=")) {
691+
// We need to make sure we're not producing a binary that overwrites the JSON file.
692+
// rustdoc should only ever pass an --emit=metadata argument for tests marked as `no_run`:
693+
assert_eq!(env.args[i], "--emit=metadata");
694+
} else {
695+
cmd.arg("--emit=dep-info,metadata");
696+
}
697+
698+
cmd.args(env.args);
699+
cmd.env("MIRI_BE_RUSTC", "1");
700+
701+
if verbose {
702+
eprintln!("[cargo-miri rustc] captured input:\n{}", std::str::from_utf8(&env.stdin).unwrap());
703+
eprintln!("[cargo-miri rustc] {:?}", cmd);
704+
}
705+
706+
exec_with_pipe(cmd, &env.stdin);
707+
}
708+
640709
return;
641710
}
642711

643712
if runnable_crate && ArgFlagValueIter::new("--extern").any(|krate| krate == "proc_macro") {
644713
// This is a "runnable" `proc-macro` crate (unit tests). We do not support
645714
// interpreting that under Miri now, so we write a JSON file to (display a
646715
// helpful message and) skip it in the runner phase.
647-
store_json(CrateRunInfo::SkipProcMacroTest);
716+
store_json(&CrateRunInfo::SkipProcMacroTest);
648717
return;
649718
}
650719

@@ -715,6 +784,18 @@ fn phase_cargo_rustc(mut args: env::Args) {
715784
}
716785
}
717786

787+
fn forward_patched_extern_arg(args: &mut impl Iterator<Item = String>, cmd: &mut Command) {
788+
cmd.arg("--extern"); // always forward flag, but adjust filename:
789+
let path = args.next().expect("`--extern` should be followed by a filename");
790+
if let Some(lib) = path.strip_suffix(".rlib") {
791+
// If this is an rlib, make it an rmeta.
792+
cmd.arg(format!("{}.rmeta", lib));
793+
} else {
794+
// Some other extern file (e.g. a `.so`). Forward unchanged.
795+
cmd.arg(path);
796+
}
797+
}
798+
718799
fn phase_cargo_runner(binary: &Path, binary_args: env::Args) {
719800
let verbose = std::env::var_os("MIRI_VERBOSE").is_some();
720801

@@ -801,6 +882,73 @@ fn phase_cargo_runner(binary: &Path, binary_args: env::Args) {
801882
if verbose {
802883
eprintln!("[cargo-miri runner] {:?}", cmd);
803884
}
885+
886+
if std::env::var_os("MIRI_CALLED_FROM_RUSTDOC").is_some() {
887+
exec_with_pipe(cmd, &info.stdin)
888+
} else {
889+
exec(cmd)
890+
}
891+
}
892+
893+
fn phase_cargo_rustdoc(fst_arg: &str, mut args: env::Args) {
894+
let verbose = std::env::var_os("MIRI_VERBOSE").is_some();
895+
896+
// phase_cargo_miri sets the RUSTDOC env var to ourselves, so we can't use that here;
897+
// just default to a straight-forward invocation for now:
898+
let mut cmd = Command::new(OsString::from("rustdoc"));
899+
900+
// Because of the way the main function is structured, we have to take the first argument spearately
901+
// from the rest; to simplify the following argument patching loop, we'll just skip that one.
902+
// This is fine for now, because cargo will never pass --extern arguments in the first position,
903+
// but we should defensively assert that this will work.
904+
let extern_flag = "--extern";
905+
assert!(fst_arg != extern_flag);
906+
cmd.arg(fst_arg);
907+
908+
let runtool_flag = "--runtool";
909+
let mut crossmode = fst_arg == runtool_flag;
910+
while let Some(arg) = args.next() {
911+
if arg == extern_flag {
912+
// Patch --extern arguments to use *.rmeta files, since phase_cargo_rustc only creates stub *.rlib files.
913+
forward_patched_extern_arg(&mut args, &mut cmd);
914+
} else if arg == runtool_flag {
915+
// An existing --runtool flag indicates cargo is running in cross-target mode, which we don't support.
916+
// Note that this is only passed when cargo is run with the unstable -Zdoctest-xcompile flag;
917+
// otherwise, we won't be called as rustdoc at all.
918+
crossmode = true;
919+
break;
920+
} else {
921+
cmd.arg(arg);
922+
}
923+
}
924+
925+
if crossmode {
926+
eprintln!("Cross-interpreting doc-tests is not currently supported by Miri.");
927+
return;
928+
}
929+
930+
// For each doc-test, rustdoc starts two child processes: first the test is compiled,
931+
// then the produced executable is invoked. We want to reroute both of these to cargo-miri,
932+
// such that the first time we'll enter phase_cargo_rustc, and phase_cargo_runner second.
933+
//
934+
// rustdoc invokes the test-builder by forwarding most of its own arguments, which makes
935+
// it difficult to determine when phase_cargo_rustc should run instead of phase_cargo_rustdoc.
936+
// Furthermore, the test code is passed via stdin, rather than a temporary file, so we need
937+
// to let phase_cargo_rustc know to expect that. We'll use this environment variable as a flag:
938+
cmd.env("MIRI_CALLED_FROM_RUSTDOC", "1");
939+
940+
// The `--test-builder` and `--runtool` arguments are unstable rustdoc features,
941+
// which are disabled by default. We first need to enable them explicitly:
942+
cmd.arg("-Z").arg("unstable-options");
943+
944+
let cargo_miri_path = std::env::current_exe().expect("current executable path invalid");
945+
cmd.arg("--test-builder").arg(&cargo_miri_path); // invoked by forwarding most arguments
946+
cmd.arg("--runtool").arg(&cargo_miri_path); // invoked with just a single path argument
947+
948+
if verbose {
949+
eprintln!("[cargo-miri rustdoc] {:?}", cmd);
950+
}
951+
804952
exec(cmd)
805953
}
806954

@@ -817,6 +965,30 @@ fn main() {
817965
return;
818966
}
819967

968+
// The way rustdoc invokes rustc is indistuingishable from the way cargo invokes rustdoc
969+
// by the arguments alone, and we can't take from the args iterator in this case.
970+
// phase_cargo_rustdoc sets this environment variable to let us disambiguate here
971+
let invoked_by_rustdoc = env::var_os("MIRI_CALLED_FROM_RUSTDOC").is_some();
972+
if invoked_by_rustdoc {
973+
// ...however, we then also see this variable when rustdoc invokes us as the testrunner!
974+
// The runner is invoked as `$runtool ($runtool-arg)* output_file`;
975+
// since we don't specify any runtool-args, and rustdoc supplies multiple arguments to
976+
// the test-builder unconditionally, we can just check the number of remaining arguments:
977+
if args.len() == 1 {
978+
let arg = args.next().unwrap();
979+
let binary = Path::new(&arg);
980+
if binary.exists() {
981+
phase_cargo_runner(binary, args);
982+
} else {
983+
show_error(format!("`cargo-miri` called with non-existing path argument `{}` in rustdoc mode; please invoke this binary through `cargo miri`", arg));
984+
}
985+
} else {
986+
phase_cargo_rustc(args);
987+
}
988+
989+
return;
990+
}
991+
820992
// Dispatch to `cargo-miri` phase. There are three phases:
821993
// - When we are called via `cargo miri`, we run as the frontend and invoke the underlying
822994
// cargo. We set RUSTC_WRAPPER and CARGO_TARGET_RUNNER to ourselves.
@@ -829,16 +1001,15 @@ fn main() {
8291001
Some("miri") => phase_cargo_miri(args),
8301002
Some("rustc") => phase_cargo_rustc(args),
8311003
Some(arg) => {
832-
// We have to distinguish the "runner" and "rustfmt" cases.
1004+
// We have to distinguish the "runner" and "rustdoc" cases.
8331005
// As runner, the first argument is the binary (a file that should exist, with an absolute path);
834-
// as rustfmt, the first argument is a flag (`--something`).
1006+
// as rustdoc, the first argument is a flag (`--something`).
8351007
let binary = Path::new(arg);
8361008
if binary.exists() {
8371009
assert!(!arg.starts_with("--")); // not a flag
8381010
phase_cargo_runner(binary, args);
8391011
} else if arg.starts_with("--") {
840-
// We are rustdoc.
841-
eprintln!("Running doctests is not currently supported by Miri.")
1012+
phase_cargo_rustdoc(arg, args);
8421013
} else {
8431014
show_error(format!("`cargo-miri` called with unexpected first argument `{}`; please only invoke this binary through `cargo miri`", arg));
8441015
}

test-cargo-miri/run-test.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
and the working directory to contain the cargo-miri-test project.
66
'''
77

8-
import sys, subprocess, os
8+
import sys, subprocess, os, re
99

1010
CGREEN = '\33[32m'
1111
CBOLD = '\33[1m'
@@ -23,6 +23,10 @@ def cargo_miri(cmd, quiet = True):
2323
args += ["--target", os.environ['MIRI_TEST_TARGET']]
2424
return args
2525

26+
def normalize_stdout(str):
27+
str = str.replace("src\\", "src/") # normalize paths across platforms
28+
return re.sub("finished in \d+\.\d\ds", "finished in $TIME", str)
29+
2630
def test(name, cmd, stdout_ref, stderr_ref, stdin=b'', env={}):
2731
print("Testing {}...".format(name))
2832
## Call `cargo miri`, capture all output
@@ -38,7 +42,7 @@ def test(name, cmd, stdout_ref, stderr_ref, stdin=b'', env={}):
3842
(stdout, stderr) = p.communicate(input=stdin)
3943
stdout = stdout.decode("UTF-8")
4044
stderr = stderr.decode("UTF-8")
41-
if p.returncode == 0 and stdout == open(stdout_ref).read() and stderr == open(stderr_ref).read():
45+
if p.returncode == 0 and normalize_stdout(stdout) == open(stdout_ref).read() and stderr == open(stderr_ref).read():
4246
# All good!
4347
return
4448
# Show output
@@ -101,26 +105,27 @@ def test_cargo_miri_run():
101105
def test_cargo_miri_test():
102106
# rustdoc is not run on foreign targets
103107
is_foreign = 'MIRI_TEST_TARGET' in os.environ
104-
rustdoc_ref = "test.stderr-empty.ref" if is_foreign else "test.stderr-rustdoc.ref"
108+
default_ref = "test.cross-target.stdout.ref" if is_foreign else "test.default.stdout.ref"
109+
filter_ref = "test.filter.cross-target.stdout.ref" if is_foreign else "test.filter.stdout.ref"
105110

106111
test("`cargo miri test`",
107112
cargo_miri("test"),
108-
"test.default.stdout.ref", rustdoc_ref,
113+
default_ref, "test.stderr-empty.ref",
109114
env={'MIRIFLAGS': "-Zmiri-seed=feed"},
110115
)
111116
test("`cargo miri test` (no isolation)",
112117
cargo_miri("test"),
113-
"test.default.stdout.ref", rustdoc_ref,
118+
default_ref, "test.stderr-empty.ref",
114119
env={'MIRIFLAGS': "-Zmiri-disable-isolation"},
115120
)
116121
test("`cargo miri test` (raw-ptr tracking)",
117122
cargo_miri("test"),
118-
"test.default.stdout.ref", rustdoc_ref,
123+
default_ref, "test.stderr-empty.ref",
119124
env={'MIRIFLAGS': "-Zmiri-track-raw-pointers"},
120125
)
121126
test("`cargo miri test` (with filter)",
122127
cargo_miri("test") + ["--", "--format=pretty", "le1"],
123-
"test.filter.stdout.ref", rustdoc_ref,
128+
filter_ref, "test.stderr-empty.ref",
124129
)
125130
test("`cargo miri test` (test target)",
126131
cargo_miri("test") + ["--test", "test", "--", "--format=pretty"],
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
2+
running 1 test
3+
.
4+
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
5+
6+
7+
running 7 tests
8+
..i....
9+
test result: ok. 6 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
10+

test-cargo-miri/test.default.stdout.ref

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,9 @@ running 7 tests
88
..i....
99
test result: ok. 6 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
1010

11+
12+
running 1 test
13+
test src/lib.rs - make_true (line 2) ... ok
14+
15+
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in $TIME
16+

0 commit comments

Comments
 (0)