feat(dashboard): hand-rolled Rhai parser + symbol table + Vitest

Foundation for upcoming editor features (scope-aware autocomplete,
goto-def / find-usages, source formatter). Hand-rolled recursive
descent in TypeScript with Pratt precedence climbing for expressions,
error-tolerant so partial trees stay usable while the user is typing.
Symbol table walks the AST to produce per-scope declarations, usage
sites, and object-literal field maps. Vitest added as a dev-only
runner; no editor wiring in this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-23 23:38:15 +02:00
parent a80e6d1ca4
commit bc8b512b56
11 changed files with 2361 additions and 3 deletions

View File

@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import { parse } from './parser';
import { buildSymbolTable } from './symbols';
function build(src: string) {
const r = parse(src);
return { ...r, table: buildSymbolTable(r) };
}
describe('symbols — declarations and usages', () => {
it('captures let declarations', () => {
const { table } = build('let x = 1; x + 1;');
const x = table.allDecls.find((d) => d.name === 'x')!;
expect(x.kind).toBe('let');
expect(table.usages.find((u) => u.name === 'x')!.resolved).toBe(x);
});
it('records fn signatures for completion detail', () => {
const { table } = build('fn process(order, user) { order }');
const fn = table.allDecls.find((d) => d.name === 'process')!;
expect(fn.kind).toBe('fn');
expect(fn.signature).toBe('process(order, user)');
});
it('hoists fn declarations: calls above the decl resolve', () => {
const { table } = build('greet("world"); fn greet(s) { s }');
const u = table.usages.find((u) => u.name === 'greet')!;
expect(u.resolved?.kind).toBe('fn');
});
it('function bodies do not see outer locals', () => {
const { table } = build(`
let outer = 1;
fn f() { outer }
`);
const outerUse = table.usages.find((u) => u.name === 'outer')!;
expect(outerUse.resolved).toBeNull();
});
it('function bodies do see outer fn declarations', () => {
const { table } = build(`
fn helper() { 1 }
fn caller() { helper() }
`);
const helperUse = table.usages.find((u) => u.name === 'helper' && u.range.start > 30)!;
expect(helperUse.resolved?.kind).toBe('fn');
});
it('captures function parameters in their body scope', () => {
const { table } = build('fn f(a, b) { a + b }');
const a = table.allDecls.find((d) => d.name === 'a')!;
expect(a.kind).toBe('param');
const useOfA = table.usages.find((u) => u.name === 'a')!;
expect(useOfA.resolved).toBe(a);
});
it('captures for-loop binders', () => {
const { table } = build('for item in [1, 2, 3] { item }');
const item = table.allDecls.find((d) => d.name === 'item')!;
expect(item.kind).toBe('for');
});
it('respects forward-declaration: cannot use a let before its decl', () => {
const { table } = build('x; let x = 1;');
const earlyUse = table.usages.find((u) => u.name === 'x' && u.range.start < 5)!;
expect(earlyUse.resolved).toBeNull();
});
});
describe('symbols — object-literal field maps', () => {
it('records fields of an object-map literal initializer', () => {
const { table } = build('let order = #{ id: 1, total: 5 };');
const order = table.allDecls.find((d) => d.name === 'order')!;
expect(order.objectFields).toEqual(['id', 'total']);
});
it('objectFieldsOf returns the set after the declaration', () => {
const src = 'let order = #{ id: 1 }; order.id';
const { table } = build(src);
const afterDecl = src.indexOf('order.id') + 'order.'.length;
expect(table.objectFieldsOf('order', afterDecl)).toEqual(['id']);
});
});
describe('symbols — completion + navigation helpers', () => {
it('scopeCompletions surfaces in-scope locals and hoisted fns', () => {
const src = `
let outer = 1;
fn process(order) {
order
}
`;
const { table } = build(src);
const insideFn = src.indexOf('order\n');
const names = table.scopeCompletions(insideFn).map((d) => d.name);
expect(names).toContain('order');
expect(names).toContain('process');
// outer is not visible from inside `fn process`.
expect(names).not.toContain('outer');
});
it('declOfUsageAt resolves a usage to its declaration', () => {
const src = 'fn process(o) { o } process(1)';
const { table } = build(src);
const callPos = src.lastIndexOf('process');
const d = table.declOfUsageAt(callPos)!;
expect(d.name).toBe('process');
expect(d.kind).toBe('fn');
});
it('usagesOf collects declaration + every reference', () => {
const src = 'fn process(o) { o } process(1); process(2);';
const { table } = build(src);
const fn = table.allDecls.find((d) => d.name === 'process')!;
const all = table.usagesOf(fn);
// 1 decl name + 2 call sites = 3 ranges
expect(all).toHaveLength(3);
});
});