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>
127 lines
4.3 KiB
TypeScript
127 lines
4.3 KiB
TypeScript
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();
|
|
});
|
|
});
|