Files
plainleaf/client/space_lua/runtime.ts
T
Matouš Jan FialkaandGitHub 7912f05949 Space Lua: Add goto and labels support (#1686)
* Space Lua: Add AST block type label/goto and duplicated label bools
* Space Lua: Add label/goto resolution and evaluation

Add per block map of labels metadata, per function block metadata cache
and `processBlock` to track AST blocks (`If`, `While`, `Repeat`, `For`
and `ForIn`, and `Do`) while recording labels and their positions.

Align with Lua scoping rules (_e.g._, a `goto` can not jump into the
scope of a local). Also allow the super-special "safe" end of block
cases (except inside `Repeat`). Duplicated labels are detected (error
`label 'foo' is already defined` etc.). The AST context for error
reporting is preserved. The `WeakMap` caches map any block to its
function's metadata.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: The `goto` parse flags and eval path

At parser mark each block with `hasLabel`/`hasGoto` and record the first
duplicated label. Flags then propagate through blocks and control flow.

If no `goto`, run the block in a simple loop and raise duplicate label
error if any is present. If `goto` exists, jump via a label map.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Add labels and `goto` statement test suite

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Minor `goto`/labels improvement and test hotfix

- The `getBlockGotoMeta` now called only when a block has either
  `hasGoto` or `hasLabel`.

- Add `BlocksFlags` type to get rid of some `(... as any)`s by view.

- Add `blockMetaOrThrow` helper.

- Fix test to reflect standard Lua semantics.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Goto: Add missing Lua semantics checks

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Goto: Add Lua semantics checks also covered in Lua tests

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Goto: Fix semantic divergence revealed by fixing unfinished test

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix typo

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Optimize block env creation and cache goto metadata

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Avoid using the venomous snake case

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Add comment for WeakMap use

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Block Label Fast-path Optimization

Add block flag `hasLabelHere` during parsing and use it in eval to skip
expensive metadata generation for blocks and entire functions without
label or goto.

Refactor eval logic:
- early fast path when a function is known to have no label/goto,
- later fast path for blocks in functions with goto.

Fetch metadata only when needed.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Space Lua: Remove `BlockFlags` indirection from eval

- Add `hasLabelHere` flag to `LuaBlock`.
- Remove `BlockFlags` indirection from eval.
- Set `hasLabelHere` directly.
- Add some "guards".
- Improve consistency of child filtering.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Remove redundant return statement

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

---------

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
2025-11-18 16:35:08 +01:00

1097 lines
29 KiB
TypeScript

import type { ASTCtx, LuaFunctionBody } from "./ast.ts";
import { evalStatement } from "./eval.ts";
import { asyncQuickSort } from "./util.ts";
import { isPromise, rpAll } from "./rp.ts";
export type LuaType =
| "nil"
| "boolean"
| "number"
| "string"
| "table"
| "function"
| "userdata"
| "thread";
// These types are for documentation only
export type LuaValue = any;
export type JSValue = any;
export interface ILuaFunction {
call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue;
asString(): string;
}
export interface ILuaSettable {
set(key: LuaValue, value: LuaValue, sf?: LuaStackFrame): void;
}
export interface ILuaGettable {
get(key: LuaValue, sf?: LuaStackFrame): LuaValue | undefined;
}
// Small helpers for type safety/readability
export function isILuaFunction(v: unknown): v is ILuaFunction {
return !!v && typeof (v as any).call === "function";
}
export function isLuaTable(v: unknown): v is LuaTable {
return v instanceof LuaTable;
}
export function toNumKey(key: unknown): string | number {
if (key instanceof Number) {
return Number(key);
}
if (typeof key === "number" || typeof key === "string") {
return key;
}
return key as unknown as string | number;
}
export function ctxOrNull(sf?: LuaStackFrame): ASTCtx | null {
return sf?.astCtx ?? null;
}
// Reuse a single empty context to avoid allocating `{}` in hot paths
const EMPTY_CTX = {} as ASTCtx;
export class LuaEnv implements ILuaSettable, ILuaGettable {
variables = new Map<string, LuaValue>();
constructor(readonly parent?: LuaEnv) {
}
setLocal(name: string, value: LuaValue) {
this.variables.set(name, value);
}
set(key: string, value: LuaValue, sf?: LuaStackFrame): void {
if (this.variables.has(key) || !this.parent) {
this.variables.set(key, value);
} else {
this.parent.set(key, value, sf);
}
}
has(key: string): boolean {
if (this.variables.has(key)) {
return true;
}
if (this.parent) {
return this.parent.has(key);
}
return false;
}
get(
name: string,
sf?: LuaStackFrame,
): Promise<LuaValue> | LuaValue | undefined {
if (this.variables.has(name)) {
return this.variables.get(name);
}
if (this.parent) {
return this.parent.get(name, sf);
}
return null;
}
/**
* Lists all keys in the environment including its parents
*/
keys(): string[] {
const keys = Array.from(this.variables.keys());
if (this.parent) {
return keys.concat(this.parent.keys());
}
return keys;
}
toJSON(omitKeys: string[] = []): Record<string, any> {
const result: Record<string, any> = {};
for (const key of this.keys()) {
if (omitKeys.includes(key)) {
continue;
}
result[key] = luaValueToJS(this.get(key), LuaStackFrame.lostFrame);
}
return result;
}
}
export class LuaStackFrame {
static lostFrame = new LuaStackFrame(new LuaEnv(), null);
constructor(
readonly threadLocal: LuaEnv,
readonly astCtx: ASTCtx | null,
readonly parent?: LuaStackFrame,
readonly currentFunction?: LuaFunction,
) {
}
static createWithGlobalEnv(
globalEnv: LuaEnv,
ctx: ASTCtx | null = null,
): LuaStackFrame {
const env = new LuaEnv();
env.setLocal("_GLOBAL", globalEnv);
return new LuaStackFrame(env, ctx);
}
withCtx(ctx: ASTCtx): LuaStackFrame {
return new LuaStackFrame(this.threadLocal, ctx, this, this.currentFunction);
}
withFunction(fn: LuaFunction): LuaStackFrame {
return new LuaStackFrame(this.threadLocal, this.astCtx, this.parent, fn);
}
}
export class LuaMultiRes {
values: any[];
constructor(values: LuaValue[] | LuaValue) {
if (values instanceof LuaMultiRes) {
this.values = values.values;
} else {
this.values = Array.isArray(values) ? values : [values];
}
}
unwrap(): any {
if (this.values.length === 0) {
return null;
}
return this.values[0];
}
// Takes an array of either LuaMultiRes or LuaValue and flattens them into a single LuaMultiRes
flatten(): LuaMultiRes {
const result: any[] = [];
for (const value of this.values) {
if (value instanceof LuaMultiRes) {
result.push(...value.values);
} else {
result.push(value);
}
}
return new LuaMultiRes(result);
}
}
export function singleResult(value: any): any {
if (value instanceof LuaMultiRes) {
return value.unwrap();
} else {
return value;
}
}
export class LuaFunction implements ILuaFunction {
private capturedEnv: LuaEnv;
funcHasGotos?: boolean;
constructor(readonly body: LuaFunctionBody, closure: LuaEnv) {
this.capturedEnv = closure;
}
call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
// Create a new environment that chains to the captured environment
const env = new LuaEnv(this.capturedEnv);
if (!sf) {
console.trace(sf);
}
// Set _CTX to the thread local environment from the stack frame
env.setLocal("_CTX", sf.threadLocal);
// Eval using a stack frame that knows the current function
const sfWithFn = sf.currentFunction === this ? sf : sf.withFunction(this);
// Resolve args (sync-first)
const argsRP = rpAll(args as any[]);
const resolveArgs = (resolvedArgs: any[]) => {
// Assign parameter values to variable names in env
let varargs: LuaValue[] = [];
for (let i = 0; i < this.body.parameters.length; i++) {
const paramName = this.body.parameters[i];
if (paramName === "...") {
// Vararg parameter, let's collect the remainder of the resolved args into the varargs array
varargs = resolvedArgs.slice(i);
// Done, break out of this loop
break;
}
env.setLocal(paramName, resolvedArgs[i] ?? null);
}
env.setLocal("...", new LuaMultiRes(varargs));
// Evaluate the function body with returnOnReturn set to true
try {
const r = evalStatement(this.body.block, env, sfWithFn, true);
const map = (val: any) => {
if (val !== undefined) {
return mapFunctionReturnValue(val);
}
};
if (isPromise(r)) {
return r.then(map).catch((e: any) => {
if (e instanceof LuaReturn) {
return mapFunctionReturnValue(e.values);
}
throw e;
});
} else {
return map(r);
}
} catch (e: any) {
if (e instanceof LuaReturn) {
return mapFunctionReturnValue(e.values);
}
throw e;
}
};
if (isPromise(argsRP)) {
return argsRP.then(resolveArgs);
} else {
return resolveArgs(argsRP);
}
}
asString(): string {
return `<lua function(${this.body.parameters.join(", ")})>`;
}
toString(): string {
return this.asString();
}
}
function mapFunctionReturnValue(values: any[]): any {
if (values.length === 0) {
return;
} else if (values.length === 1) {
return values[0];
} else {
return new LuaMultiRes(values);
}
}
export class LuaNativeJSFunction implements ILuaFunction {
constructor(readonly fn: (...args: JSValue[]) => JSValue) {
}
// Performs automatic conversion between Lua and JS values for arguments, but not for return values
call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
const jsArgsRP = args.map((v) => luaValueToJS(v, sf));
const resolved = rpAll(jsArgsRP);
if (isPromise(resolved)) {
return resolved.then((jsArgs) => this.fn(...jsArgs));
} else {
return this.fn(...resolved);
}
}
asString(): string {
return `<native js function: ${this.fn.name}>`;
}
toString(): string {
return this.asString();
}
}
export class LuaBuiltinFunction implements ILuaFunction {
constructor(
readonly fn: (sf: LuaStackFrame, ...args: LuaValue[]) => LuaValue,
) {
}
call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
// _CTX is already available via the stack frame
return this.fn(sf, ...args);
}
asString(): string {
return `<builtin lua function>`;
}
toString(): string {
return this.asString();
}
}
export class LuaTable implements ILuaSettable, ILuaGettable {
// To optimize the table implementation we use a combination of different data structures
public metatable: LuaTable | null;
// When tables are used as maps, the common case is that they are string keys, so we use a simple object for that
private stringKeys: Record<string, any>;
// Other keys we can support using a Map as a fallback
private otherKeys: Map<any, any> | null;
// When tables are used as arrays, we use a native JavaScript array for that
private arrayPart: any[];
// Metamethod presence flags
hasIndexMM = false;
hasNewIndexMM = false;
hasLenMM = false;
constructor(init?: any[] | Record<string, any>) {
// For efficiency and performance reasons we pre-allocate these (modern JS engines are very good at optimizing this)
this.arrayPart = Array.isArray(init) ? init : [];
this.stringKeys = init && !Array.isArray(init) ? init : {};
if (init && !Array.isArray(init)) {
for (const k in init) {
if (Object.prototype.hasOwnProperty.call(init, k)) {
this.stringKeys[k] = (init as any)[k];
}
}
}
this.otherKeys = null; // Only create this when needed
this.metatable = null;
}
private static isIntegerKey(key: any): boolean {
const k = key instanceof Number ? Number(key) : key;
return typeof k === "number" && Number.isInteger(k) && k >= 1;
}
private static toIndex(key: any): number {
return (key instanceof Number ? Number(key) : key) - 1;
}
get length(): number {
return this.arrayPart.length;
}
keys(): any[] {
const keys: any[] = [];
for (const k in this.stringKeys) {
if (Object.prototype.hasOwnProperty.call(this.stringKeys, k)) {
keys.push(k);
}
}
for (let i = 0; i < this.arrayPart.length; i++) {
keys.push(i + 1);
}
if (this.otherKeys) {
for (const key of this.otherKeys.keys()) {
keys.push(key);
}
}
return keys;
}
empty(): boolean {
for (const k in this.stringKeys) {
if (Object.prototype.hasOwnProperty.call(this.stringKeys, k)) {
return false;
}
}
if (this.arrayPart.length !== 0) {
return false;
}
if (this.otherKeys && this.otherKeys.size !== 0) {
return false;
}
return true;
}
has(key: LuaValue) {
if (typeof key === "string") {
return this.stringKeys[key] !== undefined;
} else if (LuaTable.isIntegerKey(key)) {
return this.arrayPart[LuaTable.toIndex(key)] !== undefined;
} else if (this.otherKeys) {
return this.otherKeys.has(key);
}
return false;
}
rawSet(key: LuaValue, value: LuaValue): void | Promise<void> {
if (isPromise(key)) {
return key.then((key) => this.rawSet(key, value));
}
if (isPromise(value)) {
return value.then((v) => this.rawSet(key, v));
}
if (typeof key === "string") {
if (value === null || value === undefined) {
delete this.stringKeys[key];
} else {
this.stringKeys[key] = value;
}
return;
}
if (LuaTable.isIntegerKey(key)) {
this.arrayPart[LuaTable.toIndex(key)] = value;
return;
}
if (!this.otherKeys) {
this.otherKeys = new Map();
}
if (value === null || value === undefined) {
this.otherKeys.delete(key);
} else {
this.otherKeys.set(key, value);
}
}
set(
key: LuaValue,
value: LuaValue,
sf?: LuaStackFrame,
): Promise<void> | void {
const mt = this.metatable;
if (!mt) {
return this.rawSet(key, value);
}
// Refresh flags when used (fix: treat both undefined and null as absent)
const newIndexMM = mt.rawGet("__newindex");
this.hasNewIndexMM = !(newIndexMM === undefined || newIndexMM === null);
if (this.hasNewIndexMM && !this.has(key)) {
// Invoke the meta table
const metaValue = mt.get("__newindex", sf);
// Ensure we pass a non-null ASTCtx to luaCall
const callCtx: ASTCtx = sf?.astCtx ?? EMPTY_CTX;
if (isPromise(metaValue)) {
// This is a promise, we need to wait for it
return (metaValue as Promise<any>).then((mv: any) => {
return luaCall(
mv,
[this, key, value],
callCtx,
sf,
);
});
} else {
return luaCall(
metaValue,
[this, key, value],
callCtx,
sf,
);
}
}
// Just set the value
return this.rawSet(key, value);
}
rawGet(key: LuaValue): LuaValue | null {
if (typeof key === "string") {
return this.stringKeys[key];
} else if (LuaTable.isIntegerKey(key)) {
return this.arrayPart[LuaTable.toIndex(key)];
} else if (this.otherKeys) {
return this.otherKeys.get(key);
}
}
get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise<LuaValue> | null {
return luaIndexValue(this, key, sf);
}
insert(value: LuaValue, pos: number) {
this.arrayPart.splice(pos - 1, 0, value);
}
remove(pos: number) {
this.arrayPart.splice(pos - 1, 1);
}
async sort(fn?: ILuaFunction, sf?: LuaStackFrame) {
if (fn && sf) {
this.arrayPart = await asyncQuickSort(this.arrayPart, async (a, b) => {
return (await fn.call(sf, a, b)) ? -1 : 1;
});
} else {
this.arrayPart.sort();
}
}
toJSObject(sf = LuaStackFrame.lostFrame): Record<string, any> {
const result: Record<string, any> = {};
for (const key of this.keys()) {
result[key] = luaValueToJS(this.get(key, sf), sf);
}
return result;
}
toJSArray(sf = LuaStackFrame.lostFrame): any[] {
return this.arrayPart.map((v) => luaValueToJS(v, sf));
}
toJS(sf = LuaStackFrame.lostFrame): Record<string, any> | any[] {
if (this.length > 0) {
return this.toJSArray(sf);
} else {
return this.toJSObject(sf);
}
}
async toStringAsync(): Promise<string> {
const metatable = getMetatable(this);
if (metatable && metatable.has("__tostring")) {
const metaValue = await metatable.get("__tostring");
if (metaValue.call) {
return metaValue.call(LuaStackFrame.lostFrame, this);
} else {
throw new Error("Meta table __tostring must be a function");
}
}
let result = "{";
let first = true;
for (const key of this.keys()) {
if (first) {
first = false;
} else {
result += ", ";
}
if (typeof key === "number") {
result += await luaToString(this.get(key));
continue;
}
if (typeof key === "string") {
result += key;
} else {
result += "[" + key + "]";
}
result += " = " + await luaToString(this.get(key));
}
result += "}";
return result;
}
}
/**
* Lookup a key in a table or a metatable
*/
export function luaIndexValue(
value: LuaValue,
key: LuaValue,
sf?: LuaStackFrame,
): LuaValue | Promise<LuaValue> | null {
if (value === null || value === undefined) {
return null;
}
// The value is a table, so we can try to get the value directly
if (value instanceof LuaTable) {
const rawValue = value.rawGet(key);
if (rawValue !== undefined) {
return rawValue;
}
// Skip metatable lookup fast path
if (value.metatable === null) {
return null;
}
}
// If not, let's see if the value has a metatable and if it has a __index metamethod
const metatable = getMetatable(value, sf);
if (metatable && metatable.has("__index")) {
// Invoke the meta table
const metaValue = metatable.get("__index", sf);
if (isPromise(metaValue)) {
// Got a promise, we need to wait for it
return (metaValue as Promise<any>).then((mv: any) => {
if (mv?.call) {
return luaCall(mv, [value, key], sf?.astCtx ?? EMPTY_CTX, sf);
} else if (mv instanceof LuaTable) {
return mv.get(key, sf);
} else {
throw new LuaRuntimeError(
"Meta table __index must be a function or table",
sf || LuaStackFrame.lostFrame,
);
}
});
} else {
const mv = metaValue as any;
if (mv?.call) {
return luaCall(mv, [value, key], sf?.astCtx ?? EMPTY_CTX, sf);
} else if (mv instanceof LuaTable) {
return mv.get(key, sf);
} else {
throw new LuaRuntimeError(
"Meta table __index must be a function or table",
sf || LuaStackFrame.lostFrame,
);
}
}
}
// If not, perhaps let's assume this is a plain JavaScript object and we just index into it
const k = toNumKey(key);
if (
typeof value === "object" && Array.isArray(value) &&
typeof k === "number"
) {
return (value as any[])[k - 1];
}
const objValue = (value as Record<string | number, any>)[k];
if (objValue === undefined || objValue === null) {
return null;
} else {
return objValue;
}
}
export type LuaLValueContainer = { env: ILuaSettable; key: LuaValue };
export async function luaSet(
obj: any,
key: any,
value: any,
sf: LuaStackFrame,
): Promise<void> {
if (!obj) {
throw new LuaRuntimeError(
`Not a settable object: nil`,
sf,
);
}
if (obj instanceof LuaTable || obj instanceof LuaEnv) {
await obj.set(key, value, sf);
} else {
const k = toNumKey(key);
(obj as Record<string | number, any>)[k] = value;
}
}
export function luaGet(
obj: any,
key: any,
ctx: ASTCtx | null,
sf: LuaStackFrame,
): Promise<any> | any {
const errSf = ctx ? sf.withCtx(ctx) : sf;
if (obj === null || obj === undefined) {
throw new LuaRuntimeError(
`attempt to index a nil value`,
errSf,
);
}
if (key === null || key === undefined) {
throw new LuaRuntimeError(
`attempt to index with a nil key`,
errSf,
);
}
if (obj instanceof LuaTable || obj instanceof LuaEnv) {
return obj.get(key, sf);
} else if (typeof key === "number" || key instanceof Number) {
const idx = key instanceof Number ? Number(key) : key;
return (obj as any[])[idx - 1];
} else {
// Native JS object
const k = toNumKey(key);
const val = (obj as Record<string | number, any>)[k];
if (typeof val === "function") {
// Automatically bind the function to the object
return val.bind(obj);
} else if (val === undefined) {
return null;
} else {
return val;
}
}
}
export function luaLen(
obj: any,
sf?: LuaStackFrame,
): number {
// Only strings, Lua tables and JS arrays
if (
typeof obj === "string" || obj instanceof LuaTable || Array.isArray(obj)
) {
return obj.length;
}
const t = luaTypeOf(obj) as LuaType;
throw new LuaRuntimeError(
`bad argument #1 to 'rawlen' (table or string expected, got ${t})`,
sf || LuaStackFrame.lostFrame,
);
}
export function luaCall(
callee: any,
args: any[],
ctx: ASTCtx,
sf?: LuaStackFrame,
): any {
if (!callee) {
throw new LuaRuntimeError(
`attempt to call a nil value`,
(sf || LuaStackFrame.lostFrame).withCtx(ctx),
);
}
if (typeof callee === "function") {
const jsArgs = rpAll(
args.map((v) => luaValueToJS(v, sf || LuaStackFrame.lostFrame)),
);
if (isPromise(jsArgs)) {
return jsArgs.then((resolved) =>
(callee as (...a: any[]) => any)(...resolved)
);
} else {
return (callee as (...a: any[]) => any)(...jsArgs);
}
} else if (callee instanceof LuaTable) {
const metatable = getMetatable(callee, sf);
if (metatable && metatable.has("__call")) {
// Invoke the meta table
const metaValue = metatable.get("__call", sf);
const callMetaValue = (value: any) => {
if (value && value.call) {
return luaCall(value, [callee, ...args], ctx, sf);
} else {
throw new LuaRuntimeError(
"Meta table __call must be a function",
(sf || LuaStackFrame.lostFrame).withCtx(ctx),
);
}
};
if (isPromise(metaValue)) {
// Got a promise, we need to wait for it
return (metaValue as Promise<any>).then((value: any) => {
return callMetaValue(value);
});
} else {
return callMetaValue(metaValue);
}
}
} else if (isILuaFunction(callee)) {
const base = (sf || LuaStackFrame.lostFrame).withCtx(ctx);
const frameForCall = callee instanceof LuaFunction
? base.withFunction(callee)
: base;
return callee.call(
frameForCall,
...args,
);
} else {
throw new LuaRuntimeError(
`attempt to call a non-callable value of type: ${luaTypeOf(callee)}`,
(sf || LuaStackFrame.lostFrame).withCtx(ctx),
);
}
}
export function luaEquals(a: any, b: any): boolean {
const an = a instanceof Number ? Number(a) : a;
const bn = b instanceof Number ? Number(b) : b;
if ((typeof an === "number") && (typeof bn === "number")) {
return an === bn;
}
return a === b;
}
export function luaKeys(val: any): any[] {
if (val instanceof LuaTable) {
return val.keys();
} else if (Array.isArray(val)) {
return val.map((_, i) => i + 1);
} else {
return Object.keys(val);
}
}
export function luaTypeOf(val: any): LuaType | Promise<LuaType> {
if (val === null || val === undefined) {
return "nil";
}
if (isPromise(val)) {
return (val as Promise<any>).then((v) => luaTypeOf(v));
} else if (typeof val === "boolean") {
return "boolean";
} else if (typeof val === "number" || val instanceof Number) {
return "number";
} else if (typeof val === "string") {
return "string";
} else if (val instanceof LuaTable) {
return "table";
} else if (Array.isArray(val)) {
return "table";
} else if (typeof val === "function" || isILuaFunction(val)) {
return "function";
} else if (typeof val === "object" && (val as any).constructor === Object) {
return "table";
} else {
return "userdata";
}
}
// Both `break` and `return` are implemented by exception throwing
export class LuaBreak extends Error {
}
export class LuaReturn extends Error {
constructor(readonly values: LuaValue[]) {
super();
}
}
export class LuaRuntimeError extends Error {
constructor(
override readonly message: string,
public sf: LuaStackFrame,
cause?: Error,
) {
super(message, cause);
}
toPrettyString(code: string): string {
if (!this.sf || !this.sf.astCtx?.from || !this.sf.astCtx?.to) {
return this.toString();
}
let traceStr = "";
let current: LuaStackFrame | undefined = this.sf;
while (current) {
const ctx = current.astCtx;
if (!ctx || !ctx.from || !ctx.to) {
break;
}
// Find the line and column
let line = 1;
let column = 0;
let lastNewline = -1;
for (let i = 0; i < ctx.from; i++) {
if (code[i] === "\n") {
line++;
lastNewline = i;
column = 0;
} else {
column++;
}
}
// Get the full line of code for context
const lineStart = lastNewline + 1;
const lineEnd = code.indexOf("\n", ctx.from);
const codeLine = code.substring(
lineStart,
lineEnd === -1 ? undefined : lineEnd,
);
// Add position indicator
const pointer = " ".repeat(column) + "^";
traceStr += `* ${ctx.ref || "(unknown source)"} @ ${line}:${column}:\n` +
` ${codeLine}\n` +
` ${pointer}\n`;
current = current.parent;
}
return `LuaRuntimeError: ${this.message}\nStack trace:\n${traceStr}`;
}
override toString() {
return `LuaRuntimeError: ${this.message} at ${this.sf.astCtx?.from}, ${this.sf.astCtx?.to}`;
}
}
export function luaTruthy(value: any): boolean {
if (value === undefined || value === null || value === false) {
return false;
}
if (typeof value === "object" && value instanceof LuaMultiRes) {
// for multi-return values, only the first result determines truthiness
const first = value.unwrap();
return !(first === null || first === undefined || first === false);
}
// all non-`nil`/non-`false` values are truthy (including empty tables)
return true;
}
export function luaToString(
value: any,
visited: Set<any> = new Set(),
): string | Promise<string> {
if (value === null || value === undefined) {
return "nil";
}
if (isPromise(value)) {
return (value as Promise<any>).then((v) => luaToString(v, visited));
}
// Check for circular references
if (typeof value === "object" && visited.has(value)) {
return "<circular reference>";
}
if ((value as any).toStringAsync) {
// Add to visited before recursing
visited.add(value);
return (value as any).toStringAsync();
}
if ((value as any).asString) {
visited.add(value);
return (value as any).asString();
}
if (value instanceof LuaFunction) {
// Don't recurse into the function body, just show the function signature
return `<lua-function (${value.body.parameters.join(", ")})>`;
}
// Handle plain JavaScript objects in a Lua-like format
if (typeof value === "object") {
// Add to visited before recursing
visited.add(value);
return (async () => {
let result = "{";
let first = true;
// Handle arrays
if (Array.isArray(value)) {
for (const val of value) {
if (first) {
first = false;
} else {
result += ", ";
}
// Recursively stringify the value, passing the visited set
const strVal = await luaToString(val, visited);
result += strVal;
}
return result + "}";
}
// Handle objects
for (const [key, val] of Object.entries(value)) {
if (first) {
first = false;
} else {
result += ", ";
}
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
result += `${key} = `;
} else {
result += `["${key}"] = `;
}
// Recursively stringify the value, passing the visited set
const strVal = await luaToString(val, visited);
result += strVal;
}
result += "}";
return result;
})();
}
return String(value);
}
export function getMetatable(
value: LuaValue,
sf?: LuaStackFrame,
): LuaValue | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value === "string") {
// Prefer per-thread cached string metatable if `_GLOBAL` available
const thread = sf?.threadLocal;
const globalEnv = thread?.get("_GLOBAL") as LuaEnv | null | undefined;
if (thread && globalEnv instanceof LuaEnv) {
const cached = thread.get("_STRING_MT") as LuaTable | undefined;
if (cached instanceof LuaTable) {
return cached;
}
const stringMetatable = new LuaTable();
stringMetatable.set("__index", (globalEnv as any).get("string"));
thread.setLocal("_STRING_MT", stringMetatable);
return stringMetatable;
}
return null;
}
if ((value as any).metatable) {
return (value as any).metatable;
} else {
return null;
}
}
export function jsToLuaValue(value: any): any {
if (isPromise(value)) {
return (value as Promise<any>).then(jsToLuaValue);
}
if (value instanceof LuaTable) {
return value;
} else if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
return value;
} else if (Array.isArray(value) && "index" in value && "input" in value) {
// This is a RegExpMatchArray
const regexMatch = value as RegExpMatchArray;
const regexMatchTable = new LuaTable();
for (let i = 0; i < regexMatch.length; i++) {
regexMatchTable.set(i + 1, regexMatch[i]);
}
regexMatchTable.set("index", regexMatch.index);
regexMatchTable.set("input", regexMatch.input);
regexMatchTable.set("groups", regexMatch.groups);
return regexMatchTable;
} else if (Array.isArray(value)) {
const table = new LuaTable();
for (let i = 0; i < value.length; i++) {
table.set(i + 1, jsToLuaValue(value[i]));
}
return table;
} else if (typeof value === "object") {
const table = new LuaTable();
for (const key in value) {
table.set(key, jsToLuaValue((value as any)[key]));
}
return table;
} else if (typeof value === "function") {
return new LuaNativeJSFunction(value);
} else {
return value;
}
}
// Inverse of jsToLuaValue
export function luaValueToJS(value: any, sf: LuaStackFrame): any {
if (isPromise(value)) {
return (value as Promise<any>).then((v) => luaValueToJS(v, sf));
}
if (value instanceof LuaTable) {
return value.toJS(sf);
} else if (
value instanceof LuaNativeJSFunction || value instanceof LuaFunction ||
value instanceof LuaBuiltinFunction
) {
return (...args: any[]) => {
const jsArgs = rpAll(
args.map((v) => luaValueToJS(v, sf)),
);
if (isPromise(jsArgs)) {
return luaValueToJS(
jsArgs.then((jsArgs) => (value as ILuaFunction).call(sf, ...jsArgs)),
sf,
);
} else {
return luaValueToJS((value as ILuaFunction).call(sf, ...jsArgs), sf);
}
};
} else if (value instanceof Number) {
return Number(value);
} else {
return value;
}
}