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