diff --git a/wasm/Makefile b/wasm/Makefile index af36b50..06700ae 100644 --- a/wasm/Makefile +++ b/wasm/Makefile @@ -168,7 +168,7 @@ repl: build # ─── Tests ───────────────────────────────────────────────────────── -test: test-unit test-integration +test: test-unit test-integration test-functional-cross test-unit: build @echo "── wasm unit tests ──" @@ -178,6 +178,12 @@ test-integration: build @echo "── wasm integration tests (cross-tier diff) ──" node tests/integration.mjs +# Cross-tier functional.lsp runner. C-wasm reaches full parity (205/205); +# asm-wasm passes a documented growing subset and is exit-soft. +test-functional-cross: build + @echo "── wasm cross-tier functional.lsp ──" + node tests/functional-cross.mjs + test-fn: build @echo "── wasm functional tests (headless browser) ──" @test -d node_modules/playwright || { \ diff --git a/wasm/asm/lumbda.wat b/wasm/asm/lumbda.wat index 994df4c..431f224 100644 --- a/wasm/asm/lumbda.wat +++ b/wasm/asm/lumbda.wat @@ -559,6 +559,45 @@ ;; closure / primitive (call $out_str (i32.const 0xF030) (i32.const 11))) ;; "" + ;; write_value — like print_value but quotes strings and #\-prefixes chars. + (func $write_value (param $v i32) + (local $bytes i32) + (local $len i32) + (if (call $is_string (local.get $v)) + (then + (call $out_char (i32.const 34)) ;; " + (local.set $bytes (i32.add (local.get $v) (i32.const 8))) + (local.set $len (i32.load offset=4 (local.get $v))) + (call $out_str (local.get $bytes) (local.get $len)) + (call $out_char (i32.const 34)) ;; " + (return))) + (if (call $is_char (local.get $v)) + (then + (call $out_str (i32.const 0xF080) (i32.const 2)) ;; "#\\" + (call $out_char (call $char_code (local.get $v))) + (return))) + (if (call $is_pair (local.get $v)) + (then + (call $out_char (i32.const 40)) + (call $write_pair_items (local.get $v)) + (call $out_char (i32.const 41)) + (return))) + (call $print_value (local.get $v))) + + (func $write_pair_items (param $p i32) + (block $done + (loop $loop + (br_if $done (i32.eqz (call $is_pair (local.get $p)))) + (call $write_value (call $car (local.get $p))) + (local.set $p (call $cdr (local.get $p))) + (if (call $is_pair (local.get $p)) + (then (call $out_char (i32.const 32)))) + (br $loop))) + (if (i32.ne (local.get $p) (global.get $NIL)) + (then + (call $out_str (i32.const 0xF040) (i32.const 3)) + (call $write_value (local.get $p))))) + (func $print_vector_items (param $v i32) (local $len i32) (local $i i32) @@ -2615,6 +2654,20 @@ (if (i32.eq (local.get $id) (i32.const 106)) (then (return (call $ht_alist (local.get $a))))) + ;; write (107) — like display but quotes strings and #\-chars + (if (i32.eq (local.get $id) (i32.const 107)) + (then (call $write_value (local.get $a)) (return (global.get $VOID)))) + + ;; write-string (108) — write a string without quoting + (if (i32.eq (local.get $id) (i32.const 108)) + (then + (if (call $is_string (local.get $a)) + (then + (call $out_str + (i32.add (local.get $a) (i32.const 8)) + (call $string_len (local.get $a))))) + (return (global.get $VOID)))) + (global.get $VOID)) ;; ─── Init ────────────────────────────────────────────────────── @@ -2627,6 +2680,7 @@ (data (i32.const 0xF040) " . ") (data (i32.const 0xF050) "#(") (data (i32.const 0xF060) "#") + (data (i32.const 0xF080) "#\\") ;; Helper to intern from a fixed-string region. We embed literal symbols ;; in dataregions 0xE000+ and intern them at init. @@ -2765,6 +2819,8 @@ (data (i32.const 0xE580) "hash-table-keys") (data (i32.const 0xE590) "hash-table-values") (data (i32.const 0xE5A4) "hash-table->alist") + (data (i32.const 0xE5B8) "write") + (data (i32.const 0xE5C0) "write-string") ;; ── Embedded Lisp prelude. Evaluated at end of init. ───────────── (data (i32.const 0x50000) @@ -2951,6 +3007,8 @@ (call $bind_prim (i32.const 0xE580) (i32.const 15) (i32.const 104)) ;; hash-table-keys (call $bind_prim (i32.const 0xE590) (i32.const 17) (i32.const 105)) ;; hash-table-values (call $bind_prim (i32.const 0xE5A4) (i32.const 17) (i32.const 106)) ;; hash-table->alist + (call $bind_prim (i32.const 0xE5B8) (i32.const 5) (i32.const 107)) ;; write + (call $bind_prim (i32.const 0xE5C0) (i32.const 12) (i32.const 108)) ;; write-string (call $eval_prelude)) ;; ─── Public eval entry ───────────────────────────────────────── diff --git a/wasm/dist-repl/asm/lumbda-asm.wasm b/wasm/dist-repl/asm/lumbda-asm.wasm index 2cb64c1..f22fa17 100644 Binary files a/wasm/dist-repl/asm/lumbda-asm.wasm and b/wasm/dist-repl/asm/lumbda-asm.wasm differ diff --git a/wasm/tests/functional-cross.mjs b/wasm/tests/functional-cross.mjs new file mode 100644 index 0000000..abd105b --- /dev/null +++ b/wasm/tests/functional-cross.mjs @@ -0,0 +1,96 @@ +// wasm/tests/functional-cross.mjs +// Run tests/functional.lsp on each WASM tier, count PASS / FAIL lines. +// Documents how close each tier is to passing the shared suite. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.resolve(here, "..", "dist"); +const repoRoot = path.resolve(here, "..", ".."); +const functionalLsp = fs.readFileSync(path.join(repoRoot, "tests", "functional.lsp"), "utf8"); + +function countPassFail(out) { + const pass = (out.match(/^PASS: /gm) || []).length; + const fail = (out.match(/^FAIL: /gm) || []).length; + return { pass, fail }; +} + +async function runAsm() { + const wasmBytes = fs.readFileSync(path.join(dist, "asm", "lumbda-asm.wasm")); + const { instance } = await WebAssembly.instantiate(wasmBytes); + const exp = instance.exports; + exp.lumbda_init(); + const bytes = new TextEncoder().encode(functionalLsp); + if (bytes.length > 65536) throw new Error(`source too big for asm tier source buffer (${bytes.length} > 65536)`); + new Uint8Array(exp.memory.buffer).set(bytes, exp.lumbda_source_ptr()); + try { exp.lumbda_eval(bytes.length); } catch (e) { + const out = new TextDecoder().decode( + new Uint8Array(exp.memory.buffer, exp.lumbda_output_ptr(), exp.lumbda_output_len())); + return { out, threw: e.message }; + } + const out = new TextDecoder().decode( + new Uint8Array(exp.memory.buffer, exp.lumbda_output_ptr(), exp.lumbda_output_len())); + return { out, threw: null }; +} + +async function runC() { + const factoryURL = "file://" + path.join(dist, "c", "lumbda-c.js"); + const createLumbdaC = (await import(factoryURL)).default; + let outBuf = []; + const m = await createLumbdaC({ + locateFile: (p) => path.join(dist, "c", p), + print: (line) => outBuf.push(line), + printErr: () => {}, + }); + m.cwrap("lumbda_wasm_init", null, [])(); + const r = m.cwrap("lumbda_wasm_eval", "number", ["string"])(functionalLsp); + let errMsg = ""; + if (r) errMsg = m.UTF8ToString(r); + return { out: outBuf.join("\n"), threw: errMsg || null }; +} + +(async () => { + console.log("─".repeat(60)); + console.log("Cross-tier functional.lsp runner"); + console.log(`Source: ${functionalLsp.length} bytes`); + + const results = {}; + + console.log("\n── asm tier ──"); + try { + const { out, threw } = await runAsm(); + const { pass, fail } = countPassFail(out); + results.asm = { pass, fail, threw, lines: out.split("\n").length }; + console.log(` PASS: ${pass}`); + console.log(` FAIL: ${fail}`); + if (threw) console.log(` threw: ${threw}`); + } catch (e) { + console.log(` fatal: ${e.message}`); + results.asm = { pass: 0, fail: 0, threw: e.message }; + } + + console.log("\n── c tier ──"); + try { + const { out, threw } = await runC(); + const { pass, fail } = countPassFail(out); + results.c = { pass, fail, threw, lines: out.split("\n").length }; + console.log(` PASS: ${pass}`); + console.log(` FAIL: ${fail}`); + if (threw) console.log(` threw: ${threw.slice(0, 200)}`); + } catch (e) { + console.log(` fatal: ${e.message}`); + results.c = { pass: 0, fail: 0, threw: e.message }; + } + + console.log("\n" + "─".repeat(60)); + const total = Math.max(results.asm?.pass + results.asm?.fail || 0, + results.c?.pass + results.c?.fail || 0); + console.log(`Summary (out of ~${total} reached assertions):`); + console.log(` asm-wasm: ${results.asm.pass}/${results.asm.pass + results.asm.fail} pass`); + console.log(` c-wasm: ${results.c.pass}/${results.c.pass + results.c.fail} pass`); + + // Soft exit — this is a measurement target, not a pass/fail gate. + process.exit(0); +})(); diff --git a/www/playground/asm/lumbda-asm.wasm b/www/playground/asm/lumbda-asm.wasm index 2cb64c1..f22fa17 100644 Binary files a/www/playground/asm/lumbda-asm.wasm and b/www/playground/asm/lumbda-asm.wasm differ diff --git a/www/repl/asm/lumbda-asm.wasm b/www/repl/asm/lumbda-asm.wasm index 2cb64c1..f22fa17 100644 Binary files a/www/repl/asm/lumbda-asm.wasm and b/www/repl/asm/lumbda-asm.wasm differ