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>
This commit is contained in:
@@ -21,10 +21,10 @@ import type {
|
||||
import { tokenize, type Token, type TokenKind } from './lexer';
|
||||
|
||||
export function parse(source: string): ParseResult {
|
||||
const { tokens, comments } = tokenize(source);
|
||||
const { tokens, comments, blankLines } = tokenize(source);
|
||||
const p = new Parser(source, tokens);
|
||||
const program = p.parseProgram();
|
||||
return { source, program, errors: p.errors, comments };
|
||||
return { source, program, errors: p.errors, comments, blankLines };
|
||||
}
|
||||
|
||||
// Precedence levels for binary operators. Higher binds tighter. Assignment
|
||||
@@ -92,13 +92,21 @@ class Parser {
|
||||
return true;
|
||||
}
|
||||
|
||||
private expect(kind: TokenKind, text?: string): Token {
|
||||
// `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();
|
||||
}
|
||||
const desc = text !== undefined ? `'${text}'` : kind.toLowerCase();
|
||||
this.error(t, `expected ${desc}, got '${t.text || 'end of input'}'`);
|
||||
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;
|
||||
@@ -192,7 +200,7 @@ class Parser {
|
||||
const expr = this.tryParseExpr();
|
||||
if (!expr) {
|
||||
const bad = this.peek();
|
||||
this.error(bad, `unexpected '${bad.text || 'end of input'}'`);
|
||||
this.error(bad, bad.kind === 'EOF' ? 'Script is incomplete' : `Unexpected token '${bad.text}'`);
|
||||
this.resyncStmt();
|
||||
return null;
|
||||
}
|
||||
@@ -208,7 +216,7 @@ class Parser {
|
||||
|
||||
private parseLetOrConst(kind: 'Let' | 'Const'): Stmt {
|
||||
const start = this.advance().start; // let|const
|
||||
const nameTok = this.expect('Ident');
|
||||
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;
|
||||
@@ -222,11 +230,11 @@ class Parser {
|
||||
|
||||
private parseFnDecl(): FnDecl {
|
||||
const start = this.advance().start; // fn
|
||||
const nameTok = this.expect('Ident');
|
||||
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');
|
||||
const pTok = this.expect('Ident', undefined, 'parameter name');
|
||||
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
|
||||
if (!this.match('Punct', ',')) break;
|
||||
}
|
||||
@@ -269,7 +277,7 @@ class Parser {
|
||||
|
||||
private parseFor(): Stmt {
|
||||
const start = this.advance().start; // for
|
||||
const nameTok = this.expect('Ident');
|
||||
const nameTok = this.expect('Ident', undefined, 'loop variable name');
|
||||
this.expect('Keyword', 'in');
|
||||
const iter = this.tryParseExpr() ?? this.placeholderExpr();
|
||||
const body = this.parseBlockExpr();
|
||||
@@ -305,7 +313,7 @@ class Parser {
|
||||
private parseBlockExpr(): BlockExpr {
|
||||
const openTok = this.peek();
|
||||
if (!this.match('Punct', '{')) {
|
||||
this.error(openTok, "expected '{'");
|
||||
this.error(openTok, "Expecting '{' to begin a block");
|
||||
return { kind: 'BlockExpr', start: openTok.start, end: openTok.start, stmts: [] };
|
||||
}
|
||||
const start = openTok.start;
|
||||
@@ -372,7 +380,7 @@ class Parser {
|
||||
const t = this.peek();
|
||||
if (t.kind === 'Punct' && t.text === '.') {
|
||||
this.advance();
|
||||
const prop = this.expect('Ident');
|
||||
const prop = this.expect('Ident', undefined, 'name of a property');
|
||||
expr = {
|
||||
kind: 'Member',
|
||||
start: expr.start,
|
||||
@@ -403,7 +411,7 @@ class Parser {
|
||||
// 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');
|
||||
const next = this.expect('Ident', undefined, "name after '::'");
|
||||
expr = {
|
||||
kind: 'Member',
|
||||
start: expr.start,
|
||||
@@ -476,7 +484,7 @@ class Parser {
|
||||
return this.parseBlockExpr();
|
||||
}
|
||||
|
||||
this.error(t, `unexpected '${t.text || 'end of input'}'`);
|
||||
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();
|
||||
@@ -534,7 +542,7 @@ class Parser {
|
||||
this.expect('Punct', '(');
|
||||
const params: Param[] = [];
|
||||
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
||||
const pTok = this.expect('Ident');
|
||||
const pTok = this.expect('Ident', undefined, 'parameter name');
|
||||
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
|
||||
if (!this.match('Punct', ',')) break;
|
||||
}
|
||||
@@ -577,7 +585,7 @@ class Parser {
|
||||
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');
|
||||
this.error(k, 'Expecting name of a map key');
|
||||
break;
|
||||
}
|
||||
this.expect('Punct', ':');
|
||||
|
||||
Reference in New Issue
Block a user