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

@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.5.0" version = "0.5.1"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"

View File

@@ -1,6 +1,6 @@
{ {
"name": "picloud-dashboard", "name": "picloud-dashboard",
"version": "0.5.0", "version": "0.5.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest';
import { format } from './format';
function formatted(src: string): string {
const r = format(src);
if (!r.ok) throw new Error(`expected format to succeed, got: ${r.error.message}`);
return r.text;
}
describe('format — basic shape', () => {
it('normalizes a simple let with operator spacing', () => {
const out = formatted('let x=1+2 * 3;');
expect(out).toBe('let x = 1 + 2 * 3;\n');
});
it('renders a fn declaration with body', () => {
const out = formatted('fn process(order,user){order.total}');
expect(out).toBe(
'fn process(order, user) {\n' +
'\torder.total\n' +
'}\n'
);
});
it('separates top-level fn decls with a blank line', () => {
const out = formatted('fn a(){1}fn b(){2}');
expect(out).toBe(
'fn a() {\n\t1\n}\n\nfn b() {\n\t2\n}\n'
);
});
it('renders if / else if / else with blocks', () => {
const out = formatted('if a{1}else if b{2}else{3}');
expect(out).toBe(
'if a {\n\t1\n} else if b {\n\t2\n} else {\n\t3\n}\n'
);
});
it('renders an object-map literal inline when short', () => {
const out = formatted('let o=#{a:1,b:2};');
expect(out).toBe('let o = #{ a: 1, b: 2 };\n');
});
it('renders log::info as a namespace call', () => {
const out = formatted('log::info( "hi" );');
expect(out).toBe('log::info("hi");\n');
});
it('preserves comments verbatim before statements', () => {
const out = formatted('// docstring\nfn process(){1}');
expect(out).toBe(
'// docstring\nfn process() {\n\t1\n}\n'
);
});
it('keeps block comments verbatim', () => {
const out = formatted('/* keep me */ let x = 1;');
expect(out).toContain('/* keep me */');
expect(out).toContain('let x = 1;');
});
it('emits an empty block as `{}` without padding', () => {
const out = formatted('fn noop(){}');
expect(out).toBe('fn noop() {}\n');
});
it('preserves string literals verbatim', () => {
const out = formatted('let s = "hello\\nworld";');
expect(out).toBe('let s = "hello\\nworld";\n');
});
});
describe('format — reflow', () => {
it('reflows a long argument list onto separate lines', () => {
const src =
'process(aaaaaaaaaa, bbbbbbbbbb, cccccccccc, dddddddddd, eeeeeeeeee, ffffffffff, gggggggggg, hhhhhhhhhh);';
const out = formatted(src);
// Should contain at least one newline inside the parens (multi-line).
const callBlock = out.slice(out.indexOf('('), out.lastIndexOf(')') + 1);
expect(callBlock).toContain('\n');
expect(callBlock.endsWith(',\n)')).toBe(true);
});
it('keeps short argument lists inline', () => {
const out = formatted('process(1, 2, 3);');
expect(out).toBe('process(1, 2, 3);\n');
});
});
describe('format — parse failures', () => {
it('returns ok=false with the first parse error', () => {
const r = format('let = ;');
expect(r.ok).toBe(false);
if (!r.ok) {
expect(typeof r.error.message).toBe('string');
expect(r.error.offset).toBeGreaterThanOrEqual(0);
}
});
it('does not partially rewrite when parsing fails', () => {
const r = format('let x = 1; this is garbage');
expect(r.ok).toBe(false);
});
});
describe('format — idempotent', () => {
it('formatting twice yields the same output', () => {
const src = `
fn process(order,user) {
if order.total > 100 {
log::info("big", #{id:order.id});
} else {
log::info("small");
}
return order;
}
`;
const a = formatted(src);
const b = formatted(a);
expect(b).toBe(a);
});
});

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;
}

View File

@@ -4,6 +4,8 @@
export { parse } from './parser'; 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 type { 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,

View File

@@ -14,6 +14,7 @@
import { logLevelColor, statusColor } from '$lib/styles'; import { logLevelColor, statusColor } from '$lib/styles';
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils'; import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
import CodeEditor from '$lib/CodeEditor.svelte'; import CodeEditor from '$lib/CodeEditor.svelte';
import { format as formatRhai } from '$lib/rhai';
/// Pretty-print a JSON string in place, leaving it untouched if the /// Pretty-print a JSON string in place, leaving it untouched if the
/// input doesn't parse. The error state is shown next to the button /// input doesn't parse. The error state is shown next to the button
@@ -67,6 +68,17 @@
let editableSource = $state(''); let editableSource = $state('');
let savingSource = $state(false); let savingSource = $state(false);
let saveSourceError = $state<string | null>(null); let saveSourceError = $state<string | null>(null);
let rhaiFormatError = $state<string | null>(null);
function formatRhaiSource() {
const r = formatRhai(editableSource);
if (r.ok) {
editableSource = r.text;
rhaiFormatError = null;
} else {
rhaiFormatError = `Parse error at offset ${r.error.offset}: ${r.error.message}`;
}
}
async function saveSource() { async function saveSource() {
if (!script) return; if (!script) return;
@@ -384,8 +396,16 @@
{#if tab === 'edit'} {#if tab === 'edit'}
<div class="grid"> <div class="grid">
<section class="card"> <section class="card">
<h2>Source</h2> <header class="editor-header">
<h2>Source</h2>
<button type="button" class="ghost small" onclick={formatRhaiSource}>
Format
</button>
</header>
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" /> <CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
{#if rhaiFormatError}
<div class="error inline">{rhaiFormatError}</div>
{/if}
{#if saveSourceError} {#if saveSourceError}
<div class="error inline">{saveSourceError}</div> <div class="error inline">{saveSourceError}</div>
{/if} {/if}
@@ -794,6 +814,14 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #cbd5e1; color: #cbd5e1;
} }
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.editor-header h2 {
margin: 0;
}
button.link { button.link {
background: transparent; background: transparent;
color: #94a3b8; color: #94a3b8;

View File

@@ -126,7 +126,7 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
| | Version | | | Version |
|---|---| |---|---|
| Product | `0.5.0` | | Product | `0.5.1` |
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) | | SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
| API | `1` | | API | `1` |
| Schema | `3` (matches `migrations/0003_routes.sql`) | | Schema | `3` (matches `migrations/0003_routes.sql`) |