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