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:
126
dashboard/src/lib/rhai/parser.test.ts
Normal file
126
dashboard/src/lib/rhai/parser.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user