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>
480 lines
13 KiB
TypeScript
480 lines
13 KiB
TypeScript
// Rhai source formatter.
|
|
//
|
|
// Parses the source, walks the AST, emits canonical text. On a parse
|
|
// failure it returns the first error and leaves the caller responsible
|
|
// for showing it (the dashboard's UX mirrors the JSON "Format" button:
|
|
// the doc is untouched and the error is surfaced inline).
|
|
//
|
|
// Choices:
|
|
// * Indent = one tab. The dashboard CSS is tab-based and the editor
|
|
// keymaps `indentWithTab`, so matching the existing convention.
|
|
// * Print width = 100 cols. If an inline-printed call's argument list
|
|
// would push the current line past 100 cols, the args reflow one
|
|
// per line with a trailing comma.
|
|
// * Comments preserved verbatim. Each comment lands on its own line at
|
|
// 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 with line / column
|
|
// coordinates (1-based, matching Rhai's own diagnostic format).
|
|
|
|
import type {
|
|
BlockExpr,
|
|
Comment,
|
|
Expr,
|
|
IfExpr,
|
|
ObjectMapExpr,
|
|
ParseError,
|
|
ParseResult,
|
|
Stmt,
|
|
SwitchExpr
|
|
} from './ast';
|
|
import { parse } from './parser';
|
|
|
|
const PRINT_WIDTH = 100;
|
|
|
|
export type FormatResult =
|
|
| { ok: true; text: string }
|
|
| { 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) {
|
|
return { ok: false, error: errorPayload(source, result.errors[0]) };
|
|
}
|
|
const p = new Printer(result);
|
|
p.printProgram();
|
|
return { ok: true, text: p.finish() };
|
|
}
|
|
|
|
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 {
|
|
private buf = '';
|
|
private indent = 0;
|
|
private commentPtr = 0;
|
|
|
|
constructor(private result: ParseResult) {}
|
|
|
|
finish(): string {
|
|
this.drainCommentsBefore(this.result.source.length + 1);
|
|
// Strip trailing whitespace from every line; ensure a single
|
|
// terminating newline.
|
|
const text = this.buf.replace(/[ \t]+$/gm, '').replace(/\n*$/, '\n');
|
|
return text;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- emit
|
|
|
|
private emit(s: string): void {
|
|
this.buf += s;
|
|
}
|
|
|
|
private newline(): void {
|
|
this.buf += '\n' + '\t'.repeat(this.indent);
|
|
}
|
|
|
|
private blankLine(): void {
|
|
// Two newlines, but never more than that.
|
|
if (this.buf.endsWith('\n\n' + '\t'.repeat(this.indent))) return;
|
|
// Remove any trailing indent we already wrote so the blank line is
|
|
// truly blank (no stray tabs).
|
|
this.buf = this.buf.replace(/[ \t]*$/, '');
|
|
if (!this.buf.endsWith('\n')) this.buf += '\n';
|
|
this.buf += '\n' + '\t'.repeat(this.indent);
|
|
}
|
|
|
|
private column(): number {
|
|
const last = this.buf.lastIndexOf('\n');
|
|
return this.buf.length - (last + 1);
|
|
}
|
|
|
|
// Run `body` against a scratch buffer, return the text it would have
|
|
// appended. Useful for measuring before deciding whether to reflow.
|
|
private measure(body: () => void): string {
|
|
const prev = this.buf;
|
|
body();
|
|
const text = this.buf.slice(prev.length);
|
|
this.buf = prev;
|
|
return text;
|
|
}
|
|
|
|
// ------------------------------------------------------------ comments
|
|
|
|
private drainCommentsBefore(pos: number): void {
|
|
const comments = this.result.comments;
|
|
while (this.commentPtr < comments.length && comments[this.commentPtr].start < pos) {
|
|
const c = comments[this.commentPtr++];
|
|
this.emitComment(c);
|
|
}
|
|
}
|
|
|
|
private emitComment(c: Comment): void {
|
|
// Emit each comment on its own line at the current indent. For
|
|
// block comments we still keep the original text (which may span
|
|
// multiple lines) verbatim.
|
|
this.emit(c.text);
|
|
this.newline();
|
|
}
|
|
|
|
// ------------------------------------------------------------- program
|
|
|
|
printProgram(): void {
|
|
const stmts = this.result.program.stmts;
|
|
for (let i = 0; i < stmts.length; i++) {
|
|
const stmt = stmts[i];
|
|
if (i > 0) {
|
|
if (this.hadBlankBetween(stmts[i - 1].end, stmt.start)) this.blankLine();
|
|
else this.newline();
|
|
}
|
|
this.drainCommentsBefore(stmt.start);
|
|
this.printStmt(stmt);
|
|
}
|
|
}
|
|
|
|
// "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 {
|
|
switch (stmt.kind) {
|
|
case 'Let':
|
|
case 'Const': {
|
|
this.emit(stmt.kind === 'Let' ? 'let ' : 'const ');
|
|
this.emit(stmt.name);
|
|
if (stmt.init) {
|
|
this.emit(' = ');
|
|
this.printExpr(stmt.init);
|
|
}
|
|
this.emit(';');
|
|
return;
|
|
}
|
|
case 'FnDecl': {
|
|
this.emit('fn ');
|
|
this.emit(stmt.name);
|
|
this.emit('(');
|
|
this.emit(stmt.params.map((p) => p.name).join(', '));
|
|
this.emit(') ');
|
|
this.printBlock(stmt.body);
|
|
return;
|
|
}
|
|
case 'ExprStmt': {
|
|
this.printExpr(stmt.expr);
|
|
// Preserve whether the user terminated with `;`. Block-form
|
|
// expressions never take one, so suppress regardless.
|
|
if (stmt.semi && !isBlockForm(stmt.expr)) this.emit(';');
|
|
return;
|
|
}
|
|
case 'Return': {
|
|
this.emit('return');
|
|
if (stmt.value) {
|
|
this.emit(' ');
|
|
this.printExpr(stmt.value);
|
|
}
|
|
this.emit(';');
|
|
return;
|
|
}
|
|
case 'While': {
|
|
this.emit('while ');
|
|
this.printExpr(stmt.cond);
|
|
this.emit(' ');
|
|
this.printBlock(stmt.body);
|
|
return;
|
|
}
|
|
case 'Loop': {
|
|
this.emit('loop ');
|
|
this.printBlock(stmt.body);
|
|
return;
|
|
}
|
|
case 'For': {
|
|
this.emit('for ');
|
|
this.emit(stmt.varName);
|
|
this.emit(' in ');
|
|
this.printExpr(stmt.iter);
|
|
this.emit(' ');
|
|
this.printBlock(stmt.body);
|
|
return;
|
|
}
|
|
case 'Break':
|
|
this.emit('break;');
|
|
return;
|
|
case 'Continue':
|
|
this.emit('continue;');
|
|
return;
|
|
case 'Try': {
|
|
this.emit('try ');
|
|
this.printBlock(stmt.body);
|
|
this.emit(' catch');
|
|
if (stmt.catchVar) {
|
|
this.emit(' (');
|
|
this.emit(stmt.catchVar);
|
|
this.emit(') ');
|
|
} else {
|
|
this.emit(' ');
|
|
}
|
|
this.printBlock(stmt.handler);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private printBlock(block: BlockExpr): void {
|
|
if (block.stmts.length === 0) {
|
|
this.drainCommentsBefore(block.end);
|
|
this.emit('{}');
|
|
return;
|
|
}
|
|
this.emit('{');
|
|
this.indent++;
|
|
for (let i = 0; i < block.stmts.length; i++) {
|
|
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]);
|
|
}
|
|
this.drainCommentsBefore(block.end);
|
|
this.indent--;
|
|
this.newline();
|
|
this.emit('}');
|
|
}
|
|
|
|
// --------------------------------------------------------- expressions
|
|
|
|
private printExpr(expr: Expr): void {
|
|
switch (expr.kind) {
|
|
case 'Ident':
|
|
this.emit(expr.name);
|
|
return;
|
|
case 'Number':
|
|
case 'String':
|
|
this.emit(expr.raw);
|
|
return;
|
|
case 'Bool':
|
|
this.emit(expr.value ? 'true' : 'false');
|
|
return;
|
|
case 'Null':
|
|
this.emit('null');
|
|
return;
|
|
case 'Member': {
|
|
this.printExpr(expr.object);
|
|
// `log::info` was parsed as Member(Ident(log), 'info'); restore
|
|
// the namespace separator for known namespaces. `ctx.request`
|
|
// always uses `.` because we parsed it that way.
|
|
const sep = isNamespacePath(expr.object) ? '::' : '.';
|
|
this.emit(sep);
|
|
this.emit(expr.property);
|
|
return;
|
|
}
|
|
case 'Index':
|
|
this.printExpr(expr.object);
|
|
this.emit('[');
|
|
this.printExpr(expr.index);
|
|
this.emit(']');
|
|
return;
|
|
case 'Call':
|
|
this.printExpr(expr.callee);
|
|
this.printArgList('(', ')', expr.args);
|
|
return;
|
|
case 'Unary':
|
|
this.emit(expr.op);
|
|
this.printExpr(expr.operand);
|
|
return;
|
|
case 'Binary':
|
|
this.printExpr(expr.left);
|
|
this.emit(` ${expr.op} `);
|
|
this.printExpr(expr.right);
|
|
return;
|
|
case 'Assign':
|
|
this.printExpr(expr.target);
|
|
this.emit(` ${expr.op} `);
|
|
this.printExpr(expr.value);
|
|
return;
|
|
case 'Paren':
|
|
this.emit('(');
|
|
this.printExpr(expr.expr);
|
|
this.emit(')');
|
|
return;
|
|
case 'Array':
|
|
this.printArgList('[', ']', expr.elements);
|
|
return;
|
|
case 'ObjectMap':
|
|
this.printObjectMap(expr);
|
|
return;
|
|
case 'FnExpr':
|
|
this.emit('fn (');
|
|
this.emit(expr.params.map((p) => p.name).join(', '));
|
|
this.emit(') ');
|
|
this.printBlock(expr.body);
|
|
return;
|
|
case 'IfExpr':
|
|
this.printIf(expr);
|
|
return;
|
|
case 'SwitchExpr':
|
|
this.printSwitch(expr);
|
|
return;
|
|
case 'BlockExpr':
|
|
this.printBlock(expr);
|
|
return;
|
|
}
|
|
}
|
|
|
|
private printArgList(open: string, close: string, items: Expr[]): void {
|
|
if (items.length === 0) {
|
|
this.emit(open);
|
|
this.emit(close);
|
|
return;
|
|
}
|
|
const inline = this.measure(() => {
|
|
this.emit(open);
|
|
for (let i = 0; i < items.length; i++) {
|
|
if (i > 0) this.emit(', ');
|
|
this.printExpr(items[i]);
|
|
}
|
|
this.emit(close);
|
|
});
|
|
if (!inline.includes('\n') && this.column() + inline.length <= PRINT_WIDTH) {
|
|
this.emit(inline);
|
|
return;
|
|
}
|
|
this.emit(open);
|
|
this.indent++;
|
|
for (const item of items) {
|
|
this.newline();
|
|
this.printExpr(item);
|
|
this.emit(',');
|
|
}
|
|
this.indent--;
|
|
this.newline();
|
|
this.emit(close);
|
|
}
|
|
|
|
private printObjectMap(expr: ObjectMapExpr): void {
|
|
if (expr.entries.length === 0) {
|
|
this.emit('#{}');
|
|
return;
|
|
}
|
|
const inline = this.measure(() => {
|
|
this.emit('#{ ');
|
|
for (let i = 0; i < expr.entries.length; i++) {
|
|
const e = expr.entries[i];
|
|
if (i > 0) this.emit(', ');
|
|
this.emit(e.key);
|
|
this.emit(': ');
|
|
this.printExpr(e.value);
|
|
}
|
|
this.emit(' }');
|
|
});
|
|
if (!inline.includes('\n') && this.column() + inline.length <= PRINT_WIDTH) {
|
|
this.emit(inline);
|
|
return;
|
|
}
|
|
this.emit('#{');
|
|
this.indent++;
|
|
for (const e of expr.entries) {
|
|
this.newline();
|
|
this.emit(e.key);
|
|
this.emit(': ');
|
|
this.printExpr(e.value);
|
|
this.emit(',');
|
|
}
|
|
this.indent--;
|
|
this.newline();
|
|
this.emit('}');
|
|
}
|
|
|
|
private printIf(expr: IfExpr): void {
|
|
this.emit('if ');
|
|
this.printExpr(expr.cond);
|
|
this.emit(' ');
|
|
this.printBlock(expr.then);
|
|
if (expr.else_) {
|
|
this.emit(' else ');
|
|
if (expr.else_.kind === 'IfExpr') this.printIf(expr.else_);
|
|
else this.printBlock(expr.else_);
|
|
}
|
|
}
|
|
|
|
private printSwitch(expr: SwitchExpr): void {
|
|
this.emit('switch ');
|
|
this.printExpr(expr.subject);
|
|
this.emit(' {');
|
|
this.indent++;
|
|
for (const arm of expr.arms) {
|
|
this.newline();
|
|
if (arm.pattern === null) this.emit('_');
|
|
else this.printExpr(arm.pattern);
|
|
if (arm.guard) {
|
|
this.emit(' if ');
|
|
this.printExpr(arm.guard);
|
|
}
|
|
this.emit(' => ');
|
|
this.printExpr(arm.value);
|
|
this.emit(',');
|
|
}
|
|
this.indent--;
|
|
this.newline();
|
|
this.emit('}');
|
|
}
|
|
}
|
|
|
|
function isBlockForm(expr: Expr): boolean {
|
|
return expr.kind === 'IfExpr' || expr.kind === 'SwitchExpr' || expr.kind === 'BlockExpr' || expr.kind === 'FnExpr';
|
|
}
|
|
|
|
// Namespace path detection — used by `Member` printing to decide between
|
|
// `.` and `::`. Currently the only well-known namespace in scripts is
|
|
// `log`, but we generalize to any bare identifier whose name happens to
|
|
// be the namespace token. False positives are harmless (we'd render
|
|
// `something::field` for a local named `log`); the parser-side fix would
|
|
// be a dedicated `Path` node — not worth it for one keyword.
|
|
function isNamespacePath(expr: Expr): boolean {
|
|
if (expr.kind === 'Ident') return expr.name === 'log';
|
|
return false;
|
|
}
|