feat(dashboard): Rhai source formatter with Format button

AST-based pretty-printer: tab-indented, 100-col print width, normalized
operator spacing, predictable reflow of long argument lists, comments
preserved verbatim. Refuses to emit on a parse failure and returns the
first error, so the Edit-tab button mirrors the JSON Format UX —
inline `.error.inline` banner; doc untouched on failure.

Patch bump to `0.5.1` across Cargo.toml workspace.package, the
dashboard package.json, and the docs/versioning.md Current versions
table.

Bundle delta versus the previous build: +6 KB raw, +1.5 KB gzipped.
Cumulative since the start of this work: +28 KB raw, +7.3 KB gzipped —
well under the +100 KB budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-23 23:51:19 +02:00
parent 1dc53a0226
commit 267c40f59c
7 changed files with 592 additions and 4 deletions

View File

@@ -0,0 +1,436 @@
// 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".
// * 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.
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: { message: string; 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) };
}
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 };
}
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;
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();
else this.newline();
}
this.drainCommentsBefore(stmt.start);
this.printStmt(stmt);
prevWasFn = stmt.kind === 'FnDecl';
}
}
// ---------------------------------------------------------- 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++) {
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;
}