// 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 = { '??': 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: '' }; } }