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>
606 lines
18 KiB
TypeScript
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: '' };
|
|
}
|
|
}
|