Two follow-ups on the Rhai formatter shipped in 0.5.1.
* Formatter no longer collapses user-intent blank lines between
statements. The lexer now records a side-channel list of offsets
where the source contained two-or-more consecutive newlines; the
formatter consults it and emits a single blank in the same spot
(rustfmt's `blank_lines_upper_bound = 1` policy applied strictly —
the prior forced blank between top-level `fn` decls is dropped, so
the formatter never *adds* a blank the user didn't write).
* Parse errors now read like Rhai's own diagnostics. `expect()` takes
an optional `role` hint and each call site supplies a domain phrase
(`name of a variable`, `function name in function declaration`,
`'{' to begin a block`, `name of a property`, …). End-of-input is
reported as `script is incomplete`. The dashboard banner renders
`Parse error: {message} (line L, position C)` with 1-based
coordinates, matching Rhai's format exactly.
The FormatError payload also keeps the byte `offset` so callers that
want to drive the editor cursor (CodeMirror works in offsets) still
have it.
Also folds the workspace Cargo.lock version bumps for 0.5.1 — the
lock-file rewrite that should have travelled with the prior commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
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('does not insert a blank between fn decls the user did not separate', () => {
|
|
// Strict preserve-only policy: no source blank => no emitted blank.
|
|
const out = formatted('fn a(){1}fn b(){2}');
|
|
expect(out).toBe('fn a() {\n\t1\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 — blank-line preservation', () => {
|
|
it('preserves a single blank line between statements', () => {
|
|
const src = 'let a = 1;\n\nlet b = 2;';
|
|
expect(formatted(src)).toBe('let a = 1;\n\nlet b = 2;\n');
|
|
});
|
|
|
|
it('collapses multiple blank lines to a single one', () => {
|
|
const src = 'let a = 1;\n\n\n\nlet b = 2;';
|
|
expect(formatted(src)).toBe('let a = 1;\n\nlet b = 2;\n');
|
|
});
|
|
|
|
it('preserves blanks inside block bodies', () => {
|
|
const src = 'fn process() {\n\tlet a = 1;\n\n\tlet b = 2;\n}';
|
|
expect(formatted(src)).toBe('fn process() {\n\tlet a = 1;\n\n\tlet b = 2;\n}\n');
|
|
});
|
|
|
|
it('does not invent blanks between adjacent statements', () => {
|
|
expect(formatted('let a=1;let b=2;')).toBe('let a = 1;\nlet b = 2;\n');
|
|
});
|
|
});
|
|
|
|
describe('format — parse failures', () => {
|
|
it('returns ok=false with a Rhai-flavored message and 1-based line/column', () => {
|
|
// Pattern from the user complaint: `let;` should surface as
|
|
// "Expecting name of a variable" at line/column.
|
|
const r = format('let msg = ctx.request.params.name;\nlet;\n');
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) {
|
|
expect(r.error.message).toBe('Expecting name of a variable');
|
|
expect(r.error.line).toBe(2);
|
|
expect(r.error.column).toBe(4);
|
|
expect(r.error.offset).toBeGreaterThanOrEqual(0);
|
|
}
|
|
});
|
|
|
|
it('reports script-incomplete on truncated input', () => {
|
|
// `fn` alone — the parser expects a function name and hits EOF.
|
|
const r = format('fn');
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error.message).toMatch(/script is incomplete/i);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|