Skip to content

Commit ed5941e

Browse files
committed
Remove toObject from the engine: visit + tree accessors are the only surface
The arena design's premise (PR #36) is that parse() hands out a tree to TRAVERSE, not an object tree to materialize - toObject was the materialization back door left on both the module API and the handle API, and its only real consumer was the gate layer's byte-identical JSON comparison. That is a test concern: gates now build the comparison object through visit + tree accessors (test/emitted-obj.ts, mirroring the interpreter's object shape and key order exactly, so the emit ≡ interp and incremental ≡ fresh comparisons are unchanged). The unused emitted getText went with it; the interpreter keeps returning its native object trees (that IS its representation, not a conversion). 31/31 gates, emit-parser-verify 0 mismatches, multi-doc contract 5/5, handle-API keystroke median 0.028ms (9MB) / 0.089ms (8MB nested).
1 parent 70064d2 commit ed5941e

5 files changed

Lines changed: 62 additions & 36 deletions

File tree

src/emit-parser.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2247,10 +2247,6 @@ export function tokenAt(i) {
22472247
}
22482248
22492249
// The CST is span-only: a node's text is derived from the source it was parsed from.
2250-
export function getText(node, source) {
2251-
return source.slice(node.offset, node.end);
2252-
}
2253-
22542250
// ── Arena tree access ──
22552251
// The arena IS the tree: parse() returns the root node id and consumers traverse
22562252
// via visit()/the accessors — nothing is materialized on the parse path. All views
@@ -2323,20 +2319,6 @@ function visitCore(entry, fns, charBase, tokBase) {
23232319
}
23242320
if (fns.leave) fns.leave(entry, charBase, tokBase);
23252321
}
2326-
// Materialize the classic object CST from a node id — a BRIDGE for tests/debugging
2327-
// (the byte-identical gate against the interpreter), not a parse-path product.
2328-
function toObjectCore(id, charBase, tokBase) {
2329-
if (charBase === undefined) { charBase = rootCharBase; tokBase = rootTokBase; }
2330-
const n = rowCount[id];
2331-
const cs = rowStart[id];
2332-
const children = new Array(n);
2333-
for (let i = 0; i < n; i++) {
2334-
const entry = kids[cs + i];
2335-
children[i] = entry >= 0 ? toObjectCore(entry, charBase + kcr(id, cs + i), tokBase + ktr(id, cs + i))
2336-
: { tokenType: leafTokenType(entry, tokBase), offset: toff(tokBase + ((~entry) >>> 2)), end: tend(tokBase + ((~entry) >>> 2)) };
2337-
}
2338-
return { rule: RULE_NAMES[rowRule[id]], children, offset: charBase, end: charBase + rowLen[id] };
2339-
}
23402322
23412323
// Parse to the ARENA: returns the root node id.
23422324
function lexInto(source) {
@@ -3159,7 +3141,6 @@ export { tokenize };
31593141
export function parse(source, entryRule) { activate(docDefault); return parseCore(source, entryRule); }
31603142
export function parseEdited(source, entryRule, edits) { activate(docDefault); return editCore(source, entryRule, edits); }
31613143
export function visit(entry, fns, charBase, tokBase) { activate(docDefault); return visitCore(entry, fns, charBase, tokBase); }
3162-
export function toObject(id, charBase, tokBase) { activate(docDefault); return toObjectCore(id, charBase, tokBase); }
31633144
// ── Handle API: explicit trees over per-instance documents ──
31643145
// const p = createParser(); const cst = p.parse(text); p.edit(cst, next[, edits]);
31653146
// The handle is the STABLE IDENTITY of this document's tree: edit() mutates it in
@@ -3194,7 +3175,6 @@ export function createParser() {
31943175
cst.root = editCore(source, entryUsed, edits);
31953176
},
31963177
visit(cst, fns) { chk(cst); activate(d); return visitCore(cst.root, fns); },
3197-
toObject(cst) { chk(cst); activate(d); return toObjectCore(cst.root); },
31983178
tree: view,
31993179
};
32003180
}

test/emit-parser-verify.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
// node test/emit-parser-verify.ts # 4 bench files + ~400-file corpus sample
1010
// node test/emit-parser-verify.ts <N> # sample stride N (default ~ to hit ~400)
1111
// node test/emit-parser-verify.ts all # every .ts file under conformance
12+
import { objectify } from './emitted-obj.ts';
1213
import { createParser } from '../src/gen-parser.ts';
1314
import { emitParser } from '../src/emit-parser.ts';
1415
import { readdir } from 'fs/promises';
@@ -41,7 +42,7 @@ function compare(code: string): { verdict: string; detail?: string } {
4142
const o = run(oracle.parse, code);
4243
// The emitted parser returns an arena node id; materialize the object view for the
4344
// byte-identical comparison against the interpreter's object tree.
44-
const e = run((s: string) => emitted.toObject(emitted.parse(s)), code);
45+
const e = run((s: string) => { const r = emitted.parse(s); return objectify(emitted.tree, (fns) => emitted.visit(r, fns)); }, code);
4546
if (!o.ok && o.err.includes('Maximum call stack')) {
4647
// The interpreter recursed out of stack — a CAPACITY limit, not a parse verdict;
4748
// the emitted parser's flatter frames can legitimately survive deeper inputs

test/emitted-obj.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Materialize an emitted-engine tree as a plain object — TEST-SIDE ONLY. The engine
2+
// deliberately exposes a single consumption surface (visit + tree accessors); full
3+
// materialization is a consumer choice, and the only consumer that needs it is the
4+
// gate layer's byte-identical JSON comparison (incremental ≡ fresh, emit ≡ interp).
5+
// The shape (and KEY ORDER — JSON.stringify equality depends on it) mirrors the
6+
// interpreter's native object trees: nodes { rule, children, offset, end }, leaves
7+
// { tokenType, offset, end }.
8+
export interface TreeView {
9+
ruleNameOf(id: number): string;
10+
lenOf(id: number): number;
11+
leafTokenType(entry: number, tokBase: number): string;
12+
leafOffsetOf(entry: number, tokBase: number): number;
13+
leafEndOf(entry: number, tokBase: number): number;
14+
}
15+
type VisitFns = {
16+
enter?(id: number, charBase: number, tokBase: number): boolean | void;
17+
leave?(id: number, charBase: number, tokBase: number): void;
18+
leaf?(entry: number, tok: number): void;
19+
};
20+
export type ObjNode = { rule: string; children: (ObjNode | ObjLeaf)[]; offset: number; end: number };
21+
export type ObjLeaf = { tokenType: string; offset: number; end: number };
22+
23+
export function objectify(tree: TreeView, runVisit: (fns: VisitFns) => void): ObjNode {
24+
const rootHolder: { children: (ObjNode | ObjLeaf)[] } = { children: [] };
25+
const stack: { children: (ObjNode | ObjLeaf)[] }[] = [rootHolder];
26+
runVisit({
27+
enter(id, charBase) {
28+
const node: ObjNode = { rule: tree.ruleNameOf(id), children: [], offset: charBase, end: charBase + tree.lenOf(id) };
29+
stack[stack.length - 1].children.push(node);
30+
stack.push(node);
31+
},
32+
leave() { stack.pop(); },
33+
leaf(entry, tok) {
34+
const tb = tok - ((~entry) >>> 2);
35+
stack[stack.length - 1].children.push({ tokenType: tree.leafTokenType(entry, tb), offset: tree.leafOffsetOf(entry, tb), end: tree.leafEndOf(entry, tb) });
36+
},
37+
});
38+
return rootHolder.children[0] as ObjNode;
39+
}

test/incremental-verify.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// and the arena growth, so reuse is MEASURED, not assumed.
77
//
88
// node test/incremental-verify.ts
9+
import { objectify } from './emitted-obj.ts';
910
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
1011
import { emitParser } from '../src/emit-parser.ts';
1112

@@ -17,11 +18,13 @@ type Cst = { root: number };
1718
type Parser = {
1819
parse(s: string): Cst;
1920
edit(cst: Cst, s: string, edits?: Edit[]): void;
20-
toObject(cst: Cst): unknown;
21+
visit(cst: Cst, fns: object): void;
22+
tree: import('./emitted-obj.ts').TreeView;
2123
};
2224
type Em = {
2325
parse(s: string): number;
24-
toObject(id: number): unknown;
26+
visit(entry: number, fns: object): void;
27+
tree: import('./emitted-obj.ts').TreeView;
2528
createParser(): Parser;
2629
};
2730
const session = ((await import(emPath + '?session=' + process.pid)) as Em).createParser();
@@ -109,8 +112,8 @@ for (const [base, edited] of GLUE) {
109112
else bothReject++;
110113
continue;
111114
}
112-
const a = JSON.stringify(fresh.toObject(fr));
113-
const b = JSON.stringify(session.toObject(c0));
115+
const a = JSON.stringify(objectify(fresh.tree, (fns) => fresh.visit(fr, fns)));
116+
const b = JSON.stringify(objectify(session.tree, (fns) => session.visit(c0, fns)));
114117
if (a === b) equal++;
115118
else { mismatch++; if (failures.length < 5) failures.push(`glue «${edited.slice(0, 30)}»: tree diverges`); }
116119
}
@@ -139,8 +142,8 @@ for (const f of FILES) {
139142
continue;
140143
}
141144
tFresh += tf1 - tf0; tInc += ti1 - ti0;
142-
const a = JSON.stringify(fresh.toObject(freshRoot));
143-
const b = JSON.stringify(session.toObject(cst));
145+
const a = JSON.stringify(objectify(fresh.tree, (fns) => fresh.visit(freshRoot, fns)));
146+
const b = JSON.stringify(objectify(session.tree, (fns) => session.visit(cst, fns)));
144147
if (a === b) equal++;
145148
else {
146149
mismatch++;

test/multi-doc.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// an in-place-mutated tree, and a REJECTED edit leaves the old handle valid.
99
//
1010
// node test/multi-doc.ts
11+
import { objectify } from './emitted-obj.ts';
1112
import { writeFileSync } from 'node:fs';
1213
import { emitParser } from '../src/emit-parser.ts';
1314

@@ -16,8 +17,8 @@ const emPath = '/tmp/emitted-multidoc.mjs';
1617
writeFileSync(emPath, emitParser(grammar));
1718
type Edit = { start: number; oldEnd: number; newEnd: number };
1819
type Cst = { root: number };
19-
type Parser = { parse(s: string): Cst; edit(cst: Cst, s: string, edits?: Edit[]): void; toObject(cst: Cst): unknown; visit(cst: Cst, fns: object): void };
20-
type Em = { parse(s: string): number; toObject(id: number): unknown; createParser(): Parser };
20+
type Parser = { parse(s: string): Cst; edit(cst: Cst, s: string, edits?: Edit[]): void; visit(cst: Cst, fns: object): void; tree: import('./emitted-obj.ts').TreeView };
21+
type Em = { parse(s: string): number; createParser(): Parser };
2122
const em = (await import(emPath + '?v=' + process.pid)) as Em;
2223

2324
// Two synthetic documents (no corpus dependency — the gate always exercises).
@@ -65,8 +66,9 @@ for (let k = 0; k < 60; k++) {
6566
}
6667
// mix the module-level default doc in between: it must not disturb either instance
6768
if (k % 5 === 0) em.parse('const mix = ' + k + ';');
68-
const a = JSON.stringify(f.toObject(fc!));
69-
const b = JSON.stringify((onA ? p1 : p2).toObject(onA ? cstA : cstB));
69+
const a = JSON.stringify(objectify(f.tree, (fns) => f.visit(fc!, fns)));
70+
const q = onA ? p1 : p2;
71+
const b = JSON.stringify(objectify(q.tree, (fns) => q.visit(onA ? cstA : cstB, fns)));
7072
if (a === b) equal++;
7173
else {
7274
mismatch++;
@@ -84,22 +86,23 @@ let contract = 0;
8486
{
8587
const p = em.createParser();
8688
const c1 = p.parse('const a = 1;');
87-
const before = JSON.stringify(p.toObject(c1));
89+
const obj = (h: Cst) => JSON.stringify(objectify(p.tree, (fns) => p.visit(h, fns)));
90+
const before = obj(c1);
8891
p.edit(c1, 'const ab = 1;');
89-
const after = JSON.stringify(p.toObject(c1));
92+
const after = obj(c1);
9093
if (after !== before && after.includes('"end":8')) contract++; // same handle, new tree
9194
else failures.push('in-place edit did not update the handle');
9295
try { p2.edit(c1, 'const y = 3;'); failures.push('foreign handle did not throw'); } catch { contract++; }
9396
let rejected = false;
9497
try { p.edit(c1, 'const ] = ;'); } catch { rejected = true; }
95-
if (rejected && JSON.stringify(p.toObject(c1)) === after) contract++; // reject keeps the tree
98+
if (rejected && obj(c1) === after) contract++; // reject keeps the tree
9699
else failures.push('reject-then-read flow broke');
97100
const c2 = p.parse('let q = 1;');
98-
try { p.toObject(c1); failures.push('re-opened document: old handle did not throw'); } catch { contract++; }
101+
try { obj(c1); failures.push('re-opened document: old handle did not throw'); } catch { contract++; }
99102
// a REJECTING parse() resets the arena too — it must invalidate prior handles
100103
try { p.parse('const ] = ;'); } catch { /* expected reject */ }
101104
let dead = false;
102-
try { p.toObject(c2); } catch { dead = true; }
105+
try { obj(c2); } catch { dead = true; }
103106
if (dead) contract++;
104107
else failures.push('rejecting parse() left the old handle readable over a reset arena');
105108
}

0 commit comments

Comments
 (0)