From da01aa3c73c529c922fad7547190809549d65894 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 19:24:30 +0000 Subject: [PATCH] feat(wasm): closures of each value type, and named functions as values (E9, closures) v2 plan rung E9, the closure half. Recursive types wait for decision D7. A closure function has the wasm type of its parameter and of its result: (env: i32, param) -> result. Each closure had the one type (i32, i32) -> i32, and a lambda with an I64, F32 or F64 parameter or result was refused. Such a lambda is now behind the flag `--unstable-core` (check, compile, run), as the plan says for E9. 3 defects of the old lowering are repaired. Each one gave a wrong program with no error from the checker: - A named function as a value (`apply(inc, 41)`, `let f = add`, `add(40)`) gave a module that was not valid (no table). The value is now a closure cell for a closure function that takes the parameters 1 at a time and then calls the function. An extern function also. - A lambda in a lambda gave a module that was not valid (unknown function): the list of closure functions was copied before its loop. - A local with the name of a function of the module was called as that function (0, where the interpreter gave 42). Cause of the second defect, also removed: each function body was compiled 2 times, to count its locals, so each lambda was added to the module 2 times. A body is now compiled 1 time, and the type section is written after the bodies, because a closure adds the type of its signature while its body is compiled. The driver has `Features { unstable_core }`, `check_program_with`, `load_program_with`, `load_source_with`, and the load error `UnstableCore`. Tests: closures.rs (4 tests, wasm and interpreter). The E5 matrix has closure parameter, closure result and captured value columns for each value type: 3396 cases, 0 disagreements. 2 tests that pinned the old refusal now check the limit that remains and the flag. 628 tests pass; the all-target count is 625. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u --- TEST-NEEDS.adoc | 6 +- src/ephapax-cli/src/main.rs | 22 +- src/ephapax-cli/tests/closures.rs | 261 +++++++++ src/ephapax-cli/tests/driver_agreement.rs | 33 +- src/ephapax-cli/tests/generics.rs | 19 +- src/ephapax-cli/tests/typed_codegen_matrix.rs | 87 ++- src/ephapax-driver/src/lib.rs | 74 ++- src/ephapax-wasm/src/lib.rs | 519 +++++++++--------- 8 files changed, 725 insertions(+), 296 deletions(-) create mode 100644 src/ephapax-cli/tests/closures.rs diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index a6e1ebb..2582d1a 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -21,9 +21,9 @@ compliance, 16 tests) performance, correctness, 13 tests) * Benchmarks: `+src/ephapax-parser/benches/parse_bench.rs+`, `+src/ephapax-vram-cache/benches/cache_bench.rs+` -* Total: *621 tests* (`+cargo test --workspace --all-targets+`); +* Total: *625 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 621 +* Documented all-target tests: 625 === CRG Testing Taxonomy — Status @@ -83,7 +83,7 @@ eliminator-fork invariant + its gated capstone, per PROOF-NEEDS.adoc §4) ==== Build & Execution * [x] cargo build — compiles -* [x] cargo test –workspace –all-targets — 621 pass, 0 fail +* [x] cargo test –workspace –all-targets — 625 pass, 0 fail * [x] idris2/ephapax-parse-tests.ipkg — compiles AND runs green (2026-07-07, idris2 0.8.0 + zig-built libephapax_tokbuf.so); CI-gated compile-only by abi-verify.yml `+idris2-parse-front+` (runtime run stays diff --git a/src/ephapax-cli/src/main.rs b/src/ephapax-cli/src/main.rs index 23c5ea5..3bd1feb 100644 --- a/src/ephapax-cli/src/main.rs +++ b/src/ephapax-cli/src/main.rs @@ -40,6 +40,20 @@ struct Cli { /// Enable verbose output #[arg(short, long, global = true)] verbose: bool, + + /// Accept the forms of the core language that the proofs do not cover + /// yet (v2 plan rung E9): a lambda with an I64, F32 or F64 parameter + /// or result. Applies to `run`, `check` and `compile`. + #[arg(long, global = true)] + unstable_core: bool, +} + +/// The features that the command line asked for. `main` sets it one time. +static FEATURES: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// The features that the command line asked for. +fn features() -> ephapax_driver::Features { + FEATURES.get().copied().unwrap_or_default() } #[derive(Subcommand)] @@ -196,6 +210,9 @@ enum PackageCommands { fn main() -> ExitCode { let cli = Cli::parse(); + FEATURES.get_or_init(|| ephapax_driver::Features { + unstable_core: cli.unstable_core, + }); let result = match cli.command { Some(Commands::Repl { preload }) => run_repl(preload, cli.verbose), @@ -279,7 +296,8 @@ fn load_program(path: &Path, stdlib: Option) -> Result, verbose: bool) -> Result<( let mut checked = 0; for path in files { - match ephapax_driver::load_program(path, stdlib_dir.as_deref()) { + match ephapax_driver::load_program_with(path, stdlib_dir.as_deref(), features()) { Ok(program) => { if verbose { println!( diff --git a/src/ephapax-cli/tests/closures.rs b/src/ephapax-cli/tests/closures.rs new file mode 100644 index 0000000..5a7b0b3 --- /dev/null +++ b/src/ephapax-cli/tests/closures.rs @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// +// closures.rs: v2 plan rung E9, closures. +// +// A closure is a cell `(table index, environment)` for a closure function +// `(environment, parameter) -> result`. Each program here runs as wasm and +// in the interpreter, and the 2 must print the expected value. + +use std::path::Path; +use std::process::{Command, Output}; + +/// Run the `ephapax` binary with `args`, in the directory `dir`. +fn ephapax(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_ephapax")) + .current_dir(dir) + .args(args) + .output() + .expect("spawn ephapax") +} + +/// Write each program (name, text, output) and run it as wasm and in the +/// interpreter, with the flags `flags`. +fn each_runs(flags: &[&str], programs: &[(&str, &str, &str)]) { + let tmp = tempfile::tempdir().expect("tempdir"); + for (name, text, want) in programs { + let file = format!("{name}.eph"); + std::fs::write(tmp.path().join(&file), text).expect("write program"); + for mode in [&["run"][..], &["run", "--interp"]] { + let args: Vec<&str> = [mode, flags, &[file.as_str()]].concat(); + let out = ephapax(tmp.path(), &args); + assert!( + out.status.success(), + "{name} under {args:?} failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(stdout.trim(), *want, "{name} under {args:?}"); + } + } +} + +/// A function of the module is a value: an argument, a `let` value, or a +/// call with too few arguments. The wasm module had no table for it and +/// was not valid. +#[test] +fn a_named_function_is_a_value() { + let inc = "fn inc(n: I32): I32 = n + 1\n\n"; + let apply = "fn apply(f: I32 -> I32, x: I32): I32 = f(x)\n\n"; + let add = "fn add(a: I32, b: I32): I32 = a + b\n\n"; + let add3 = "fn add3(a: I32, b: I32, c: I32): I32 = a + b + c\n\n"; + each_runs( + &[], + &[ + ( + "argument", + &format!("{inc}{apply}fn main(): I32 = apply(inc, 41)\n"), + "42", + ), + ( + "let_value", + &format!("{add}fn main(): I32 = let f = add in f(40, 2)\n"), + "42", + ), + ( + "too_few", + &format!("{add}fn main(): I32 = let f = add(40) in f(2)\n"), + "42", + ), + ( + "one_at_a_time", + &format!( + "{add3}fn main(): I32 = let f = add3 in let g = f(30) in \ + let h = g(10) in h(2)\n" + ), + "42", + ), + ( + "too_few_then_more", + &format!("{add3}fn main(): I32 = add3(30, 10)(2)\n"), + "42", + ), + ( + "no_parameter", + "fn answer(): I32 = 42\n\nfn call(f: () -> I32): I32 = f(())\n\n\ + fn main(): I32 = call(answer)\n", + "42", + ), + // The name is free in the lambda and is not a local. + ( + "in_a_lambda", + &format!( + "{inc}{apply}fn main(): I32 = let g = fn(x: I32) -> apply(inc, x) in g(41)\n" + ), + "42", + ), + ( + "used_2_times", + &format!("{inc}fn main(): I32 = let f = inc in let g = inc in f(g(40))\n"), + "42", + ), + ( + "generic", + &format!("fn id(x: T): T = x\n\n{apply}fn main(): I32 = apply(id, 42)\n"), + "42", + ), + // Each parameter and the result keep their width. + ( + "wide", + "fn pick(a: I64, b: F64): I64 = a + a\n\n\ + fn main(): I64 = let f = pick(5000000000) in f(1.5)\n", + "10000000000i64", + ), + ], + ); + + // An extern function is a value too. The host of `run` does not have + // these functions, so the program is compiled only. + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("ext.eph"), + "extern \"c\" {\n fn host_inc(n: I32): I32\n fn host_now(): I64\n \ + fn host_log(n: I32): ()\n}\n\n\ + fn apply(f: I32 -> I32, x: I32): I32 = f(x)\n\n\ + fn main(): I32 = let t = host_now in let l = host_log in let u = l(1) in \ + apply(host_inc, 41)\n", + ) + .expect("write program"); + let out = ephapax(tmp.path(), &["compile", "ext.eph"]); + assert!( + out.status.success(), + "compile failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let wasm = std::fs::read(tmp.path().join("ext.wasm")).expect("read module"); + wasmparser::validate(&wasm).expect("the module must be valid wasm"); +} + +/// The body of a lambda can hold a lambda. The inner closure function was +/// not in the module: "unknown function". +#[test] +fn a_lambda_in_a_lambda_runs() { + each_runs( + &[], + &[ + ( + "curry", + "fn main(): I32 = let add = fn(a: I32) -> fn(b: I32) -> a + b in add(40)(2)\n", + "42", + ), + ( + "curry3", + "fn main(): I32 = let add = fn(a: I32) -> fn(b: I32) -> fn(c: I32) -> a + b + c \ + in add(30)(10)(2)\n", + "42", + ), + ( + "compose", + "fn compose(f: I32 -> I32, g: I32 -> I32): I32 -> I32 = fn(x: I32) -> f(g(x))\n\n\ + fn main(): I32 = compose(fn(a: I32) -> a + 1, fn(b: I32) -> b * 2)(20) + 1\n", + "42", + ), + ], + ); +} + +/// A parameter or a `let` name hides a function of the module that has +/// the same name. The wasm call went to the function of the module. +#[test] +fn a_local_hides_a_function_of_the_same_name() { + let f = "fn f(n: I32): I32 = 0\n\n"; + each_runs( + &[], + &[ + ( + "parameter", + &format!( + "{f}fn apply(f: I32 -> I32, x: I32): I32 = f(x)\n\n\ + fn main(): I32 = apply(fn(n: I32) -> n + 1, 41)\n" + ), + "42", + ), + ( + "let_name", + &format!("{f}fn main(): I32 = let f = fn(n: I32) -> n + 1 in f(41)\n"), + "42", + ), + ], + ); +} + +/// A lambda with an I64, F32 or F64 parameter or result is behind the +/// flag `--unstable-core`. With the flag, the closure function has the +/// wasm type of its parameter and of its result. (Each closure had the +/// type `(i32, i32) -> i32`, and such a lambda was refused.) +#[test] +fn a_wide_lambda_is_behind_the_flag() { + let programs = [ + ( + "f64", + "fn main(): F64 = let f = fn(x: F64) -> x + 1.5 in f(40.5)\n", + "42", + ), + ( + "f32", + "fn main(): F32 = let f = fn(x: F32) -> x + 1.5f32 in f(40.5f32)\n", + "42f32", + ), + ( + "i64", + "fn main(): I64 = let f = fn(x: I64) -> x + 5000000000 in f(5000000000)\n", + "10000000000i64", + ), + ( + "result_only", + "fn main(): I64 = let f = fn(x: I32) -> 5000000000 + 5000000000 in f(1)\n", + "10000000000i64", + ), + ( + "parameter_only", + "fn main(): I32 = let f = fn(x: I64) -> 42 in f(5000000000)\n", + "42", + ), + ( + "argument", + "fn apply(f: F64 -> F64, x: F64): F64 = f(x)\n\n\ + fn main(): F64 = apply(fn(n: F64) -> n * 2.0, 21.0)\n", + "42", + ), + ( + "captured", + "fn main(): I64 = let k = 5000000000 in let f = fn(x: F64) -> k + k in f(1.5)\n", + "10000000000i64", + ), + ( + "3_types", + "fn main(): F64 = let f = fn(a: F64) -> fn(b: I64) -> fn(c: F32) -> a \ + in f(42.0)(5000000000)(1.5f32)\n", + "42", + ), + ]; + each_runs(&["--unstable-core"], &programs); + + // Without the flag, each command refuses the program by name. + let tmp = tempfile::tempdir().expect("tempdir"); + for (name, text, _) in programs { + let file = format!("{name}.eph"); + std::fs::write(tmp.path().join(&file), text).expect("write program"); + for command in [&["check"][..], &["compile"], &["run"], &["run", "--interp"]] { + let args: Vec<&str> = [command, &[file.as_str()]].concat(); + let out = ephapax(tmp.path(), &args); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success() + && stderr.contains("behind the flag `--unstable-core`: a lambda"), + "{name} under {args:?}: exit {:?}, stderr:\n{stderr}", + out.status.code() + ); + } + } +} diff --git a/src/ephapax-cli/tests/driver_agreement.rs b/src/ephapax-cli/tests/driver_agreement.rs index 8d33e54..c75027a 100644 --- a/src/ephapax-cli/tests/driver_agreement.rs +++ b/src/ephapax-cli/tests/driver_agreement.rs @@ -53,13 +53,14 @@ fn corpus(root: &Path) -> Vec { } /// The text every CLI command prints for each way a program fails to load. -const LOAD_ERRORS: [&str; 6] = [ +const LOAD_ERRORS: [&str; 7] = [ "cannot read", "import cycle", "Parse error", "Desugar error", "Type error", "Codegen error", + "behind the flag", ]; /// The text that the CLI prints for each kind of load error. @@ -71,6 +72,7 @@ fn load_error_text(e: &DriverError) -> &'static str { DriverError::Desugar { .. } => LOAD_ERRORS[3], DriverError::Type { .. } => LOAD_ERRORS[4], DriverError::Unsupported { .. } => LOAD_ERRORS[5], + DriverError::UnstableCore { .. } => LOAD_ERRORS[6], } } @@ -218,23 +220,22 @@ fn out_of_range_integer_literal_is_a_parse_error() { } /// A limit of the typed lowering is a load error too: `check` must not -/// accept a program that `compile` refuses. Not lowered yet: a lambda -/// with a parameter or result that is not 32 bits wide. +/// accept a program that `compile` refuses. Not lowered yet: a literal +/// pattern on a value that is not 32 bits wide. (A lambda with such a +/// parameter or result was the other limit. It is lowered since rung E9: +/// see `closures.rs`.) #[test] fn check_rejects_what_typed_codegen_cannot_lower() { let tmp = tempfile::tempdir().expect("tempdir"); - // (result type, body, text the error names) - for (ty, body, what) in [ - ( - "F32", - "let f = fn(x: F32) -> x + 1.0f32\n f(2.0f32)", - "lambda", - ), - ("F64", "let f = fn(x: I32) -> 1.5\n f(1)", "lambda"), - ] { + // (payload type, payload) + for (ty, value) in [("I64", "5000000000"), ("F64", "1.5")] { std::fs::write( tmp.path().join("wide.eph"), - format!("module wide\n\nfn main(): {ty} =\n {body}\n"), + format!( + "module wide\n\ndata Opt = None | Some({ty})\n\nfn main(): I32 =\n \ + match Some({value}) of\n | Some({value}) => 1\n | Some(n) => 2\n \ + | None => 0\n end\n" + ), ) .expect("write program"); for args in [&["check", "wide.eph"][..], &["compile", "wide.eph"]] { @@ -245,8 +246,10 @@ fn check_rejects_what_typed_codegen_cannot_lower() { .expect("spawn ephapax"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - !out.status.success() && stderr.contains("Codegen error") && stderr.contains(what), - "`{body}` under {args:?}: exit {:?}, stderr:\n{stderr}", + !out.status.success() + && stderr.contains("Codegen error") + && stderr.contains("`match` with a pattern that has no lowering"), + "a literal pattern on {ty} under {args:?}: exit {:?}, stderr:\n{stderr}", out.status.code() ); } diff --git a/src/ephapax-cli/tests/generics.rs b/src/ephapax-cli/tests/generics.rs index 1e84aa2..3804c96 100644 --- a/src/ephapax-cli/tests/generics.rs +++ b/src/ephapax-cli/tests/generics.rs @@ -304,8 +304,9 @@ fn a_generic_main_of_an_imported_module_is_not_an_entry_point() { ); } -/// A limit of the typed lowering that only a copy at a wide type shows -/// (here a lambda with an F64 parameter) is a load error on `check` too. +/// A form that only a copy at a wide type shows (here a lambda with an +/// F64 parameter, which is behind the flag `--unstable-core`) is a load +/// error on `check` too. With the flag, the copy runs. #[test] fn check_sees_the_limits_of_the_monomorphic_copies() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -323,9 +324,21 @@ fn check_sees_the_limits_of_the_monomorphic_copies() { let out = ephapax(tmp.path(), args); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - !out.status.success() && stderr.contains("Codegen error") && stderr.contains("lambda"), + !out.status.success() && stderr.contains("behind the flag `--unstable-core`: a lambda"), "{args:?}: exit {:?}, stderr:\n{stderr}", out.status.code() ); } + for args in [ + &["run", "--unstable-core", "lam.eph"][..], + &["run", "--interp", "--unstable-core", "lam.eph"], + ] { + let out = ephapax(tmp.path(), args); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "2.5", + "{args:?}:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + } } diff --git a/src/ephapax-cli/tests/typed_codegen_matrix.rs b/src/ephapax-cli/tests/typed_codegen_matrix.rs index 8328792..35c3dc6 100644 --- a/src/ephapax-cli/tests/typed_codegen_matrix.rs +++ b/src/ephapax-cli/tests/typed_codegen_matrix.rs @@ -12,7 +12,9 @@ // Covered: every binary operator, unary operator and comparison on every // operand type the checker accepts, and each value type in each container // position (pair field, tuple field, list element, sum payload, `copy`, -// `if` / `let` / `match` result, extern parameter and result). +// `if` / `let` / `match` result, extern parameter and result), and each +// value type as a closure parameter, a closure result and a captured +// value (v2 plan rung E9). use ephapax_interp::Interpreter; use wasmtime::{Engine, Linker, Module, Store, Val}; @@ -176,6 +178,40 @@ fn cases_matrix() -> Vec { format!("match Some(1) of\n | None => {w}\n | Some(n) => {v}\n end"), )); } + + // Each value type as a closure parameter and as a closure result, next + // to each other type (v2 plan rung E9). A closure function has the + // wasm type of its parameter and of its result. + for ty in VALUE_TYPES { + let v = &samples(ty)[1]; + for other in VALUE_TYPES { + let o = &samples(other)[1]; + // A lambda: parameter `other`, result `ty`. + out.push(case(ty, format!("let f = fn(x: {other}) -> {v}\n f({o})"))); + // The result is a captured value. + out.push(case( + ty, + format!("let k = {v}\n let f = fn(x: {other}) -> k\n f({o})"), + )); + // A lambda in a lambda: the first parameter is captured. + out.push(case( + ty, + format!("let f = fn(a: {ty}) -> fn(b: {other}) -> a\n f({v})({o})"), + )); + // A named function as a value, with 1 argument at a time. + out.push(case( + ty, + format!("let f = first_{ty}_{other}\n f({v})({o})"), + )); + out.push(case( + ty, + format!("let f = first_{ty}_{other}({v})\n f({o})"), + )); + } + // A closure as an argument: a lambda, then a named function. + out.push(case(ty, format!("apply_{ty}(fn(x: {ty}) -> x, {v})"))); + out.push(case(ty, format!("apply_{ty}(same_{ty}, {v})"))); + } out } @@ -250,12 +286,51 @@ fn pinned() -> Vec<(Case, Option<&'static str>)> { ), Some("true"), ), + // A closure keeps the full width of its parameter, of its result + // and of a captured value (v2 plan rung E9). + ( + case("I64", "let f = fn(x: I64) -> x + x\n f(5000000000)"), + Some("10000000000i64"), + ), + ( + case("F64", "let f = fn(x: F64) -> x + 1.5\n f(40.5)"), + Some("42"), + ), + ( + case("F32", "let f = fn(x: F32) -> x + 1.5f32\n f(40.5f32)"), + Some("42f32"), + ), + ( + case( + "I64", + "let k = 5000000000\n let f = fn(x: I32) -> k + k\n f(1)", + ), + Some("10000000000i64"), + ), + // A named function as a value takes its arguments 1 at a time. + ( + case("I64", "let f = first_I64_F64(5000000000)\n f(1.5)"), + Some("5000000000i64"), + ), + (case("F64", "apply_F64(same_F64, 2.5)"), Some("2.5")), ] } -/// The module: `data Option`, then one function per case. +/// The module: `data Option`, the functions that the closure cases call, +/// then one function per case. fn module_source(cases: &[Case]) -> String { let mut src = String::from("module matrix\n\ndata Option(a) = None | Some(a)\n\n"); + for ty in VALUE_TYPES { + src.push_str(&format!("fn same_{ty}(x: {ty}): {ty} = x\n\n")); + src.push_str(&format!( + "fn apply_{ty}(f: {ty} -> {ty}, x: {ty}): {ty} = f(x)\n\n" + )); + for other in VALUE_TYPES { + src.push_str(&format!( + "fn first_{ty}_{other}(a: {ty}, b: {other}): {ty} = a\n\n" + )); + } + } for (i, c) in cases.iter().enumerate() { src.push_str(&format!("fn c{i}(): {} =\n {}\n\n", c.ty, c.expr)); } @@ -275,10 +350,14 @@ fn show(ty: &str, val: Val) -> String { } } -/// Load a program through the driver, as one merged module. +/// Load a program through the driver, as one merged module. The closure +/// cases need the flag `--unstable-core`. fn load(src: &str) -> ephapax_syntax::Module { let stdlib = ephapax_driver::stdlib_dir(None); - ephapax_driver::load_source(src, "matrix", None, stdlib.as_deref()) + let features = ephapax_driver::Features { + unstable_core: true, + }; + ephapax_driver::load_source_with(src, "matrix", None, stdlib.as_deref(), features) .unwrap_or_else(|e| panic!("the matrix module must load: {e}")) .into_merged() .expect("the matrix module merges") diff --git a/src/ephapax-driver/src/lib.rs b/src/ephapax-driver/src/lib.rs index 14f9b1e..17a52ed 100644 --- a/src/ephapax-driver/src/lib.rs +++ b/src/ephapax-driver/src/lib.rs @@ -170,6 +170,11 @@ pub enum DriverError { /// The program type-checks but uses a construct with no wasm lowering. #[error("{}: Codegen error: {error}", path.display())] Unsupported { path: PathBuf, error: CodegenError }, + + /// The program type-checks but uses a form that is behind the flag + /// `--unstable-core`, and [`Features::unstable_core`] is off. + #[error("{}: {error}", path.display())] + UnstableCore { path: PathBuf, error: CodegenError }, } /// Every parse error with its byte span, on one line. @@ -250,10 +255,35 @@ pub fn parse_source( Ok(Program { modules }) } +/// The load error for a program that [`ephapax_wasm::validate_accepted`] +/// refuses: a form behind the flag, or a construct with no wasm lowering. +fn not_accepted(path: &Path, error: CodegenError) -> DriverError { + let path = path.to_path_buf(); + if error.0.starts_with(ephapax_wasm::UNSTABLE_CORE_PREFIX) { + DriverError::UnstableCore { path, error } + } else { + DriverError::Unsupported { path, error } + } +} + +/// The language forms that a program can use only on request. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Features { + /// The flag `--unstable-core` (v2 plan rung E9): the forms of the + /// core language that the proofs do not cover yet. + pub unstable_core: bool, +} + +/// [`check_program_with`] with no feature on request. +pub fn check_program(program: &Program) -> Result<(), DriverError> { + check_program_with(program, Features::default()) +} + /// Type-check every module (a single registry is chained so imports /// resolve), then run the same unsupported-construct validation codegen -/// runs, so no tool accepts a program that `compile` must reject. -pub fn check_program(program: &Program) -> Result<(), DriverError> { +/// runs, so no tool accepts a program that `compile` must reject. A form +/// that is behind a flag is an error unless `features` has that flag. +pub fn check_program_with(program: &Program, features: Features) -> Result<(), DriverError> { let mut registry = ModuleRegistry::new(); let mut types = Vec::with_capacity(program.modules.len()); for m in &program.modules { @@ -334,12 +364,8 @@ pub fn check_program(program: &Program) -> Result<(), DriverError> { // With the types, so a limit of the typed lowering (a `match` on a // 64-bit component) is an error here and not only in `compile`. for (m, types) in program.modules.iter().zip(&types) { - ephapax_wasm::validate_supported_typed(&m.core, types).map_err(|error| { - DriverError::Unsupported { - path: m.file_path.clone(), - error, - } - })?; + ephapax_wasm::validate_accepted(&m.core, types, features.unstable_core) + .map_err(|error| not_accepted(&m.file_path, error))?; } // A generic function is compiled as its monomorphic copies, so the // copies get the same check: a copy at a wide type can meet a limit @@ -360,20 +386,25 @@ pub fn check_program(program: &Program) -> Result<(), DriverError> { error: Box::new(error), } })?; - ephapax_wasm::validate_supported_typed(&merged, &types).map_err(|error| { - DriverError::Unsupported { - path: root.file_path.clone(), - error, - } - })?; + ephapax_wasm::validate_accepted(&merged, &types, features.unstable_core) + .map_err(|error| not_accepted(&root.file_path, error))?; } Ok(()) } /// [`parse_program`] then [`check_program`]. pub fn load_program(root: &Path, stdlib_dir: Option<&Path>) -> Result { + load_program_with(root, stdlib_dir, Features::default()) +} + +/// [`parse_program`] then [`check_program_with`]. +pub fn load_program_with( + root: &Path, + stdlib_dir: Option<&Path>, + features: Features, +) -> Result { let program = parse_program(root, stdlib_dir)?; - check_program(&program)?; + check_program_with(&program, features)?; Ok(program) } @@ -383,9 +414,20 @@ pub fn load_source( name: &str, base_dir: Option<&Path>, stdlib_dir: Option<&Path>, +) -> Result { + load_source_with(source, name, base_dir, stdlib_dir, Features::default()) +} + +/// [`parse_source`] then [`check_program_with`]. +pub fn load_source_with( + source: &str, + name: &str, + base_dir: Option<&Path>, + stdlib_dir: Option<&Path>, + features: Features, ) -> Result { let program = parse_source(source, name, base_dir, stdlib_dir)?; - check_program(&program)?; + check_program_with(&program, features)?; Ok(program) } diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index ce68cc5..e3e1140 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -149,10 +149,6 @@ const TYPE_VOID_VOID: u32 = 4; const TYPE_CONCAT: u32 = 5; /// Type 6: `(i32) -> i32` (single-param unary functions) const TYPE_I32_TO_I32: u32 = 6; -/// Closure call type: `(i32, i32) -> i32` (env_ptr + param -> result) -/// Same signature as TYPE_I32_I32_I32 (type 3), aliased for clarity. -const TYPE_CLOSURE_CALL: u32 = TYPE_I32_I32_I32; - /// Type 7: `(i32, i64) -> i32` (list_append: handle + element slot) const TYPE_LIST_APPEND: u32 = 7; /// Type 8: `(i32, i32) -> i64` (list_get: handle + index -> element slot) @@ -197,23 +193,34 @@ pub const UNSUPPORTED_PREFIX: &str = "unsupported in codegen"; /// it only lowers well-typed programs. pub const NOT_WELL_TYPED: &str = "not well-typed"; +/// Message prefix of the [`CodegenError`] for a form that a program can +/// use only with the flag `--unstable-core` (v2 plan rung E9). Codegen +/// lowers such a form; [`validate_accepted`] refuses it without the flag. +pub const UNSTABLE_CORE_PREFIX: &str = "behind the flag `--unstable-core`"; + /// Return the first construct in `expr` that codegen cannot lower: /// effect `perform` / `handle` (no lowering until E17) and raw variadic /// `__ffi` (no sound lowering at all; typed `extern` is the FFI path). pub fn first_unsupported(expr: &Expr) -> Option { - first_unsupported_in(expr, None) + first_unsupported_in(expr, None, true) } /// [`first_unsupported`], plus the constructs whose lowering depends on a /// type: with the checker's table, a `match` on a value that has an I64, /// F32 or F64 component (patterns read every component as an i32). pub fn first_unsupported_typed(expr: &Expr, types: &ExprTypes) -> Option { - first_unsupported_in(expr, Some(types)) + first_unsupported_in(expr, Some(types), true) } /// The first construct in `expr` that code generation cannot lower, as an /// error. With `types`, it also finds the limits of the typed lowering. -fn first_unsupported_in(expr: &Expr, types: Option<&ExprTypes>) -> Option { +/// Without `unstable_core`, it also finds each form that is behind the +/// flag `--unstable-core`. +fn first_unsupported_in( + expr: &Expr, + types: Option<&ExprTypes>, + unstable_core: bool, +) -> Option { fn unsupported(what: &str, span: ephapax_syntax::Span) -> CodegenError { CodegenError(format!( "{UNSUPPORTED_PREFIX}: {what} (at byte {}..{})", @@ -235,19 +242,20 @@ fn first_unsupported_in(expr: &Expr, types: Option<&ExprTypes>) -> Option i32 - // until the closure ABI is typed (v2 plan rung E9). + // A closure function has the type of its parameter and of its + // result (v2 plan rung E9). A type other than i32 is behind the flag. ExprKind::Lambda { param_ty, body, .. } - if ty_to_valtype(param_ty) != ValType::I32 - || types - .and_then(|t| t.get(&body.id)) - .is_some_and(|ty| ty_to_valtype(ty) != ValType::I32) => + if !unstable_core + && (ty_to_valtype(param_ty) != ValType::I32 + || types + .and_then(|t| t.get(&body.id)) + .is_some_and(|ty| ty_to_valtype(ty) != ValType::I32)) => { - Some(unsupported( - "a lambda with an I64, F32 or F64 parameter or result (a closure \ - passes 32-bit values only); use a named function", - expr.span, - )) + Some(CodegenError(format!( + "{UNSTABLE_CORE_PREFIX}: a lambda with an I64, F32 or F64 parameter or \ + result (at byte {}..{})", + expr.span.start, expr.span.end + ))) } ExprKind::Match { scrutinee, arms } => types .and_then(|t| t.get(&scrutinee.id)) @@ -323,7 +331,7 @@ fn first_unsupported_in(expr: &Expr, types: Option<&ExprTypes>) -> Option) -> Option Result<(), CodegenError> { - validate_supported_in(module, None) + validate_supported_in(module, None, true) } /// [`validate_supported`] with the checker's type table, so the /// type-dependent limits ([`first_unsupported_typed`]) are checked too. -/// The driver runs this, so `check` rejects what `compile` must reject. pub fn validate_supported_typed(module: &AstModule, types: &ExprTypes) -> Result<(), CodegenError> { - validate_supported_in(module, Some(types)) + validate_supported_in(module, Some(types), true) +} + +/// [`validate_supported_typed`] for the front door of the language. The +/// driver runs this, so `check` rejects what `compile` must reject. +/// Without `unstable_core`, it also refuses each form that is behind the +/// flag `--unstable-core` (v2 plan rung E9), with [`UNSTABLE_CORE_PREFIX`]. +pub fn validate_accepted( + module: &AstModule, + types: &ExprTypes, + unstable_core: bool, +) -> Result<(), CodegenError> { + validate_supported_in(module, Some(types), unstable_core) } /// Check each function body of `module` with [`first_unsupported_in`]. fn validate_supported_in( module: &AstModule, types: Option<&ExprTypes>, + unstable_core: bool, ) -> Result<(), CodegenError> { for decl in &module.decls { let (name, expr, kind) = match decl { @@ -352,7 +372,7 @@ fn validate_supported_in( Decl::Const { name, value, .. } => (name, value, "constant"), _ => continue, }; - if let Some(CodegenError(msg)) = first_unsupported_in(expr, types) { + if let Some(CodegenError(msg)) = first_unsupported_in(expr, types, unstable_core) { return Err(CodegenError(format!("{msg} in {kind} `{name}`"))); } } @@ -511,11 +531,6 @@ impl LocalTracker { } } - /// Total number of extra (non-parameter) locals needed. - fn num_extra_locals(&self) -> u32 { - self.extra_local_types.len() as u32 - } - /// Declarations for `wasm_encoder::Function::new`, preserving local /// index order across mixed-width Ephapax bindings. fn wasm_local_declarations(&self) -> Vec<(u32, ValType)> { @@ -570,6 +585,14 @@ pub struct Codegen { /// Each lambda gets compiled to a named function and stored here lambda_fns: Vec, + /// The parameter types of each function that a program can call by + /// its name: a function of the module, or an extern function. A use + /// of the name as a value needs them ([`Self::compile_fn_value`]). + callable_params: HashMap>, + /// The table index of the closure function that stands for a named + /// function used as a value, by the name of that function. + fn_value_slots: HashMap, + /// Optional debug information (enabled with --debug flag) debug_info: Option, @@ -677,6 +700,8 @@ impl Codegen { access_sites: Vec::new(), extra_types: Vec::new(), lambda_fns: Vec::new(), + callable_params: HashMap::new(), + fn_value_slots: HashMap::new(), debug_info: None, optimize_closures: false, extern_imports: Vec::new(), @@ -844,8 +869,6 @@ impl Codegen { if let Some(err) = first_unsupported_typed(main_expr, &self.types) { return Err(err); } - self.emit_types(); - self.emit_imports(); // Register main as a user function so lambda index calculation is correct. // compile_lambda uses user_fns.len() to compute function/table indices. @@ -873,7 +896,11 @@ impl Codegen { let _ = self.append_lambda_funcs(&mut func_sec, &mut code_sec); // Emit sections in WASM-required order: - // Type, Import, Function, Table, Memory, Export, Element, Code, Data + // Type, Import, Function, Table, Memory, Export, Element, Code, Data. + // The type section comes after the bodies are compiled: a closure + // adds the type of its signature while its body is compiled. + self.emit_types(); + self.emit_imports(); self.module.section(&func_sec); self.emit_table(); self.emit_memory(); @@ -906,6 +933,14 @@ impl Codegen { self.compile_expr(&mut scratch, body); scratch.instruction(&Instruction::Drop); scratch.instruction(&Instruction::End); + self.with_locals(scratch) + } + + /// The function `scratch`, which was compiled with no local, with the + /// declarations of the locals that its body turned out to need. One + /// compile of a body is thus enough: a second one would add each + /// lambda of the body to the module again. + fn with_locals(&self, scratch: Function) -> Function { let raw = scratch.into_raw_body(); let mut func = Function::new(self.locals.wasm_local_declarations()); // Skip the empty locals vector: one zero byte. @@ -950,9 +985,6 @@ impl Codegen { self.populate_data_ctors(ast); // ----- Phase 2: emit sections -------------------------------------- - self.emit_types(); - self.emit_imports(); - // Build function + code sections together (runtime + user) let mut func_sec = FunctionSection::new(); let mut code_sec = CodeSection::new(); @@ -967,7 +999,11 @@ impl Codegen { self.append_lambda_funcs(&mut func_sec, &mut code_sec)?; // Emit sections in WASM-required order: - // Type, Import, Function, Table, Memory, Export, Element, Code, Data + // Type, Import, Function, Table, Memory, Export, Element, Code, Data. + // The type section comes after the bodies are compiled: a closure + // adds the type of its signature while its body is compiled. + self.emit_types(); + self.emit_imports(); self.module.section(&func_sec); self.emit_table(); self.emit_memory(); @@ -1003,8 +1039,8 @@ impl Codegen { return Err(err); } // Try full module compilation first; fall back to expression mode. - // An unsupported construct that only the types show (a wide - // lambda) is a hard error too. + // An unsupported construct that only the types show (a `match` + // on a 64-bit component) is a hard error too. match self.compile_ast_module(module) { Ok(bytes) => return Ok(bytes), Err(e) if e.0.starts_with(UNSUPPORTED_PREFIX) => return Err(e), @@ -1042,6 +1078,10 @@ impl Codegen { .iter() .map(|(_, ty)| ty_to_ownership_kind(ty)) .collect(); + self.callable_params.insert( + name.to_string(), + params.iter().map(|(_, ty)| ty.clone()).collect(), + ); self.user_fns.insert( name.to_string(), @@ -1230,6 +1270,10 @@ impl Codegen { }); self.extern_fn_indices .insert(name.to_string(), (import_idx, wasm_result)); + self.callable_params.insert( + name.to_string(), + params.iter().map(|(_, ty)| ty.clone()).collect(), + ); } } } @@ -1367,14 +1411,8 @@ impl Codegen { /// Append user-defined functions from the AST. /// - /// Uses a two-pass approach per function: - /// 1. First pass: compile body to count how many extra locals are needed. - /// 2. Second pass: compile body again into a Function with the correct - /// local declarations. - /// - /// Side effects like `add_string_literal` are idempotent for the same - /// strings, so running twice is safe (duplicates are harmless since each - /// string gets its own data segment entry). + /// Each body is compiled one time, into a function with no local; + /// [`Self::with_locals`] then declares the locals that it needs. fn append_user_funcs( &mut self, ast: &AstModule, @@ -1392,10 +1430,7 @@ impl Codegen { func_sec.function(info.wasm_type_idx); - let num_params = params.len() as u32; - - // --- Pass 1: count locals by doing a dry-run compile ---- - self.locals = LocalTracker::new(num_params); + self.locals = LocalTracker::new(params.len() as u32); for (i, (pname, pty)) in params.iter().enumerate() { self.locals.name_to_idx.insert(pname.to_string(), i as u32); self.locals @@ -1406,44 +1441,10 @@ impl Codegen { } } - // Save data state so pass 1 string literals don't duplicate - let data_snapshot = (self.data_entries.len(), self.data_offset); - - let mut dummy_func = Function::new(vec![(64, ValType::I32)]); // generous - self.compile_expr(&mut dummy_func, body); - let extra = self.locals.num_extra_locals(); - let local_declarations = self.locals.wasm_local_declarations(); - - // Restore data state - self.data_entries.truncate(data_snapshot.0); - self.data_offset = data_snapshot.1; - - // --- Pass 2: compile for real with correct local count -- - self.locals = LocalTracker::new(num_params); - for (i, (pname, pty)) in params.iter().enumerate() { - self.locals.name_to_idx.insert(pname.to_string(), i as u32); - self.locals - .name_to_type - .insert(pname.to_string(), ty_to_valtype(pty)); - if pty.is_linear() { - self.locals.linear_locals.insert(i as u32, false); - } - } - - debug_assert_eq!( - extra, - local_declarations - .iter() - .map(|(count, _)| count) - .copied() - .sum::() - ); - let mut func = Function::new(local_declarations); - - self.compile_expr(&mut func, body); - func.instruction(&Instruction::End); - - code_sec.function(&func); + let mut scratch = Function::new(Vec::<(u32, ValType)>::new()); + self.compile_expr(&mut scratch, body); + scratch.instruction(&Instruction::End); + code_sec.function(&self.with_locals(scratch)); } Decl::Type { .. } => {} Decl::Const { .. } => {} // constants inlined @@ -1461,128 +1462,48 @@ impl Codegen { Ok(()) } - /// Append lambda functions to the code section. - /// Called after user functions are compiled, since lambdas are discovered - /// during user function compilation. + /// Append the closure functions to the code section, after the user + /// functions: a closure function is found while a body is compiled. fn append_lambda_funcs( &mut self, func_sec: &mut FunctionSection, code_sec: &mut CodeSection, ) -> Result<(), CodegenError> { - // Clone lambda_fns to avoid borrow checker issues - let lambdas = self.lambda_fns.clone(); - - for lambda_info in lambdas { - // Register function type in function section - // Lambda functions take (env_ptr: i32, param: i32) -> i32 - func_sec.function(lambda_info.wasm_type_idx); - - // Two parameters: param 0 = env_ptr, param 1 = lambda parameter - let num_params: u32 = 2; - - // --- Pass 1: count locals by doing a dry-run compile ---- - self.locals = LocalTracker::new(num_params); - // param 0 = env_ptr (anonymous, used internally) + // The body of a lambda can hold a lambda: it joins the list while + // this loop runs. (The loop used to take a copy of the list first, + // so the module called a function that it did not have.) + let mut next = 0; + while let Some(lambda) = self.lambda_fns.get(next).cloned() { + next += 1; + func_sec.function(lambda.wasm_type_idx); + + // Parameter 0 is the environment, parameter 1 is the + // parameter of the lambda. + self.locals = LocalTracker::new(2); self.locals.name_to_idx.insert("__env_ptr".to_string(), 0); self.locals .name_to_type .insert("__env_ptr".to_string(), ValType::I32); - // param 1 = lambda parameter - self.locals.name_to_idx.insert(lambda_info.param.clone(), 1); - self.locals.name_to_type.insert( - lambda_info.param.clone(), - ty_to_valtype(&lambda_info.param_ty), - ); - if lambda_info.param_ty.is_linear() { - self.locals.linear_locals.insert(1, false); - } - - // Bind captured variables as locals (loaded from env_ptr) - for (captured_name, (captured_type, _)) in lambda_info - .captured_vars - .iter() - .zip(&lambda_info.captured_layout) - { - self.locals.bind_typed(captured_name, false, *captured_type); - } - - // Save data state so pass 1 string literals don't duplicate - let data_snapshot = (self.data_entries.len(), self.data_offset); - - let mut dummy_func = Function::new(vec![(64, ValType::I32)]); // generous - - // Emit captured var loads in dry run - for (i, (_, (captured_type, offset))) in lambda_info - .captured_vars - .iter() - .zip(&lambda_info.captured_layout) - .enumerate() - { - dummy_func.instruction(&Instruction::LocalGet(0)); // env_ptr - emit_typed_load(&mut dummy_func, *captured_type, *offset); - let local_idx = num_params + i as u32; - dummy_func.instruction(&Instruction::LocalSet(local_idx)); - } - - self.compile_expr(&mut dummy_func, &lambda_info.body); - let extra = self.locals.num_extra_locals(); - let local_declarations = self.locals.wasm_local_declarations(); - - // Restore data state - self.data_entries.truncate(data_snapshot.0); - self.data_offset = data_snapshot.1; - - // --- Pass 2: compile for real with correct local count -- - self.locals = LocalTracker::new(num_params); - self.locals.name_to_idx.insert("__env_ptr".to_string(), 0); + self.locals.name_to_idx.insert(lambda.param.clone(), 1); self.locals .name_to_type - .insert("__env_ptr".to_string(), ValType::I32); - self.locals.name_to_idx.insert(lambda_info.param.clone(), 1); - self.locals.name_to_type.insert( - lambda_info.param.clone(), - ty_to_valtype(&lambda_info.param_ty), - ); - if lambda_info.param_ty.is_linear() { + .insert(lambda.param.clone(), ty_to_valtype(&lambda.param_ty)); + if lambda.param_ty.is_linear() { self.locals.linear_locals.insert(1, false); } - // Re-bind captured variables as locals - for (captured_name, (captured_type, _)) in lambda_info - .captured_vars - .iter() - .zip(&lambda_info.captured_layout) - { - self.locals.bind_typed(captured_name, false, *captured_type); - } - - debug_assert_eq!( - extra, - local_declarations - .iter() - .map(|(count, _)| count) - .copied() - .sum::() - ); - let mut func = Function::new(local_declarations); - - // Load captured variables from env_ptr into local slots - for (i, (_, (captured_type, offset))) in lambda_info - .captured_vars - .iter() - .zip(&lambda_info.captured_layout) - .enumerate() - { - func.instruction(&Instruction::LocalGet(0)); // env_ptr (param 0) - emit_typed_load(&mut func, *captured_type, *offset); - let local_idx = num_params + i as u32; - func.instruction(&Instruction::LocalSet(local_idx)); + // Each captured variable is a local, loaded from the environment. + let mut scratch = Function::new(Vec::<(u32, ValType)>::new()); + for (name, (ty, offset)) in lambda.captured_vars.iter().zip(&lambda.captured_layout) { + let local = self.locals.bind_typed(name, false, *ty); + scratch.instruction(&Instruction::LocalGet(0)); + emit_typed_load(&mut scratch, *ty, *offset); + scratch.instruction(&Instruction::LocalSet(local)); } - self.compile_expr(&mut func, &lambda_info.body); - func.instruction(&Instruction::End); - - code_sec.function(&func); + self.compile_expr(&mut scratch, &lambda.body); + scratch.instruction(&Instruction::End); + code_sec.function(&self.with_locals(scratch)); } Ok(()) } @@ -2356,12 +2277,68 @@ impl Codegen { func.instruction(&Instruction::LocalGet(idx)); // Mark linear local as consumed self.locals.mark_consumed(idx); + } else if self.callable_params.contains_key(name) { + self.compile_fn_value(func, name); } else { // Unknown variable -- should have been caught by type checker. func.instruction(&Instruction::Unreachable); } } + /// A function of the module, or an extern function, used as a value + /// (`apply(inc, 41)`, `let f = add in ...`, `add(40)`). The value is a + /// closure cell for a closure function that takes the parameters one + /// at a time and then calls the function: `fn(a) -> fn(b) -> add(a, b)`. + /// The closure function is made at the first use of the name. + /// (Such a name had no value: the module was not valid.) + fn compile_fn_value(&mut self, func: &mut Function, name: &str) { + let slot = match self.fn_value_slots.get(name) { + Some(&slot) => slot, + None => { + let (param, param_ty, body) = self.eta_expansion(name); + let slot = self.register_lambda(¶m, ¶m_ty, &body, Vec::new(), Vec::new()); + self.fn_value_slots.insert(name.to_string(), slot); + slot + } + }; + // The closure function captures nothing: it has no environment. + func.instruction(&Instruction::I32Const(0)); + let env_local = self.locals.temp(); + func.instruction(&Instruction::LocalSet(env_local)); + self.emit_closure_cell(func, slot, env_local); + } + + /// The function `name` as a lambda that takes its first parameter: + /// the parameter, its type and the body. For `add` with 2 parameters + /// the body is `fn(b) -> add(a, b)`. A function with no parameter has + /// the type `() -> T`: the lambda takes a unit, and its body is the + /// call `name()`. + fn eta_expansion(&self, name: &str) -> (String, Ty, Expr) { + let params = self.callable_params.get(name).cloned().unwrap_or_default(); + let var = |name: &str| Expr::dummy(ExprKind::Var(name.into())); + // The names of the lambda parameters: no program can write them. + let param = |i: usize| format!("__arg{i}"); + let app = |func: Expr, arg: Expr| { + Expr::dummy(ExprKind::App { + func: Box::new(func), + arg: Box::new(arg), + }) + }; + if params.is_empty() { + let unit = Expr::dummy(ExprKind::Lit(Literal::Unit)); + return (param(0), Ty::Base(BaseTy::Unit), app(var(name), unit)); + } + let call = (0..params.len()).fold(var(name), |call, i| app(call, var(¶m(i)))); + let body = (1..params.len()).rev().fold(call, |body, i| { + Expr::dummy(ExprKind::Lambda { + param: param(i).into(), + param_ty: params[i].clone(), + body: Box::new(body), + }) + }); + (param(0), params[0].clone(), body) + } + fn compile_string_new(&mut self, func: &mut Function, value: &str) { let (ptr, len) = self.add_string_literal(value); func.instruction(&Instruction::I32Const(ptr as i32)); @@ -2645,14 +2622,21 @@ impl Codegen { } fn compile_lambda(&mut self, func: &mut Function, param: &str, param_ty: &Ty, body: &Expr) { - // 1. Find free variables in the lambda body + // 1. The locals of this function that the body uses. A free name + // that is not a local is a function of the module: the body finds + // it by its name. (It used to be captured as a zero, which then + // hid the function in the body.) let mut bound_vars = std::collections::HashSet::new(); bound_vars.insert(param.to_string()); - let captured_vars = self.find_free_vars(body, &bound_vars); + let captured: Vec<(String, u32)> = self + .find_free_vars(body, &bound_vars) + .into_iter() + .filter_map(|name| self.locals.get(&name).map(|local| (name, local))) + .collect(); let mut next_capture_offset = 0_u64; - let captured_layout: Vec<(ValType, u64)> = captured_vars + let captured_layout: Vec<(ValType, u64)> = captured .iter() - .map(|name| { + .map(|(name, _)| { let ty = self.locals.get_type(name).unwrap_or(ValType::I32); let (size, alignment, _) = valtype_storage(ty); next_capture_offset = align_up(next_capture_offset, alignment); @@ -2662,34 +2646,19 @@ impl Codegen { }) .collect(); - // 2. Lambda functions take (env_ptr, param) -> result - let lambda_type_idx = TYPE_CLOSURE_CALL; - - // 3. Calculate function index for this lambda. Lambdas live - // after the first user fn block, shifted forward by any - // dynamic imports (extern / FFI) registered earlier. - let lambda_fn_idx = - self.first_user_fn() + self.user_fns.len() as u32 + self.lambda_fns.len() as u32; - - // 4. Table index: position in the function table (0-based) - let table_idx = self.user_fns.len() as u32 + self.lambda_fns.len() as u32; - - // 5. Record this lambda for later emission - self.lambda_fns.push(LambdaInfo { - wasm_fn_idx: lambda_fn_idx, - wasm_type_idx: lambda_type_idx, - captured_vars: captured_vars.clone(), - captured_layout: captured_layout.clone(), - param: param.to_string(), - param_ty: param_ty.clone(), - body: body.clone(), - }); + // 2. Record the closure function for later emission. + let captured_vars = captured.iter().map(|(name, _)| name.clone()).collect(); + let table_idx = self.register_lambda( + param, + param_ty, + body, + captured_vars, + captured_layout.clone(), + ); - // 6. Allocate environment block for captured variables - // Layout preserves each captured value's width and alignment. + // 3. Allocate the environment block for the captured variables. + // The layout preserves the width and alignment of each value. let env_size = next_capture_offset.max(4); - - // Allocate env block via bump allocator func.instruction(&Instruction::I32Const(env_size as i32)); func.instruction(&Instruction::I32Const(0)); // alignment func.instruction(&Instruction::Call(self.fn_bump_alloc())); @@ -2697,37 +2666,66 @@ impl Codegen { let env_local = self.locals.temp(); func.instruction(&Instruction::LocalSet(env_local)); - // Store each captured variable's current value into the env block - for (var_name, (captured_type, offset)) in captured_vars.iter().zip(&captured_layout) { - func.instruction(&Instruction::LocalGet(env_local)); // env_ptr (address) - if let Some(var_idx) = self.locals.get(var_name) { - func.instruction(&Instruction::LocalGet(var_idx)); // captured value - } else { - // Variable not in locals — could be a top-level function reference. - // Default to 0 (will be resolved by name during lambda body compilation). - emit_typed_zero(func, *captured_type); - } + // Store the current value of each captured variable in the block. + for ((_, local), (captured_type, offset)) in captured.iter().zip(&captured_layout) { + func.instruction(&Instruction::LocalGet(env_local)); + func.instruction(&Instruction::LocalGet(*local)); emit_typed_store(func, *captured_type, *offset); } - // 7. Allocate closure cell: (table_idx: i32, env_ptr: i32) + self.emit_closure_cell(func, table_idx, env_local); + } + + /// Add a closure function to the module: `(env, param) -> body`. + /// Returns its index in the function table. + fn register_lambda( + &mut self, + param: &str, + param_ty: &Ty, + body: &Expr, + captured_vars: Vec, + captured_layout: Vec<(ValType, u64)>, + ) -> u32 { + let wasm_type_idx = self.closure_type(ty_to_valtype(param_ty), self.valtype_of(body)); + // The closure functions come after the user functions, in the + // function index space and in the table. + let table_idx = self.user_fns.len() as u32 + self.lambda_fns.len() as u32; + self.lambda_fns.push(LambdaInfo { + wasm_fn_idx: self.first_user_fn() + table_idx, + wasm_type_idx, + captured_vars, + captured_layout, + param: param.to_string(), + param_ty: param_ty.clone(), + body: body.clone(), + }); + table_idx + } + + /// The type of a closure function: `(env: i32, param) -> result` + /// (v2 plan rung E9). `call_indirect` checks it at each call, so a + /// closure with an I64, F32 or F64 parameter or result has its own + /// type. (Each closure had the one type `(i32, i32) -> i32`.) + fn closure_type(&mut self, param: ValType, result: ValType) -> u32 { + self.register_type(vec![ValType::I32, param], vec![result]) + } + + /// Allocate a closure cell `(table_idx: i32 @ 0, env_ptr: i32 @ 4)` + /// and leave its address on the stack. + fn emit_closure_cell(&mut self, func: &mut Function, table_idx: u32, env_local: u32) { func.instruction(&Instruction::I32Const(CLOSURE_SIZE as i32)); func.instruction(&Instruction::I32Const(0)); // alignment func.instruction(&Instruction::Call(self.fn_bump_alloc())); let closure_local = self.locals.temp(); func.instruction(&Instruction::LocalTee(closure_local)); - - // Store table index at offset 0 func.instruction(&Instruction::I32Const(table_idx as i32)); func.instruction(&Instruction::I32Store(mem_arg(0))); - // Store env_ptr at offset 4 func.instruction(&Instruction::LocalGet(closure_local)); func.instruction(&Instruction::LocalGet(env_local)); func.instruction(&Instruction::I32Store(mem_arg(4))); - // Push closure cell pointer as result func.instruction(&Instruction::LocalGet(closure_local)); } @@ -2758,7 +2756,13 @@ impl Codegen { // arg. `effective_call_args` drops that placeholder so the args // pushed match the function's flat wasm signature (zero params), // rather than pushing a stray `()` and over-filling the stack. - if let Some((head, args)) = flatten_app_chain(fn_expr, arg) { + // + // A local hides a function of the same name (`fn apply(f: ...) = + // f(x)` with a top-level `f`): the call is then a closure call. + // (The call went to the top-level function.) + let head = + flatten_app_chain(fn_expr, arg).filter(|(head, _)| self.locals.get(head).is_none()); + if let Some((head, args)) = head { if let Some(info) = self.user_fns.get(head).cloned() { let arity = info.param_names.len(); if let Some(call_args) = effective_call_args(&args, arity) { @@ -2800,25 +2804,31 @@ impl Codegen { // Indirect call via closure cell. // // Closure cell layout: (table_idx: i32 @ offset 0, env_ptr: i32 @ offset 4) - // Lambda function signature: (env_ptr: i32, param: i32) -> i32 + // Closure function signature: (env_ptr: i32, param) -> result, with + // the wasm types of the parameter and of the result of the callee. // // Steps: // 1. Evaluate closure expression -> closure_ptr // 2. Load env_ptr from closure_ptr + 4 // 3. Evaluate argument -> param value // 4. Load table_idx from closure_ptr + 0 - // 5. call_indirect with TYPE_CLOSURE_CALL (i32, i32) -> i32 + // 5. call_indirect with the type of the closure function + let (param, result) = self + .ty_of(fn_expr) + .and_then(fun_valtypes) + .unwrap_or((self.valtype_of(arg), ValType::I32)); + let type_index = self.closure_type(param, result); // Evaluate closure to get closure cell pointer self.compile_expr(func, fn_expr); let closure_local = self.locals.temp(); func.instruction(&Instruction::LocalSet(closure_local)); - // Push env_ptr (first argument to lambda function) + // Push env_ptr (first argument to the closure function) func.instruction(&Instruction::LocalGet(closure_local)); func.instruction(&Instruction::I32Load(mem_arg(4))); // env_ptr at offset 4 - // Push param (second argument to lambda function) + // Push param (second argument to the closure function) self.compile_expr(func, arg); // Push table index (popped by call_indirect) @@ -2827,7 +2837,7 @@ impl Codegen { // Indirect call: (env_ptr, param) -> result func.instruction(&Instruction::CallIndirect { - type_index: TYPE_CLOSURE_CALL, + type_index, table_index: 0, }); } @@ -4054,15 +4064,6 @@ fn typed_mem_arg(ty: ValType, offset: u64) -> wasm_encoder::MemArg { } } -fn emit_typed_zero(func: &mut Function, ty: ValType) { - match ty { - ValType::I64 => func.instruction(&Instruction::I64Const(0)), - ValType::F32 => func.instruction(&Instruction::F32Const(0.0)), - ValType::F64 => func.instruction(&Instruction::F64Const(0.0)), - _ => func.instruction(&Instruction::I32Const(0)), - }; -} - fn emit_typed_store(func: &mut Function, ty: ValType, offset: u64) { let arg = typed_mem_arg(ty, offset); match ty { @@ -4102,6 +4103,18 @@ fn ty_to_valtype(ty: &Ty) -> ValType { } } +/// The wasm types of the parameter and of the result of a value of the +/// function type `ty` (through a borrow). +fn fun_valtypes(ty: &Ty) -> Option<(ValType, ValType)> { + match ty { + Ty::Fun { param, ret } | Ty::Effectful { param, ret, .. } => { + Some((ty_to_valtype(param), ty_to_valtype(ret))) + } + Ty::Borrow { inner, .. } | Ty::Ref { inner, .. } => fun_valtypes(inner), + _ => None, + } +} + /// Walk a curried `App` chain and extract `(head_name, args_in_order)` /// if the leftmost expression is a `Var`. ///