// 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". // * Blank lines between statements are preserved when the user wrote // them; multiples collapse to one. The formatter never *adds* blank // lines the user didn't write (rustfmt's default policy applied // strictly — no forced separation between top-level fn decls). // * 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 with line / column // coordinates (1-based, matching Rhai's own diagnostic format). 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: FormatError }; export interface FormatError { message: string; // 1-based line and column, matching Rhai's own diagnostic format. line: number; column: number; // Byte offset retained for callers that want to jump the editor // cursor (CodeMirror works in offsets, not line/col). offset: number; } export function format(source: string): FormatResult { const result = parse(source); if (result.errors.length > 0) { return { ok: false, error: errorPayload(source, result.errors[0]) }; } const p = new Printer(result); p.printProgram(); return { ok: true, text: p.finish() }; } function errorPayload(source: string, e: ParseError): FormatError { const { line, column } = lineColAt(source, e.start); return { message: e.message, line, column, offset: e.start }; } // Convert a byte offset into 1-based (line, column). Used for rendering // parser errors in a way that matches Rhai's own diagnostic format // (e.g. "Expecting name of a variable (line 2, position 4)"). function lineColAt(source: string, offset: number): { line: number; column: number } { let line = 1; let lineStart = 0; const limit = Math.min(offset, source.length); for (let i = 0; i < limit; i++) { if (source.charCodeAt(i) === 10) { line++; lineStart = i + 1; } } return { line, column: limit - lineStart + 1 }; } 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; for (let i = 0; i < stmts.length; i++) { const stmt = stmts[i]; if (i > 0) { if (this.hadBlankBetween(stmts[i - 1].end, stmt.start)) this.blankLine(); else this.newline(); } this.drainCommentsBefore(stmt.start); this.printStmt(stmt); } } // "Did the user leave a blank line in this gap?" Consulted between // every pair of emitted statements to decide whether to keep the // vertical separator the source originally had. private hadBlankBetween(prevEnd: number, currStart: number): boolean { for (const offset of this.result.blankLines) { if (offset >= prevEnd && offset < currStart) return true; } return false; } // ---------------------------------------------------------- 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++) { if (i > 0 && this.hadBlankBetween(block.stmts[i - 1].end, block.stmts[i].start)) { this.blankLine(); } else { 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; }