Skip to content

Commit af11c91

Browse files
authored
compiler: add support for standalone wasm build using javy (#604)
1 parent 70d2ac5 commit af11c91

9 files changed

Lines changed: 366 additions & 1 deletion

File tree

packages/compiler/Makefile

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,39 @@ build/json.wasm: src/cli.ts ../lang-json/json.ohm
5050
build/liquid-html.wasm: src/cli.ts test/data/liquid-html.ohm
5151
$(NODE) src/cli.ts -g LiquidHTML -o build/liquid-html.wasm test/data/liquid-html.ohm
5252

53+
JAVY ?= javy
54+
55+
# --- Javy: self-contained Wasm build of the compiler ---
56+
#
57+
# Javy (https://github.com/bytecodealliance/javy) compiles JavaScript into a
58+
# standalone Wasm module by embedding QuickJS. This lets us run the Ohm
59+
# compiler in any environment with a Wasm runtime, without requiring Node.js.
60+
#
61+
# How it works:
62+
# 1. tsdown bundles the compiler into a single IIFE (build/compiler-javy.iife.js),
63+
# using ohm-js-legacy for grammar parsing (pure JS, no Wasm dependency)
64+
# and stubbing out ohm-js v18 (which requires WebAssembly).
65+
# 2. Javy compiles the IIFE into a Wasm module (build/compiler.wasm).
66+
#
67+
# The resulting module reads an Ohm grammar from stdin and writes compiled
68+
# Wasm bytes to stdout. Errors are written to stderr.
69+
#
70+
# Prerequisites: `javy` CLI (https://github.com/bytecodealliance/javy/releases)
71+
# Testing requires `wasmtime` (https://wasmtime.dev/).
72+
.PHONY: compiler-wasm
73+
compiler-wasm: build/compiler.wasm
74+
75+
build/compiler-javy.iife.js: src/javy-entry.ts src/javy-api.ts src/javy-ohm-js-stub.ts \
76+
build/ohmRuntime.wasm_sections.ts build/ohm-grammar-wasm.ts $(SRC_TS_FILES) tsdown.javy.config.ts
77+
pnpm tsdown --config tsdown.javy.config.ts
78+
79+
build/compiler.wasm: build/compiler-javy.iife.js
80+
$(JAVY) build $< -o $@
81+
82+
.PHONY: test-compiler-wasm
83+
test-compiler-wasm: build/compiler.wasm
84+
pnpm ava test/_test-javy.js
85+
5386
.PHONY: go-test-es5
5487
go-test-es5: build/es5.wasm
5588
cd test/go && go test -v

packages/compiler/src/javy-api.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Javy-specific compile API. The normal compile path (parseGrammars.ts) uses
2+
// the Wasm-compiled Ohm meta-grammar to parse grammars, but QuickJS/Javy
3+
// doesn't have a WebAssembly runtime. So this module uses ohm-js-legacy's
4+
// pure-JS grammar parser instead, the same approach as scripts/bootstrap.ts.
5+
6+
import {grammars} from 'ohm-js-legacy';
7+
8+
import {Compiler} from './Compiler.ts';
9+
10+
export function compile(source: string, grammarName?: string): Uint8Array {
11+
const ns = grammars(source);
12+
let g;
13+
if (grammarName) {
14+
g = ns[grammarName];
15+
if (!g) throw new Error(`Grammar '${grammarName}' not found`);
16+
} else {
17+
g = Object.values(ns).at(-1);
18+
}
19+
return new Compiler(g, {debug: false}).compile();
20+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Entry point for the Javy-compiled Wasm module.
2+
// Protocol: stdin = UTF-8 grammar source, stdout = compiled Wasm bytes.
3+
4+
import {compile} from './javy-api.ts';
5+
6+
declare const Javy: {
7+
IO: {
8+
readSync(fd: number, buf: Uint8Array): number;
9+
writeSync(fd: number, buf: Uint8Array): void;
10+
};
11+
};
12+
13+
function readStdin(): string {
14+
const chunks: Uint8Array[] = [];
15+
const buf = new Uint8Array(4096);
16+
while (true) {
17+
const n = Javy.IO.readSync(0, buf);
18+
if (n === 0) break;
19+
chunks.push(buf.slice(0, n));
20+
}
21+
let total = 0;
22+
for (const c of chunks) total += c.length;
23+
const result = new Uint8Array(total);
24+
let offset = 0;
25+
for (const c of chunks) {
26+
result.set(c, offset);
27+
offset += c.length;
28+
}
29+
return new TextDecoder().decode(result);
30+
}
31+
32+
function stderr(msg: string): void {
33+
Javy.IO.writeSync(2, new TextEncoder().encode(msg));
34+
}
35+
36+
// Parse an optional "#grammarName <name>" directive from the first line of input.
37+
// If present, returns [grammarName, remainingSource]; otherwise [undefined, source].
38+
function parseHeader(input: string): [string | undefined, string] {
39+
const match = input.match(/^#grammarName (\S+)\n([\s\S]*)$/);
40+
if (match) return [match[1], match[2]];
41+
return [undefined, input];
42+
}
43+
44+
try {
45+
const raw = readStdin();
46+
const [grammarName, source] = parseHeader(raw);
47+
const wasmBytes = compile(source, grammarName);
48+
Javy.IO.writeSync(1, wasmBytes);
49+
} catch (e: any) {
50+
const msg = e?.message ?? String(e);
51+
stderr(msg + '\n');
52+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Stub for ohm-js (v18) — used in the Javy build where WebAssembly is not
2+
// available. Compiler.ts transitively imports parseGrammars.ts which imports
3+
// Grammar from ohm-js, but this code path is never reached when grammars are
4+
// parsed via ohm-js-legacy.
5+
6+
export class Grammar {
7+
constructor() {
8+
throw new Error('ohm-js v18 Grammar is not available in this environment');
9+
}
10+
}

packages/compiler/src/leb128.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Pure JS LEB128 decoding, replacing @thi.ng/leb128 (which uses Wasm internally).
2+
// API matches @thi.ng/leb128: (data: Uint8Array, offset?: number) => [bigint, number]
3+
4+
export function decodeULEB128(data: Uint8Array, offset: number = 0): [bigint, number] {
5+
let result = 0n;
6+
let shift = 0n;
7+
let pos = offset;
8+
while (true) {
9+
const byte = data[pos++];
10+
result |= BigInt(byte & 0x7f) << shift;
11+
if ((byte & 0x80) === 0) break;
12+
shift += 7n;
13+
}
14+
return [result, pos - offset];
15+
}
16+
17+
export function decodeSLEB128(data: Uint8Array, offset: number = 0): [bigint, number] {
18+
let result = 0n;
19+
let shift = 0n;
20+
let pos = offset;
21+
let byte: number;
22+
do {
23+
byte = data[pos++];
24+
result |= BigInt(byte & 0x7f) << shift;
25+
shift += 7n;
26+
} while ((byte & 0x80) !== 0);
27+
// Sign extend if the highest bit of the last byte is set.
28+
if (shift < 64n && (byte & 0x40) !== 0) {
29+
result |= -(1n << shift);
30+
}
31+
return [result, pos - offset];
32+
}

packages/compiler/src/rewriteFuncIdx.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// for additional imports in the dest module. Used by Compiler.ts at compile
33
// time to adjust prebuilt code for extra imports (e.g. debug imports).
44

5-
import {decodeULEB128, decodeSLEB128} from '@thi.ng/leb128';
5+
import {decodeULEB128, decodeSLEB128} from './leb128.ts';
66
import * as w from '@wasmgroundup/emit';
77

88
// For sanity checking, assume that the number of locals is never
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Tests for the Javy-compiled Wasm module (build/compiler.wasm).
2+
// Run via `make test-compiler-wasm`.
3+
4+
/* eslint-disable ava/no-ignored-test-files */
5+
6+
import test from 'ava';
7+
import {execFileSync} from 'node:child_process';
8+
import {URL} from 'node:url';
9+
import {Grammar} from 'ohm-js';
10+
11+
import {compile} from '../src/javy-api.ts';
12+
13+
const COMPILER_WASM = new URL('../build/compiler.wasm', import.meta.url).pathname;
14+
15+
function javyCompile(grammarSource, grammarName) {
16+
const input = grammarName ? `#grammarName ${grammarName}\n${grammarSource}` : grammarSource;
17+
return execFileSync('wasmtime', [COMPILER_WASM], {
18+
input,
19+
maxBuffer: 1024 * 1024,
20+
});
21+
}
22+
23+
test('javy: simple grammar', t => {
24+
const bytes = javyCompile('G { start = "hello" "world" }');
25+
const g = new Grammar(bytes);
26+
g.match('helloworld').use(r => t.false(r.failed()));
27+
g.match('goodbye').use(r => t.true(r.failed()));
28+
});
29+
30+
test('javy: arithmetic grammar', t => {
31+
const source = `Arithmetic {
32+
Exp = AddExp
33+
AddExp = AddExp "+" MulExp -- plus
34+
| AddExp "-" MulExp -- minus
35+
| MulExp
36+
MulExp = MulExp "*" PriExp -- times
37+
| MulExp "/" PriExp -- divide
38+
| PriExp
39+
PriExp = "(" Exp ")" -- paren
40+
| number
41+
number = digit+
42+
}`;
43+
const bytes = javyCompile(source);
44+
const g = new Grammar(bytes);
45+
g.match('1+2*3').use(r => t.false(r.failed()));
46+
g.match('(1+2)*3').use(r => t.false(r.failed()));
47+
g.match('hello').use(r => t.true(r.failed()));
48+
});
49+
50+
test('javy: output matches Node compiler', t => {
51+
const source = 'G { start = "a" | "b" | "c" }';
52+
const javyBytes = javyCompile(source);
53+
const nodeBytes = compile(source);
54+
t.deepEqual(new Uint8Array(javyBytes), nodeBytes);
55+
});
56+
57+
test('javy: #grammarName selects grammar', t => {
58+
const source = 'A { start = "a" }\nB { start = "b" }';
59+
const bytesA = javyCompile(source, 'A');
60+
const gA = new Grammar(bytesA);
61+
gA.match('a').use(r => t.false(r.failed()));
62+
gA.match('b').use(r => t.true(r.failed()));
63+
64+
const bytesB = javyCompile(source, 'B');
65+
const gB = new Grammar(bytesB);
66+
gB.match('b').use(r => t.false(r.failed()));
67+
gB.match('a').use(r => t.true(r.failed()));
68+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Cross-check local leb128.ts against @thi.ng/leb128.
2+
3+
import test from 'ava';
4+
import * as thi from '@thi.ng/leb128';
5+
6+
import {decodeULEB128, decodeSLEB128} from '../src/leb128.ts';
7+
8+
function encodeULEB128(value) {
9+
const bytes = [];
10+
let v = BigInt(value);
11+
do {
12+
let byte = Number(v & 0x7fn);
13+
v >>= 7n;
14+
if (v !== 0n) byte |= 0x80;
15+
bytes.push(byte);
16+
} while (v !== 0n);
17+
return new Uint8Array(bytes);
18+
}
19+
20+
function encodeSLEB128(value) {
21+
const bytes = [];
22+
let v = BigInt(value);
23+
let more = true;
24+
while (more) {
25+
let byte = Number(v & 0x7fn);
26+
v >>= 7n;
27+
if ((v === 0n && (byte & 0x40) === 0) || (v === -1n && (byte & 0x40) !== 0)) {
28+
more = false;
29+
} else {
30+
byte |= 0x80;
31+
}
32+
bytes.push(byte);
33+
}
34+
return new Uint8Array(bytes);
35+
}
36+
37+
const uleb128Cases = [
38+
0n,
39+
1n,
40+
63n, // max 1-byte value (6 bits + no sign bit concern for unsigned)
41+
127n, // 0x7f — largest value fitting in 7 bits
42+
128n, // 0x80 — first 2-byte value
43+
255n,
44+
256n,
45+
16383n, // max 2-byte value
46+
16384n, // first 3-byte value
47+
624485n, // classic Wikipedia example
48+
2097151n, // max 3-byte value
49+
(1n << 32n) - 1n, // u32 max
50+
1n << 32n, // just over u32
51+
(1n << 53n) - 1n, // Number.MAX_SAFE_INTEGER as bigint
52+
];
53+
54+
const sleb128Cases = [
55+
0n,
56+
1n,
57+
-1n,
58+
63n, // max positive 1-byte value
59+
64n, // first positive 2-byte value (bit 6 = sign)
60+
-64n, // min negative 1-byte value
61+
-65n, // first negative 2-byte value
62+
127n,
63+
-128n,
64+
128n,
65+
-129n,
66+
8191n, // max positive 2-byte value
67+
-8192n, // min negative 2-byte value
68+
(1n << 31n) - 1n, // i32 max
69+
-(1n << 31n), // i32 min
70+
];
71+
72+
for (const value of uleb128Cases) {
73+
test(`decodeULEB128: ${value}`, t => {
74+
const encoded = encodeULEB128(value);
75+
const [localVal, localLen] = decodeULEB128(encoded);
76+
const [thiVal, thiLen] = thi.decodeULEB128(encoded);
77+
t.is(localVal, thiVal);
78+
t.is(localLen, thiLen);
79+
});
80+
}
81+
82+
for (const value of sleb128Cases) {
83+
test(`decodeSLEB128: ${value}`, t => {
84+
const encoded = encodeSLEB128(value);
85+
const [localVal, localLen] = decodeSLEB128(encoded);
86+
const [thiVal, thiLen] = thi.decodeSLEB128(encoded);
87+
t.is(localVal, thiVal);
88+
t.is(localLen, thiLen);
89+
});
90+
}
91+
92+
test('decodeULEB128: with offset', t => {
93+
const prefix = new Uint8Array([0xde, 0xad]);
94+
const encoded = encodeULEB128(624485n);
95+
const buf = new Uint8Array([...prefix, ...encoded]);
96+
const [localVal, localLen] = decodeULEB128(buf, 2);
97+
const [thiVal, thiLen] = thi.decodeULEB128(buf, 2);
98+
t.is(localVal, thiVal);
99+
t.is(localLen, thiLen);
100+
});
101+
102+
test('decodeSLEB128: with offset', t => {
103+
const prefix = new Uint8Array([0xde, 0xad, 0xbe]);
104+
const encoded = encodeSLEB128(-123456n);
105+
const buf = new Uint8Array([...prefix, ...encoded]);
106+
const [localVal, localLen] = decodeSLEB128(buf, 3);
107+
const [thiVal, thiLen] = thi.decodeSLEB128(buf, 3);
108+
t.is(localVal, thiVal);
109+
t.is(localLen, thiLen);
110+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {dirname, resolve} from 'node:path';
2+
import {fileURLToPath} from 'node:url';
3+
import {defineConfig} from 'tsdown';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
7+
export default defineConfig({
8+
entry: {'compiler-javy': 'src/javy-entry.ts'},
9+
format: 'iife',
10+
outDir: 'build',
11+
clean: false,
12+
sourcemap: false,
13+
dts: false,
14+
external: [],
15+
noExternal: [/.*/],
16+
inlineOnly: false,
17+
alias: {
18+
'ohm-js': resolve(__dirname, 'src/javy-ohm-js-stub.ts'),
19+
},
20+
// Polyfill atob — not available in QuickJS but used by base64-encoded
21+
// Wasm sections. Must run before the IIFE body.
22+
banner: `
23+
var atob = function(encoded) {
24+
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
25+
var result = '';
26+
encoded = encoded.replace(/=+$/, '');
27+
for (var i = 0; i < encoded.length; ) {
28+
var a = chars.indexOf(encoded[i++]);
29+
var b = i < encoded.length ? chars.indexOf(encoded[i++]) : 0;
30+
var c = i < encoded.length ? chars.indexOf(encoded[i++]) : -1;
31+
var d = i < encoded.length ? chars.indexOf(encoded[i++]) : -1;
32+
var bits = (a << 18) | (b << 12) | ((c === -1 ? 0 : c) << 6) | (d === -1 ? 0 : d);
33+
result += String.fromCharCode((bits >> 16) & 0xff);
34+
if (c !== -1) result += String.fromCharCode((bits >> 8) & 0xff);
35+
if (d !== -1) result += String.fromCharCode(bits & 0xff);
36+
}
37+
return result;
38+
};
39+
`,
40+
});

0 commit comments

Comments
 (0)