Files
PiCloud/dashboard/src/lib/rhai/parser.ts
MechaCat02 3d4c7b160b fix(dashboard): preserve blank lines and improve Rhai parser errors
Two follow-ups on the Rhai formatter shipped in 0.5.1.

* Formatter no longer collapses user-intent blank lines between
  statements. The lexer now records a side-channel list of offsets
  where the source contained two-or-more consecutive newlines; the
  formatter consults it and emits a single blank in the same spot
  (rustfmt's `blank_lines_upper_bound = 1` policy applied strictly —
  the prior forced blank between top-level `fn` decls is dropped, so
  the formatter never *adds* a blank the user didn't write).
* Parse errors now read like Rhai's own diagnostics. `expect()` takes
  an optional `role` hint and each call site supplies a domain phrase
  (`name of a variable`, `function name in function declaration`,
  `'{' to begin a block`, `name of a property`, …). End-of-input is
  reported as `script is incomplete`. The dashboard banner renders
  `Parse error: {message} (line L, position C)` with 1-based
  coordinates, matching Rhai's format exactly.

The FormatError payload also keeps the byte `offset` so callers that
want to drive the editor cursor (CodeMirror works in offsets) still
have it.

Also folds the workspace Cargo.lock version bumps for 0.5.1 — the
lock-file rewrite that should have travelled with the prior commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:26:42 +02:00

606 lines
18 KiB
TypeScript

// 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, blankLines } = tokenize(source);
const p = new Parser(source, tokens);
const program = p.parseProgram();
return { source, program, errors: p.errors, comments, blankLines };
}
// 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;
}
// `role` is a human-readable description of what was expected, used
// in place of the bare token kind so the message reads like Rhai's
// own diagnostics (`Expecting name of a variable` rather than
// `expected ident`). Falls back to the literal/kind when omitted.
private expect(kind: TokenKind, text?: string, role?: string): Token {
const t = this.peek();
if (t.kind === kind && (text === undefined || t.text === text)) {
return this.advance();
}
if (t.kind === 'EOF') {
this.error(t, role ? `Expecting ${role} — script is incomplete` : 'Script is incomplete');
} else {
const desc = role ?? (text !== undefined ? `'${text}'` : kind.toLowerCase());
this.error(t, `Expecting ${desc}`);
}
// 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, bad.kind === 'EOF' ? 'Script is incomplete' : `Unexpected token '${bad.text}'`);
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', undefined, 'name of a variable');
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', undefined, 'function name in function declaration');
this.expect('Punct', '(');
const params: Param[] = [];
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
const pTok = this.expect('Ident', undefined, 'parameter name');
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', undefined, 'loop variable name');
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, "Expecting '{' to begin a block");
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', undefined, 'name of a property');
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', undefined, "name after '::'");
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, t.kind === 'EOF' ? 'Script is incomplete' : `Unexpected token '${t.text}'`);
// 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', undefined, 'parameter name');
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, 'Expecting name of a 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: '' };
}
}