use std::path::{Path, PathBuf};
use crate::backends::wasm::WasmBackend;
use crate::core::backend::Backend;
use crate::core::config::{NewAlefConfig, ResolvedCrateConfig};
use crate::test_support::{spawn_from_stable_dir, toolchain};
const FIXTURE_CORE_LIB_RS: &str = r#"//! Fixture core crate for the wasm-bindgen JS glue oracle.
/// A palette.
#[derive(Clone, Debug, Default)]
pub struct Palette {
/// Human-readable label.
pub label: String,
}
/// Render options carrying a required class-typed field. Deliberately not `Default`.
#[derive(Clone, Debug)]
pub struct RenderOptions {
/// Primary palette.
pub palette: Palette,
/// Window title.
pub title: String,
/// Palette consulted when the primary one has no entry.
pub fallback_palette: Option<Palette>,
}
/// Options that derive `Default`, so every generated constructor parameter is `Option<T>`.
#[derive(Clone, Debug, Default)]
pub struct DefaultedOptions {
/// Primary palette.
pub palette: Palette,
/// Window title.
pub title: String,
}
/// A tagged data enum whose payload carries a class-typed field.
#[derive(Clone, Debug)]
pub enum Layer {
/// A solid fill.
Solid {
/// Palette used for the fill.
palette: Palette,
/// Fill opacity.
opacity: f64,
},
/// Nothing drawn.
Blank,
}
"#;
const CRATE_NAME: &str = "fixture-core";
const BINDING_CRATE_NAME: &str = "fixture-core-wasm";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct ConsumedHandle {
class: String,
member: String,
argument: String,
}
impl std::fmt::Display for ConsumedHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{} consumes `{}`", self.class, self.member, self.argument)
}
}
fn consumed_handles(js: &str) -> Vec<ConsumedHandle> {
let mut found = Vec::new();
let mut class = String::new();
let mut member = String::new();
for line in js.lines() {
if let Some(rest) = class_declaration(line) {
class = rest;
member.clear();
continue;
}
if let Some(declared) = member_declaration(line) {
member = declared;
}
for argument in destroy_receivers(line) {
if argument == "this" {
continue;
}
found.push(ConsumedHandle {
class: class.clone(),
member: member.clone(),
argument,
});
}
}
found.sort();
found.dedup();
found
}
fn class_declaration(line: &str) -> Option<String> {
let rest = line
.strip_prefix("class ")
.or_else(|| line.strip_prefix("export class "))?;
Some(rest.split_whitespace().next()?.to_string())
}
fn member_declaration(line: &str) -> Option<String> {
let body = line.strip_prefix(" ")?;
if body.starts_with(' ') || !line.trim_end().ends_with('{') {
return None;
}
let name = body.split_once('(')?.0.trim();
(!name.is_empty()).then(|| name.to_string())
}
fn destroy_receivers(line: &str) -> Vec<String> {
const CALL: &str = ".__destroy_into_raw()";
let mut receivers = Vec::new();
let bytes = line.as_bytes();
let mut search = 0;
while let Some(offset) = line[search..].find(CALL) {
let end = search + offset;
let mut start = end;
while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || matches!(bytes[start - 1], b'_' | b'$')) {
start -= 1;
}
if start < end {
receivers.push(line[start..end].to_string());
}
search = end + CALL.len();
}
receivers
}
fn resolved_wasm_config() -> ResolvedCrateConfig {
let config: NewAlefConfig = toml::from_str(&format!(
r#"
[workspace]
languages = ["wasm"]
[[crates]]
name = "{CRATE_NAME}"
sources = ["src/lib.rs"]
[crates.wasm]
"#
))
.expect("fixture config must parse");
config.resolve().expect("fixture config must resolve").remove(0)
}
fn cli_crate_version(cli: &Path) -> String {
let output = spawn_from_stable_dir(&cli.to_string_lossy())
.arg("--version")
.output()
.expect("run wasm-bindgen --version");
let banner = String::from_utf8_lossy(&output.stdout).trim().to_string();
banner
.split_whitespace()
.nth(1)
.unwrap_or_else(|| panic!("cannot read a version out of `wasm-bindgen --version` output {banner:?}"))
.to_string()
}
fn materialize_workspace(root: &Path, wasm_bindgen_version: &str) -> PathBuf {
let core = root.join("core");
let bindings = root.join("bindings");
std::fs::create_dir_all(core.join("src")).expect("create fixture core crate");
std::fs::create_dir_all(bindings.join("src")).expect("create fixture binding crate");
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nresolver = \"2\"\nmembers = [\"core\", \"bindings\"]\n",
)
.expect("write fixture workspace manifest");
let core_lib = core.join("src/lib.rs");
std::fs::write(&core_lib, FIXTURE_CORE_LIB_RS).expect("write fixture core source");
std::fs::write(
core.join("Cargo.toml"),
format!("[package]\nname = \"{CRATE_NAME}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
)
.expect("write fixture core manifest");
let api = crate::extract::extractor::extract(&[core_lib.as_path()], CRATE_NAME, "0.1.0", None)
.expect("extract the fixture core crate");
let generated = WasmBackend
.generate_bindings(&api, &resolved_wasm_config())
.expect("generate wasm bindings for the fixture");
let lib_rs = generated
.iter()
.find(|file| file.path.to_string_lossy().ends_with("lib.rs"))
.expect("the wasm backend must emit a lib.rs");
std::fs::write(bindings.join("src/lib.rs"), &lib_rs.content).expect("write generated bindings");
let manifest = bindings.join("Cargo.toml");
std::fs::write(
&manifest,
format!(
"[package]\nname = \"{BINDING_CRATE_NAME}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[lib]\ncrate-type = [\"cdylib\"]\n\n\
[dependencies]\n{CRATE_NAME} = {{ path = \"../core\" }}\n\
wasm-bindgen = \"={wasm_bindgen_version}\"\n"
),
)
.expect("write fixture binding manifest");
manifest
}
fn build_wasm_module(root: &Path, manifest: &Path) -> PathBuf {
let target_dir = root.join("target");
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let output = spawn_from_stable_dir(&cargo)
.arg("build")
.arg("--target")
.arg("wasm32-unknown-unknown")
.arg("--manifest-path")
.arg(manifest)
.arg("--target-dir")
.arg(&target_dir)
.output()
.expect("run cargo build for wasm32-unknown-unknown");
assert!(
output.status.success(),
"alef's generated wasm crate did not build for wasm32-unknown-unknown.\n\
--- stdout ---\n{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
target_dir
.join("wasm32-unknown-unknown/debug")
.join(format!("{}.wasm", BINDING_CRATE_NAME.replace('-', "_")))
}
fn emit_js_glue(cli: &Path, root: &Path, module: &Path) -> String {
let out_dir = root.join("pkg");
let output = spawn_from_stable_dir(&cli.to_string_lossy())
.arg("--target")
.arg("web")
.arg("--out-dir")
.arg(&out_dir)
.arg(module)
.output()
.expect("run the wasm-bindgen CLI");
assert!(
output.status.success(),
"wasm-bindgen could not process alef's generated module.\n\
--- stdout ---\n{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let js = out_dir.join(format!("{}.js", BINDING_CRATE_NAME.replace('-', "_")));
std::fs::read_to_string(&js).unwrap_or_else(|error| panic!("read emitted glue {js:?}: {error}"))
}
fn wasm_bindgen_cli() -> Option<PathBuf> {
toolchain::WASM_BINDGEN.open()
}
#[test]
fn generated_bindings_do_not_destroy_handles_the_caller_still_owns() {
let Some(cli) = wasm_bindgen_cli() else { return };
let keep = std::env::var_os("ALEF_WASM_FIXTURE_DIR").map(PathBuf::from);
let tmp = tempfile::tempdir().expect("create fixture workspace dir");
let root = keep.as_deref().unwrap_or_else(|| tmp.path());
let manifest = materialize_workspace(root, &cli_crate_version(&cli));
let module = build_wasm_module(root, &manifest);
let js = emit_js_glue(&cli, root, &module);
let declared: Vec<String> = js.lines().filter_map(class_declaration).collect();
for class in ["WasmPalette", "WasmRenderOptions", "WasmDefaultedOptions", "WasmLayer"] {
assert!(
declared.iter().any(|name| name == class),
"the emitted glue declares {declared:?}, missing `{class}`, so this run examined less \
than it claims"
);
}
let consumed = consumed_handles(&js);
let rendered: Vec<String> = consumed.iter().map(ToString::to_string).collect();
assert_eq!(
rendered,
vec![
"WasmDefaultedOptions.constructor consumes `palette`".to_string(),
"WasmRenderOptions.constructor consumes `fallbackPalette`".to_string(),
],
"the JS glue destroys a handle the caller still owns"
);
assert!(
js.contains("wasm.wasmlayer_set_palette(this.__wbg_ptr, value.__wbg_ptr)"),
"the tagged-enum payload setter must lower its argument as a borrowed pointer"
);
assert!(
js.contains("wasm.wasmrenderoptions_new(palette.__wbg_ptr,"),
"the borrowed constructor parameter must lower as a borrowed pointer"
);
}
#[test]
fn ci_requires_the_wasm_bindgen_cli_for_the_js_glue_oracle() {
let workflow = include_str!("../../../.github/workflows/ci.yml");
assert!(
workflow.contains("ALEF_REQUIRE_WASM_BINDGEN: \"1\""),
"the test job must make a missing wasm-bindgen CLI a hard failure"
);
assert!(
workflow.contains("tool: wasm-bindgen-cli"),
"`ALEF_REQUIRE_WASM_BINDGEN` needs an explicit CLI install on every leg of the matrix"
);
assert!(
workflow.contains("--require wasm-bindgen"),
"the toolchain census must require wasm-bindgen so a skipped fixture fails the leg"
);
}
#[cfg(test)]
mod scanner_tests {
use super::*;
#[test]
fn the_scanner_reports_argument_consumption_and_ignores_free() {
let js = "export class WasmThing {\n\
\x20 free() {\n\
\x20 const ptr = this.__destroy_into_raw();\n\
\x20 }\n\
\x20 set palette(value) {\n\
\x20 var ptr0 = value.__destroy_into_raw();\n\
\x20 }\n\
}\n";
let found: Vec<String> = consumed_handles(js).iter().map(ToString::to_string).collect();
assert_eq!(found, vec!["WasmThing.set palette consumes `value`".to_string()]);
}
}