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:
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -1273,7 +1273,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud"
|
name = "picloud"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1297,7 +1297,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor"
|
name = "picloud-executor"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
@@ -1309,7 +1309,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor-core"
|
name = "picloud-executor-core"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
@@ -1323,7 +1323,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager"
|
name = "picloud-manager"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
@@ -1335,7 +1335,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager-core"
|
name = "picloud-manager-core"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1353,7 +1353,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator"
|
name = "picloud-orchestrator"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
@@ -1365,7 +1365,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator-core"
|
name = "picloud-orchestrator-core"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1384,7 +1384,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-shared"
|
name = "picloud-shared"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -272,4 +272,8 @@ export interface ParseResult {
|
|||||||
program: BlockExpr;
|
program: BlockExpr;
|
||||||
errors: ParseError[];
|
errors: ParseError[];
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
|
// Offsets at which the source contained a blank line (a whitespace
|
||||||
|
// run with two or more newlines). One entry per blank run; the
|
||||||
|
// formatter consults these to preserve user-intent vertical grouping.
|
||||||
|
blankLines: number[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ describe('format — basic shape', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('separates top-level fn decls with a blank line', () => {
|
it('does not insert a blank between fn decls the user did not separate', () => {
|
||||||
|
// Strict preserve-only policy: no source blank => no emitted blank.
|
||||||
const out = formatted('fn a(){1}fn b(){2}');
|
const out = formatted('fn a(){1}fn b(){2}');
|
||||||
expect(out).toBe(
|
expect(out).toBe('fn a() {\n\t1\n}\nfn b() {\n\t2\n}\n');
|
||||||
'fn a() {\n\t1\n}\n\nfn b() {\n\t2\n}\n'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders if / else if / else with blocks', () => {
|
it('renders if / else if / else with blocks', () => {
|
||||||
@@ -87,16 +86,48 @@ describe('format — reflow', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('format — blank-line preservation', () => {
|
||||||
|
it('preserves a single blank line between statements', () => {
|
||||||
|
const src = 'let a = 1;\n\nlet b = 2;';
|
||||||
|
expect(formatted(src)).toBe('let a = 1;\n\nlet b = 2;\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses multiple blank lines to a single one', () => {
|
||||||
|
const src = 'let a = 1;\n\n\n\nlet b = 2;';
|
||||||
|
expect(formatted(src)).toBe('let a = 1;\n\nlet b = 2;\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves blanks inside block bodies', () => {
|
||||||
|
const src = 'fn process() {\n\tlet a = 1;\n\n\tlet b = 2;\n}';
|
||||||
|
expect(formatted(src)).toBe('fn process() {\n\tlet a = 1;\n\n\tlet b = 2;\n}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not invent blanks between adjacent statements', () => {
|
||||||
|
expect(formatted('let a=1;let b=2;')).toBe('let a = 1;\nlet b = 2;\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('format — parse failures', () => {
|
describe('format — parse failures', () => {
|
||||||
it('returns ok=false with the first parse error', () => {
|
it('returns ok=false with a Rhai-flavored message and 1-based line/column', () => {
|
||||||
const r = format('let = ;');
|
// Pattern from the user complaint: `let;` should surface as
|
||||||
|
// "Expecting name of a variable" at line/column.
|
||||||
|
const r = format('let msg = ctx.request.params.name;\nlet;\n');
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
expect(typeof r.error.message).toBe('string');
|
expect(r.error.message).toBe('Expecting name of a variable');
|
||||||
|
expect(r.error.line).toBe(2);
|
||||||
|
expect(r.error.column).toBe(4);
|
||||||
expect(r.error.offset).toBeGreaterThanOrEqual(0);
|
expect(r.error.offset).toBeGreaterThanOrEqual(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports script-incomplete on truncated input', () => {
|
||||||
|
// `fn` alone — the parser expects a function name and hits EOF.
|
||||||
|
const r = format('fn');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error.message).toMatch(/script is incomplete/i);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not partially rewrite when parsing fails', () => {
|
it('does not partially rewrite when parsing fails', () => {
|
||||||
const r = format('let x = 1; this is garbage');
|
const r = format('let x = 1; this is garbage');
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
|
|||||||
@@ -15,9 +15,14 @@
|
|||||||
// the indent of the statement it precedes — same-line inline
|
// the indent of the statement it precedes — same-line inline
|
||||||
// positioning is intentionally NOT recovered; the goal is "verbatim
|
// positioning is intentionally NOT recovered; the goal is "verbatim
|
||||||
// text", not "byte-exact placement".
|
// 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.
|
// * Block bodies always use multi-line braces. `{}` for empty.
|
||||||
// * If parse errors are reported by the parser, the formatter refuses
|
// * 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 {
|
import type {
|
||||||
BlockExpr,
|
BlockExpr,
|
||||||
@@ -36,21 +41,47 @@ const PRINT_WIDTH = 100;
|
|||||||
|
|
||||||
export type FormatResult =
|
export type FormatResult =
|
||||||
| { ok: true; text: string }
|
| { 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 {
|
export function format(source: string): FormatResult {
|
||||||
const result = parse(source);
|
const result = parse(source);
|
||||||
if (result.errors.length > 0) {
|
if (result.errors.length > 0) {
|
||||||
const first = result.errors[0];
|
return { ok: false, error: errorPayload(source, result.errors[0]) };
|
||||||
return { ok: false, error: errorPayload(first) };
|
|
||||||
}
|
}
|
||||||
const p = new Printer(result);
|
const p = new Printer(result);
|
||||||
p.printProgram();
|
p.printProgram();
|
||||||
return { ok: true, text: p.finish() };
|
return { ok: true, text: p.finish() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorPayload(e: ParseError): { message: string; offset: number } {
|
function errorPayload(source: string, e: ParseError): FormatError {
|
||||||
return { message: e.message, offset: e.start };
|
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 {
|
class Printer {
|
||||||
@@ -125,19 +156,27 @@ class Printer {
|
|||||||
|
|
||||||
printProgram(): void {
|
printProgram(): void {
|
||||||
const stmts = this.result.program.stmts;
|
const stmts = this.result.program.stmts;
|
||||||
let prevWasFn = false;
|
|
||||||
for (let i = 0; i < stmts.length; i++) {
|
for (let i = 0; i < stmts.length; i++) {
|
||||||
const stmt = stmts[i];
|
const stmt = stmts[i];
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
if (prevWasFn || stmt.kind === 'FnDecl') this.blankLine();
|
if (this.hadBlankBetween(stmts[i - 1].end, stmt.start)) this.blankLine();
|
||||||
else this.newline();
|
else this.newline();
|
||||||
}
|
}
|
||||||
this.drainCommentsBefore(stmt.start);
|
this.drainCommentsBefore(stmt.start);
|
||||||
this.printStmt(stmt);
|
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
|
// ---------------------------------------------------------- statements
|
||||||
|
|
||||||
private printStmt(stmt: Stmt): void {
|
private printStmt(stmt: Stmt): void {
|
||||||
@@ -231,7 +270,11 @@ class Printer {
|
|||||||
this.emit('{');
|
this.emit('{');
|
||||||
this.indent++;
|
this.indent++;
|
||||||
for (let i = 0; i < block.stmts.length; i++) {
|
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.drainCommentsBefore(block.stmts[i].start);
|
||||||
this.printStmt(block.stmts[i]);
|
this.printStmt(block.stmts[i]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export { parse } from './parser';
|
|||||||
export { tokenize, KEYWORDS } from './lexer';
|
export { tokenize, KEYWORDS } from './lexer';
|
||||||
export { buildSymbolTable, renderFnSignature } from './symbols';
|
export { buildSymbolTable, renderFnSignature } from './symbols';
|
||||||
export { format } from './format';
|
export { format } from './format';
|
||||||
export type { FormatResult } from './format';
|
export type { FormatError, FormatResult } from './format';
|
||||||
export type { Decl, DeclKind, Scope, SymbolTable, Usage } from './symbols';
|
export type { Decl, DeclKind, Scope, SymbolTable, Usage } from './symbols';
|
||||||
export type {
|
export type {
|
||||||
BlockExpr,
|
BlockExpr,
|
||||||
|
|||||||
@@ -88,20 +88,38 @@ const PUNCTS = new Set(['(', ')', '{', '}', '[', ']', ';', ',', '.', ':', '#']);
|
|||||||
export interface LexResult {
|
export interface LexResult {
|
||||||
tokens: Token[];
|
tokens: Token[];
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
|
// Offsets at which the source contained at least one blank line (a
|
||||||
|
// run of whitespace with two or more newlines). One entry per blank
|
||||||
|
// run, pointing at the second-newline position. Used by the formatter
|
||||||
|
// to preserve user-intent vertical grouping.
|
||||||
|
blankLines: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tokenize(source: string): LexResult {
|
export function tokenize(source: string): LexResult {
|
||||||
const tokens: Token[] = [];
|
const tokens: Token[] = [];
|
||||||
const comments: Comment[] = [];
|
const comments: Comment[] = [];
|
||||||
|
const blankLines: number[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const n = source.length;
|
const n = source.length;
|
||||||
|
|
||||||
while (i < n) {
|
while (i < n) {
|
||||||
const ch = source[i];
|
const ch = source[i];
|
||||||
|
|
||||||
// Whitespace
|
// Whitespace — coalesce runs and record blank-line offsets.
|
||||||
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
||||||
i++;
|
let newlines = 0;
|
||||||
|
let blankAt = -1;
|
||||||
|
while (i < n) {
|
||||||
|
const c = source[i];
|
||||||
|
if (c === '\n') {
|
||||||
|
newlines++;
|
||||||
|
if (newlines === 2) blankAt = i;
|
||||||
|
} else if (c !== ' ' && c !== '\t' && c !== '\r') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (blankAt >= 0) blankLines.push(blankAt);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +246,7 @@ export function tokenize(source: string): LexResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokens.push({ kind: 'EOF', start: n, end: n, text: '' });
|
tokens.push({ kind: 'EOF', start: n, end: n, text: '' });
|
||||||
return { tokens, comments };
|
return { tokens, comments, blankLines };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDigit(c: string): boolean {
|
function isDigit(c: string): boolean {
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import type {
|
|||||||
import { tokenize, type Token, type TokenKind } from './lexer';
|
import { tokenize, type Token, type TokenKind } from './lexer';
|
||||||
|
|
||||||
export function parse(source: string): ParseResult {
|
export function parse(source: string): ParseResult {
|
||||||
const { tokens, comments } = tokenize(source);
|
const { tokens, comments, blankLines } = tokenize(source);
|
||||||
const p = new Parser(source, tokens);
|
const p = new Parser(source, tokens);
|
||||||
const program = p.parseProgram();
|
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
|
// Precedence levels for binary operators. Higher binds tighter. Assignment
|
||||||
@@ -92,13 +92,21 @@ class Parser {
|
|||||||
return true;
|
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();
|
const t = this.peek();
|
||||||
if (t.kind === kind && (text === undefined || t.text === text)) {
|
if (t.kind === kind && (text === undefined || t.text === text)) {
|
||||||
return this.advance();
|
return this.advance();
|
||||||
}
|
}
|
||||||
const desc = text !== undefined ? `'${text}'` : kind.toLowerCase();
|
if (t.kind === 'EOF') {
|
||||||
this.error(t, `expected ${desc}, got '${t.text || 'end of input'}'`);
|
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
|
// Return the token without consuming so the caller's parent can
|
||||||
// still resync at its own boundary.
|
// still resync at its own boundary.
|
||||||
return t;
|
return t;
|
||||||
@@ -192,7 +200,7 @@ class Parser {
|
|||||||
const expr = this.tryParseExpr();
|
const expr = this.tryParseExpr();
|
||||||
if (!expr) {
|
if (!expr) {
|
||||||
const bad = this.peek();
|
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();
|
this.resyncStmt();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -208,7 +216,7 @@ class Parser {
|
|||||||
|
|
||||||
private parseLetOrConst(kind: 'Let' | 'Const'): Stmt {
|
private parseLetOrConst(kind: 'Let' | 'Const'): Stmt {
|
||||||
const start = this.advance().start; // let|const
|
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 name = nameTok.text;
|
||||||
const nameRange = { start: nameTok.start, end: nameTok.end };
|
const nameRange = { start: nameTok.start, end: nameTok.end };
|
||||||
let init: Expr | null = null;
|
let init: Expr | null = null;
|
||||||
@@ -222,11 +230,11 @@ class Parser {
|
|||||||
|
|
||||||
private parseFnDecl(): FnDecl {
|
private parseFnDecl(): FnDecl {
|
||||||
const start = this.advance().start; // fn
|
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', '(');
|
this.expect('Punct', '(');
|
||||||
const params: Param[] = [];
|
const params: Param[] = [];
|
||||||
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
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 });
|
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
|
||||||
if (!this.match('Punct', ',')) break;
|
if (!this.match('Punct', ',')) break;
|
||||||
}
|
}
|
||||||
@@ -269,7 +277,7 @@ class Parser {
|
|||||||
|
|
||||||
private parseFor(): Stmt {
|
private parseFor(): Stmt {
|
||||||
const start = this.advance().start; // for
|
const start = this.advance().start; // for
|
||||||
const nameTok = this.expect('Ident');
|
const nameTok = this.expect('Ident', undefined, 'loop variable name');
|
||||||
this.expect('Keyword', 'in');
|
this.expect('Keyword', 'in');
|
||||||
const iter = this.tryParseExpr() ?? this.placeholderExpr();
|
const iter = this.tryParseExpr() ?? this.placeholderExpr();
|
||||||
const body = this.parseBlockExpr();
|
const body = this.parseBlockExpr();
|
||||||
@@ -305,7 +313,7 @@ class Parser {
|
|||||||
private parseBlockExpr(): BlockExpr {
|
private parseBlockExpr(): BlockExpr {
|
||||||
const openTok = this.peek();
|
const openTok = this.peek();
|
||||||
if (!this.match('Punct', '{')) {
|
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: [] };
|
return { kind: 'BlockExpr', start: openTok.start, end: openTok.start, stmts: [] };
|
||||||
}
|
}
|
||||||
const start = openTok.start;
|
const start = openTok.start;
|
||||||
@@ -372,7 +380,7 @@ class Parser {
|
|||||||
const t = this.peek();
|
const t = this.peek();
|
||||||
if (t.kind === 'Punct' && t.text === '.') {
|
if (t.kind === 'Punct' && t.text === '.') {
|
||||||
this.advance();
|
this.advance();
|
||||||
const prop = this.expect('Ident');
|
const prop = this.expect('Ident', undefined, 'name of a property');
|
||||||
expr = {
|
expr = {
|
||||||
kind: 'Member',
|
kind: 'Member',
|
||||||
start: expr.start,
|
start: expr.start,
|
||||||
@@ -403,7 +411,7 @@ class Parser {
|
|||||||
// Namespace path: treat `log::info` as a Member chain on an
|
// Namespace path: treat `log::info` as a Member chain on an
|
||||||
// Ident so completion and lookup can walk the same shape.
|
// Ident so completion and lookup can walk the same shape.
|
||||||
this.advance();
|
this.advance();
|
||||||
const next = this.expect('Ident');
|
const next = this.expect('Ident', undefined, "name after '::'");
|
||||||
expr = {
|
expr = {
|
||||||
kind: 'Member',
|
kind: 'Member',
|
||||||
start: expr.start,
|
start: expr.start,
|
||||||
@@ -476,7 +484,7 @@ class Parser {
|
|||||||
return this.parseBlockExpr();
|
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
|
// Consume one token so we make forward progress, then return a
|
||||||
// placeholder so the surrounding parser keeps its shape.
|
// placeholder so the surrounding parser keeps its shape.
|
||||||
this.advance();
|
this.advance();
|
||||||
@@ -534,7 +542,7 @@ class Parser {
|
|||||||
this.expect('Punct', '(');
|
this.expect('Punct', '(');
|
||||||
const params: Param[] = [];
|
const params: Param[] = [];
|
||||||
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
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 });
|
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
|
||||||
if (!this.match('Punct', ',')) break;
|
if (!this.match('Punct', ',')) break;
|
||||||
}
|
}
|
||||||
@@ -577,7 +585,7 @@ class Parser {
|
|||||||
key = k.text.length >= 2 ? k.text.slice(1, -1) : k.text;
|
key = k.text.length >= 2 ? k.text.slice(1, -1) : k.text;
|
||||||
keyRange = { start: k.start, end: k.end };
|
keyRange = { start: k.start, end: k.end };
|
||||||
} else {
|
} else {
|
||||||
this.error(k, 'expected map key');
|
this.error(k, 'Expecting name of a map key');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
this.expect('Punct', ':');
|
this.expect('Punct', ':');
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
editableSource = r.text;
|
editableSource = r.text;
|
||||||
rhaiFormatError = null;
|
rhaiFormatError = null;
|
||||||
} else {
|
} else {
|
||||||
rhaiFormatError = `Parse error at offset ${r.error.offset}: ${r.error.message}`;
|
rhaiFormatError = `Parse error: ${r.error.message} (line ${r.error.line}, position ${r.error.column})`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user