lumbda/wasm/Makefile
russell@unturf.com 346b873247
wasm: three-tier Lumbda to WebAssembly + browser playground
Adds a parallel build of all three Lumbda implementations to WASM, a
single-page playground at www/playground/, and a verified test suite.

Tiers
  - Python: Pyodide (CPython-in-WASM) hosting lumbda.py
  - C:      Emscripten build of c/ (tree-walker + bytecode VM; jit.c
            stubbed, gc.c uses its existing no-Boehm fallback)
  - Asm:    hand-written asm/lumbda.wat — parallel impl to asm/lumbda.s.
            Reader, eval (lambda/define/if/cond/let/and/or/quote/set!),
            recursion across mutated top-level env, bump allocator with
            memory.grow, 24 primitives. ~1200 lines of raw WAT.

SPA (wasm/app/, deployed to www/playground/)
  - CodeMirror 6 editor (Scheme highlighting) on left, output on right
  - Radios: 4 demos (Mandelbrot, Fib+Ack, Sieve, self-interp meta-eval)
            x 4 tiers (Python | C | Asm | All three)
  - All-three mode renders the three tier outputs side by side with
    per-tier elapsed timing

Tests (38 verified assertions)
  - 20 unit (Node): per-tier module loads, eval smoke
  - 8 integration (Node): each demo on c+asm WASM byte-matches the
                          canonical native Python run
  - 10 functional (Playwright headless Chromium): page mounts, every
                          demo runs on every tier, all-three renders

Makefile
  - Root targets: wasm-build, wasm-test, wasm-test-fn, wasm-serve,
                  wasm-deploy, wasm-clean
  - wasm/Makefile orchestrates the three tier builds; deploy copies
    dist/ into www/playground/

Asm tier notes
  - WAT linear symbol intern + linear env lookup is MOAD-0001 at scale;
    documented in the asm/lumbda.wat header and in the SPA footer. The
    demos hit ~30 globals so the linear walks are cheap enough.
  - Bump allocator never frees (matches asm/lumbda.s heap discipline);
    memory.grow expands by 1 MB chunks. Browser tab tears down at unload.

Toolchain (developer prerequisites)
  - Emscripten 6.0.0 via emsdk at ~/git/emsdk
  - wabt 1.0.36 at ~/git/wabt
  - Playwright for functional tests (symlinked from ~/git/agnt)
2026-06-14 11:40:34 -04:00

177 lines
7.2 KiB
Makefile

# ═══════════════════════════════════════════════════════════════════
# wasm/Makefile — three-tier Lumbda to WebAssembly
# ═══════════════════════════════════════════════════════════════════
#
# Builds:
# python/ — Pyodide loader + lumbda.py (CPython-in-WASM)
# c/ — Emscripten build of c/ (tree-walker + bytecode VM, no JIT)
# asm/ — Hand-written WAT compiled to wasm (parallel "asm" tier)
# app/ — Single-page app shell (HTML + CodeMirror + JS glue)
#
# Targets:
# make build all three tiers + SPA bundle in dist/
# make test unit (node) + integration (node, cross-tier diff)
# make test-fn functional (playwright headless browser)
# make serve local dev server on :8080
# make deploy copy dist/ -> ../www/playground/
# make clean
EMSDK_DIR := $(HOME)/git/emsdk
EMSDK_ENV := $(EMSDK_DIR)/emsdk_env.sh
EMCC := $(EMSDK_DIR)/upstream/emscripten/emcc
WAT2WASM := $(HOME)/git/wabt/bin/wat2wasm
WASM_VALIDATE := $(HOME)/git/wabt/bin/wasm-validate
REPO_ROOT := $(abspath ..)
C_SRC_DIR := $(REPO_ROOT)/c
PY_SRC := $(REPO_ROOT)/lumbda.py
STDLIB := $(REPO_ROOT)/stdlib.lsp
DIST := dist
# Pyodide pinned version
PYODIDE_VER := 0.27.2
PYODIDE_CDN := https://cdn.jsdelivr.net/pyodide/v$(PYODIDE_VER)/full
# ─── Top-level ─────────────────────────────────────────────────────
.PHONY: all build test test-unit test-integration test-fn serve deploy clean \
python c asm app dist-bundle check-tools
all: build
build: check-tools python c asm app dist-bundle
check-tools:
@test -x $(EMCC) || { echo "emcc missing at $(EMCC)"; exit 1; }
@test -x $(WAT2WASM) || { echo "wat2wasm missing at $(WAT2WASM)"; exit 1; }
@command -v node >/dev/null || { echo "node missing"; exit 1; }
# ─── Python tier (Pyodide) ─────────────────────────────────────────
#
# Pyodide loads CPython into WASM. We ship a small JS loader that:
# 1. Imports Pyodide from CDN (pinned version)
# 2. Drops lumbda.py + stdlib.lsp into Pyodide's virtual FS
# 3. Exposes evalLisp(src) -> string
python: $(DIST)/python/lumbda-py.js $(DIST)/python/lumbda.py $(DIST)/python/stdlib.lsp
$(DIST)/python/lumbda-py.js: python/lumbda-py.js
@mkdir -p $(@D)
@cp $< $@
$(DIST)/python/lumbda.py: $(PY_SRC)
@mkdir -p $(@D)
@cp $< $@
$(DIST)/python/stdlib.lsp: $(STDLIB)
@mkdir -p $(@D)
@cp $< $@
# ─── C tier (Emscripten) ───────────────────────────────────────────
#
# Build the C tier under emcc. Drops:
# * jit.c — x86_64 machine-code emitter, no WASM equivalent
# * gc.c — Boehm-specific tracing (replaced with malloc-no-free)
#
# Keeps tree-walker + bytecode VM + reader + printer + portal.
#
# -DLUMBDA_WASM gates conditional code paths in main.c / eval.c.
C_SOURCES := $(addprefix $(C_SRC_DIR)/, \
reader.c printer.c eval.c vm.c types.c builtins.c bignum.c portal.c gc.c)
# jit.c is included but g_jit_enabled stays false at runtime so emit paths
# are never executed. mmap PROT_EXEC would fail in WASM anyway; harmless.
C_JIT_STUB := c/jit-stub.c
C_CFLAGS := -O2 -DLUMBDA_WASM -std=c11 -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE \
-I$(C_SRC_DIR) -Wno-everything
C_LDFLAGS := -s WASM=1 -s MODULARIZE=1 -s EXPORT_ES6=0 \
-s EXPORT_NAME=createLumbdaC \
-s EXPORTED_FUNCTIONS='["_lumbda_wasm_init","_lumbda_wasm_eval","_lumbda_wasm_free_result","_malloc","_free"]' \
-s EXPORTED_RUNTIME_METHODS='["cwrap","ccall","UTF8ToString","stringToUTF8","lengthBytesUTF8"]' \
-s ALLOW_MEMORY_GROWTH=1 -s INITIAL_MEMORY=32MB \
-s STACK_SIZE=8MB \
-s ENVIRONMENT=web,worker,node \
-s SINGLE_FILE=0
c: $(DIST)/c/lumbda-c.js $(DIST)/c/lumbda-c.wasm $(DIST)/c/lumbda-c.loader.js
$(DIST)/c/lumbda-c.js $(DIST)/c/lumbda-c.wasm: $(C_SOURCES) $(C_JIT_STUB) c/lumbda_wasm_entry.c
@mkdir -p $(@D)
EMSDK=$(EMSDK_DIR) EMSDK_NODE=$(EMSDK_DIR)/node/22.16.0_64bit/bin/node \
PATH=$(EMSDK_DIR)/node/22.16.0_64bit/bin:$(EMSDK_DIR)/upstream/emscripten:$$PATH \
$(EMCC) $(C_CFLAGS) $(C_SOURCES) $(C_JIT_STUB) c/lumbda_wasm_entry.c \
$(C_LDFLAGS) -o $(DIST)/c/lumbda-c.js
$(DIST)/c/lumbda-c.loader.js: c/lumbda-c.loader.js
@mkdir -p $(@D)
@cp $< $@
# ─── Asm tier (hand-written WAT) ───────────────────────────────────
#
# asm/lumbda.wat is a Lisp interpreter written directly in WebAssembly
# Text format. Stack-machine assembly, no libc, raw linear memory.
# Parallel implementation to asm/lumbda.s (x86_64). Same tier semantics.
asm: $(DIST)/asm/lumbda-asm.wasm $(DIST)/asm/lumbda-asm.loader.js
$(DIST)/asm/lumbda-asm.wasm: asm/lumbda.wat
@mkdir -p $(@D)
$(WAT2WASM) $< -o $@
$(WASM_VALIDATE) $@
$(DIST)/asm/lumbda-asm.loader.js: asm/lumbda-asm.loader.js
@mkdir -p $(@D)
@cp $< $@
# ─── SPA shell ─────────────────────────────────────────────────────
APP_SRC := $(wildcard app/*.html app/*.css app/*.js app/demos/*.lsp)
app: $(DIST)/index.html $(DIST)/style.css $(DIST)/app.js $(DIST)/runner.js
$(DIST)/%: app/%
@mkdir -p $(@D)
@cp $< $@
dist-bundle: app
@mkdir -p $(DIST)/demos
@cp app/demos/*.lsp $(DIST)/demos/ 2>/dev/null || true
# ─── Tests ─────────────────────────────────────────────────────────
test: test-unit test-integration
test-unit: build
@echo "── wasm unit tests ──"
node tests/unit.mjs
test-integration: build
@echo "── wasm integration tests (cross-tier diff) ──"
node tests/integration.mjs
test-fn: build
@echo "── wasm functional tests (headless browser) ──"
@test -d node_modules/playwright || { \
echo "SKIP: node_modules/playwright not present (symlink or npm install)"; exit 0; }
node tests/functional.mjs
# ─── Dev server ────────────────────────────────────────────────────
serve: build
@echo "Serving $(DIST) on http://localhost:8080"
@cd $(DIST) && python3 -m http.server 8080
# ─── Deploy ────────────────────────────────────────────────────────
deploy: build
@mkdir -p $(REPO_ROOT)/www/playground
@cp -r $(DIST)/* $(REPO_ROOT)/www/playground/
@echo "Deployed to www/playground/"
# ─── Clean ─────────────────────────────────────────────────────────
clean:
rm -rf $(DIST)