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,126 @@
import { describe, it, expect } from 'vitest';
import { parse } from './parser';
import type { BinaryExpr, ExprStmt, FnDecl, LetStmt } from './ast';
describe('parser — declarations', () => {
it('parses a let binding with initializer', () => {
const { program, errors } = parse('let x = 1 + 2;');
expect(errors).toEqual([]);
expect(program.stmts).toHaveLength(1);
const let_ = program.stmts[0] as LetStmt;
expect(let_.kind).toBe('Let');
expect(let_.name).toBe('x');
expect(let_.init?.kind).toBe('Binary');
});
it('parses a const binding', () => {
const { program, errors } = parse('const PI = 3.14;');
expect(errors).toEqual([]);
expect(program.stmts[0]).toMatchObject({ kind: 'Const', name: 'PI' });
});
it('parses fn declarations with parameters', () => {
const { program, errors } = parse('fn process(order, user) { order.total }');
expect(errors).toEqual([]);
const fn = program.stmts[0] as FnDecl;
expect(fn.kind).toBe('FnDecl');
expect(fn.name).toBe('process');
expect(fn.params.map((p) => p.name)).toEqual(['order', 'user']);
expect(fn.body.stmts).toHaveLength(1);
});
});
describe('parser — expressions', () => {
it('respects binary precedence (* before +)', () => {
const { program } = parse('let a = 1 + 2 * 3;');
const e = (program.stmts[0] as LetStmt).init as BinaryExpr;
expect(e.kind).toBe('Binary');
expect(e.op).toBe('+');
const right = e.right as BinaryExpr;
expect(right.op).toBe('*');
});
it('parses method chains (member + call + index)', () => {
const { program, errors } = parse('let x = ctx.request.body["k"];');
expect(errors).toEqual([]);
const init = (program.stmts[0] as LetStmt).init!;
expect(init.kind).toBe('Index');
});
it('parses log::info("hi") as Call(Member(Ident(log), "info"), ["hi"])', () => {
const { program, errors } = parse('log::info("hi");');
expect(errors).toEqual([]);
const stmt = program.stmts[0] as ExprStmt;
expect(stmt.expr.kind).toBe('Call');
});
it('parses object-map literal #{} with keys', () => {
const { program, errors } = parse('let o = #{ a: 1, b: 2 };');
expect(errors).toEqual([]);
const init = (program.stmts[0] as LetStmt).init!;
expect(init.kind).toBe('ObjectMap');
if (init.kind === 'ObjectMap') {
expect(init.entries.map((e) => e.key)).toEqual(['a', 'b']);
}
});
it('parses array literals', () => {
const { program, errors } = parse('let xs = [1, 2, 3];');
expect(errors).toEqual([]);
expect((program.stmts[0] as LetStmt).init!.kind).toBe('Array');
});
it('parses if-as-expression for let RHS', () => {
const { program, errors } = parse('let x = if true { 1 } else { 2 };');
expect(errors).toEqual([]);
expect((program.stmts[0] as LetStmt).init!.kind).toBe('IfExpr');
});
});
describe('parser — control flow', () => {
it('parses while, for, loop', () => {
const { errors: e1 } = parse('while true { break; }');
const { errors: e2 } = parse('for x in [1, 2] { x }');
const { errors: e3 } = parse('loop { break; }');
expect(e1).toEqual([]);
expect(e2).toEqual([]);
expect(e3).toEqual([]);
});
it('parses if / else if / else chains', () => {
const { program, errors } = parse(`
if a { 1 } else if b { 2 } else { 3 }
`);
expect(errors).toEqual([]);
const stmt = program.stmts[0] as ExprStmt;
expect(stmt.expr.kind).toBe('IfExpr');
});
it('parses try / catch with binding', () => {
const { errors } = parse('try { foo(); } catch (e) { log::error(e); }');
expect(errors).toEqual([]);
});
});
describe('parser — error tolerance', () => {
it('is lenient about missing semicolons between statements', () => {
// The parser accepts implicit statement separation so completions
// remain useful while the user is still typing. Both bindings
// should land in the program regardless of the missing `;`.
const { program } = parse('let x = 1 let y = 2;');
const names = program.stmts.flatMap((s) => (s.kind === 'Let' ? [s.name] : []));
expect(names).toContain('x');
expect(names).toContain('y');
});
it('does not loop forever on garbage', () => {
const { errors } = parse('@@@ ### }}}');
expect(errors.length).toBeGreaterThan(0);
});
it('recovers after a bad statement and parses the next one', () => {
const { program } = parse('let = ; let y = 2;');
const y = program.stmts.find((s) => s.kind === 'Let' && s.name === 'y');
expect(y).toBeDefined();
});
});