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,275 @@
// AST node definitions for the dashboard's hand-rolled Rhai parser.
//
// Every node carries `start` / `end` byte offsets into the source so the
// editor features (autocomplete, goto-def, find-usages, format) can map
// between positions in the document and nodes in the tree.
//
// The shape mirrors the Rhai book grammar (https://rhai.rs/book/language/)
// but simplified: type annotations are absent (Rhai is dynamic), and
// statement-vs-expression duality is collapsed by letting `if` / `switch` /
// block expressions appear in both positions (an `ExprStmt` wrapper turns
// any expression into a statement).
export interface Range {
start: number;
end: number;
}
// ---------------------------------------------------------------------------
// Comments — captured by the lexer with their positions and re-emitted by
// the formatter. Kept off the AST tree so they don't clutter walkers.
// ---------------------------------------------------------------------------
export interface Comment extends Range {
kind: 'LineComment' | 'BlockComment';
text: string;
}
// ---------------------------------------------------------------------------
// Statements
// ---------------------------------------------------------------------------
export type Stmt =
| LetStmt
| ConstStmt
| FnDecl
| ExprStmt
| ReturnStmt
| WhileStmt
| LoopStmt
| ForStmt
| BreakStmt
| ContinueStmt
| TryStmt;
export interface LetStmt extends Range {
kind: 'Let';
name: string;
nameRange: Range;
init: Expr | null;
}
export interface ConstStmt extends Range {
kind: 'Const';
name: string;
nameRange: Range;
init: Expr | null;
}
export interface Param extends Range {
name: string;
}
export interface FnDecl extends Range {
kind: 'FnDecl';
name: string;
nameRange: Range;
params: Param[];
body: BlockExpr;
}
export interface ExprStmt extends Range {
kind: 'ExprStmt';
expr: Expr;
// Whether the statement is terminated with `;`. Block-form expressions
// (`if`/`switch`/`{...}`) don't require it; everything else does.
semi: boolean;
}
export interface ReturnStmt extends Range {
kind: 'Return';
value: Expr | null;
}
export interface WhileStmt extends Range {
kind: 'While';
cond: Expr;
body: BlockExpr;
}
export interface LoopStmt extends Range {
kind: 'Loop';
body: BlockExpr;
}
export interface ForStmt extends Range {
kind: 'For';
varName: string;
varRange: Range;
iter: Expr;
body: BlockExpr;
}
export interface BreakStmt extends Range {
kind: 'Break';
}
export interface ContinueStmt extends Range {
kind: 'Continue';
}
export interface TryStmt extends Range {
kind: 'Try';
body: BlockExpr;
catchVar: string | null;
catchVarRange: Range | null;
handler: BlockExpr;
}
// ---------------------------------------------------------------------------
// Expressions
// ---------------------------------------------------------------------------
export type Expr =
| IdentExpr
| NumberExpr
| StringExpr
| BoolExpr
| NullExpr
| CallExpr
| MemberExpr
| IndexExpr
| UnaryExpr
| BinaryExpr
| AssignExpr
| ParenExpr
| ObjectMapExpr
| ArrayExpr
| FnExpr
| IfExpr
| SwitchExpr
| BlockExpr;
export interface IdentExpr extends Range {
kind: 'Ident';
name: string;
}
export interface NumberExpr extends Range {
kind: 'Number';
raw: string;
}
export interface StringExpr extends Range {
kind: 'String';
// The surrounding quote — `"` is escape-processed, backtick is raw and
// may span multiple lines. We don't decode escapes; the formatter just
// preserves the raw text between the quotes.
quote: '"' | '`';
raw: string;
}
export interface BoolExpr extends Range {
kind: 'Bool';
value: boolean;
}
export interface NullExpr extends Range {
kind: 'Null';
}
export interface CallExpr extends Range {
kind: 'Call';
callee: Expr;
args: Expr[];
}
export interface MemberExpr extends Range {
kind: 'Member';
object: Expr;
property: string;
propertyRange: Range;
}
export interface IndexExpr extends Range {
kind: 'Index';
object: Expr;
index: Expr;
}
export interface UnaryExpr extends Range {
kind: 'Unary';
op: string;
operand: Expr;
}
export interface BinaryExpr extends Range {
kind: 'Binary';
op: string;
left: Expr;
right: Expr;
}
export interface AssignExpr extends Range {
kind: 'Assign';
op: string; // = += -= *= /= %= ??=
target: Expr;
value: Expr;
}
export interface ParenExpr extends Range {
kind: 'Paren';
expr: Expr;
}
export interface ObjectMapEntry extends Range {
key: string;
keyRange: Range;
value: Expr;
}
export interface ObjectMapExpr extends Range {
kind: 'ObjectMap';
entries: ObjectMapEntry[];
}
export interface ArrayExpr extends Range {
kind: 'Array';
elements: Expr[];
}
export interface FnExpr extends Range {
kind: 'FnExpr';
params: Param[];
body: BlockExpr;
}
export interface IfExpr extends Range {
kind: 'IfExpr';
cond: Expr;
then: BlockExpr;
// else branch: either a block or another `if` for `else if` chains.
else_: BlockExpr | IfExpr | null;
}
export interface SwitchArm extends Range {
pattern: Expr | null; // null = `_` default case
guard: Expr | null;
value: Expr;
}
export interface SwitchExpr extends Range {
kind: 'SwitchExpr';
subject: Expr;
arms: SwitchArm[];
}
export interface BlockExpr extends Range {
kind: 'BlockExpr';
stmts: Stmt[];
}
// ---------------------------------------------------------------------------
// Top-level parse output
// ---------------------------------------------------------------------------
export interface ParseError extends Range {
message: string;
}
export interface ParseResult {
source: string;
program: BlockExpr;
errors: ParseError[];
comments: Comment[];
}

View File

@@ -0,0 +1,16 @@
// Public entry points for the Rhai parser package. Editor features
// import from here.
export { parse } from './parser';
export { tokenize, KEYWORDS } from './lexer';
export { buildSymbolTable, renderFnSignature } from './symbols';
export type { Decl, DeclKind, Scope, SymbolTable, Usage } from './symbols';
export type {
BlockExpr,
Comment,
Expr,
ParseError,
ParseResult,
Range,
Stmt
} from './ast';

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { tokenize } from './lexer';
function kinds(src: string): string[] {
return tokenize(src).tokens.filter((t) => t.kind !== 'EOF').map((t) => t.kind);
}
function texts(src: string): string[] {
return tokenize(src).tokens.filter((t) => t.kind !== 'EOF').map((t) => t.text);
}
describe('lexer', () => {
it('emits an EOF for empty input', () => {
const { tokens } = tokenize('');
expect(tokens).toHaveLength(1);
expect(tokens[0].kind).toBe('EOF');
});
it('distinguishes keywords from identifiers', () => {
const { tokens } = tokenize('let foo = bar;');
expect(tokens[0]).toMatchObject({ kind: 'Keyword', text: 'let' });
expect(tokens[1]).toMatchObject({ kind: 'Ident', text: 'foo' });
expect(tokens[2]).toMatchObject({ kind: 'Operator', text: '=' });
expect(tokens[3]).toMatchObject({ kind: 'Ident', text: 'bar' });
expect(tokens[4]).toMatchObject({ kind: 'Punct', text: ';' });
});
it('lexes integer, float, hex, and binary numbers', () => {
expect(texts('1 1.5 0xff 0b1010 1e10 1_000')).toEqual(['1', '1.5', '0xff', '0b1010', '1e10', '1_000']);
expect(kinds('1 1.5 0xff')).toEqual(['Number', 'Number', 'Number']);
});
it('lexes double-quote and backtick strings', () => {
const { tokens } = tokenize('"hi" `world`');
expect(tokens[0]).toMatchObject({ kind: 'String', text: '"hi"' });
expect(tokens[1]).toMatchObject({ kind: 'String', text: '`world`' });
});
it('preserves backslash escapes inside double-quoted strings', () => {
const { tokens } = tokenize('"a\\"b"');
expect(tokens[0].text).toBe('"a\\"b"');
});
it('captures line and block comments as comments, not tokens', () => {
const { tokens, comments } = tokenize('let x = 1; // tail\n/* block */ y');
expect(comments.map((c) => c.kind)).toEqual(['LineComment', 'BlockComment']);
expect(tokens.find((t) => t.text === '//' || t.text === '/*')).toBeUndefined();
});
it('handles nested block comments', () => {
const { comments } = tokenize('/* outer /* inner */ still outer */');
expect(comments).toHaveLength(1);
expect(comments[0].text).toBe('/* outer /* inner */ still outer */');
});
it('lexes multi-character operators greedily', () => {
expect(texts('a == b && c != d')).toEqual(['a', '==', 'b', '&&', 'c', '!=', 'd']);
expect(texts('a ?? b ??= c')).toEqual(['a', '??', 'b', '??=', 'c']);
expect(texts('1..=10')).toEqual(['1', '..=', '10']);
});
it('recognizes #{ as separate punctuation tokens', () => {
const { tokens } = tokenize('#{}');
expect(tokens.slice(0, 3).map((t) => t.text)).toEqual(['#', '{', '}']);
});
it('records accurate byte ranges', () => {
const src = 'let abc = 42;';
const { tokens } = tokenize(src);
const abc = tokens.find((t) => t.text === 'abc')!;
expect(src.slice(abc.start, abc.end)).toBe('abc');
});
});

View File

@@ -0,0 +1,248 @@
// Tokenizer for the dashboard's Rhai parser.
//
// Produces a flat array of tokens (eager — Rhai scripts in the dashboard
// are small, 20200 lines typical) plus a separate list of comments. The
// parser only sees tokens; comments are handed to the formatter so it
// can re-emit them at the right positions.
//
// Keyword and operator lists trace back to the upstream TextMate grammar
// (rhaiscript/vscode-rhai). We don't copy any grammar bytes.
import type { Comment, Range } from './ast';
export type TokenKind =
| 'Ident'
| 'Keyword'
| 'Number'
| 'String'
| 'Punct'
| 'Operator'
| 'EOF';
export interface Token extends Range {
kind: TokenKind;
// For Ident/Keyword/Punct/Operator: the literal source text. For
// Number/String: the full literal including quotes.
text: string;
}
export const KEYWORDS = new Set([
'let',
'const',
'fn',
'if',
'else',
'while',
'loop',
'do',
'for',
'in',
'return',
'break',
'continue',
'switch',
'case',
'default',
'true',
'false',
'null',
'try',
'catch',
'throw',
'as',
'is',
'private'
]);
// Multi-char operators, longest first so the lexer picks them up greedily.
const MULTI_CHAR_OPS = [
'??=',
'..=',
'??',
'..',
'::',
'==',
'!=',
'<=',
'>=',
'&&',
'||',
'<<',
'>>',
'+=',
'-=',
'*=',
'/=',
'%=',
'=>',
'->'
];
const SINGLE_CHAR_OPS = new Set(['+', '-', '*', '/', '%', '<', '>', '!', '&', '|', '^', '~', '=', '?']);
// `#` is included so we can recognize the start of `#{` object-map literals;
// the lexer emits it as a separate `Punct` and the parser combines it with
// the following `{`.
const PUNCTS = new Set(['(', ')', '{', '}', '[', ']', ';', ',', '.', ':', '#']);
export interface LexResult {
tokens: Token[];
comments: Comment[];
}
export function tokenize(source: string): LexResult {
const tokens: Token[] = [];
const comments: Comment[] = [];
let i = 0;
const n = source.length;
while (i < n) {
const ch = source[i];
// Whitespace
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
i++;
continue;
}
// Line comment
if (ch === '/' && source[i + 1] === '/') {
const start = i;
while (i < n && source[i] !== '\n') i++;
comments.push({ kind: 'LineComment', start, end: i, text: source.slice(start, i) });
continue;
}
// Block comment (supports nesting per the Rhai book)
if (ch === '/' && source[i + 1] === '*') {
const start = i;
i += 2;
let depth = 1;
while (i < n && depth > 0) {
if (source[i] === '/' && source[i + 1] === '*') {
depth++;
i += 2;
} else if (source[i] === '*' && source[i + 1] === '/') {
depth--;
i += 2;
} else {
i++;
}
}
comments.push({ kind: 'BlockComment', start, end: i, text: source.slice(start, i) });
continue;
}
// Strings: " ... " (escape-aware, single-line by convention) and
// ` ... ` (raw, multi-line). We tokenize the entire literal including
// quotes; the parser only cares about its position and text.
if (ch === '"' || ch === '`') {
const quote = ch;
const start = i;
i++;
while (i < n) {
const c = source[i];
if (c === '\\' && quote === '"') {
i += 2;
continue;
}
if (c === quote) {
i++;
break;
}
i++;
}
tokens.push({ kind: 'String', start, end: i, text: source.slice(start, i) });
continue;
}
// Numbers: hex, binary, decimal, optional `.frac`, optional exponent.
// Underscores are allowed as digit separators per Rhai.
if (isDigit(ch)) {
const start = i;
if (ch === '0' && (source[i + 1] === 'x' || source[i + 1] === 'X')) {
i += 2;
while (i < n && (isHexDigit(source[i]) || source[i] === '_')) i++;
} else if (ch === '0' && (source[i + 1] === 'b' || source[i + 1] === 'B')) {
i += 2;
while (i < n && (source[i] === '0' || source[i] === '1' || source[i] === '_')) i++;
} else {
while (i < n && (isDigit(source[i]) || source[i] === '_')) i++;
if (source[i] === '.' && isDigit(source[i + 1])) {
i++;
while (i < n && (isDigit(source[i]) || source[i] === '_')) i++;
}
if (source[i] === 'e' || source[i] === 'E') {
i++;
if (source[i] === '+' || source[i] === '-') i++;
while (i < n && isDigit(source[i])) i++;
}
}
tokens.push({ kind: 'Number', start, end: i, text: source.slice(start, i) });
continue;
}
// Identifier or keyword
if (isIdentStart(ch)) {
const start = i;
i++;
while (i < n && isIdentCont(source[i])) i++;
const text = source.slice(start, i);
tokens.push({
kind: KEYWORDS.has(text) ? 'Keyword' : 'Ident',
start,
end: i,
text
});
continue;
}
// Multi-char operators
let matched = false;
for (const op of MULTI_CHAR_OPS) {
if (source.startsWith(op, i)) {
tokens.push({ kind: 'Operator', start: i, end: i + op.length, text: op });
i += op.length;
matched = true;
break;
}
}
if (matched) continue;
// Single-char operator
if (SINGLE_CHAR_OPS.has(ch)) {
tokens.push({ kind: 'Operator', start: i, end: i + 1, text: ch });
i++;
continue;
}
// Punctuation
if (PUNCTS.has(ch)) {
tokens.push({ kind: 'Punct', start: i, end: i + 1, text: ch });
i++;
continue;
}
// Unrecognized: skip and let the parser report the gap if needed.
i++;
}
tokens.push({ kind: 'EOF', start: n, end: n, text: '' });
return { tokens, comments };
}
function isDigit(c: string): boolean {
return c >= '0' && c <= '9';
}
function isHexDigit(c: string): boolean {
return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
function isIdentStart(c: string): boolean {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
}
function isIdentCont(c: string): boolean {
return isIdentStart(c) || isDigit(c);
}

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();
});
});

View File

@@ -0,0 +1,597 @@
// Parser for the dashboard's Rhai mode.
//
// Recursive descent for statements, Pratt precedence climbing for
// expressions. Error-tolerant: on unexpected input the parser records an
// error, resyncs to the next `;` or matching `}`, and keeps going. The
// AST it returns is best-effort — partial trees are fine; callers
// (autocomplete, goto-def) tolerate gaps.
import type {
BlockExpr,
Expr,
FnDecl,
IfExpr,
ObjectMapEntry,
Param,
ParseError,
ParseResult,
Stmt,
SwitchArm
} from './ast';
import { tokenize, type Token, type TokenKind } from './lexer';
export function parse(source: string): ParseResult {
const { tokens, comments } = tokenize(source);
const p = new Parser(source, tokens);
const program = p.parseProgram();
return { source, program, errors: p.errors, comments };
}
// Precedence levels for binary operators. Higher binds tighter. Assignment
// is special-cased outside the binary chain because it's right-associative
// and only legal at the top of an expression.
const BINARY_PRECEDENCE: Record<string, number> = {
'??': 1,
'||': 2,
'&&': 3,
'==': 4,
'!=': 4,
'<': 5,
'<=': 5,
'>': 5,
'>=': 5,
'|': 6,
'^': 7,
'&': 8,
'<<': 9,
'>>': 9,
'+': 10,
'-': 10,
'*': 11,
'/': 11,
'%': 11,
'..': 12,
'..=': 12
};
const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '??=']);
const UNARY_OPS = new Set(['!', '-', '+', '~']);
class Parser {
pos = 0;
errors: ParseError[] = [];
constructor(
private source: string,
private tokens: Token[]
) {}
// -------------------------------------------------------------------- nav
private peek(offset = 0): Token {
return this.tokens[Math.min(this.pos + offset, this.tokens.length - 1)];
}
private advance(): Token {
const t = this.tokens[this.pos];
if (this.pos < this.tokens.length - 1) this.pos++;
return t;
}
private match(kind: TokenKind, text?: string): boolean {
const t = this.peek();
if (t.kind !== kind) return false;
if (text !== undefined && t.text !== text) return false;
this.advance();
return true;
}
private check(kind: TokenKind, text?: string): boolean {
const t = this.peek();
if (t.kind !== kind) return false;
if (text !== undefined && t.text !== text) return false;
return true;
}
private expect(kind: TokenKind, text?: string): Token {
const t = this.peek();
if (t.kind === kind && (text === undefined || t.text === text)) {
return this.advance();
}
const desc = text !== undefined ? `'${text}'` : kind.toLowerCase();
this.error(t, `expected ${desc}, got '${t.text || 'end of input'}'`);
// Return the token without consuming so the caller's parent can
// still resync at its own boundary.
return t;
}
private error(at: Token, message: string): void {
this.errors.push({ start: at.start, end: at.end, message });
}
// Resync to the next statement boundary inside the current block. Used
// when a statement fails to parse — we drop tokens until we either land
// on `;` (consumed) or `}` / EOF (left for the caller).
private resyncStmt(): void {
let depth = 0;
while (true) {
const t = this.peek();
if (t.kind === 'EOF') return;
if (t.kind === 'Punct') {
if (t.text === '{' || t.text === '(' || t.text === '[') depth++;
else if (t.text === '}' || t.text === ')' || t.text === ']') {
if (depth === 0) return;
depth--;
} else if (depth === 0 && t.text === ';') {
this.advance();
return;
}
}
this.advance();
}
}
// ------------------------------------------------------------ top level
parseProgram(): BlockExpr {
const start = this.peek().start;
const stmts: Stmt[] = [];
while (this.peek().kind !== 'EOF') {
const before = this.pos;
const stmt = this.parseStmt();
if (stmt) stmts.push(stmt);
else if (this.pos === before) {
// No forward progress — drop a token to avoid an infinite loop.
this.advance();
}
}
const last = this.tokens[this.tokens.length - 1];
return { kind: 'BlockExpr', start, end: last.end, stmts };
}
// ----------------------------------------------------------- statements
private parseStmt(): Stmt | null {
const t = this.peek();
if (t.kind === 'Keyword') {
switch (t.text) {
case 'let':
return this.parseLetOrConst('Let');
case 'const':
return this.parseLetOrConst('Const');
case 'fn':
return this.parseFnDecl();
case 'return':
return this.parseReturn();
case 'while':
return this.parseWhile();
case 'loop':
return this.parseLoop();
case 'for':
return this.parseFor();
case 'break': {
this.advance();
const semi = this.match('Punct', ';');
return { kind: 'Break', start: t.start, end: semi ? t.end + 1 : t.end };
}
case 'continue': {
this.advance();
const semi = this.match('Punct', ';');
return { kind: 'Continue', start: t.start, end: semi ? t.end + 1 : t.end };
}
case 'try':
return this.parseTry();
}
}
// Stray semicolons are no-ops; consume and try again.
if (this.match('Punct', ';')) return null;
// Expression statement (also covers if/switch/block-as-stmt because
// those parse as expressions).
const expr = this.tryParseExpr();
if (!expr) {
const bad = this.peek();
this.error(bad, `unexpected '${bad.text || 'end of input'}'`);
this.resyncStmt();
return null;
}
const semi = this.match('Punct', ';');
return {
kind: 'ExprStmt',
start: expr.start,
end: semi ? this.tokens[this.pos - 1].end : expr.end,
expr,
semi
};
}
private parseLetOrConst(kind: 'Let' | 'Const'): Stmt {
const start = this.advance().start; // let|const
const nameTok = this.expect('Ident');
const name = nameTok.text;
const nameRange = { start: nameTok.start, end: nameTok.end };
let init: Expr | null = null;
if (this.match('Operator', '=')) {
init = this.tryParseExpr() ?? null;
}
const semi = this.match('Punct', ';');
const end = semi ? this.tokens[this.pos - 1].end : init ? init.end : nameTok.end;
return { kind, start, end, name, nameRange, init } as Stmt;
}
private parseFnDecl(): FnDecl {
const start = this.advance().start; // fn
const nameTok = this.expect('Ident');
this.expect('Punct', '(');
const params: Param[] = [];
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
const pTok = this.expect('Ident');
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
if (!this.match('Punct', ',')) break;
}
this.expect('Punct', ')');
const body = this.parseBlockExpr();
return {
kind: 'FnDecl',
start,
end: body.end,
name: nameTok.text,
nameRange: { start: nameTok.start, end: nameTok.end },
params,
body
};
}
private parseReturn(): Stmt {
const start = this.advance().start; // return
let value: Expr | null = null;
if (!this.check('Punct', ';') && !this.check('Punct', '}') && this.peek().kind !== 'EOF') {
value = this.tryParseExpr() ?? null;
}
const semi = this.match('Punct', ';');
const end = semi ? this.tokens[this.pos - 1].end : value ? value.end : start + 'return'.length;
return { kind: 'Return', start, end, value };
}
private parseWhile(): Stmt {
const start = this.advance().start; // while
const cond = this.tryParseExpr() ?? this.placeholderExpr();
const body = this.parseBlockExpr();
return { kind: 'While', start, end: body.end, cond, body };
}
private parseLoop(): Stmt {
const start = this.advance().start; // loop
const body = this.parseBlockExpr();
return { kind: 'Loop', start, end: body.end, body };
}
private parseFor(): Stmt {
const start = this.advance().start; // for
const nameTok = this.expect('Ident');
this.expect('Keyword', 'in');
const iter = this.tryParseExpr() ?? this.placeholderExpr();
const body = this.parseBlockExpr();
return {
kind: 'For',
start,
end: body.end,
varName: nameTok.text,
varRange: { start: nameTok.start, end: nameTok.end },
iter,
body
};
}
private parseTry(): Stmt {
const start = this.advance().start; // try
const body = this.parseBlockExpr();
this.expect('Keyword', 'catch');
let catchVar: string | null = null;
let catchVarRange: { start: number; end: number } | null = null;
if (this.match('Punct', '(')) {
if (this.check('Ident')) {
const id = this.advance();
catchVar = id.text;
catchVarRange = { start: id.start, end: id.end };
}
this.expect('Punct', ')');
}
const handler = this.parseBlockExpr();
return { kind: 'Try', start, end: handler.end, body, catchVar, catchVarRange, handler };
}
private parseBlockExpr(): BlockExpr {
const openTok = this.peek();
if (!this.match('Punct', '{')) {
this.error(openTok, "expected '{'");
return { kind: 'BlockExpr', start: openTok.start, end: openTok.start, stmts: [] };
}
const start = openTok.start;
const stmts: Stmt[] = [];
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
const before = this.pos;
const s = this.parseStmt();
if (s) stmts.push(s);
else if (this.pos === before) this.advance();
}
const closeTok = this.peek();
this.match('Punct', '}');
return { kind: 'BlockExpr', start, end: closeTok.end, stmts };
}
// ---------------------------------------------------------- expressions
private tryParseExpr(): Expr | null {
const t = this.peek();
if (t.kind === 'EOF' || (t.kind === 'Punct' && (t.text === ';' || t.text === '}' || t.text === ')' || t.text === ']' || t.text === ','))) {
return null;
}
return this.parseAssign();
}
private parseAssign(): Expr {
const left = this.parseBinary(0);
const t = this.peek();
if (t.kind === 'Operator' && ASSIGN_OPS.has(t.text)) {
this.advance();
const right = this.parseAssign();
return { kind: 'Assign', start: left.start, end: right.end, op: t.text, target: left, value: right };
}
return left;
}
private parseBinary(minPrec: number): Expr {
let left = this.parseUnary();
while (true) {
const t = this.peek();
if (t.kind !== 'Operator') break;
const prec = BINARY_PRECEDENCE[t.text];
if (prec === undefined || prec < minPrec) break;
this.advance();
const right = this.parseBinary(prec + 1);
left = { kind: 'Binary', start: left.start, end: right.end, op: t.text, left, right };
}
return left;
}
private parseUnary(): Expr {
const t = this.peek();
if (t.kind === 'Operator' && UNARY_OPS.has(t.text)) {
this.advance();
const operand = this.parseUnary();
return { kind: 'Unary', start: t.start, end: operand.end, op: t.text, operand };
}
return this.parsePostfix(this.parsePrimary());
}
private parsePostfix(initial: Expr): Expr {
let expr = initial;
while (true) {
const t = this.peek();
if (t.kind === 'Punct' && t.text === '.') {
this.advance();
const prop = this.expect('Ident');
expr = {
kind: 'Member',
start: expr.start,
end: prop.end,
object: expr,
property: prop.text,
propertyRange: { start: prop.start, end: prop.end }
};
} else if (t.kind === 'Punct' && t.text === '(') {
this.advance();
const args: Expr[] = [];
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
const a = this.tryParseExpr();
if (!a) break;
args.push(a);
if (!this.match('Punct', ',')) break;
}
const close = this.peek();
this.expect('Punct', ')');
expr = { kind: 'Call', start: expr.start, end: close.end, callee: expr, args };
} else if (t.kind === 'Punct' && t.text === '[') {
this.advance();
const idx = this.tryParseExpr() ?? this.placeholderExpr();
const close = this.peek();
this.expect('Punct', ']');
expr = { kind: 'Index', start: expr.start, end: close.end, object: expr, index: idx };
} else if (t.kind === 'Operator' && t.text === '::') {
// Namespace path: treat `log::info` as a Member chain on an
// Ident so completion and lookup can walk the same shape.
this.advance();
const next = this.expect('Ident');
expr = {
kind: 'Member',
start: expr.start,
end: next.end,
object: expr,
property: next.text,
propertyRange: { start: next.start, end: next.end }
};
} else {
break;
}
}
return expr;
}
private parsePrimary(): Expr {
const t = this.peek();
// Literals
if (t.kind === 'Number') {
this.advance();
return { kind: 'Number', start: t.start, end: t.end, raw: t.text };
}
if (t.kind === 'String') {
this.advance();
const quote = t.text.charAt(0) === '`' ? '`' : '"';
return { kind: 'String', start: t.start, end: t.end, quote, raw: t.text };
}
if (t.kind === 'Keyword') {
if (t.text === 'true' || t.text === 'false') {
this.advance();
return { kind: 'Bool', start: t.start, end: t.end, value: t.text === 'true' };
}
if (t.text === 'null') {
this.advance();
return { kind: 'Null', start: t.start, end: t.end };
}
if (t.text === 'if') return this.parseIfExpr();
if (t.text === 'switch') return this.parseSwitchExpr();
if (t.text === 'fn') return this.parseFnExpr();
}
// Identifier
if (t.kind === 'Ident') {
this.advance();
return { kind: 'Ident', start: t.start, end: t.end, name: t.text };
}
// Paren expression
if (t.kind === 'Punct' && t.text === '(') {
this.advance();
const inner = this.tryParseExpr() ?? this.placeholderExpr();
const close = this.peek();
this.expect('Punct', ')');
return { kind: 'Paren', start: t.start, end: close.end, expr: inner };
}
// Array literal
if (t.kind === 'Punct' && t.text === '[') {
return this.parseArray();
}
// Object-map literal: `#{`
if (t.kind === 'Punct' && t.text === '#' && this.peek(1).kind === 'Punct' && this.peek(1).text === '{') {
return this.parseObjectMap();
}
// Block expression `{ ... }`
if (t.kind === 'Punct' && t.text === '{') {
return this.parseBlockExpr();
}
this.error(t, `unexpected '${t.text || 'end of input'}'`);
// Consume one token so we make forward progress, then return a
// placeholder so the surrounding parser keeps its shape.
this.advance();
return this.placeholderExpr(t);
}
private parseIfExpr(): IfExpr {
const start = this.advance().start; // if
const cond = this.tryParseExpr() ?? this.placeholderExpr();
const thenB = this.parseBlockExpr();
let else_: BlockExpr | IfExpr | null = null;
if (this.match('Keyword', 'else')) {
if (this.check('Keyword', 'if')) {
else_ = this.parseIfExpr();
} else {
else_ = this.parseBlockExpr();
}
}
const end = else_ ? else_.end : thenB.end;
return { kind: 'IfExpr', start, end, cond, then: thenB, else_ };
}
private parseSwitchExpr(): Expr {
const start = this.advance().start; // switch
const subject = this.tryParseExpr() ?? this.placeholderExpr();
this.expect('Punct', '{');
const arms: SwitchArm[] = [];
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
const armStart = this.peek().start;
let pattern: Expr | null;
if (this.check('Operator', '_') || (this.peek().kind === 'Ident' && this.peek().text === '_')) {
this.advance();
pattern = null;
} else {
pattern = this.tryParseExpr() ?? this.placeholderExpr();
}
let guard: Expr | null = null;
if (this.match('Keyword', 'if')) {
guard = this.tryParseExpr() ?? this.placeholderExpr();
}
this.expect('Operator', '=>');
const value = this.tryParseExpr() ?? this.placeholderExpr();
arms.push({ start: armStart, end: value.end, pattern, guard, value });
if (!this.match('Punct', ',')) break;
}
const close = this.peek();
this.expect('Punct', '}');
return { kind: 'SwitchExpr', start, end: close.end, subject, arms };
}
private parseFnExpr(): Expr {
// `fn (params) { ... }` — anonymous function expression. Rare in
// Rhai but legal; some scripts use it for callbacks.
const start = this.advance().start; // fn
this.expect('Punct', '(');
const params: Param[] = [];
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
const pTok = this.expect('Ident');
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
if (!this.match('Punct', ',')) break;
}
this.expect('Punct', ')');
const body = this.parseBlockExpr();
return { kind: 'FnExpr', start, end: body.end, params, body };
}
private parseArray(): Expr {
const start = this.advance().start; // [
const elements: Expr[] = [];
while (!this.check('Punct', ']') && this.peek().kind !== 'EOF') {
const e = this.tryParseExpr();
if (!e) break;
elements.push(e);
if (!this.match('Punct', ',')) break;
}
const close = this.peek();
this.expect('Punct', ']');
return { kind: 'Array', start, end: close.end, elements };
}
private parseObjectMap(): Expr {
const start = this.advance().start; // #
this.advance(); // {
const entries: ObjectMapEntry[] = [];
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
const k = this.peek();
let key: string;
let keyRange: { start: number; end: number };
if (k.kind === 'Ident' || k.kind === 'Keyword') {
this.advance();
key = k.text;
keyRange = { start: k.start, end: k.end };
} else if (k.kind === 'String') {
this.advance();
// Strip surrounding quotes for the key name (best-effort —
// we don't decode escape sequences; this is only used for
// completion labels).
key = k.text.length >= 2 ? k.text.slice(1, -1) : k.text;
keyRange = { start: k.start, end: k.end };
} else {
this.error(k, 'expected map key');
break;
}
this.expect('Punct', ':');
const value = this.tryParseExpr() ?? this.placeholderExpr();
entries.push({ start: keyRange.start, end: value.end, key, keyRange, value });
if (!this.match('Punct', ',')) break;
}
const close = this.peek();
this.expect('Punct', '}');
return { kind: 'ObjectMap', start, end: close.end, entries };
}
private placeholderExpr(at?: Token): Expr {
const t = at ?? this.peek();
return { kind: 'Ident', start: t.start, end: t.start, name: '' };
}
}

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);
});
});

View File

@@ -0,0 +1,447 @@
// Symbol table built from the parsed AST.
//
// One walk produces everything the editor features need:
// * declarations (let, const, fn, params, for-loop binders, catch binder)
// * usages (every Ident reference, resolved by walking the scope chain)
// * object-literal field maps (so `obj.` can suggest known keys)
//
// Resolution rules (matching Rhai):
// * `fn` declarations live in the script-root scope regardless of where
// they appear textually. They form a flat namespace; nested functions
// are not allowed in standard Rhai.
// * A function body is a fresh scope that does NOT inherit the enclosing
// locals — Rhai's `fn` is a pure function, not a closure. It can
// still see top-level `fn`s (call them) but not top-level `let`s.
// * Blocks (if/while/loop/for/try) nest within their containing scope.
// * `let`/`const` are visible only after their declaration site within
// their scope.
//
// Known limit: object-literal field tracking is best-effort. We only
// record fields at the literal-initialization site — `let o = #{ a: 1 };`.
// Reassignments and member writes (`o.b = 2;`) don't update the field set.
import type {
BlockExpr,
Comment,
Expr,
ForStmt,
FnDecl,
IfExpr,
ObjectMapExpr,
ParseResult,
Range,
Stmt,
SwitchExpr,
TryStmt
} from './ast';
export type DeclKind = 'let' | 'const' | 'fn' | 'param' | 'for' | 'catch';
export interface Decl {
kind: DeclKind;
name: string;
nameRange: Range;
// For `fn`: rendered signature like `process(order, user)`.
signature?: string;
// For `let`/`const` initialized to an object-map literal — the field
// names at the literal site. Empty otherwise.
objectFields?: string[];
// Lexical visibility: the offset at which references can resolve to
// this declaration. For `let`/`const` it's just past the declaration;
// for `fn`, parameters, `for`, and `catch` binders it's the start of
// the scope they belong to.
visibleFrom: number;
scope: Scope;
}
export interface Usage {
name: string;
range: Range;
scope: Scope;
resolved: Decl | null;
}
export interface Scope {
id: number;
kind: 'root' | 'fn' | 'block';
// A scope's range covers the source span where its locals are
// reachable. For the root scope this is the whole document.
range: Range;
parent: Scope | null;
children: Scope[];
decls: Decl[];
}
export interface SymbolTable {
root: Scope;
allDecls: Decl[];
usages: Usage[];
// Public API used by the editor features.
declAt(pos: number): Decl | null;
declOfUsageAt(pos: number): Decl | null;
usagesOf(decl: Decl): Range[];
objectFieldsOf(name: string, atPos: number): string[];
scopeCompletions(atPos: number): Decl[];
}
export function buildSymbolTable(result: ParseResult): SymbolTable {
const builder = new Builder(result);
builder.walkProgram();
builder.resolveUsages();
return builder.finish();
}
// Cosmetic helper: format a function's signature for the completion
// `detail` field. Kept here so the symbol table is the single source of
// truth for "how a `fn` shows up in the UI".
export function renderFnSignature(fn: FnDecl): string {
return `${fn.name}(${fn.params.map((p) => p.name).join(', ')})`;
}
class Builder {
allDecls: Decl[] = [];
usages: Usage[] = [];
root: Scope;
private currentScope: Scope;
private nextScopeId = 0;
constructor(private result: ParseResult) {
const span = { start: 0, end: result.source.length };
this.root = this.makeScope('root', span, null);
this.currentScope = this.root;
}
private makeScope(kind: Scope['kind'], range: Range, parent: Scope | null): Scope {
const s: Scope = { id: this.nextScopeId++, kind, range, parent, children: [], decls: [] };
if (parent) parent.children.push(s);
return s;
}
private declare(d: Decl): void {
this.currentScope.decls.push(d);
this.allDecls.push(d);
}
// ----------------------------------------------------------------- walk
walkProgram(): void {
// First pass: hoist `fn` decls into the root scope so calls anywhere
// in the file can resolve to them regardless of source order.
for (const stmt of this.result.program.stmts) {
if (stmt.kind === 'FnDecl') {
this.declare({
kind: 'fn',
name: stmt.name,
nameRange: stmt.nameRange,
signature: renderFnSignature(stmt),
visibleFrom: 0,
scope: this.root
});
}
}
// Second pass: walk statements normally. Skip re-declaring the
// already-hoisted fn names; just descend into their bodies.
for (const stmt of this.result.program.stmts) this.walkStmt(stmt);
}
private walkStmt(stmt: Stmt): void {
switch (stmt.kind) {
case 'Let':
case 'Const': {
if (stmt.init) this.walkExpr(stmt.init);
const objectFields =
stmt.init && stmt.init.kind === 'ObjectMap'
? (stmt.init as ObjectMapExpr).entries.map((e) => e.key)
: undefined;
this.declare({
kind: stmt.kind === 'Let' ? 'let' : 'const',
name: stmt.name,
nameRange: stmt.nameRange,
objectFields,
visibleFrom: stmt.nameRange.end,
scope: this.currentScope
});
return;
}
case 'FnDecl': {
const prev = this.currentScope;
const fnScope = this.makeScope('fn', stmt.body, prev);
this.currentScope = fnScope;
for (const p of stmt.params) {
this.declare({
kind: 'param',
name: p.name,
nameRange: { start: p.start, end: p.end },
visibleFrom: stmt.body.start,
scope: fnScope
});
}
for (const s of stmt.body.stmts) this.walkStmt(s);
this.currentScope = prev;
return;
}
case 'ExprStmt':
this.walkExpr(stmt.expr);
return;
case 'Return':
if (stmt.value) this.walkExpr(stmt.value);
return;
case 'While':
this.walkExpr(stmt.cond);
this.walkBlock(stmt.body);
return;
case 'Loop':
this.walkBlock(stmt.body);
return;
case 'For':
this.walkFor(stmt);
return;
case 'Try':
this.walkTry(stmt);
return;
case 'Break':
case 'Continue':
return;
}
}
private walkFor(stmt: ForStmt): void {
this.walkExpr(stmt.iter);
const prev = this.currentScope;
const blockScope = this.makeScope('block', stmt.body, prev);
this.currentScope = blockScope;
this.declare({
kind: 'for',
name: stmt.varName,
nameRange: stmt.varRange,
visibleFrom: stmt.body.start,
scope: blockScope
});
for (const s of stmt.body.stmts) this.walkStmt(s);
this.currentScope = prev;
}
private walkTry(stmt: TryStmt): void {
this.walkBlock(stmt.body);
const prev = this.currentScope;
const handlerScope = this.makeScope('block', stmt.handler, prev);
this.currentScope = handlerScope;
if (stmt.catchVar && stmt.catchVarRange) {
this.declare({
kind: 'catch',
name: stmt.catchVar,
nameRange: stmt.catchVarRange,
visibleFrom: stmt.handler.start,
scope: handlerScope
});
}
for (const s of stmt.handler.stmts) this.walkStmt(s);
this.currentScope = prev;
}
private walkBlock(block: BlockExpr): void {
const prev = this.currentScope;
const blockScope = this.makeScope('block', block, prev);
this.currentScope = blockScope;
for (const s of block.stmts) this.walkStmt(s);
this.currentScope = prev;
}
private walkExpr(expr: Expr): void {
switch (expr.kind) {
case 'Ident':
if (expr.name) {
this.usages.push({
name: expr.name,
range: { start: expr.start, end: expr.end },
scope: this.currentScope,
resolved: null
});
}
return;
case 'Number':
case 'String':
case 'Bool':
case 'Null':
return;
case 'Call':
this.walkExpr(expr.callee);
for (const a of expr.args) this.walkExpr(a);
return;
case 'Member':
this.walkExpr(expr.object);
// We don't record the property as a usage — it's resolved
// against the object's shape, not the lexical scope.
return;
case 'Index':
this.walkExpr(expr.object);
this.walkExpr(expr.index);
return;
case 'Unary':
this.walkExpr(expr.operand);
return;
case 'Binary':
this.walkExpr(expr.left);
this.walkExpr(expr.right);
return;
case 'Assign':
this.walkExpr(expr.target);
this.walkExpr(expr.value);
return;
case 'Paren':
this.walkExpr(expr.expr);
return;
case 'ObjectMap':
for (const e of expr.entries) this.walkExpr(e.value);
return;
case 'Array':
for (const e of expr.elements) this.walkExpr(e);
return;
case 'FnExpr': {
const prev = this.currentScope;
const fnScope = this.makeScope('fn', expr.body, prev);
this.currentScope = fnScope;
for (const p of expr.params) {
this.declare({
kind: 'param',
name: p.name,
nameRange: { start: p.start, end: p.end },
visibleFrom: expr.body.start,
scope: fnScope
});
}
for (const s of expr.body.stmts) this.walkStmt(s);
this.currentScope = prev;
return;
}
case 'IfExpr':
this.walkIf(expr);
return;
case 'SwitchExpr':
this.walkSwitch(expr);
return;
case 'BlockExpr':
this.walkBlock(expr);
return;
}
}
private walkIf(expr: IfExpr): void {
this.walkExpr(expr.cond);
this.walkBlock(expr.then);
if (expr.else_) {
if (expr.else_.kind === 'IfExpr') this.walkIf(expr.else_);
else this.walkBlock(expr.else_);
}
}
private walkSwitch(expr: SwitchExpr): void {
this.walkExpr(expr.subject);
for (const arm of expr.arms) {
if (arm.pattern) this.walkExpr(arm.pattern);
if (arm.guard) this.walkExpr(arm.guard);
this.walkExpr(arm.value);
}
}
// ----------------------------------------------------------- resolution
resolveUsages(): void {
for (const u of this.usages) {
u.resolved = this.resolveName(u.name, u.range.start, u.scope);
}
}
private resolveName(name: string, atPos: number, fromScope: Scope): Decl | null {
let scope: Scope | null = fromScope;
let crossedFn = false;
while (scope) {
for (const d of scope.decls) {
if (d.name !== name) continue;
// Function scopes don't see outer locals — only the root's
// `fn` decls. So once we've crossed a fn boundary, accept
// only `fn` declarations.
if (crossedFn && d.kind !== 'fn') continue;
if (d.visibleFrom <= atPos) return d;
}
if (scope.kind === 'fn') crossedFn = true;
scope = scope.parent;
}
return null;
}
// --------------------------------------------------------------- finish
finish(): SymbolTable {
const { allDecls, usages, root } = this;
const declAt = (pos: number): Decl | null => {
let best: Decl | null = null;
for (const d of allDecls) {
if (pos >= d.nameRange.start && pos <= d.nameRange.end) {
best = d;
}
}
return best;
};
const findScopeAt = (pos: number, scope: Scope = root): Scope => {
for (const c of scope.children) {
if (pos >= c.range.start && pos <= c.range.end) {
return findScopeAt(pos, c);
}
}
return scope;
};
const declOfUsageAt = (pos: number): Decl | null => {
const direct = declAt(pos);
if (direct) return direct;
for (const u of usages) {
if (pos >= u.range.start && pos <= u.range.end) return u.resolved;
}
return null;
};
const usagesOf = (decl: Decl): Range[] => {
const out: Range[] = [{ start: decl.nameRange.start, end: decl.nameRange.end }];
for (const u of usages) {
if (u.resolved === decl) out.push({ start: u.range.start, end: u.range.end });
}
out.sort((a, b) => a.start - b.start);
return out;
};
const objectFieldsOf = (name: string, atPos: number): string[] => {
const fromScope = findScopeAt(atPos);
const d = (this as Builder).resolveName(name, atPos, fromScope);
return d?.objectFields ?? [];
};
const scopeCompletions = (atPos: number): Decl[] => {
const seen = new Set<string>();
const out: Decl[] = [];
let scope: Scope | null = findScopeAt(atPos);
let crossedFn = false;
while (scope) {
for (const d of scope.decls) {
if (seen.has(d.name)) continue;
if (crossedFn && d.kind !== 'fn') continue;
if (d.visibleFrom > atPos) continue;
seen.add(d.name);
out.push(d);
}
if (scope.kind === 'fn') crossedFn = true;
scope = scope.parent;
}
return out;
};
return { root, allDecls, usages, declAt, declOfUsageAt, usagesOf, objectFieldsOf, scopeCompletions };
}
}
// Used by symbols.test.ts; harmless to export.
export function commentsAt(comments: Comment[], pos: number): Comment | undefined {
return comments.find((c) => pos >= c.start && pos < c.end);
}