Skip to content

Commit 63d302d

Browse files
committed
emit: separate the parser library from the CLI runner (Target ≠ CLI)
The portable targets baked a stdin → CST-JSON `main` into the emitted source — harness scaffolding the gate needs to execute and diff a compiled go/rust binary, but not part of the parser. So `emitParser(grammar, tsTarget|goTarget|rustTarget)` shipped a CLI app, not a parser; the consumption model also diverged from jsTarget (an importable library). Split the two: - `emitParser(grammar, target)` now emits a parser LIBRARY only — a `parse` entry, no I/O, no `main`. All four targets are consistent: you import/embed and call `parse`. - `target.emitRunner()` (new, optional on Target) emits the stdin → JSON CLI wrapper. The gate assembles library + runner itself: ts/rust append it (one file); Go writes it as a separate runner.go in the same `package main` (Go forbids a second import block after declarations), so the parser file keeps only its own imports. Net: `emitParser(grammar, tsTarget)` is now directly `import { parse }` → a plain CST tree — the "import a module, get a tree" path that was previously missing (jsTarget is a low-level arena API; the portable TS gives a materialized CST). The CLI is the harness's concern, where it belongs. portable-targets 16 grammars ×3 (library + runner, compiled & run), full suite 42/42. README updated to show library usage.
1 parent 8cca2bc commit 63d302d

6 files changed

Lines changed: 83 additions & 33 deletions

File tree

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -340,15 +340,18 @@ const Regex = token(seq(
340340

341341
### The emitted parser need not be JS — Go, Rust, native
342342

343-
The grammar also derives a **standalone parser in another language**. [`emitParser(grammar, target)`](src/emit.ts) runs one analysis into one language-agnostic IR, and each `Target` renders it — including its own regex-free lexer (`emitParser` reuses `emitLexer(grammar, target)`), so the output has no dependency on the JS runtime and compiles offline:
343+
The grammar also derives a **parser library in another language**. [`emitParser(grammar, target)`](src/emit.ts) runs one analysis into one language-agnostic IR, and each `Target` renders it — including its own regex-free lexer (`emitParser` reuses `emitLexer(grammar, target)`), so the output has no dependency on the JS runtime and compiles offline. What it emits is a **library** — a `parse` entry, no I/O — that you embed and call:
344344

345345
```ts
346-
import { emitParser, goTarget, rustTarget } from './src/emit.ts';
346+
import { emitParser, tsTarget, goTarget, rustTarget } from './src/emit.ts';
347347

348-
writeFileSync('parser.go', emitParser(grammar, goTarget)); // `go build`, no deps
349-
writeFileSync('parser.rs', emitParser(grammar, rustTarget)); // `rustc`, no crates
348+
writeFileSync('parser.mts', emitParser(grammar, tsTarget)); // import { parse } → a plain CST
349+
writeFileSync('parser.go', emitParser(grammar, goTarget)); // a Go parser, no deps
350+
writeFileSync('parser.rs', emitParser(grammar, rustTarget)); // a Rust parser, no crates
350351
```
351352

353+
The CLI shape `test/portable-targets.ts` runs (stdin → CST JSON) is a *harness* wrapper — `target.emitRunner()`, appended by the gate to make the library executable — not part of the parser.
354+
352355
The proof is the full languages: the real [`javascript.ts`](javascript.ts) and [`typescript.ts`](typescript.ts) grammars — including the `[Await]/[Yield]` fork, left recursion, the regex/division and template state machines, arrow functions, and the TS type grammar — emit to **TypeScript, Go, and Rust**, and every CST is byte-identical to the reference interpreter. [`test/portable-targets.ts`](test/portable-targets.ts) compiles and runs all three for sixteen grammars (the two real languages plus focused fixtures) on every CI run. The Rust output reaches [oxc](https://github.com/oxc-project/oxc) throughput and the Go output beats [tsgo](https://github.com/microsoft/typescript-go) on the same corpus (an arena keeps both near zero-allocation). Byte-based Go/Rust use UTF-8 offsets — identical to the JS interpreter's for ASCII; non-ASCII offset units differ inherently.
353356

354357
## Adding a language

src/emit.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ export interface Target {
1616
name: string;
1717
ext: string; // emitted file extension (no dot)
1818
emitLexer(grammar: CstGrammar): string | null; // null ⇒ runtime-lexer fallback (jsTarget markup/indent grammars)
19-
emitParser(grammar: CstGrammar, lexerSrc: string | null): string; // the parser, embedding `lexerSrc`
19+
emitParser(grammar: CstGrammar, lexerSrc: string | null): string; // the parser LIBRARY (exports a `parse` entry; no I/O)
20+
// A standalone CLI harness (stdin → CST JSON) APPENDED to the library to make it
21+
// executable — needed to run the compiled go/rust (and ts) parsers for verification.
22+
// Not part of the parser. jsTarget omits it (it is consumed by `import`, not as a CLI).
23+
emitRunner?(): string;
2024
}
2125

2226
export function emitLexer(grammar: CstGrammar, target: Target): string | null {

src/target-go.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,16 +316,15 @@ export const goTarget: Target = {
316316
\treturn finish("$template", sb, t.Off)
317317
}
318318
` : '';
319-
return `// GENERATED by emit-portable.ts (goTarget) — parser for grammar "${ir.grammarName}".
319+
return `// GENERATED by emit-portable.ts (goTarget) — parser LIBRARY for grammar "${ir.grammarName}".
320+
// Exposes parseOnce(src) + writeJSON. The CLI runner (stdin → CST JSON) is goTarget.emitRunner()
321+
// — a SEPARATE runner.go in this same \`package main\` (Go forbids a second import block after
322+
// declarations, so it can't be appended inline). Together they \`go build\` into an executable.
320323
package main
321324
322325
import (
323326
\t"fmt"
324-
\t"io"
325-
\t"os"
326-
\t"strconv"
327327
\t"strings"
328-
\t"time"
329328
)
330329
331330
type Tok struct {
@@ -432,6 +431,22 @@ func parseOnce(src string) int32 {
432431
\tnodes = nodes[:0]; kids = kids[:0]; scratch = scratch[:0]
433432
\treturn parse${ir.entry}()
434433
}
434+
`;
435+
},
436+
emitRunner(): string {
437+
return `// CLI runner (harness only): stdin -> CST JSON + a self-bench mode. A SEPARATE file in the
438+
// same package main as the parser library; NOT part of the parser. The gate writes it as
439+
// runner.go so the package builds into an executable.
440+
package main
441+
442+
import (
443+
"fmt"
444+
"io"
445+
"os"
446+
"strconv"
447+
"strings"
448+
"time"
449+
)
435450
436451
func main() {
437452
\tdata, _ := io.ReadAll(os.Stdin)

src/target-rust.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -415,24 +415,39 @@ fn write_json(c: &Cst, out: &mut String) {
415415
out.push_str(&format!("],\\"offset\\":{},\\"end\\":{}}}", c.offset, c.end));
416416
}
417417
418+
// The library entry: lex + parse the whole input, returning the CST root (None on a parse
419+
// error / trailing input). No I/O — see emitRunner() for the stdin -> JSON wrapper.
420+
fn parse(src: &str) -> Option<Cst> {
421+
let toks = lex(src);
422+
let n = toks.len();
423+
let mut p = Parser { toks, pos: 0, capped: false, suppress_next: Vec::new(), src };
424+
match p.parse_${ir.entry}() {
425+
Some(root) if p.pos == n => Some(root),
426+
_ => None,
427+
}
428+
}
429+
`;
430+
},
431+
emitRunner(): string {
432+
return `
433+
// CLI runner (harness only): stdin -> CST JSON + a self-bench mode. Appended to the parser
434+
// library by the gate (same file/crate, so it calls \`parse\`/\`write_json\` directly); NOT part
435+
// of the parser.
418436
fn main() {
437+
use std::io::Read;
419438
let mut src = String::new();
420439
std::io::stdin().read_to_string(&mut src).unwrap();
421440
// Self-bench: a numeric arg N times the lex+parse loop and prints ms/iteration.
422441
if let Some(iters) = std::env::args().nth(1).and_then(|a| a.parse::<u64>().ok()) {
423-
// black_box on the input + result so the optimizer can't elide the lex/parse.
424-
for _ in 0..3 { let toks = lex(std::hint::black_box(&src)); let mut p = Parser { toks, pos: 0, capped: false, suppress_next: Vec::new(), src: &src }; std::hint::black_box(p.parse_${ir.entry}()); }
442+
for _ in 0..3 { std::hint::black_box(parse(std::hint::black_box(&src))); }
425443
let t = std::time::Instant::now();
426-
for _ in 0..iters { let toks = lex(std::hint::black_box(&src)); let mut p = Parser { toks, pos: 0, capped: false, suppress_next: Vec::new(), src: &src }; std::hint::black_box(p.parse_${ir.entry}()); }
444+
for _ in 0..iters { std::hint::black_box(parse(std::hint::black_box(&src))); }
427445
println!("{:.4}", t.elapsed().as_secs_f64() * 1000.0 / iters as f64);
428446
return;
429447
}
430-
let toks = lex(&src);
431-
let n = toks.len();
432-
let mut p = Parser { toks, pos: 0, capped: false, suppress_next: Vec::new(), src: &src };
433-
match p.parse_${ir.entry}() {
434-
Some(root) if p.pos == n => { let mut out = String::new(); write_json(&root, &mut out); print!("{}", out); }
435-
_ => { eprintln!("parse error (pos {}/{})", p.pos, n); std::process::exit(1); }
448+
match parse(&src) {
449+
Some(root) => { let mut out = String::new(); write_json(&root, &mut out); print!("{}", out); }
450+
None => { eprintln!("parse error"); std::process::exit(1); }
436451
}
437452
}
438453
`;

src/target-ts.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,8 @@ export const tsTarget: Target = {
296296
return { rule: '$template', children, offset: children[0].offset, end: children[children.length - 1].end };
297297
}
298298
` : '';
299-
return `// GENERATED by emit-portable.ts (tsTarget) — parser for grammar "${ir.grammarName}".
300-
import { readFileSync } from 'node:fs';
299+
return `// GENERATED by emit-portable.ts (tsTarget) — parser LIBRARY for grammar "${ir.grammarName}" (exports \`parse\`).
300+
// The CLI runner (stdin → CST JSON) is a SEPARATE piece — tsTarget.emitRunner(), appended by the harness.
301301
302302
type Tok = { kind: string; text: string; off: number; end: number; nl: boolean };
303303
type Leaf = { tokenType: string; offset: number; end: number };
@@ -363,16 +363,24 @@ function altLit(opts: [string, string][], kids: Cst[]): boolean {
363363
364364
${matchTemplate}${ruleFns}
365365
366-
const src = readFileSync(0, 'utf8');
367-
_src = src;
368-
toks = lex(src);
369-
pos = 0;
370-
const root = parse${ir.entry}();
371-
if (root === null || pos !== toks.length) {
372-
process.stderr.write('parse error (pos ' + pos + '/' + toks.length + ')\\n');
373-
process.exit(1);
366+
// The library entry: lex + parse the whole input, returning the CST root (or null on a
367+
// parse error / trailing input). No I/O — see emitRunner() for the stdin → JSON wrapper.
368+
export function parse(src: string): Cst | null {
369+
_src = src;
370+
toks = lex(src);
371+
pos = 0;
372+
const root = parse${ir.entry}();
373+
return root !== null && pos === toks.length ? root : null;
374374
}
375-
process.stdout.write(JSON.stringify(root));
375+
`;
376+
},
377+
emitRunner(): string {
378+
return `// CLI runner (harness only): stdin → CST JSON. Appended to the parser library by the gate;
379+
// NOT part of the emitted parser. The import is hoisted, so it may follow the library code.
380+
import { readFileSync } from 'node:fs';
381+
const _root = parse(readFileSync(0, 'utf8'));
382+
if (_root === null) { process.stderr.write('parse error\\n'); process.exit(1); }
383+
process.stdout.write(JSON.stringify(_root));
376384
`;
377385
},
378386
};

test/portable-targets.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,20 +242,25 @@ for (const c of CASES) {
242242
mkdirSync(dir, { recursive: true });
243243
const runners: Array<{ label: string; run: (src: string) => Outcome }> = [];
244244

245+
// emitParser is the parser LIBRARY (exports a `parse` entry, no I/O); the executable CLI
246+
// runner (stdin → CST JSON) is the target's emitRunner(), assembled in HERE — the harness's
247+
// job, not the parser's. ts/rust append the runner to the library (one file); Go needs it as
248+
// a SEPARATE runner.go in the same `package main` (its import rules forbid appending inline).
245249
const tsFile = `${dir}/p.ts`;
246-
writeFileSync(tsFile, emitParser(grammar, tsTarget));
250+
writeFileSync(tsFile, emitParser(grammar, tsTarget) + (tsTarget.emitRunner?.() ?? ''));
247251
runners.push({ label: 'typescript', run: (src) => runProc('node', [tsFile], src) });
248252

249253
if (HAS_GO && !c.tsOnly) {
250254
const gdir = `${dir}/go`; mkdirSync(gdir, { recursive: true });
251-
writeFileSync(`${gdir}/main.go`, emitParser(grammar, goTarget));
255+
writeFileSync(`${gdir}/parser.go`, emitParser(grammar, goTarget));
256+
writeFileSync(`${gdir}/runner.go`, goTarget.emitRunner?.() ?? '');
252257
writeFileSync(`${gdir}/go.mod`, 'module p\n\ngo 1.21\n');
253258
execFileSync('go', ['build', '-o', `${gdir}/p`, '.'], { cwd: gdir, stdio: 'pipe' });
254259
runners.push({ label: 'go', run: (src) => runProc(`${gdir}/p`, [], src) });
255260
}
256261
if (HAS_RUST && !c.tsOnly) {
257262
const rfile = `${dir}/main.rs`;
258-
writeFileSync(rfile, emitParser(grammar, rustTarget));
263+
writeFileSync(rfile, emitParser(grammar, rustTarget) + (rustTarget.emitRunner?.() ?? ''));
259264
execFileSync('rustc', ['-O', '-A', 'warnings', rfile, '-o', `${dir}/pr`], { stdio: 'pipe' });
260265
runners.push({ label: 'rust', run: (src) => runProc(`${dir}/pr`, [], src) });
261266
}

0 commit comments

Comments
 (0)