import type { ASTCtx, LuaFunctionBody } from "./ast.ts"; import { evalStatement } from "./eval.ts"; import { asyncQuickSort } from "./util.ts"; import { isPromise, rpAll } from "./rp.ts"; import { isFloatTag, isNegativeZero } from "./numeric.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; asString(): string; } export interface ILuaSettable { set(key: LuaValue, value: LuaValue, sf?: LuaStackFrame): void | Promise; } export interface ILuaGettable { get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise | null; } // 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; // Close-stack support export type LuaCloseEntry = { value: LuaValue; ctx: ASTCtx }; type LuaThreadState = { closeStack?: LuaCloseEntry[]; }; export function luaTypeName(val: any): LuaType { if (isFloatTag(val)) { return "number"; } const t = luaTypeOf(val); if (typeof t === "string") { return t; } if (val === null || val === undefined) { return "nil"; } const ty = typeof val; if (ty === "number" || val instanceof Number) { return "number"; } if (ty === "string") { return "string"; } if (ty === "boolean") { return "boolean"; } if (ty === "function") { return "function"; } if (Array.isArray(val)) { return "table"; } if (ty === "object" && (val as any).constructor === Object) { return "table"; } return "userdata"; } // Check whether a value is callable without invoking it. export function luaIsCallable( v: LuaValue, sf: LuaStackFrame, ): boolean { if (v === null || v === undefined) { return false; } if (typeof v === "function") { return true; } if (isILuaFunction(v)) { return true; } if (v instanceof LuaTable) { const mt = getMetatable(v, sf); if (mt && mt.has("__call")) { const mm = mt.get("__call", sf); return !!mm && (typeof mm === "function" || isILuaFunction(mm)); } } return false; } // In Lua, `__close` must be a function (no `__call` fallback). function luaIsCloseMethod( v: LuaValue, ): boolean { return typeof v === "function" || isILuaFunction(v); } export function luaEnsureCloseStack(sf: LuaStackFrame): LuaCloseEntry[] { if (!sf.threadState.closeStack) { sf.threadState.closeStack = []; } return sf.threadState.closeStack as LuaCloseEntry[]; } export function luaMarkToBeClosed( sf: LuaStackFrame, value: LuaValue, ctx: ASTCtx, ): void { const closeStack = luaEnsureCloseStack(sf); // In Lua, `nil` is not closed. if (value === null) { return; } const mt = getMetatable(value, sf); if (!mt || !mt.has("__close")) { throw new LuaRuntimeError( "variable got a non-closable value", sf.withCtx(ctx), ); } const mm = mt.get("__close"); if (!luaIsCloseMethod(mm)) { throw new LuaRuntimeError( "variable got a non-closable value", sf.withCtx(ctx), ); } closeStack.push({ value, ctx }); } // Close entries from a mark (LIFO) and shrink stack back to mark. This // is the core semantic for block exits and protected call boundaries. export function luaCloseFromMark( sf: LuaStackFrame, mark: number, errObj: LuaValue | null, ): Promise | void { const closeStack = sf.threadState?.closeStack as LuaCloseEntry[] | undefined; if (!closeStack) { return; } if (closeStack.length <= mark) { return; } const callClose = (entry: LuaCloseEntry): LuaValue | Promise => { const mt = getMetatable(entry.value, sf); const mm = mt ? mt.get("__close", sf) : null; if (!luaIsCloseMethod(mm)) { throw new LuaRuntimeError( "metamethod '__close' is not callable", sf.withCtx(entry.ctx), ); } if (errObj === null) { return luaCall(mm, [entry.value], entry.ctx, sf); } return luaCall(mm, [entry.value, errObj], entry.ctx, sf); }; // Close all to-be-closed variables (LIFO) even if one close errors. // The reported error should be the first close error encountered. const runFrom = (i: number): void | Promise => { let firstErr: unknown | null = null; const recordErr = (e: unknown) => { if (firstErr === null) { firstErr = e; } }; const next = (idx: number): void | Promise => { for (let j = idx; j >= mark; j--) { let r: LuaValue | Promise; try { r = callClose(closeStack[j]); } catch (e) { recordErr(e); continue; } if (isPromise(r)) { return (r as Promise).then( () => next(j - 1), (e: any) => { recordErr(e); return next(j - 1); }, ); } } closeStack.length = mark; if (firstErr !== null) { throw firstErr; } }; return next(i); }; return runFrom(closeStack.length - 1); } export class LuaEnv implements ILuaSettable, ILuaGettable { variables = new Map(); private readonly consts = new Set(); constructor(readonly parent?: LuaEnv) { } setLocal(name: string, value: LuaValue) { this.variables.set(name, value); } setLocalConst(name: string, value: LuaValue) { this.variables.set(name, value); this.consts.add(name); } set(key: string, value: LuaValue, sf?: LuaStackFrame): void { if (this.variables.has(key) || !this.parent) { if (this.consts.has(key)) { throw new LuaRuntimeError( `attempt to assign to const variable '${key}'`, sf || LuaStackFrame.lostFrame, ); } 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 | null { 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 { const result: Record = {}; for (const key of this.keys()) { if (omitKeys.includes(key)) { continue; } result[key] = luaValueToJS(this.get(key), LuaStackFrame.lostFrame); } return result; } } export class LuaStackFrame { // Must not share mutable per-thread state across calls/tests. This is // a getter that returns a fresh frame each time. static get lostFrame(): LuaStackFrame { return new LuaStackFrame(new LuaEnv(), null, undefined, undefined, { closeStack: undefined, }); } constructor( readonly threadLocal: LuaEnv, readonly astCtx: ASTCtx | null, readonly parent?: LuaStackFrame, readonly currentFunction?: LuaFunction, readonly threadState: LuaThreadState = { closeStack: undefined }, ) { } static createWithGlobalEnv( globalEnv: LuaEnv, ctx: ASTCtx | null = null, ): LuaStackFrame { const env = new LuaEnv(); env.setLocal("_GLOBAL", globalEnv); return new LuaStackFrame(env, ctx, undefined, undefined, { closeStack: undefined, }); } withCtx(ctx: ASTCtx): LuaStackFrame { return new LuaStackFrame( this.threadLocal, ctx, this, this.currentFunction, this.threadState, ); } withFunction(fn: LuaFunction): LuaStackFrame { return new LuaStackFrame( this.threadLocal, this.astCtx, this.parent, fn, this.threadState, ); } } 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 { // 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 const r = evalStatement(this.body.block, env, sfWithFn, true); const map = (val: any) => { if (val === undefined) { return; } if (val && typeof val === "object" && val.ctrl === "return") { return mapFunctionReturnValue(val.values); } if (val && typeof val === "object" && val.ctrl === "break") { throw new LuaRuntimeError( "break outside loop", sfWithFn.withCtx(this.body.block.ctx), ); } if (val && typeof val === "object" && val.ctrl === "goto") { throw new LuaRuntimeError( "unexpected goto signal", sfWithFn.withCtx(this.body.block.ctx), ); } }; if (isPromise(r)) { return r.then(map); } else { return map(r); } }; if (isPromise(argsRP)) { return argsRP.then(resolveArgs); } else { return resolveArgs(argsRP); } } asString(): string { return ``; } 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 { 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 ``; } 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 { // _CTX is already available via the stack frame return this.fn(sf, ...args); } asString(): string { return ``; } 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; // Other keys we can support using a Map as a fallback private otherKeys: Map | 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) { // 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 numKeyValue(key: any): number | null { if (isFloatTag(key)) { return key.value; } if (key instanceof Number) { return Number(key); } if (typeof key === "number") { return key; } return null; } // Normalize numeric keys for table indexing: // * negative zero becomes positive zero, // * integer-valued floats become plain integers, // * non-integer floats stay as-is. private static normalizeNumericKey(key: any): any { const numVal = LuaTable.numKeyValue(key); if (numVal !== null) { // Normalize -0 to +0 if (isNegativeZero(numVal)) { return 0; } // Integer-valued numbers normalize to integers if (Number.isInteger(numVal)) { return numVal; } // Non-integer floats return numVal; } return key; } private static isIntegerKey(key: any): boolean { const norm = LuaTable.normalizeNumericKey(key); return typeof norm === "number" && Number.isInteger(norm) && norm >= 1; } private static toIndex(key: any): number { const norm = LuaTable.normalizeNumericKey(key); const k = typeof norm === "number" ? norm : (norm as number); return k - 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) { const normalizedKey = LuaTable.normalizeNumericKey(key); if (typeof normalizedKey === "string") { return this.stringKeys[normalizedKey] !== undefined; } else if (LuaTable.isIntegerKey(normalizedKey)) { return this.arrayPart[LuaTable.toIndex(normalizedKey)] !== undefined; } else if (this.otherKeys) { return this.otherKeys.has(normalizedKey); } return false; } rawSet(key: LuaValue, value: LuaValue): void | Promise { if (isPromise(key)) { return key.then((key) => this.rawSet(key, value)); } if (isPromise(value)) { return value.then((v) => this.rawSet(key, v)); } const normalizedKey = LuaTable.normalizeNumericKey(key); if (typeof normalizedKey === "string") { if (value === null || value === undefined) { delete this.stringKeys[normalizedKey]; } else { this.stringKeys[normalizedKey] = value; } return; } if (LuaTable.isIntegerKey(normalizedKey)) { this.arrayPart[LuaTable.toIndex(normalizedKey)] = value; return; } if (!this.otherKeys) { this.otherKeys = new Map(); } if (value === null || value === undefined) { this.otherKeys.delete(normalizedKey); } else { this.otherKeys.set(normalizedKey, value); } } set( key: LuaValue, value: LuaValue, sf?: LuaStackFrame, ): Promise | 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).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 { const normalizedKey = LuaTable.normalizeNumericKey(key); if (typeof normalizedKey === "string") { return this.stringKeys[normalizedKey]; } else if (LuaTable.isIntegerKey(normalizedKey)) { return this.arrayPart[LuaTable.toIndex(normalizedKey)]; } else if (this.otherKeys) { return this.otherKeys.get(normalizedKey); } } get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise | 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 { const result: Record = {}; 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 | any[] { if (this.length > 0) { return this.toJSArray(sf); } else { return this.toJSObject(sf); } } async toStringAsync(): Promise { 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 | 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) { const mm = metatable.rawGet("__index"); if (!(mm === undefined || mm === null)) { // Invoke the meta table const metaValue = mm; if (isPromise(metaValue)) { // Got a promise, we need to wait for it return (metaValue as Promise).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; 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)[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 { 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); // Writing into a "native" JavaScript object, need to do value conversion (obj as Record)[k] = await luaValueToJS(value, sf); } } export function luaGet( obj: any, key: any, ctx: ASTCtx | null, sf: LuaStackFrame, ): Promise | 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)[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).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 { // Unwrap Number objects let an = a instanceof Number ? Number(a) : a; let bn = b instanceof Number ? Number(b) : b; // Unwrap LuaFloatTag objects if (an && typeof an === "object" && "type" in an) { an = an.value; } if (bn && typeof bn === "object" && "type" in bn) { bn = bn.value; } return an === bn; } 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 { if (val === null || val === undefined) { return "nil"; } if (isPromise(val)) { return (val as Promise).then((v) => luaTypeOf(v)); } else if (typeof val === "boolean") { return "boolean"; } else if (typeof val === "number" || val instanceof Number) { return "number"; } else if (isFloatTag(val)) { 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"; } } 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 = new Set(), ): string | Promise { if (value === null || value === undefined) { return "nil"; } if (isPromise(value)) { return (value as Promise).then((v) => luaToString(v, visited)); } if (value && typeof value === "object" && value.type === "float") { return luaFormatNumber(value.value, "float"); } if (value instanceof Number) { const numVal = Number(value); const symbols = Object.getOwnPropertySymbols(value); for (const sym of symbols) { const kind = (value as any)[sym]; if (kind === "int" || kind === "float") { return luaFormatNumber(numVal, kind); } } return luaFormatNumber(numVal); } if (typeof value === "number") { return luaFormatNumber(value); } // Check for circular references if (typeof value === "object" && visited.has(value)) { return ""; } 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 ``; } // 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); } // Format a number according to Lua semantics: // * integers: plain format (e.g., `42`); // * floats: with decimal point (e.g., `1.0`); // * special values: `inf`, `-inf`, `nan`, `-nan`; // * negative zero: `-0.0`. function luaFormatNumber(n: number, kind?: "int" | "float"): string { if (!isFinite(n)) { if (isNaN(n)) { // NaN in Lua always formats as `-nan` return "-nan"; } return n < 0 ? "-inf" : "inf"; } if (n === 0 && isNegativeZero(n)) { return "-0.0"; } if (n === 0 && kind === "float") { return "0.0"; } if (kind === "float" && Number.isInteger(n)) { return n.toString() + ".0"; } if (Number.isInteger(n)) { return String(n); } return String(n); } export function getMetatable( value: LuaValue, sf?: LuaStackFrame, ): LuaTable | 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 as LuaTable; } else { return null; } } export function jsToLuaValue(value: any): any { if (isPromise(value)) { return (value as Promise).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).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; } }