From 03e0d29c657420aaf4d5eebacf2a9919227f919d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 30 May 2026 16:31:42 +0100 Subject: [PATCH] feat(stdlib): WasmValue + wasm_export_call typed wasm-exports binding (closes #455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 #5 of the AS bindings top-50 roadmap (#446). Owner's #455-decided Option B: generic `wasm_export_call(exports, name, args: [WasmValue]) -> WasmValue` with WasmValue as a tagged scalar carrier covering all four wasm numeric kinds (i32, i64, f32, f64). Future-proof: covers any wasm signature including i64, multi-typed args, future spec additions. No binding change required as wasm evolves. Tiny addition vs Option A's ~30 per-signature variants. Typed wrappers can be layered on top of this generic as ergonomic helpers in a follow-up sub-issue. ## Encoding decision WasmValue lands as an OPAQUE `pub extern type` rather than a true AS sum type. Rationale: the JS interop boundary needs a hand-written marshaller that pairs `wv_i32(42) -> { kind: "i32", v: 42 }` with the export-call dispatch `__as_wasm_export_call(exports, name, args)`. Mirrors the existing `WasmExports` opaque pattern in stdlib/Deno.affine. A true sum-type variant on top of this opaque base ships in a follow-up once json.affine-style tagged-variant codegen lands for the Deno-ESM backend. ## What this PR ships - `stdlib/Deno.affine`: +87 lines * `pub extern type WasmValue` * `wv_i32` / `wv_i64` / `wv_f32` / `wv_f64` constructors * `wv_as_int` / `wv_as_float` / `wv_kind` accessors * `wasm_export_call(exports, name, args: [WasmValue]) -> WasmValue` * Worked example: `addI32ViaWasm(bytes, a, b)` in the docstring - `lib/codegen_deno.ml`: +47 lines * JS prelude: 8 `__as_wv_*` / `__as_wasm_export_call` helpers * `deno_builtins` dispatch table: 8 entries * BigInt for i64 (preserves precision beyond 2^53); `Math.fround` for f32 * Return wraps as f64 by default (lossless for any numeric); i64 returns detected via `typeof result === "bigint"` and wrapped as i64 ## What's deferred (per owner's #455 implementation-scope breakdown) - Zig FFI implementation for the native backend — separate PR - Idris2 ABI pattern doc (Zig=APIs/FFIs, Idris2=ABIs convention) — separate PR - `examples/wasm-exports-demo.affine` end-to-end demo — separate PR - Smoke-test through the Zig FFI — separate PR Each of these is independently shippable now that the Deno.affine surface is in place. Will file follow-up tracking issues after this lands. ## Owner-directive compliance - Adds 8 externs in the WebAssembly section adjacent to existing `wasmCall`. - Adds 8 codegen dispatch entries in the existing `let () = ...` block. - Pure additive change; no existing surface modified. - Owner Option B confirmed in #455 comment 2026-05-30 13:18Z. ## Refs - closes #455 (Tier 1 #5, scope: Deno.affine + JS codegen) - #446 — AS bindings top-50 umbrella - `project_affinescript_bindings_top50_roadmap.md` — memory tracker - `stdlib/Deno.affine:155-176` — existing `wasmCall` / `wasmInstance` surface for context Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/codegen_deno.ml | 47 ++++++++++++++++++++++++ stdlib/Deno.affine | 87 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index eb3c779..9d6de7e 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -171,6 +171,44 @@ const __as_regexMatch = (s, pat) => new RegExp(pat).test(String(s)); const __as_wasmInstance = (bytes) => new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || []))); +// ---- WasmValue (Deno.affine #455 — Tier 1 #5, Option B) ---- +// Opaque tagged value crossing the AS/JS boundary as `{ kind, v }`. +// `kind` is one of "i32" | "i64" | "f32" | "f64". The `v` payload is +// `BigInt` for i64 (preserves precision beyond 2^53), `Number` otherwise. +const __as_wv_i32 = (n) => ({ kind: "i32", v: (Number(n) | 0) }); +const __as_wv_i64 = (n) => ({ kind: "i64", v: BigInt(n) }); +const __as_wv_f32 = (f) => ({ kind: "f32", v: Math.fround(Number(f)) }); +const __as_wv_f64 = (f) => ({ kind: "f64", v: Number(f) }); +const __as_wv_as_int = (v) => { + if (v == null) return 0; + if (typeof v.v === "bigint") { + // i64: truncate to safe-integer Number; caller's responsibility for + // precision-sensitive paths (use wv_kind to detect). + return Number(v.v); + } + // i32 / f32 / f64: truncate toward zero per AS Int semantics. + return (Number(v.v) | 0); +}; +const __as_wv_as_float = (v) => { + if (v == null) return 0; + return typeof v.v === "bigint" ? Number(v.v) : Number(v.v); +}; +const __as_wv_kind = (v) => (v && typeof v.kind === "string") ? v.kind : ""; +const __as_wasm_export_call = (exports, name, args) => { + // Unmarshal AS-side [WasmValue] to raw JS scalars for the wasm call. + const rawArgs = (args || []).map((wv) => { + if (wv == null) return 0; + // i64 payload is BigInt; wasm i64 imports accept BigInt directly. + return wv.v; + }); + const result = exports[name](...rawArgs); + // Wrap return as f64 (lossless for any numeric; callers expecting i32/i64 + // can rebuild via wv_i32(wv_as_int(result)) or inspect wv_kind). + if (typeof result === "bigint") { + return { kind: "i64", v: result }; + } + return { kind: "f64", v: Number(result) }; +}; // ---- motion (bindings #4): consumer-provided import ---- // Host JS environment must expose globalThis.__as_motion (the motion // library or a compatible mock). Tests set it in the harness before @@ -431,6 +469,15 @@ let () = b "regexMatch" (fun a -> Printf.sprintf "__as_regexMatch(%s, %s)" (arg 0 a) (arg 1 a)); b "wasmInstance" (fun a -> Printf.sprintf "__as_wasmInstance(%s)" (arg 0 a)); b "wasmCall" (fun a -> Printf.sprintf "__as_wasmCall(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); + (* WasmValue constructors / accessors / typed export call — #455. *) + b "wv_i32" (fun a -> Printf.sprintf "__as_wv_i32(%s)" (arg 0 a)); + b "wv_i64" (fun a -> Printf.sprintf "__as_wv_i64(%s)" (arg 0 a)); + b "wv_f32" (fun a -> Printf.sprintf "__as_wv_f32(%s)" (arg 0 a)); + b "wv_f64" (fun a -> Printf.sprintf "__as_wv_f64(%s)" (arg 0 a)); + b "wv_as_int" (fun a -> Printf.sprintf "__as_wv_as_int(%s)" (arg 0 a)); + b "wv_as_float" (fun a -> Printf.sprintf "__as_wv_as_float(%s)" (arg 0 a)); + b "wv_kind" (fun a -> Printf.sprintf "__as_wv_kind(%s)" (arg 0 a)); + b "wasm_export_call" (fun a -> Printf.sprintf "__as_wasm_export_call(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); (* ---- motion (bindings #4) ---- *) b "motionAnimate" (fun a -> Printf.sprintf "__as_motionAnimate(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); b "motionAwait" (fun a -> Printf.sprintf "(await __as_motionAwait(%s))" (arg 0 a)); diff --git a/stdlib/Deno.affine b/stdlib/Deno.affine index d0711ca..a69c0b8 100644 --- a/stdlib/Deno.affine +++ b/stdlib/Deno.affine @@ -175,6 +175,93 @@ pub extern fn wasmInstance(bytes: Bytes) -> WasmExports; /// }; pub extern fn wasmCall(exports: WasmExports, name: String, args: [Float]) -> Float; +// ── WebAssembly typed export call (#455 — Tier 1 #5, Option B) ──── +// +// Generic `wasm_export_call` covering any wasm signature including i64, +// multi-typed args, future spec additions. Future-proof: no binding +// change required as wasm evolves. Tiny addition vs Option A's ~30 +// per-signature variants. Typed wrappers can be layered on top of this +// generic as ergonomic helpers in a follow-up sub-issue. +// +// Trade-off: weaker static safety at the call site — user marshals +// manually via the `wv_*` constructors and reads via `wv_as_*`. Errors +// (wrong arity, missing export, type mismatch) deferred to runtime per +// owner's accepted trade-off in #455 comment. +// +// **Encoding decision (2026-05-30):** `WasmValue` lands as an OPAQUE +// extern type rather than a true AffineScript sum type. Rationale: +// the JS interop boundary needs a hand-written marshaller that pairs +// `wv_i32(42) -> { tag: "i32", v: 42 }` with the export-call dispatch +// `__as_wasm_export_call(exports, name, args)`. Mirrors the existing +// `WasmExports` opaque pattern. A true sum-type variant on top of this +// opaque base ships in a follow-up once `json.affine`-style tagged- +// variant codegen lands for the Deno-ESM backend. + +/// Opaque wasm scalar value. Constructed via `wv_i32` / `wv_i64` / +/// `wv_f32` / `wv_f64`. Read via `wv_as_int` (i32/i64 → Int) or +/// `wv_as_float` (f32/f64 → Float). The kind tag is opaque to AS code +/// but inspectable host-side via `wv_kind` for diagnostics. +pub extern type WasmValue; + +/// Wrap an `Int` as a wasm i32. Truncates to the low 32 bits at the +/// host boundary if `n` exceeds the i32 range. +pub extern fn wv_i32(n: Int) -> WasmValue; + +/// Wrap an `Int` as a wasm i64. Crosses the boundary as a `BigInt` +/// host-side. Values outside the safe-integer range (>= 2^53) are +/// preserved as BigInt; arithmetic on the AS side that goes through +/// `wv_as_int` truncates to the safe-integer range. +pub extern fn wv_i64(n: Int) -> WasmValue; + +/// Wrap a `Float` as a wasm f32. Rounded to f32 precision via +/// `Math.fround` at the host boundary. +pub extern fn wv_f32(f: Float) -> WasmValue; + +/// Wrap a `Float` as a wasm f64. Preserved at full f64 precision. +pub extern fn wv_f64(f: Float) -> WasmValue; + +/// Read a wasm scalar back as `Int`. Defined for both i32 and i64 +/// variants. For f32/f64, truncates toward zero. Caller is responsible +/// for knowing the variant — there is no runtime check; reading the +/// wrong kind silently coerces. +pub extern fn wv_as_int(v: WasmValue) -> Int; + +/// Read a wasm scalar back as `Float`. Defined for both f32 and f64 +/// variants. For i32/i64, converts via JS `Number()` — i64 values +/// beyond 2^53 lose precision; caller can detect via `wv_kind`. +pub extern fn wv_as_float(v: WasmValue) -> Float; + +/// Return the kind tag ("i32" / "i64" / "f32" / "f64") for runtime +/// dispatch when the AS-side caller doesn't statically know the +/// variant. Use sparingly — the typed `wv_as_*` accessors should be +/// the default path. +pub extern fn wv_kind(v: WasmValue) -> String; + +/// `exports[name](...args)` with typed `WasmValue` marshalling. +/// Returns a `WasmValue` wrapping the export's return — kind is `f64` +/// by default (the lossless choice for any numeric return); callers +/// expecting i32/i64 should rebuild via `wv_i32(wv_as_int(result))` +/// or inspect `wv_kind` host-side. Multi-value returns are out of +/// scope at this binding — add a `wasm_export_call_multi` extern when +/// needed. +/// +/// Example: +/// +/// use Deno::{ +/// Bytes, WasmExports, wasmInstance, wasm_export_call, +/// wv_i32, wv_as_int, +/// }; +/// +/// pub fn addI32ViaWasm(bytes: Bytes, a: Int, b: Int) -> Int { +/// let exports = wasmInstance(bytes); +/// let result = wasm_export_call( +/// exports, "add", [wv_i32(a), wv_i32(b)]); +/// wv_as_int(result) +/// } +pub extern fn wasm_export_call( + exports: WasmExports, name: String, args: [WasmValue] +) -> WasmValue; + // ── Array helper ─────────────────────────────────────────────────── // // AffineScript has no mutable-array push primitive in this subset;