From b8d087ffa55d0d262a4ab78f374c6fe89835a8f9 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 28 May 2026 13:51:26 +0100 Subject: [PATCH] =?UTF-8?q?feat(bindings):=20close=20bindings=20roadmap=20?= =?UTF-8?q?#5=20=E2=80=94=20AS-side=20wasmCall=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host-side codegen for wasmCall landed via #422 (`__as_wasmCall` helper + lowering-table entry in `lib/codegen_deno.ml`), but the AS-side surface (`pub extern fn wasmCall` in `stdlib/Deno.affine`) and test fixtures that PR #419 originally bundled were lost when #419 was closed as "superseded by #422". This re-lands the AS-side bits so the binding is end-to-end usable, flipping roadmap row #5 from `◐ host-side; AS-side ○` to `● usable (Option A landed)`. Surface: pub extern fn wasmCall( exports: WasmExports, name: String, args: [Float] ) -> Float; Lowers to `Number(exports[name](...(args || [])))` on `--deno-esm`. Number coercion means i32/i64/f32/f64 export return types all flow back as `Float` at the AS boundary. Caller is responsible for the export existing + arity-matching; absent exports raise the host `TypeError` at the boundary (documented in the extern docstring). Files: - `stdlib/Deno.affine` — new `wasmCall` extern + docstring with `Bytes -> WasmExports -> wasmCall` worked example. - `tests/codegen-deno/wasm_call.affine` — fixture exposing `addViaWasm`. - `tests/codegen-deno/wasm_call.harness.mjs` — Node ESM harness instantiating a hand-built 41-byte wasm module exporting `add(i32, i32) -> i32`; asserts 4 round-trip cases. - `docs/bindings-roadmap.adoc` — row #5 status `◐` → `●` and the cross-cutting observation reads "LANDED" instead of "one-day fix". Local verification: - `affinescript check stdlib/Deno.affine` → "Type checking passed". - `affinescript check tests/codegen-deno/wasm_call.affine` → "Type checking passed". Closes bindings roadmap #5. Closes #414 (host-side via #422, AS-side via this PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/bindings-roadmap.adoc | 16 ++++++------ stdlib/Deno.affine | 18 ++++++++++++++ tests/codegen-deno/wasm_call.affine | 12 +++++++++ tests/codegen-deno/wasm_call.harness.mjs | 31 ++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 tests/codegen-deno/wasm_call.affine create mode 100644 tests/codegen-deno/wasm_call.harness.mjs diff --git a/docs/bindings-roadmap.adoc b/docs/bindings-roadmap.adoc index 5dd7e65f..37398361 100644 --- a/docs/bindings-roadmap.adoc +++ b/docs/bindings-roadmap.adoc @@ -73,9 +73,9 @@ no further significant ReScript → AffineScript work is tractable. |5 |*WASM-exports calling pattern* — invoke individual `exports.fn_name(args)` from a `WasmExports` value -|`◐` host-side; AS-side `○` -|`stdlib/Deno.affine` extension or new `stdlib/wasm.affine` -|`stdlib/Deno.affine` already exposes `wasmInstance(bytes) -> WasmExports` + the type, but no surface to *call* exports. idaptik `vm/wasm` is blocked here; ~30 per-Zig-fn extern fns needed, or one generic `wasm_call(exports, name, args) -> Float`. *Smallest scope, highest leverage — recommended kickoff.* +|`●` usable (Option A landed) +|`stdlib/Deno.affine` +|`wasmCall(exports: WasmExports, name: String, args: [Float]) -> Float` lowers to `Number(exports[name](...args))` on `--deno-esm`. AS-side surface + docstring example landed in `stdlib/Deno.affine`; round-trip exercised by `tests/codegen-deno/wasm_call.{affine,harness.mjs}` against a hand-built 41-byte wasm module exporting `add(i32, i32) -> i32`. *Option A (generic) — typed per-Zig-fn shims can layer on top per-consumer if needed.* Closes #414 via host-side #422 + AS-side this PR. |6 |*Phoenix Channels / WebSocket* (Socket connect/disconnect, Channel join/leave/push, presence) @@ -390,10 +390,12 @@ Build, test, and cross-language surface. bindings, four (#1, #2, #3, and #4 by extension) are PixiJS-ecosystem. Investing in `affinescript-pixijs` unblocks the largest single chunk of idaptik's 215-file `src/app/` tree. -. *WASM-exports calling is a one-day fix with high leverage.* Item #5 - — adding `extern fn wasm_export_call(exports: WasmExports, name: String, args: [Float]) -> Float` - to `stdlib/Deno.affine` (or 30 per-Zig-fn externs) unblocks idaptik - `vm/wasm` *and* every future WASM-host consumer in the estate. +. *WASM-exports calling LANDED.* Item #5 — `wasmCall(exports, name, args) -> Float` + in `stdlib/Deno.affine` ships the generic Option A surface; idaptik + `vm/wasm` and every future WASM-host consumer in the estate can now + call individual exports without a per-fn extern. Typed per-Zig-fn + shims can layer on top per-consumer where the discipline is worth + the brittleness. . *DOM is closest to done* — issue #255 (for-in / while wasm codegen) is the blocker, not the binding surface itself. That's a codegen bug, not a binding gap; classified separately so it doesn't get re-scoped diff --git a/stdlib/Deno.affine b/stdlib/Deno.affine index 470fd3a0..4e4f556b 100644 --- a/stdlib/Deno.affine +++ b/stdlib/Deno.affine @@ -124,6 +124,24 @@ pub extern fn stripSuffix(s: String, suffix: String) -> String; /// `new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports`. pub extern fn wasmInstance(bytes: Bytes) -> WasmExports; +/// `exports[name](...args)` — invoke a named export with a list of +/// Float arguments. WebAssembly's i32/i64/f32/f64 scalar types all +/// coerce to JS Number, so a single Float-typed surface covers the +/// common case (multi-value / void returns are out of scope here — +/// add a specialised extern when needed). Caller is responsible for +/// the export existing and having a compatible arity; absent exports +/// throw `TypeError: ... is not a function` at the host boundary. +/// +/// Example: +/// +/// use Deno::{Bytes, WasmExports, wasmInstance, wasmCall}; +/// +/// pub fn addViaWasm(bytes: Bytes, a: Float, b: Float) -> Float = { +/// let exports = wasmInstance(bytes); +/// wasmCall(exports, "add", [a, b]) +/// }; +pub extern fn wasmCall(exports: WasmExports, name: String, args: [Float]) -> Float; + // ── Array helper ─────────────────────────────────────────────────── // // AffineScript has no mutable-array push primitive in this subset; diff --git a/tests/codegen-deno/wasm_call.affine b/tests/codegen-deno/wasm_call.affine new file mode 100644 index 00000000..f0d5c2f7 --- /dev/null +++ b/tests/codegen-deno/wasm_call.affine @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MPL-2.0 +// bindings #5 (closes #414): wasmCall extern exercises the +// `exports[name](...args)` lowering path on the Deno-ESM backend. +// +// Pure logic — the harness instantiates a tiny inline wasm module +// exporting `add(i32, i32) -> i32` and passes the WasmExports value +// into addViaWasm; nothing here touches the `Deno` global or FS. + +use Deno::{WasmExports, wasmCall}; + +pub fn addViaWasm(exports: WasmExports, a: Float, b: Float) -> Float = + wasmCall(exports, "add", [a, b]); diff --git a/tests/codegen-deno/wasm_call.harness.mjs b/tests/codegen-deno/wasm_call.harness.mjs new file mode 100644 index 00000000..b42044b0 --- /dev/null +++ b/tests/codegen-deno/wasm_call.harness.mjs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// bindings #5 (closes #414) — Node ESM harness for the wasmCall extern. +// +// Instantiates a 41-byte inline wasm module exporting +// `add(i32, i32) -> i32` and asserts that addViaWasm, which lowers to +// __as_wasmCall(exports, "add", [a, b]), round-trips correctly. + +import assert from "node:assert/strict"; +import { addViaWasm } from "./wasm_call.deno.js"; + +// Hand-built minimal wasm module: +// (module +// (func (export "add") (param i32 i32) (result i32) +// local.get 0 local.get 1 i32.add)) +const wasmBytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version + 0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f, // type: (i32,i32)->i32 + 0x03, 0x02, 0x01, 0x00, // func 0 of type 0 + 0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, // export "add" func 0 + 0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b, // body +]); + +const instance = new WebAssembly.Instance(new WebAssembly.Module(wasmBytes)); +const exports = instance.exports; + +assert.equal(addViaWasm(exports, 2, 3), 5, "addViaWasm(2,3) == 5"); +assert.equal(addViaWasm(exports, -1, 1), 0, "addViaWasm(-1,1) == 0"); +assert.equal(addViaWasm(exports, 0, 0), 0, "addViaWasm(0,0) == 0"); +assert.equal(addViaWasm(exports, 100, 200), 300, "addViaWasm(100,200) == 300"); + +console.log("wasm_call.harness.mjs OK");