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>
120 lines
4.0 KiB
TypeScript
120 lines
4.0 KiB
TypeScript
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);
|
|
});
|
|
});
|