Files
plainleaf/client/space_lua/pretty_print.ts
T

392 lines
12 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 isDef = (s: LuaStatement) =>
s.type === "Function" || s.type === "LocalFunction";
let out = "";
for (let i = 0; i < stmts.length; i++) {
if (i > 0) {
out += "\n";
// blank line separating function definitions from neighbours
if (isDef(stmts[i - 1]) || isDef(stmts[i])) out += "\n";
}
out += this.statement(stmts[i], 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);
}