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:
MechaCat02
2026-05-24 21:26:42 +02:00
parent 267c40f59c
commit 3d4c7b160b
8 changed files with 150 additions and 46 deletions

View File

@@ -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', ':');