use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Platform {
LinuxGnu,
MacOs,
}
impl Platform {
fn host() -> Platform {
if cfg!(target_os = "macos") {
Platform::MacOs
} else {
Platform::LinuxGnu
}
}
fn rust_target(self) -> &'static str {
match self {
Platform::LinuxGnu => "aarch64-unknown-linux-gnu",
Platform::MacOs => "aarch64-apple-darwin",
}
}
fn default_cc(self) -> &'static str {
match self {
Platform::LinuxGnu => "aarch64-linux-gnu-gcc",
Platform::MacOs => "cc",
}
}
fn cli_name(self) -> &'static str {
match self {
Platform::LinuxGnu => "linux",
Platform::MacOs => "macos",
}
}
fn fallback_native_libs(self) -> &'static [&'static str] {
match self {
Platform::LinuxGnu => &["-lpthread", "-ldl", "-lm", "-lrt", "-lutil"],
Platform::MacOs => &["-lSystem", "-lc", "-lm"],
}
}
fn abi_probe(self) -> &'static str {
match self {
Platform::LinuxGnu => include_str!("testdata/abi_probe.s"),
Platform::MacOs => include_str!("testdata/abi_probe_macos.s"),
}
}
fn abi_fatal_probe(self) -> &'static str {
match self {
Platform::LinuxGnu => include_str!("testdata/abi_fatal_probe.s"),
Platform::MacOs => include_str!("testdata/abi_fatal_probe_macos.s"),
}
}
}
struct Toolchain {
platform: Platform,
cc: String,
qemu: Option<String>,
runtime: PathBuf,
cli: PathBuf,
scratch: PathBuf,
}
fn tool_exists(program: &str) -> bool {
Command::new(program)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn workspace_root() -> &'static Path {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("asm/ lives inside the workspace")
}
fn cargo_build(extra: &[&str]) -> Output {
Command::new(env!("CARGO"))
.arg("build")
.args(["-p", "monkey-asm"])
.args(extra)
.current_dir(workspace_root())
.output()
.expect("cargo is runnable")
}
fn target_directory() -> PathBuf {
match std::env::var_os("CARGO_TARGET_DIR") {
Some(path) if Path::new(&path).is_absolute() => PathBuf::from(path),
Some(path) => workspace_root().join(path),
None => workspace_root().join("target"),
}
}
fn unavailable(message: String) -> Option<Toolchain> {
if std::env::var("MONKEY_ASM_E2E_REQUIRED").as_deref() == Ok("1") {
panic!("e2e requirement missing: {}", message);
}
eprintln!("skipping e2e: {}", message);
None
}
fn toolchain() -> Option<Toolchain> {
let platform = Platform::host();
if platform == Platform::MacOs && !cfg!(target_arch = "aarch64") {
return unavailable(
"macOS e2e needs Apple Silicon (arm64 Mach-O binaries do not run on Intel)".to_string(),
);
}
let cc = std::env::var("MONKEY_ASM_CC").unwrap_or_else(|_| platform.default_cc().to_string());
if !tool_exists(&cc) {
return unavailable(match platform {
Platform::LinuxGnu => format!("{} not found (install gcc-aarch64-linux-gnu)", cc),
Platform::MacOs => format!("{} not found (install the Xcode Command Line Tools)", cc),
});
}
let qemu = if platform == Platform::MacOs
|| cfg!(all(target_arch = "aarch64", target_os = "linux"))
{
None
} else {
let qemu = std::env::var("MONKEY_ASM_QEMU").unwrap_or_else(|_| "qemu-aarch64".to_string());
if !tool_exists(&qemu) {
return unavailable(format!("{} not found (install qemu-user)", qemu));
}
Some(qemu)
};
let cross = cargo_build(&["--lib", "--release", "--target", platform.rust_target()]);
if !cross.status.success() {
let stderr = String::from_utf8_lossy(&cross.stderr).into_owned();
if stderr.contains("may not be installed") || stderr.contains("can't find crate for `core`")
{
return unavailable(format!(
"rust target missing (rustup target add {})",
platform.rust_target()
));
}
panic!("aarch64 runtime build failed:\n{}", stderr);
}
let runtime = target_directory()
.join(platform.rust_target())
.join("release")
.join("libmonkey_asm.a");
assert!(runtime.exists(), "missing {}", runtime.display());
let host = cargo_build(&[]);
assert!(
host.status.success(),
"host CLI build failed:\n{}",
String::from_utf8_lossy(&host.stderr)
);
let cli = target_directory().join("debug").join("monkey-asm");
assert!(cli.exists(), "missing {}", cli.display());
let scratch = std::env::temp_dir().join(format!("monkey-asm-e2e-{}", std::process::id()));
std::fs::create_dir_all(&scratch).expect("scratch dir");
Some(Toolchain {
platform,
cc,
qemu,
runtime,
cli,
scratch,
})
}
impl Toolchain {
fn link(&self, name: &str, assembly: &str) -> PathBuf {
let source = self.scratch.join(format!("{}.s", name));
let program = self.scratch.join(name);
std::fs::write(&source, assembly).expect("write assembly");
let mut command = Command::new(&self.cc);
command
.arg(&source)
.arg(&self.runtime)
.arg("-o")
.arg(&program);
match self.platform {
Platform::LinuxGnu => {
command.arg("-static");
}
Platform::MacOs => {
command.args(["-arch", "arm64"]);
}
}
command.args(self.platform.fallback_native_libs());
let output = command.output().expect("run platform cc");
assert!(
output.status.success(),
"link failed for {}:\n{}",
name,
String::from_utf8_lossy(&output.stderr)
);
program
}
fn execute(&self, program: &Path, fd3: Option<&Path>) -> Output {
let mut command = match (&self.qemu, fd3) {
(Some(qemu), None) => {
let mut command = Command::new(qemu);
command.arg(program);
command
}
(None, None) => Command::new(program),
(Some(qemu), Some(record)) => {
let mut command = Command::new("sh");
command
.arg("-c")
.arg("exec \"$1\" \"$2\" 3>\"$3\"")
.arg("sh")
.arg(qemu)
.arg(program)
.arg(record);
command
}
(None, Some(record)) => {
let mut command = Command::new("sh");
command
.arg("-c")
.arg("exec \"$1\" 3>\"$2\"")
.arg("sh")
.arg(program)
.arg(record);
command
}
};
command.output().expect("execute arm64 binary")
}
fn cli_run(&self, name: &str, source: &str, observe: bool) -> Output {
let path = self.scratch.join(format!("{}.monkey", name));
std::fs::write(&path, source).expect("write monkey source");
let mut command = Command::new(&self.cli);
command
.arg("run")
.arg(&path)
.args(["--platform", self.platform.cli_name()])
.env("MONKEY_ASM_RUNTIME", &self.runtime)
.current_dir(workspace_root());
if observe {
command.arg("--observe");
}
command.output().expect("run monkey-asm CLI")
}
}
fn stdout_of(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr_of(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
#[test]
#[ignore]
fn abi_probe_freezes_the_calling_convention() {
let toolchain = match toolchain() {
Some(toolchain) => toolchain,
None => return,
};
let program = toolchain.link("abi_probe", toolchain.platform.abi_probe());
let output = toolchain.execute(&program, None);
assert_eq!(stdout_of(&output), "abi\n3\n28\n7\n", "stderr: {}", stderr_of(&output));
assert_eq!(output.status.code(), Some(0));
}
#[test]
#[ignore]
fn abi_fatal_probe_reports_overflow_and_exit_1() {
let toolchain = match toolchain() {
Some(toolchain) => toolchain,
None => return,
};
let program = toolchain.link("abi_fatal_probe", toolchain.platform.abi_fatal_probe());
let output = toolchain.execute(&program, None);
assert_eq!(output.status.code(), Some(1));
assert!(stderr_of(&output).contains("monkey: IntegerOverflow"));
assert_eq!(stdout_of(&output), "");
}
#[test]
#[ignore]
fn e2e_programs_behave_like_the_interpreter() {
let toolchain = match toolchain() {
Some(toolchain) => toolchain,
None => return,
};
let corpus: &[(&str, &str, &str)] = &[
(
"fib",
"let fib = fn(n) { if (n < 2) { n } else { fib(n - 1) + fib(n - 2) } };\nputs(fib(10));",
"55\n",
),
(
"closures",
"let adder = fn(x) { fn(y) { x + y } };\nlet add2 = adder(2);\nputs(add2(3));\nputs(add2(40));",
"5\n42\n",
),
(
"builtins",
"let a = [1, 2, 3];\nputs(len(a));\nputs(first(a));\nputs(last(a));\nputs(rest(a));\nputs(push(a, 4));\nputs(a[1] + a[2]);",
"3\n1\n3\n[2, 3]\n[1, 2, 3, 4]\n5\n",
),
(
"strings_hashes",
"let h = {\"name\": \"monkey\", 1: 2, true: 3};\nputs(h[\"name\"] + \"!\");\nputs(h[1] + h[true]);\nputs(h[\"missing\"]);",
"monkey!\n5\nnull\n",
),
(
"classes",
"class Counter {\n constructor(start) { this.count = start; }\n inc() { this.count = this.count + 1; this.count }\n}\nlet c = new Counter(5);\nputs(c.inc());\nputs(c.inc());\nputs(c.count);",
"6\n7\n7\n",
),
(
"big_integers",
"puts(9223372036854775807 - 1);\nputs(0 - 9223372036854775807);\nputs(4611686018427387903 + 1);",
"9223372036854775806\n-9223372036854775807\n4611686018427387904\n",
),
(
"rebinding",
"let x = 1;\nlet x = x + 2;\nputs(x);",
"3\n",
),
(
"less_than_order",
"let f = fn(x) { puts(x); x };\nputs(f(1) < f(2));",
"1\n2\ntrue\n",
),
(
"debugger_transparent",
"let f = fn(n) { n * 2; debugger; };\ndebugger;\nputs(f(21));",
"42\n",
),
];
for (name, source, expected) in corpus {
let output = toolchain.cli_run(name, source, false);
assert_eq!(
&stdout_of(&output),
expected,
"program {} stderr: {}",
name,
stderr_of(&output)
);
assert_eq!(output.status.code(), Some(0), "program {}", name);
}
}
#[test]
#[ignore]
fn e2e_fatal_errors_exit_1_with_kind() {
let toolchain = match toolchain() {
Some(toolchain) => toolchain,
None => return,
};
let corpus: &[(&str, &str, &str)] = &[
("div_zero", "puts(1 / 0);", "monkey: DivisionByZero"),
("not_callable", "class C { m() { 1 } }\nC();", "monkey: NotCallable"),
("arity", "let f = fn(a) { a };\nf(1, 2);", "monkey: ArityError"),
];
for (name, source, expected) in corpus {
let output = toolchain.cli_run(name, source, false);
assert_eq!(output.status.code(), Some(1), "program {}", name);
assert!(
stderr_of(&output).contains(expected),
"program {} stderr: {}",
name,
stderr_of(&output)
);
}
}
#[test]
#[ignore]
fn e2e_observer_record_framing_and_content() {
let toolchain = match toolchain() {
Some(toolchain) => toolchain,
None => return,
};
let ok = toolchain.cli_run("observe_ok", "1 + 2;", true);
assert_eq!(ok.status.code(), Some(0));
assert!(
stderr_of(&ok).contains(
"observer: {\"status\":\"ok\",\"value\":{\"type\":\"integer\",\"value\":\"3\"}}"
),
"stderr: {}",
stderr_of(&ok)
);
let err = toolchain.cli_run("observe_err", "1 / 0;", true);
assert_eq!(err.status.code(), Some(1));
assert!(
stderr_of(&err).contains("observer: {\"status\":\"error\",\"kind\":\"DivisionByZero\"}"),
"stderr: {}",
stderr_of(&err)
);
let source_path = toolchain.scratch.join("observe_raw.monkey");
std::fs::write(&source_path, "puts(40);\n40 + 2;").expect("write source");
let program = toolchain.scratch.join("observe_raw");
let build = Command::new(&toolchain.cli)
.arg("build")
.arg(&source_path)
.arg("-o")
.arg(&program)
.args(["--platform", toolchain.platform.cli_name()])
.arg("--observe")
.env("MONKEY_ASM_RUNTIME", &toolchain.runtime)
.current_dir(workspace_root())
.output()
.expect("run monkey-asm build");
assert!(build.status.success(), "build stderr: {}", stderr_of(&build));
let record_path = toolchain.scratch.join("observe_raw.record");
let output = toolchain.execute(&program, Some(&record_path));
assert_eq!(output.status.code(), Some(0));
assert_eq!(stdout_of(&output), "40\n");
let record = std::fs::read(&record_path).expect("observer record file");
assert!(record.len() >= 8, "record too short: {:?}", record);
let mut length_bytes = [0u8; 8];
length_bytes.copy_from_slice(&record[..8]);
let length = u64::from_be_bytes(length_bytes) as usize;
assert_eq!(length, record.len() - 8, "length prefix must cover the payload exactly");
let payload = std::str::from_utf8(&record[8..]).expect("payload is UTF-8");
assert_eq!(payload, "{\"status\":\"ok\",\"value\":{\"type\":\"integer\",\"value\":\"42\"}}");
}