blob: b7b2401fc9067431b0b9b74c7675c91f98e017c2 [file]
//! The browser reader, built from its sources: `dm-reader` compiled for
//! `wasm32-unknown-unknown` by a cargo of its own, then bound to JavaScript by
//! wasm-bindgen's library. `build.rs` runs this into `OUT_DIR`, where `src/pages.rs`
//! embeds the two files; a test in `src/pages.rs` runs it again and compares, so an embed
//! that does not follow its sources fails a test.
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// The target the reader is compiled for.
pub const TARGET: &str = "wasm32-unknown-unknown";
/// The JavaScript glue, as wasm-bindgen names it for `dm_reader.wasm`.
pub const GLUE: &str = "dm_reader.js";
/// The WebAssembly the glue loads.
pub const WASM: &str = "dm_reader_bg.wasm";
/// What the outer build hands down that is not for the nested one: Rust flags and
/// wrappers for its own target (coverage instrumentation, which wasm32 cannot link, and
/// `cargo clippy`, which would lint the reader a second time), its target and build
/// directories, and what cargo sets for this build script alone, which would otherwise
/// reach the nested build's rustc and build scripts. `CARGO_CFG_*` goes too, below.
const NOT_INHERITED: [&str; 15] = [
"RUSTFLAGS",
"CARGO_ENCODED_RUSTFLAGS",
"CARGO_BUILD_RUSTFLAGS",
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
"RUSTC_WORKSPACE_WRAPPER",
"CARGO_TARGET_DIR",
"CARGO_BUILD_TARGET",
"CARGO_BUILD_TARGET_DIR",
"CARGO_BUILD_BUILD_DIR",
"OUT_DIR",
"TARGET",
"HOST",
"PROFILE",
"OPT_LEVEL",
"DEBUG",
];
/// Where one build reads and writes.
pub struct Build<'a> {
/// The workspace root: its `Cargo.toml` and `Cargo.lock`.
pub workspace: &'a Path,
/// The cargo that runs the nested build.
pub cargo: &'a Path,
/// The rustc asked whether the target is installed.
pub rustc: &'a Path,
/// The nested build's own target directory, from [`nested_target_dir`]. Not the outer
/// one: cargo holds that one's lock for the whole outer build, so a nested build there
/// waits for ever.
pub target_dir: &'a Path,
/// Where the glue and the wasm are written.
pub out: &'a Path,
}
/// The nested build's target directory for the build script whose `OUT_DIR` is
/// `out_dir`: `reader-wasm` beside the profiles of the outer target directory, since
/// `OUT_DIR` is `<dir>/<profile>/build/dm-web-<hash>/out`. Every cargo command on one
/// target directory gets its own `OUT_DIR` (build, check, clippy, test, each package
/// selection and profile), and all of them share this one directory and what is
/// compiled in it; the nested cargo's own lock keeps two of them apart. `cargo llvm-cov`
/// has a target directory of its own, and so a nested one of its own. An `OUT_DIR` of
/// another shape keeps the build under itself.
pub fn nested_target_dir(out_dir: &Path) -> PathBuf {
let mut up = out_dir.ancestors();
match (up.nth(2), up.nth(1)) {
(Some(build), Some(dir)) if build.file_name() == Some(std::ffi::OsStr::new("build")) => {
dir.join("reader-wasm")
}
_ => out_dir.join("reader-target"),
}
}
/// Compile the reader and bind it, writing [`GLUE`] and [`WASM`] into `out`.
///
/// # Errors
///
/// When the target is not installed, when `Cargo.lock` binds the reader with another
/// wasm-bindgen than the library here, when the nested build fails, or when the bindgen
/// step fails or writes nothing. The first line says what to do.
pub fn build(b: &Build<'_>) -> Result<(), String> {
let lock = fs::read_to_string(b.workspace.join("Cargo.lock"))
.map_err(|e| format!("cannot read Cargo.lock: {e}"))?;
same_bindgen(&lock)?;
target_installed(b.rustc)?;
let wasm = compile(b)?;
bind(&wasm, b.out)
}
/// The versions of `name` that a `Cargo.lock` holds.
fn locked_versions<'a>(lock: &'a str, name: &str) -> Vec<&'a str> {
let quoted = |line: &'a str, key: &str| {
line.strip_prefix(key)
.and_then(|v| v.trim().strip_prefix('='))
.map(|v| v.trim().trim_matches('"'))
};
lock.split("[[package]]")
.filter_map(|block| {
let mut lines = block.lines().map(str::trim);
let found = lines.clone().find_map(|l| quoted(l, "name"))?;
(found == name)
.then(|| lines.find_map(|l| quoted(l, "version")))
.flatten()
})
.collect()
}
/// The reader's wasm-bindgen and the library that binds it are one release: the custom
/// section the macro writes is read by the library of the same version, and a mismatch is
/// not always caught by wasm-bindgen's own schema check.
fn same_bindgen(lock: &str) -> Result<(), String> {
let used = locked_versions(lock, "wasm-bindgen");
let binder = locked_versions(lock, "wasm-bindgen-cli-support");
match binder.as_slice() {
[v] if !used.is_empty() && used.iter().all(|u| u == v) => Ok(()),
_ => Err(format!(
"Cargo.lock has wasm-bindgen {used:?} for the reader and wasm-bindgen-cli-support \
{binder:?} to bind it; they must be one version: change the pins in \
crates/reader/Cargo.toml and crates/web/Cargo.toml together"
)),
}
}
/// The wasm target's standard library is there, or one line that says how to get it.
fn target_installed(rustc: &Path) -> Result<(), String> {
let out = Command::new(rustc)
.args(["--print", "target-libdir", "--target", TARGET])
.output()
.map_err(|e| format!("cannot run {}: {e}", rustc.display()))?;
let libdir = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
let has_core = fs::read_dir(&libdir).is_ok_and(|mut entries| {
entries.any(|e| e.is_ok_and(|e| e.file_name().to_string_lossy().starts_with("libcore-")))
});
if out.status.success() && has_core {
Ok(())
} else {
Err(format!(
"the browser reader is built for {TARGET}, which is not installed; run: rustup target add {TARGET}"
))
}
}
/// The environment the nested cargo runs in, from the outer one.
///
/// Its build directory is its target directory. A `build-dir` the user configured, in a
/// cargo config file or `CARGO_BUILD_BUILD_DIR`, is the outer build's, and an outer
/// release build holds its lock: a nested release build there waits for ever. The
/// variable wins over a config file.
///
/// `cargo llvm-cov` makes itself the rustc wrapper, which instruments every workspace
/// crate by name, `dm_core` among them, whatever the target. It keeps the wrapper it
/// replaced, if any, in a variable of its own; the nested build gets that one back.
fn nested_env(outer: &[(OsString, OsString)], target_dir: &Path) -> Vec<(OsString, OsString)> {
let coverage = outer
.iter()
.any(|(k, _)| k == "__CARGO_LLVM_COV_RUSTC_WRAPPER");
let mut env: Vec<(OsString, OsString)> = outer
.iter()
.filter(|(k, _)| {
let k = k.to_string_lossy();
let wrapper = coverage && (k == "RUSTC_WRAPPER" || k.starts_with("__CARGO_LLVM_COV"));
!(NOT_INHERITED.contains(&&*k) || k.starts_with("CARGO_CFG_") || wrapper)
})
.cloned()
.collect();
if coverage {
if let Some((_, w)) = outer
.iter()
.find(|(k, _)| k == "__CARGO_LLVM_COV_RUSTC_WRAPPER_PRE_EXISTING")
{
env.push(("RUSTC_WRAPPER".into(), w.clone()));
}
}
env.push(("CARGO_BUILD_BUILD_DIR".into(), target_dir.into()));
if let Some(home) = cargo_home(outer) {
let mut flag = OsString::from("--remap-path-prefix=");
flag.push(home);
flag.push("=/cargo");
env.push(("CARGO_ENCODED_RUSTFLAGS".into(), flag));
}
env
}
/// Where cargo keeps the registry's sources, whose paths reach the wasm in panic
/// locations: `CARGO_HOME`, or `.cargo` in the home directory. The nested build names it
/// `/cargo`, so the wasm says nothing of the machine that built it and is the same on
/// every one. The workspace's own paths are relative already, and the standard
/// library's are `/rustc/<commit>`.
fn cargo_home(outer: &[(OsString, OsString)]) -> Option<PathBuf> {
let var = |name: &str| outer.iter().find(|(k, _)| k == name).map(|(_, v)| v);
var("CARGO_HOME").map(PathBuf::from).or_else(|| {
var("HOME")
.or_else(|| var("USERPROFILE"))
.map(|home| Path::new(home).join(".cargo"))
})
}
/// The nested cargo, with an environment of its own built from `outer`. A build script
/// does not see the outer cargo's `--offline`, `--frozen` or `--config`; it does see
/// `CARGO_NET_OFFLINE`, and an offline outer build is offline here too.
fn nested_cargo(b: &Build<'_>, outer: &[(OsString, OsString)]) -> Command {
let mut cmd = Command::new(b.cargo);
let offline = outer
.iter()
.any(|(k, v)| k == "CARGO_NET_OFFLINE" && v.eq_ignore_ascii_case("true"));
if offline {
cmd.arg("--offline");
}
cmd.args(["build", "--release", "--locked", "--color", "never"])
.args(["-p", "dm-reader", "--target", TARGET])
.arg("--manifest-path")
.arg(b.workspace.join("Cargo.toml"))
.arg("--target-dir")
.arg(b.target_dir)
.env_clear()
.envs(nested_env(outer, b.target_dir));
cmd
}
/// `dm-reader` for the browser, in release, as `make wasm-check` checks it.
fn compile(b: &Build<'_>) -> Result<PathBuf, String> {
let outer: Vec<(OsString, OsString)> = std::env::vars_os().collect();
let mut cmd = nested_cargo(b, &outer);
let out = cmd
.output()
.map_err(|e| format!("cannot run {}: {e}", b.cargo.display()))?;
if !out.status.success() {
return Err(format!(
"building dm-reader for {TARGET} failed:\n{}",
String::from_utf8_lossy(&out.stderr)
));
}
Ok(b.target_dir
.join(TARGET)
.join("release")
.join("dm_reader.wasm"))
}
/// The glue and the wasm for `<script type="module">`, as `wasm-bindgen --target web
/// --no-typescript` writes them. A file left from an earlier run is removed first, so a
/// failure cannot leave it to be embedded.
fn bind(wasm: &Path, out: &Path) -> Result<(), String> {
for name in [GLUE, WASM] {
match fs::remove_file(out.join(name)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
return Err(format!("cannot remove the old {name}: {e}"));
}
_ => {}
}
}
let mut bindgen = wasm_bindgen_cli_support::Bindgen::new();
bindgen
.input_path(wasm)
.web(true)
.map_err(|e| format!("wasm-bindgen: {e:#}"))?
.typescript(false)
.omit_default_module_path(false)
.generate(out)
.map_err(|e| format!("wasm-bindgen could not bind {}: {e:#}", wasm.display()))?;
for name in [GLUE, WASM] {
let len = fs::metadata(out.join(name)).map_or(0, |m| m.len());
if len == 0 {
return Err(format!(
"wasm-bindgen wrote no {name} into {}",
out.display()
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const LOCK: &str = r#"
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
[[package]]
name = "wasm-bindgen-cli-support"
version = "0.2.129"
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.130"
"#;
#[test]
fn one_bindgen_release_passes_and_two_fail_with_what_to_change() {
assert_eq!(locked_versions(LOCK, "wasm-bindgen"), ["0.2.129"]);
assert_eq!(same_bindgen(LOCK), Ok(()));
let skewed = LOCK.replacen("0.2.129", "0.2.130", 1);
let err = same_bindgen(&skewed).unwrap_err();
assert!(
err.contains(r#"["0.2.130"]"#) && err.contains(r#"["0.2.129"]"#),
"{err}"
);
assert!(err.contains("crates/reader/Cargo.toml"), "{err}");
let unbound = LOCK.replace("wasm-bindgen-cli-support", "something-else");
assert!(same_bindgen(&unbound).is_err());
}
fn env(pairs: &[(&str, &str)]) -> Vec<(OsString, OsString)> {
pairs
.iter()
.map(|(k, v)| ((*k).into(), (*v).into()))
.collect()
}
/// The nested cargo's whole environment and its arguments, for `outer`.
fn nested(outer: &[(&str, &str)]) -> (Vec<(String, String)>, Vec<String>) {
let b = Build {
workspace: Path::new("/ws"),
cargo: Path::new("cargo"),
rustc: Path::new("rustc"),
target_dir: Path::new("/ws/target/reader-wasm"),
out: Path::new("/out"),
};
let cmd = nested_cargo(&b, &env(outer));
let lossy = |s: &std::ffi::OsStr| s.to_string_lossy().into_owned();
let vars = cmd
.get_envs()
.filter_map(|(k, v)| Some((lossy(k), lossy(v?))))
.collect();
(vars, cmd.get_args().map(lossy).collect())
}
#[test]
fn the_outer_build_scripts_variables_do_not_reach_the_nested_build() {
let (vars, _) = nested(&[
("PATH", "/bin"),
("CARGO_CFG_UNIX", ""),
("CARGO_CFG_TARGET_OS", "linux"),
("OUT_DIR", "/t/debug/build/dm-web-1/out"),
("TARGET", "x86_64-unknown-linux-gnu"),
("HOST", "x86_64-unknown-linux-gnu"),
("PROFILE", "debug"),
("CARGO_ENCODED_RUSTFLAGS", "-Cinstrument-coverage"),
("RUSTC_WORKSPACE_WRAPPER", "clippy-driver"),
]);
for (k, _) in &vars {
assert!(
!k.starts_with("CARGO_CFG_")
&& ![
"OUT_DIR",
"TARGET",
"HOST",
"PROFILE",
"RUSTC_WORKSPACE_WRAPPER"
]
.contains(&k.as_str()),
"{k} reaches the nested build"
);
}
assert!(vars.contains(&("PATH".into(), "/bin".into())), "{vars:?}");
}
#[test]
fn the_nested_build_has_a_build_dir_of_its_own() {
// a build-dir from the user's cargo config or environment is the outer build's,
// whose lock it holds: a nested release build there waits for ever
let (vars, _) = nested(&[("CARGO_BUILD_BUILD_DIR", "/cache/build/1234")]);
let dirs: Vec<_> = vars
.iter()
.filter(|(k, _)| k == "CARGO_BUILD_BUILD_DIR")
.collect();
assert_eq!(
dirs,
[&(
"CARGO_BUILD_BUILD_DIR".to_string(),
"/ws/target/reader-wasm".to_string()
)]
);
}
#[test]
fn the_cargo_home_is_one_name_in_the_wasm() {
let flags = |outer: &[(&str, &str)]| -> Vec<String> {
let (vars, _) = nested(outer);
vars.into_iter()
.filter(|(k, _)| k == "CARGO_ENCODED_RUSTFLAGS")
.map(|(_, v)| v)
.collect()
};
let home = Path::new("/h").join(".cargo");
assert_eq!(
flags(&[("HOME", "/h"), ("RUSTFLAGS", "--remap-path-prefix=/x=/y")]),
[format!("--remap-path-prefix={}=/cargo", home.display())]
);
assert_eq!(
flags(&[("CARGO_HOME", "/c"), ("HOME", "/h")]),
["--remap-path-prefix=/c=/cargo"]
);
}
#[test]
fn an_offline_outer_build_is_offline_inside_too() {
let (_, args) = nested(&[("CARGO_NET_OFFLINE", "true")]);
assert!(args.iter().any(|a| a == "--offline"), "{args:?}");
let (_, args) = nested(&[("CARGO_NET_OFFLINE", "false")]);
assert!(!args.iter().any(|a| a == "--offline"), "{args:?}");
}
#[test]
fn under_llvm_cov_the_nested_build_gets_the_wrapper_it_replaced() {
let (vars, _) = nested(&[
("RUSTC_WRAPPER", "/bin/cargo-llvm-cov"),
("__CARGO_LLVM_COV_RUSTC_WRAPPER", "1"),
(
"__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
"-Cinstrument-coverage",
),
(
"__CARGO_LLVM_COV_RUSTC_WRAPPER_PRE_EXISTING",
"/bin/sccache",
),
]);
let wrappers = |vars: Vec<(String, String)>| -> Vec<(String, String)> {
vars.into_iter()
.filter(|(k, _)| k != "CARGO_BUILD_BUILD_DIR")
.collect()
};
assert_eq!(
wrappers(vars),
[("RUSTC_WRAPPER".into(), "/bin/sccache".into())]
);
let (vars, _) = nested(&[
("RUSTC_WRAPPER", "/bin/cargo-llvm-cov"),
("__CARGO_LLVM_COV_RUSTC_WRAPPER", "1"),
]);
assert!(wrappers(vars.clone()).is_empty(), "{vars:?}");
}
#[test]
fn every_build_into_one_target_directory_shares_one_nested_build() {
let dir = |out: &str| nested_target_dir(Path::new(out));
let shared = Path::new("/t/reader-wasm");
assert_eq!(dir("/t/debug/build/dm-web-0123/out"), shared);
assert_eq!(dir("/t/debug/build/dm-web-4567/out"), shared);
assert_eq!(dir("/t/release/build/dm-web-89ab/out"), shared);
assert_eq!(
dir("/t/llvm-cov-target/debug/build/dm-web-0123/out"),
Path::new("/t/llvm-cov-target/reader-wasm")
);
assert_eq!(
dir("/elsewhere/out"),
Path::new("/elsewhere/out/reader-target")
);
}
#[test]
fn a_rustc_that_cannot_run_is_named() {
let err = target_installed(Path::new("/nonexistent/rustc")).unwrap_err();
assert!(err.contains("/nonexistent/rustc"), "{err}");
}
}