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

@@ -15,9 +15,14 @@
// the indent of the statement it precedes — same-line inline
// positioning is intentionally NOT recovered; the goal is "verbatim
// text", not "byte-exact placement".
// * Blank lines between statements are preserved when the user wrote
// them; multiples collapse to one. The formatter never *adds* blank
// lines the user didn't write (rustfmt's default policy applied
// strictly — no forced separation between top-level fn decls).
// * Block bodies always use multi-line braces. `{}` for empty.
// * If parse errors are reported by the parser, the formatter refuses
// to emit anything and returns the first error.
// to emit anything and returns the first error with line / column
// coordinates (1-based, matching Rhai's own diagnostic format).
import type {
BlockExpr,
@@ -36,21 +41,47 @@ const PRINT_WIDTH = 100;
export type FormatResult =
| { ok: true; text: string }
| { ok: false; error: { message: string; offset: number } };
| { ok: false; error: FormatError };
export interface FormatError {
message: string;
// 1-based line and column, matching Rhai's own diagnostic format.
line: number;
column: number;
// Byte offset retained for callers that want to jump the editor
// cursor (CodeMirror works in offsets, not line/col).
offset: number;
}
export function format(source: string): FormatResult {
const result = parse(source);
if (result.errors.length > 0) {
const first = result.errors[0];
return { ok: false, error: errorPayload(first) };
return { ok: false, error: errorPayload(source, result.errors[0]) };
}
const p = new Printer(result);
p.printProgram();
return { ok: true, text: p.finish() };
}
function errorPayload(e: ParseError): { message: string; offset: number } {
return { message: e.message, offset: e.start };
function errorPayload(source: string, e: ParseError): FormatError {
const { line, column } = lineColAt(source, e.start);
return { message: e.message, line, column, offset: e.start };
}
// Convert a byte offset into 1-based (line, column). Used for rendering
// parser errors in a way that matches Rhai's own diagnostic format
// (e.g. "Expecting name of a variable (line 2, position 4)").
function lineColAt(source: string, offset: number): { line: number; column: number } {
let line = 1;
let lineStart = 0;
const limit = Math.min(offset, source.length);
for (let i = 0; i < limit; i++) {
if (source.charCodeAt(i) === 10) {
line++;
lineStart = i + 1;
}
}
return { line, column: limit - lineStart + 1 };
}
class Printer {
@@ -125,19 +156,27 @@ class Printer {
printProgram(): void {
const stmts = this.result.program.stmts;
let prevWasFn = false;
for (let i = 0; i < stmts.length; i++) {
const stmt = stmts[i];
if (i > 0) {
if (prevWasFn || stmt.kind === 'FnDecl') this.blankLine();
if (this.hadBlankBetween(stmts[i - 1].end, stmt.start)) this.blankLine();
else this.newline();
}
this.drainCommentsBefore(stmt.start);
this.printStmt(stmt);
prevWasFn = stmt.kind === 'FnDecl';
}
}
// "Did the user leave a blank line in this gap?" Consulted between
// every pair of emitted statements to decide whether to keep the
// vertical separator the source originally had.
private hadBlankBetween(prevEnd: number, currStart: number): boolean {
for (const offset of this.result.blankLines) {
if (offset >= prevEnd && offset < currStart) return true;
}
return false;
}
// ---------------------------------------------------------- statements
private printStmt(stmt: Stmt): void {
@@ -231,7 +270,11 @@ class Printer {
this.emit('{');
this.indent++;
for (let i = 0; i < block.stmts.length; i++) {
this.newline();
if (i > 0 && this.hadBlankBetween(block.stmts[i - 1].end, block.stmts[i].start)) {
this.blankLine();
} else {
this.newline();
}
this.drainCommentsBefore(block.stmts[i].start);
this.printStmt(block.stmts[i]);
}