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:
122
dashboard/src/lib/rhai/format.test.ts
Normal file
122
dashboard/src/lib/rhai/format.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user