Files
plainleaf/client/space_lua/pretty_print.ts
T
2026-07-28 14:30:10 +02:00

466 lines
13 KiB
TypeScript

import type {
LuaBlock,
LuaExpression,
LuaOrderBy,
LuaQueryClause,
LuaStatement,
LuaTableField,
} from "./ast.ts";
import { luaFormatNumber } from "./runtime.ts";
export type PrintOptions = {
indentWidth?: number;
quote?: "double" | "single";
trailingComma?: boolean;
};
type ResolvedOptions = {
indentWidth: number;
quote: "double" | "single";
trailingComma: boolean;
};
function resolveOptions(opts?: PrintOptions): ResolvedOptions {
return {
indentWidth: opts?.indentWidth ?? 2,
quote: opts?.quote ?? "double",
trailingComma: opts?.trailingComma ?? true,
};
}
const RESERVED = new Set([
"and",
"break",
"do",
"else",
"elseif",
"end",
"false",
"for",
"function",
"goto",
"if",
"in",
"local",
"nil",
"not",
"or",
"repeat",
"return",
"then",
"true",
"until",
"while",
]);
function isLuaIdentifier(s: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(s) && !RESERVED.has(s);
}
const BINARY_PREC: Record<string, number> = {
or: 1,
and: 2,
"<": 3,
">": 3,
"<=": 3,
">=": 3,
"~=": 3,
"==": 3,
"|": 4,
"~": 5, // binary bitwise xor
"&": 6,
"<<": 7,
">>": 7,
"..": 8,
"+": 9,
"-": 9,
"*": 10,
"/": 10,
"//": 10,
"%": 10,
"^": 12,
};
const UNARY_PREC = 11;
const RIGHT_ASSOC = new Set(["..", "^"]);
const ATOM_PREC = 99;
function exprPrec(e: LuaExpression): number {
if (e.type === "Binary") return BINARY_PREC[e.operator] ?? ATOM_PREC;
if (e.type === "Unary") return UNARY_PREC;
return ATOM_PREC;
}
class Printer {
constructor(private opts: ResolvedOptions) {}
private indent(depth: number): string {
return " ".repeat(depth * this.opts.indentWidth);
}
private quoteString(s: string): string {
const q = this.opts.quote === "double" ? '"' : "'";
let out = q;
for (const ch of s) {
switch (ch) {
case "\\":
out += "\\\\";
break;
case "\n":
out += "\\n";
break;
case "\r":
out += "\\r";
break;
case "\t":
out += "\\t";
break;
case "\0":
out += "\\0";
break;
default:
out += ch === q ? `\\${ch}` : ch;
}
}
return out + q;
}
expression(e: LuaExpression, depth = 0): string {
switch (e.type) {
case "Nil":
return "nil";
case "Boolean":
return e.value ? "true" : "false";
case "Number":
return luaFormatNumber(e.value, e.numericType);
case "String":
return this.quoteString(e.value);
case "Variable":
return e.name;
case "PropertyAccess":
return `${this.expression(e.object, depth)}.${e.property}`;
case "TableAccess":
return `${this.expression(e.object, depth)}[${this.expression(e.key, depth)}]`;
case "Parenthesized":
return `(${this.expression(e.expression, depth)})`;
case "Binary": {
const p = BINARY_PREC[e.operator] ?? ATOM_PREC;
const rightAssoc = RIGHT_ASSOC.has(e.operator);
const left = this.operand(e.left, p, false, rightAssoc, depth);
const right = this.operand(e.right, p, true, rightAssoc, depth);
return `${left} ${e.operator} ${right}`;
}
case "Unary": {
const arg = this.unaryOperand(e.argument, depth);
return /^[a-z]+$/.test(e.operator)
? `${e.operator} ${arg}`
: `${e.operator}${arg}`;
}
case "TableConstructor":
return this.tableConstructor(e.fields, depth);
case "FunctionDefinition":
return `function${this.functionRest(
e.body.parameters,
e.body.block,
depth,
)}`;
case "FunctionCall":
return this.functionCall(e, depth);
case "Query": {
const lines = e.clauses.map(
(c) => this.indent(depth + 1) + this.queryClause(c, depth + 1),
);
return `query[[\n${lines.join("\n")}\n${this.indent(depth)}]]`;
}
case "FilteredCall":
return `${this.expression(e.call, depth)} filter ${this.expression(
e.filter,
depth,
)}`;
case "AggregateCall":
return `${this.expression(e.call, depth)} order by ${this.orderByList(
e.orderBy,
depth,
)}`;
default:
throw new Error(
`pretty_print: unsupported expression type: ${(e as any).type}`,
);
}
}
private operand(
e: LuaExpression,
parentPrec: number,
isRight: boolean,
parentRightAssoc: boolean,
depth: number,
): string {
const cp = exprPrec(e);
let parens = false;
if (cp < parentPrec) {
parens = true;
} else if (cp === parentPrec) {
// left-assoc parent: the right child of equal precedence needs parens.
// right-assoc parent: the left child of equal precedence needs parens.
parens = parentRightAssoc ? !isRight : isRight;
}
const s = this.expression(e, depth);
return parens ? `(${s})` : s;
}
private unaryOperand(e: LuaExpression, depth: number): string {
const s = this.expression(e, depth);
return exprPrec(e) < UNARY_PREC ? `(${s})` : s;
}
private field(f: LuaTableField, depth: number): string {
switch (f.type) {
case "PropField":
return isLuaIdentifier(f.key)
? `${f.key} = ${this.expression(f.value, depth)}`
: `[${this.quoteString(f.key)}] = ${this.expression(f.value, depth)}`;
case "DynamicField":
return `[${this.expression(f.key, depth)}] = ${this.expression(f.value, depth)}`;
case "ExpressionField":
return this.expression(f.value, depth);
}
}
private tableConstructor(fields: LuaTableField[], depth: number): string {
if (fields.length === 0) return "{}";
if (fields.length === 1) return `{${this.field(fields[0], depth)}}`;
const lines = fields.map(
(f) => this.indent(depth + 1) + this.field(f, depth + 1),
);
const tail = this.opts.trailingComma ? "," : "";
return `{\n${lines.join(",\n")}${tail}\n${this.indent(depth)}}`;
}
// returns the part of a function after the `function` keyword (+ optional
// name): "(params) end" or "(params)\n <body>\nend"
private functionRest(
params: string[],
body: LuaBlock,
depth: number,
): string {
const head = `(${params.join(", ")})`;
const rendered = this.block(body, depth + 1);
if (!rendered) return `${head} end`;
return `${head}\n${rendered}\n${this.indent(depth)}end`;
}
private functionCall(
e: Extract<LuaExpression, { type: "FunctionCall" }>,
depth: number,
): string {
let base = this.expression(e.prefix, depth);
if (e.name) base += `:${e.name}`;
const args = e.args;
const orderBy = e.orderBy
? ` order by ${this.orderByList(e.orderBy, depth)}`
: "";
if (
!e.orderBy &&
args.length === 1 &&
(args[0].type === "TableConstructor" || args[0].type === "String")
) {
return `${base} ${this.expression(args[0], depth)}`;
}
return `${base}(${args.map((a) => this.expression(a, depth)).join(", ")})${orderBy}`;
}
private fieldList(fields: LuaTableField[], depth: number): string {
return fields.map((f) => this.field(f, depth)).join(", ");
}
private orderByList(orders: LuaOrderBy[], depth: number): string {
return orders
.map((o) => {
let s = this.expression(o.expression, depth);
if (o.direction === "desc") s += " desc";
if (o.nulls) s += ` nulls ${o.nulls}`;
return s;
})
.join(", ");
}
private queryClause(c: LuaQueryClause, depth: number): string {
switch (c.type) {
case "From":
return `from ${this.fieldList(c.fields, depth)}`;
case "Where":
return `where ${this.expression(c.expression, depth)}`;
case "Limit":
return (
`limit ${this.expression(c.limit, depth)}` +
(c.offset ? ` offset ${this.expression(c.offset, depth)}` : "")
);
case "Offset":
return `offset ${this.expression(c.offset, depth)}`;
case "OrderBy":
return `order by ${this.orderByList(c.orderBy, depth)}`;
case "Select":
return `select ${this.fieldList(c.fields, depth)}`;
case "GroupBy":
return `group by ${this.fieldList(c.fields, depth)}`;
case "Having":
return `having ${this.expression(c.expression, depth)}`;
}
}
block(b: LuaBlock, depth: number): string {
const stmts = b.statements.filter((s) => s.type !== "Semicolon");
const items: (LuaStatement | NonNullable<LuaBlock["comments"]>[number])[] =
[...stmts, ...(b.comments ?? [])].sort(
(a, z) => (a.ctx.from ?? 0) - (z.ctx.from ?? 0),
);
const isDef = (s: (typeof items)[number]) =>
s.type === "Function" || s.type === "LocalFunction";
let out = "";
for (let i = 0; i < items.length; i++) {
if (i > 0) {
out += "\n";
// blank line separating function definitions from neighbours
const previous = items[i - 1];
const isDocumentationPair =
previous.type === "Comment" &&
previous.text.startsWith("---") &&
isDef(items[i]);
if (!isDocumentationPair && (isDef(previous) || isDef(items[i]))) {
out += "\n";
}
}
const item = items[i];
out +=
item.type === "Comment"
? this.indent(depth) +
item.text.replaceAll("\n", `\n${this.indent(depth)}`)
: this.statement(item, depth);
}
return out;
}
private statement(s: LuaStatement, depth: number): string {
const ind = this.indent(depth);
switch (s.type) {
case "Local": {
const names = s.names
.map(
(n) =>
n.name +
(n.attribute
? ` <${n.attribute}>`
: n.attributes?.length
? ` <${n.attributes[0]}>`
: ""),
)
.join(", ");
const exprs = s.expressions?.length
? ` = ${s.expressions.map((e) => this.expression(e, depth)).join(", ")}`
: "";
return `${ind}local ${names}${exprs}`;
}
case "Assignment":
return `${ind}${s.variables
.map((v) => this.expression(v, depth))
.join(
", ",
)} = ${s.expressions.map((e) => this.expression(e, depth)).join(", ")}`;
case "Return":
return s.expressions.length
? `${ind}return ${s.expressions
.map((e) => this.expression(e, depth))
.join(", ")}`
: `${ind}return`;
case "FunctionCallStatement":
return ind + this.expression(s.call, depth);
case "Break":
return `${ind}break`;
case "Goto":
return `${ind}goto ${s.name}`;
case "Label":
return `${ind}::${s.name}::`;
case "Block": {
const body = this.block(s, depth + 1);
return body ? `${ind}do\n${body}\n${ind}end` : `${ind}do end`;
}
case "If": {
let out = "";
s.conditions.forEach((c, i) => {
const kw = i === 0 ? "if" : "elseif";
const head = i === 0 ? `${ind}${kw} ` : `\n${ind}${kw} `;
const body = this.block(c.block, depth + 1);
out +=
`${head}${this.expression(c.condition, depth)} then` +
(body ? `\n${body}` : "");
});
if (s.elseBlock) {
const body = this.block(s.elseBlock, depth + 1);
out += `\n${ind}else${body ? `\n${body}` : ""}`;
}
out += `\n${ind}end`;
return out;
}
case "While": {
const body = this.block(s.block, depth + 1);
return (
`${ind}while ${this.expression(s.condition, depth)} do` +
(body ? `\n${body}` : "") +
`\n${ind}end`
);
}
case "Repeat": {
const body = this.block(s.block, depth + 1);
return (
`${ind}repeat` +
(body ? `\n${body}` : "") +
`\n${ind}until ${this.expression(s.condition, depth)}`
);
}
case "For": {
const step = s.step ? `, ${this.expression(s.step, depth)}` : "";
const body = this.block(s.block, depth + 1);
return `${ind}for ${s.name} = ${this.expression(s.start, depth)}, ${this.expression(
s.end,
depth,
)}${step} do${body ? `\n${body}` : ""}\n${ind}end`;
}
case "ForIn": {
const body = this.block(s.block, depth + 1);
return `${ind}for ${s.names.join(", ")} in ${s.expressions
.map((e) => this.expression(e, depth))
.join(", ")} do${body ? `\n${body}` : ""}\n${ind}end`;
}
case "Function": {
const name =
s.name.propNames.join(".") +
(s.name.colonName ? `:${s.name.colonName}` : "");
return (
`${ind}function ${name}` +
this.functionRest(s.body.parameters, s.body.block, depth)
);
}
case "LocalFunction":
return (
`${ind}local function ${s.name}` +
this.functionRest(s.body.parameters, s.body.block, depth)
);
default:
throw new Error(
`pretty_print: unsupported statement type: ${(s as any).type}`,
);
}
}
}
export function prettyPrintExpression(
expr: LuaExpression,
opts?: PrintOptions,
): string {
return new Printer(resolveOptions(opts)).expression(expr, 0);
}
export function prettyPrintBlock(block: LuaBlock, opts?: PrintOptions): string {
return new Printer(resolveOptions(opts)).block(block, 0);
}