Skip to content

Commit 847999e

Browse files
authored
Add WASM interpreter component (#1273)
* Add morphir-interpreter-wasm package scaffolding * Add WIT interface definition for morphir interpreter * Add Elm Worker entry point for WASM interpreter * Add JS glue layer with morphir-value conversion Implements the bridge between WIT component exports and the Elm Worker: - morphirValueToJson/jsonToMorphirValue: convert between WIT flat indexed tree and nested Elm IR Value codec JSON format - fqName conversion between WIT record and Elm codec/Worker formats - Elm app management with synchronous port communication - WIT eval_.evaluate for stateless evaluation - WIT types.IrStore resource class with constructor, uri, evaluate, reload - Result/error mapping between Elm Worker JSON and WIT eval-error variant * Add mise build tasks for WASM interpreter * Get WASM component building successfully Fix three issues preventing jco componentize from building the WASM component: 1. Fix jco CLI flag: --output -> -o (jco uses short flag) 2. Fix eval export: jco expects `export { eval_ as eval }` since "eval" is a JS reserved word but the WIT interface name 3. Bundle Elm IIFE output into single ESM file: Elm compiles to an IIFE wrapping `(function(scope){...}(this))` which isn't ESM. The build task now reads the Elm output, replaces `this` with a scope object, and concatenates it with the JS glue into a single ESM bundle. 4. Break up deeply nested && chains: Elm's compiler generates pattern match conditions with 100+ levels of parenthesis nesting. SpiderMonkey's JS engine (used by ComponentizeJS) has a ~50-frame recursion limit for expression parsing. The build task post-processes the Elm output to flatten these into sequential `var _c = a; _c = _c && b; ...` checks. * Add test fixture for WASM interpreter integration tests * Include compiled morphir-ir.json test fixture * Add integration tests for WASM interpreter component Test the interpreter bundle's eval.evaluate (stateless) and types.IrStore (stateful) interfaces with 11 test cases covering addInts, isPositive, error handling for unknown functions and invalid IR, store URI management, reload, and repeated evaluation. Also patch _Process_sleep(0) in the build script to be synchronous, which is needed to test the Elm port communication outside the WASM runtime (where setTimeout is inherently sync in StarlingMonkey). * Add mise task for WASM interpreter tests
1 parent 9d595db commit 847999e

File tree

14 files changed

+1481
-0
lines changed

14 files changed

+1481
-0
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env bun
2+
//MISE description="Transpile WASM component to browser ESM"
3+
//MISE depends=["build:interpreter-wasm"]
4+
5+
import { exec, log, mkdir, join, ROOT_DIR } from "../_lib.ts";
6+
7+
const pkgDir = join(ROOT_DIR, "packages/morphir-interpreter-wasm");
8+
9+
log("build:interpreter-browser", "Transpiling WASM component for browser...");
10+
11+
await mkdir(join(pkgDir, "build/browser"), { recursive: true });
12+
13+
await exec("npx", [
14+
"jco", "transpile",
15+
"build/interpreter.wasm",
16+
"--out-dir", "build/browser",
17+
], { cwd: pkgDir });
18+
19+
log("build:interpreter-browser", "Done");
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/usr/bin/env bun
2+
//MISE description="Compile Elm interpreter worker to JS"
3+
4+
import { elmMake, log, mkdir, join, ROOT_DIR } from "../_lib.ts";
5+
6+
const pkgDir = join(ROOT_DIR, "packages/morphir-interpreter-wasm");
7+
8+
log("build:interpreter-elm", "Compiling Elm interpreter worker...");
9+
10+
await mkdir(join(pkgDir, "build"), { recursive: true });
11+
12+
await elmMake(["src/Morphir/Interpreter/Worker.elm"], {
13+
cwd: pkgDir,
14+
output: "build/Morphir.Interpreter.js",
15+
});
16+
17+
log("build:interpreter-elm", "Done");
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
#!/usr/bin/env bun
2+
//MISE description="Build WASM component from JS + Elm"
3+
//MISE depends=["build:interpreter-elm"]
4+
5+
import { exec, log, join, ROOT_DIR } from "../_lib.ts";
6+
import { readFile, writeFile } from "fs/promises";
7+
8+
const pkgDir = join(ROOT_DIR, "packages/morphir-interpreter-wasm");
9+
10+
log("build:interpreter-wasm", "Bundling Elm output into ESM glue...");
11+
12+
// Read the Elm IIFE output and the JS glue
13+
let elmCode = await readFile(join(pkgDir, "build/Morphir.Interpreter.js"), "utf-8");
14+
const glueCode = await readFile(join(pkgDir, "src/interpreter.js"), "utf-8");
15+
16+
// ---------------------------------------------------------------------------
17+
// Post-process Elm output to work in SpiderMonkey (ComponentizeJS)
18+
// ---------------------------------------------------------------------------
19+
20+
// 1. The Elm output is: (function(scope){...}(this));
21+
// In ESM/strict mode, `this` is undefined. Replace the final `}(this));`
22+
// with `})(scope);` where scope is our own object.
23+
elmCode = elmCode.replace(
24+
/\}\(this\)\);?\s*$/,
25+
"})(scope);"
26+
);
27+
28+
// 2. SpiderMonkey has a limited JS recursion depth (~50 frames). Elm's compiler
29+
// generates deeply nested `if (((...) && ...) && ...)` chains for exhaustive
30+
// pattern matching that can exceed 100 levels of nesting. We break these up
31+
// by converting chains of `&&` in `if` conditions into sequential checks.
32+
//
33+
// Strategy: find `if (` followed by many `&&`-joined conditions, and rewrite
34+
// them as a series of `if` checks that set a flag.
35+
log("build:interpreter-wasm", "Breaking up deeply nested && chains for SpiderMonkey...");
36+
37+
elmCode = breakUpDeepConditions(elmCode);
38+
39+
// 3. Elm's _Process_sleep(0) uses setTimeout which is async in normal JS
40+
// runtimes. Inside ComponentizeJS (StarlingMonkey), setTimeout is sync.
41+
// For testing the bundle outside WASM (e.g. with Bun), we make sleep(0)
42+
// synchronous by returning an immediate succeed value.
43+
log("build:interpreter-wasm", "Patching _Process_sleep(0) for sync execution...");
44+
elmCode = elmCode.replace(
45+
/function _Process_sleep\(time\)\s*\{/,
46+
`function _Process_sleep(time) {\n` +
47+
`\tif (time === 0) { return _Scheduler_succeed(_Utils_Tuple0); }\n`
48+
);
49+
50+
// ---------------------------------------------------------------------------
51+
// Bundle Elm + glue into a single ESM file
52+
// ---------------------------------------------------------------------------
53+
54+
// Strip the import statement from the glue code since Elm is embedded
55+
const glueWithoutImport = glueCode.replace(
56+
/^import\s*\{[^}]*\}\s*from\s*["'][^"']*["']\s*;?\s*$/m,
57+
"// (Elm is provided by the embedded IIFE above)"
58+
);
59+
60+
// Build the combined ESM module
61+
const bundled = `// Auto-generated bundle: Elm IIFE + interpreter glue
62+
// Do not edit — generated by build:interpreter-wasm task
63+
64+
// Provide a scope object for Elm's IIFE (replaces \`this\`)
65+
const scope = {};
66+
${elmCode}
67+
68+
// _Platform_export writes to scope.Elm
69+
const Elm = scope.Elm;
70+
71+
${glueWithoutImport}
72+
`;
73+
74+
const bundlePath = join(pkgDir, "build/interpreter-bundle.js");
75+
await writeFile(bundlePath, bundled);
76+
77+
log("build:interpreter-wasm", "Building WASM component...");
78+
79+
await exec("npx", [
80+
"jco", "componentize",
81+
"build/interpreter-bundle.js",
82+
"--wit", "wit/",
83+
"--world-name", "interpreter",
84+
"-o", "build/interpreter.wasm",
85+
], { cwd: pkgDir });
86+
87+
log("build:interpreter-wasm", "Done");
88+
89+
90+
// ---------------------------------------------------------------------------
91+
// Helper: break up deeply nested && chains in if-conditions
92+
// ---------------------------------------------------------------------------
93+
94+
/**
95+
* Find `if (cond1 && cond2 && ... && condN)` where N is large, and rewrite
96+
* them to reduce nesting depth.
97+
*
98+
* The Elm compiler generates patterns like:
99+
* if (((((a && b) && c) && d) && e) && f) {
100+
*
101+
* Each `&&` with its parentheses creates one level of nesting for the parser.
102+
* SpiderMonkey's parser/evaluator has a ~50-level recursion limit.
103+
*
104+
* We rewrite these as:
105+
* var _cond = a; _cond = _cond && b; _cond = _cond && c; ...
106+
* if (_cond) {
107+
*
108+
* This flattens the expression tree while preserving short-circuit semantics.
109+
*/
110+
function breakUpDeepConditions(code: string): string {
111+
// Match if-statements with deeply nested conditions.
112+
// We look for `if (` followed by content up to `) {`
113+
// Only process lines that are very long (likely deep conditions).
114+
const lines = code.split('\n');
115+
let condCounter = 0;
116+
117+
for (let i = 0; i < lines.length; i++) {
118+
const line = lines[i];
119+
120+
// Quick filter: only process lines with many && operators
121+
const andCount = (line.match(/&&/g) || []).length;
122+
if (andCount < 30) continue;
123+
124+
// Match the if-condition pattern
125+
const match = line.match(/^(\s*)if\s*\((.*)\)\s*\{$/);
126+
if (!match) continue;
127+
128+
const indent = match[1];
129+
const fullCondition = match[2];
130+
131+
// Split the condition on top-level && operators
132+
// The Elm compiler generates left-nested: ((((a && b) && c) && d) && e)
133+
// We need to flatten these.
134+
const parts = splitAndChain(fullCondition);
135+
if (parts.length < 30) continue;
136+
137+
// Rewrite as sequential assignments
138+
const varName = `_c$${condCounter++}`;
139+
const stmts = [];
140+
stmts.push(`${indent}var ${varName} = ${parts[0]};`);
141+
for (let j = 1; j < parts.length; j++) {
142+
stmts.push(`${indent}${varName} = ${varName} && ${parts[j]};`);
143+
}
144+
stmts.push(`${indent}if (${varName}) {`);
145+
146+
lines[i] = stmts.join('\n');
147+
}
148+
149+
return lines.join('\n');
150+
}
151+
152+
/**
153+
* Split a left-nested && chain like ((((a && b) && c) && d) && e)
154+
* into individual conditions [a, b, c, d, e].
155+
*
156+
* The Elm compiler generates left-associated chains:
157+
* ((((a && b) && c) && d) && e)
158+
* After unwrapping outer parens: (((a && b) && c) && d) && e
159+
* At depth 0 there's only ONE && (the last). So we split there
160+
* to get left="(((a && b) && c) && d)" and right="e", then
161+
* recursively flatten the left part.
162+
*/
163+
function splitAndChain(expr: string): string[] {
164+
let s = expr.trim();
165+
166+
// Unwrap outermost parens if they wrap the entire expression
167+
while (s.startsWith('(') && findMatchingParen(s, 0) === s.length - 1) {
168+
s = s.slice(1, -1).trim();
169+
}
170+
171+
// Find the rightmost top-level && operator
172+
let depth = 0;
173+
let lastAnd = -1;
174+
for (let i = 0; i < s.length; i++) {
175+
if (s[i] === '(') depth++;
176+
else if (s[i] === ')') depth--;
177+
else if (depth === 0 && s[i] === '&' && i + 1 < s.length && s[i + 1] === '&') {
178+
lastAnd = i;
179+
}
180+
}
181+
182+
if (lastAnd === -1) {
183+
// No && at top level — this is a leaf condition
184+
return [s];
185+
}
186+
187+
const left = s.slice(0, lastAnd).trim();
188+
const right = s.slice(lastAnd + 2).trim();
189+
190+
// Recursively flatten the left side
191+
return [...splitAndChain(left), right];
192+
}
193+
194+
function findMatchingParen(s: string, openPos: number): number {
195+
let depth = 0;
196+
for (let i = openPos; i < s.length; i++) {
197+
if (s[i] === '(') depth++;
198+
else if (s[i] === ')') {
199+
depth--;
200+
if (depth === 0) return i;
201+
}
202+
}
203+
return -1;
204+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env bun
2+
//MISE description="Run WASM interpreter integration tests"
3+
//MISE depends=["build:interpreter-browser"]
4+
5+
import { exec, log, join, ROOT_DIR } from "../_lib.ts";
6+
7+
const pkgDir = join(ROOT_DIR, "packages/morphir-interpreter-wasm");
8+
9+
log("test:interpreter-wasm", "Running WASM interpreter integration tests...");
10+
11+
await exec("bun", ["test"], { cwd: pkgDir });
12+
13+
log("test:interpreter-wasm", "Done");
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
build/
2+
elm-stuff/
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"type": "application",
3+
"source-directories": [
4+
"../../src",
5+
"src"
6+
],
7+
"elm-version": "0.19.1",
8+
"dependencies": {
9+
"direct": {
10+
"TSFoster/elm-uuid": "4.2.0",
11+
"chain-partners/elm-bignum": "1.0.1",
12+
"cuducos/elm-format-number": "9.0.1",
13+
"dillonkearns/elm-markdown": "7.0.1",
14+
"dosarf/elm-tree-view": "3.0.0",
15+
"elm/browser": "1.0.2",
16+
"elm/core": "1.0.5",
17+
"elm/html": "1.0.0",
18+
"elm/http": "2.0.0",
19+
"elm/json": "1.1.3",
20+
"elm/parser": "1.1.0",
21+
"elm/regex": "1.0.0",
22+
"elm/svg": "1.0.1",
23+
"elm/time": "1.0.0",
24+
"elm/url": "1.0.0",
25+
"elm-community/array-extra": "2.6.0",
26+
"elm-community/graph": "6.0.0",
27+
"elm-community/list-extra": "8.7.0",
28+
"elm-community/maybe-extra": "5.3.0",
29+
"elm-explorations/markdown": "1.0.0",
30+
"fabhof/elm-ui-datepicker": "5.0.0",
31+
"justinmimbs/date": "3.2.1",
32+
"lattyware/elm-fontawesome": "6.0.0",
33+
"matthewsj/elm-ordering": "2.0.0",
34+
"mdgriffith/elm-ui": "1.1.8",
35+
"perzanko/elm-loading": "2.0.5",
36+
"pzp1997/assoc-list": "1.0.0",
37+
"rtfeldman/elm-iso8601-date-strings": "1.1.4",
38+
"rundis/elm-bootstrap": "5.2.0",
39+
"stil4m/elm-syntax": "7.2.9",
40+
"waratuman/json-extra": "1.0.2"
41+
},
42+
"indirect": {
43+
"TSFoster/elm-bytes-extra": "1.3.0",
44+
"TSFoster/elm-md5": "2.0.1",
45+
"TSFoster/elm-sha1": "2.1.1",
46+
"avh4/elm-color": "1.0.0",
47+
"avh4/elm-fifo": "1.0.4",
48+
"danfishgold/base64-bytes": "1.1.0",
49+
"elm/bytes": "1.0.8",
50+
"elm/file": "1.0.5",
51+
"elm/random": "1.0.0",
52+
"elm/virtual-dom": "1.0.3",
53+
"elm-community/intdict": "3.0.0",
54+
"justinmimbs/timezone-data": "2.1.4",
55+
"miniBill/elm-unicode": "1.0.3",
56+
"myrho/elm-round": "1.0.5",
57+
"robinheghan/murmur3": "1.0.0",
58+
"rtfeldman/elm-css": "17.1.1",
59+
"rtfeldman/elm-hex": "1.0.0",
60+
"stil4m/structured-writer": "1.0.3",
61+
"waratuman/time-extra": "1.1.0"
62+
}
63+
},
64+
"test-dependencies": {
65+
"direct": {},
66+
"indirect": {}
67+
}
68+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@morphir/interpreter-wasm",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "Morphir interpreter as a WebAssembly Component Model component",
6+
"type": "module",
7+
"devDependencies": {
8+
"@bytecodealliance/jco": "^1.9.0",
9+
"@bytecodealliance/componentize-js": "^0.14.0"
10+
}
11+
}

0 commit comments

Comments
 (0)