Skip to content

Commit e4b0aec

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents 3565507 + 66c28b7 commit e4b0aec

18 files changed

Lines changed: 840 additions & 238 deletions

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/scripts/parseLiquid.js

Lines changed: 6 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {Bench} from 'tinybench';
1919
import {Grammar} from 'ohm-js';
2020
import {compileGrammars} from '../src/api.ts';
2121
import {unparse} from '../test/_helpers.js';
22-
import {createReader} from '../../runtime/src/cstReader.ts';
22+
import {createReader, CstNodeType} from '../../runtime/src/cstReader.ts';
2323

2424
const __dirname = dirname(fileURLToPath(import.meta.url));
2525
const datadir = join(__dirname, '../test/data');
@@ -36,7 +36,6 @@ const positionalArgs = process.argv.slice(2).filter(a => !a.startsWith('--'));
3636
const smallSize = flags.has('--small-size');
3737
const includeUnparse = flags.has('--include-unparse');
3838
const useCstReader = flags.has('--cst-reader');
39-
const useCstReaderPacked = flags.has('--cst-reader-packed');
4039

4140
// Get pattern from command line arguments
4241
const pattern = positionalArgs[0];
@@ -105,34 +104,12 @@ const pattern = positionalArgs[0];
105104
opts
106105
);
107106

108-
// Walk CST using CstReader (raw handles), collecting terminal text.
109-
function unparseCstReaderRaw(matchResult) {
107+
// Walk CST using CstReader, collecting terminal text.
108+
function unparseCstReader(matchResult) {
110109
const reader = createReader(matchResult);
111-
const inp = reader.input;
112-
let ans = '';
113-
function walk(handle, startIdx) {
114-
if (reader.isTerminal(handle)) {
115-
ans += inp.slice(startIdx, startIdx + reader.matchLength(handle));
116-
return;
117-
}
118-
reader.forEachChild(
119-
handle,
120-
(child, _leadingSpaces, offset) => {
121-
walk(child, startIdx + offset);
122-
},
123-
startIdx
124-
);
125-
}
126-
walk(reader.rootHandle, reader.rootStartIdx);
127-
return ans;
128-
}
129-
130-
// Walk CST using CstReader (handles with startIdx), collecting terminal text.
131-
function unparseCstReaderPacked(matchResult) {
132-
const reader = createReader(matchResult, {packStartIdx: true});
133110
let ans = '';
134111
function walk(handle) {
135-
if (reader.isTerminal(handle)) {
112+
if (reader.type(handle) === CstNodeType.TERMINAL) {
136113
ans += reader.sourceString(handle);
137114
return;
138115
}
@@ -146,11 +123,7 @@ const pattern = positionalArgs[0];
146123

147124
const wasmLabel = includeUnparse ? 'Wasm parse+unparse' : 'Wasm parse';
148125
bench.add(
149-
useCstReaderPacked
150-
? `${wasmLabel} (CstReader packed)`
151-
: useCstReader
152-
? `${wasmLabel} (CstReader)`
153-
: wasmLabel,
126+
useCstReader ? `${wasmLabel} (CstReader)` : wasmLabel,
154127
() => {
155128
let overriddenDuration = 0;
156129
for (const {input} of files) {
@@ -167,11 +140,7 @@ const pattern = positionalArgs[0];
167140
peakWasmMemoryBytes,
168141
exports.memory.buffer.byteLength
169142
);
170-
return useCstReaderPacked
171-
? unparseCstReaderPacked(m)
172-
: useCstReader
173-
? unparseCstReaderRaw(m)
174-
: unparse(g);
143+
return useCstReader ? unparseCstReader(m) : unparse(g);
175144
});
176145
if (includeUnparse) overriddenDuration += bench.now() - start;
177146
}

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+
});

0 commit comments

Comments
 (0)