From 267c40f59c982734e6be1f76792acba5611c207a Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 23 May 2026 23:51:19 +0200 Subject: [PATCH] feat(dashboard): Rhai source formatter with Format button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Cargo.toml | 2 +- dashboard/package.json | 2 +- dashboard/src/lib/rhai/format.test.ts | 122 +++++ dashboard/src/lib/rhai/format.ts | 436 ++++++++++++++++++ dashboard/src/lib/rhai/index.ts | 2 + .../src/routes/scripts/[id]/+page.svelte | 30 +- docs/versioning.md | 2 +- 7 files changed, 592 insertions(+), 4 deletions(-) create mode 100644 dashboard/src/lib/rhai/format.test.ts create mode 100644 dashboard/src/lib/rhai/format.ts diff --git a/Cargo.toml b/Cargo.toml index f160d01..b4c092a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.5.0" +version = "0.5.1" edition = "2021" rust-version = "1.92" license = "MIT OR Apache-2.0" diff --git a/dashboard/package.json b/dashboard/package.json index abb7fc2..92ac4c5 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "picloud-dashboard", - "version": "0.5.0", + "version": "0.5.1", "private": true, "type": "module", "scripts": { diff --git a/dashboard/src/lib/rhai/format.test.ts b/dashboard/src/lib/rhai/format.test.ts new file mode 100644 index 0000000..e0b62f8 --- /dev/null +++ b/dashboard/src/lib/rhai/format.test.ts @@ -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); + }); +}); diff --git a/dashboard/src/lib/rhai/format.ts b/dashboard/src/lib/rhai/format.ts new file mode 100644 index 0000000..e80fb12 --- /dev/null +++ b/dashboard/src/lib/rhai/format.ts @@ -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; +} diff --git a/dashboard/src/lib/rhai/index.ts b/dashboard/src/lib/rhai/index.ts index 6e825e8..e8e803f 100644 --- a/dashboard/src/lib/rhai/index.ts +++ b/dashboard/src/lib/rhai/index.ts @@ -4,6 +4,8 @@ export { parse } from './parser'; export { tokenize, KEYWORDS } from './lexer'; 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 { BlockExpr, diff --git a/dashboard/src/routes/scripts/[id]/+page.svelte b/dashboard/src/routes/scripts/[id]/+page.svelte index d64d15e..61ed87e 100644 --- a/dashboard/src/routes/scripts/[id]/+page.svelte +++ b/dashboard/src/routes/scripts/[id]/+page.svelte @@ -14,6 +14,7 @@ import { logLevelColor, statusColor } from '$lib/styles'; import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils'; 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 /// input doesn't parse. The error state is shown next to the button @@ -67,6 +68,17 @@ let editableSource = $state(''); let savingSource = $state(false); let saveSourceError = $state(null); + let rhaiFormatError = $state(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() { if (!script) return; @@ -384,8 +396,16 @@ {#if tab === 'edit'}
-

Source

+
+

Source

+ +
+ {#if rhaiFormatError} +
{rhaiFormatError}
+ {/if} {#if saveSourceError}
{saveSourceError}
{/if} @@ -794,6 +814,14 @@ font-size: 0.85rem; color: #cbd5e1; } + .editor-header { + display: flex; + justify-content: space-between; + align-items: center; + } + .editor-header h2 { + margin: 0; + } button.link { background: transparent; color: #94a3b8; diff --git a/docs/versioning.md b/docs/versioning.md index e0a17a9..8c45f03 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -126,7 +126,7 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu | | Version | |---|---| -| Product | `0.5.0` | +| Product | `0.5.1` | | SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) | | API | `1` | | Schema | `3` (matches `migrations/0003_routes.sql`) |