import type { ASTCtx, LuaFunctionBody, NumericType } from "./ast.ts"; import { evalStatement } from "./eval.ts"; import { asyncQuickSort } from "./util.ts"; import { isPromise, rpAll } from "./rp.ts"; import { isNegativeZero, isTaggedFloat } from "./numeric.ts"; import { isSqlNull } from "./sliq_null.ts"; import { luaFormat } from "./stdlib/format.ts"; import type { LuaFunctionDocumentation, LuaFunctionInfo, } from "../../plug-api/types/index.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; type LuaFunctionDefinitionMetadata = LuaFunctionDocumentation & Partial>; export type LuaFunctionDefinition = { callback: Callback; } & LuaFunctionDefinitionMetadata; export interface ILuaFunction { call(sf: LuaStackFrame, ...args: LuaValue[]): Promise | LuaValue; asString(): string; info?: LuaFunctionInfo; } export interface ILuaSettable { set( key: LuaValue, value: LuaValue, sf?: LuaStackFrame, numType?: NumericType, ): void | Promise; } export interface ILuaGettable { get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise | null; getNumericType?(key: LuaValue): NumericType | 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 (isTaggedFloat(key)) { return key.value; } 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; const MAX_TAG_LOOP = 200; // Close-stack support export type LuaCloseEntry = { value: LuaValue; ctx: ASTCtx }; type LuaThreadState = { closeStack?: LuaCloseEntry[]; }; function isLuaNumber(v: any): boolean { return typeof v === "number" || isTaggedFloat(v); } export function luaTypeName(val: any): LuaType { if (val === null || val === undefined || isSqlNull(val)) { return "nil"; } const t = luaTypeOf(val); if (typeof t === "string") { return t; } const ty = typeof val; if (ty === "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?.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(); private readonly numericTypes = new Map(); constructor(readonly parent?: LuaEnv) {} setLocal(name: string, value: LuaValue, numType?: NumericType) { this.variables.set(name, value); if (numType) { this.numericTypes.set(name, numType); } else if (this.numericTypes.size > 0) { this.numericTypes.delete(name); } } setLocalConst(name: string, value: LuaValue, numType?: NumericType) { this.variables.set(name, value); this.consts.add(name); if (numType) { this.numericTypes.set(name, numType); } else if (this.numericTypes.size > 0) { this.numericTypes.delete(name); } } set( key: string, value: LuaValue, sf?: LuaStackFrame, numType?: NumericType, ): 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); if (numType) { this.numericTypes.set(key, numType); } else if (this.numericTypes.size > 0) { this.numericTypes.delete(key); } } else { this.parent.set(key, value, sf, numType); } } getNumericType(name: string): NumericType | undefined { if (this.numericTypes.has(name)) { return this.numericTypes.get(name); } if (this.parent) { return this.parent.getNumericType(name); } return undefined; } 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 { // Fast path: single Map.get() instead of has() + get() for the common case // where the variable exists and has a non-undefined value (null = Lua nil is fine). const v = this.variables.get(name); if (v !== undefined) { return v; } // Variable set to null (Lua nil) returns null from Map.get() which is !== undefined, // so it's handled above. Only fall through when the key is truly absent. 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(); } return value; } export class LuaFunction implements ILuaFunction { private capturedEnv: LuaEnv; funcHasGotos?: boolean; info: LuaFunctionInfo; constructor( readonly body: LuaFunctionBody, closure: LuaEnv, info: Partial = {}, ) { this.capturedEnv = closure; const documentation = body.documentation ?? {}; this.info = { kind: "lua", source: { ...body.ctx }, ...documentation, ...info, parameters: info.parameters ?? documentation.parameters ?? body.parameters.map((name) => ({ name })), }; } call(sf: LuaStackFrame, ...args: LuaValue[]): Promise | LuaValue { // Create a new environment that chains to the captured environment const env = new LuaEnv(this.capturedEnv); // 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); const params = this.body.parameters; const hasVarargs = params.length > 0 && params[params.length - 1] === "..."; // Resolve args (sync-first) const argsRP = rpAll(args as any[]); const resolveArgs = (resolvedArgs: any[]) => { if (hasVarargs) { // Variadic function: bind named params, collect rest into varargs const varargStart = params.length - 1; for (let i = 0; i < varargStart; i++) { env.setLocal(params[i], resolvedArgs[i] ?? null); } env.setLocal("...", new LuaMultiRes(resolvedArgs.slice(varargStart))); } else { // Non-variadic: bind all named params directly for (let i = 0; i < params.length; i++) { env.setLocal(params[i], resolvedArgs[i] ?? null); } } // Evaluate the function body with returnOnReturn set to true const r = evalStatement(this.body.block, env, sfWithFn, true); if (!isPromise(r)) { // Fast path: synchronous result if (r === undefined) return; if (r && typeof r === "object" && r.ctrl === "return") { return mapFunctionReturnValue(r.values); } if (r && typeof r === "object" && r.ctrl === "break") { throw new LuaRuntimeError( "break outside loop", sfWithFn.withCtx(this.body.block.ctx), ); } if (r && typeof r === "object" && r.ctrl === "goto") { throw new LuaRuntimeError( "unexpected goto signal", sfWithFn.withCtx(this.body.block.ctx), ); } return; } return r.then((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(argsRP)) { return argsRP.then(resolveArgs); } return resolveArgs(argsRP); } asString(): string { return ``; } toString(): string { return this.asString(); } } function mapFunctionReturnValue(values: any[]): any { if (values.length === 0) { return; } if (values.length === 1) { return values[0]; } return new LuaMultiRes(values); } export class LuaNativeJSFunction implements ILuaFunction { readonly fn: (...args: JSValue[]) => JSValue; public info: LuaFunctionInfo; constructor( definition: | ((...args: JSValue[]) => JSValue) | LuaFunctionDefinition<(...args: JSValue[]) => JSValue>, ) { if (typeof definition === "function") { this.fn = definition; this.info = { kind: "builtin" }; } else { const { callback, ...info } = definition; this.fn = callback; this.info = { kind: "builtin", ...info, }; } } // Performs automatic conversion between Lua and JS values for arguments, but not for return values call(sf: LuaStackFrame, ...args: LuaValue[]): Promise | LuaValue { // Avoid .map() allocation: convert in-place and check for promises const len = args.length; let hasAsync = false; for (let i = 0; i < len; i++) { const converted = luaValueToJS(args[i], sf); args[i] = converted; if (!hasAsync && isPromise(converted)) { hasAsync = true; } } if (hasAsync) { return Promise.all(args).then((jsArgs) => this.fn(...jsArgs)); } return this.fn(...args); } asString(): string { return ``; } toString(): string { return this.asString(); } } export class LuaBuiltinFunction implements ILuaFunction { readonly fn: (sf: LuaStackFrame, ...args: LuaValue[]) => LuaValue; public info: LuaFunctionInfo; constructor( definition: | ((sf: LuaStackFrame, ...args: LuaValue[]) => LuaValue) | LuaFunctionDefinition< (sf: LuaStackFrame, ...args: LuaValue[]) => LuaValue >, ) { if (typeof definition === "function") { this.fn = definition; this.info = { kind: "builtin" }; } else { const { callback, ...info } = definition; this.fn = callback; this.info = { kind: "builtin", ...info, }; } } 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[]; // Numeric type metadata at storage boundaries (lazily allocated) private stringKeyTypes: Map | null = null; private otherKeyTypes: Map | null = null; private arrayTypes: (NumericType | undefined)[] | null = null; 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 : {}; this.otherKeys = null; this.metatable = null; } private static numKeyValue(key: any): number | null { if (isTaggedFloat(key)) { return key.value; } 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. static normalizeNumericKey(key: any): any { if (typeof key === "string") { return key; } 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 rawLength(): number { return this.arrayPart.length; } get length(): number { let n = this.arrayPart.length; while (n > 0) { const v = this.arrayPart[n - 1]; if (v === null || v === undefined) { n--; continue; } break; } return n; } keys(): any[] { const keys: any[] = []; for (const k in this.stringKeys) { if (Object.hasOwn(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.hasOwn(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; } // Fast path for plain integer keys (common in for loops) if (typeof key === "number") { if (key >= 1 && (key | 0) === key) { if (this.arrayPart[key - 1] !== undefined) return true; return this.otherKeys ? this.otherKeys.has(key) : false; } return this.otherKeys ? this.otherKeys.has(key) : false; } const normalizedKey = LuaTable.normalizeNumericKey(key); if ( typeof normalizedKey === "number" && Number.isInteger(normalizedKey) && normalizedKey >= 1 ) { const idx = normalizedKey - 1; const v = this.arrayPart[idx]; if (v !== undefined) { return true; } return this.otherKeys ? this.otherKeys.has(normalizedKey) : false; } if (typeof normalizedKey === "string") { return this.stringKeys[normalizedKey] !== undefined; } if (this.otherKeys) { return this.otherKeys.has(normalizedKey); } return false; } // Used by table constructors to preserve positional semantics // including nils and ensure the array part grows to the final // constructor size. rawSetArrayIndex( index1: number, value: LuaValue, numType?: NumericType, ): void { const idx = index1 - 1; this.arrayPart[idx] = value; if (isLuaNumber(value) && numType) { if (!this.arrayTypes) this.arrayTypes = []; this.arrayTypes[idx] = numType; } else if (this.arrayTypes) { this.arrayTypes[idx] = undefined; } } private promoteIntegerKeysFromHash(): void { if (!this.otherKeys) return; while (true) { const nextIndex1 = this.arrayPart.length + 1; if (!this.otherKeys.has(nextIndex1)) { break; } const v = this.otherKeys.get(nextIndex1); const nt = this.otherKeyTypes ? this.otherKeyTypes.get(nextIndex1) : undefined; this.otherKeys.delete(nextIndex1); if (this.otherKeyTypes) { this.otherKeyTypes.delete(nextIndex1); } this.arrayPart.push(v); if (nt) { if (!this.arrayTypes) this.arrayTypes = []; this.arrayTypes.push(nt); } else if (this.arrayTypes) { this.arrayTypes.push(undefined); } } } rawSet( key: LuaValue, value: LuaValue, numType?: NumericType, ): void | Promise { if (isPromise(key)) { return key.then((key) => this.rawSet(key, value, numType)); } if (isPromise(value)) { return value.then((v) => this.rawSet(key, v, numType)); } // Fast path: string keys (the dominant case) if (typeof key === "string") { if (value === null || value === undefined) { delete this.stringKeys[key]; if (this.stringKeyTypes) this.stringKeyTypes.delete(key); } else { this.stringKeys[key] = value; if (numType) { if (!this.stringKeyTypes) this.stringKeyTypes = new Map(); this.stringKeyTypes.set(key, numType); } else if (this.stringKeyTypes) { this.stringKeyTypes.delete(key); } } return; } const normalizedKey = LuaTable.normalizeNumericKey(key); if (typeof normalizedKey === "string") { if (value === null || value === undefined) { delete this.stringKeys[normalizedKey]; if (this.stringKeyTypes) this.stringKeyTypes.delete(normalizedKey); } else { this.stringKeys[normalizedKey] = value; if (isLuaNumber(value) && numType) { if (!this.stringKeyTypes) this.stringKeyTypes = new Map(); this.stringKeyTypes.set(normalizedKey, numType); } else if (this.stringKeyTypes) { this.stringKeyTypes.delete(normalizedKey); } } return; } if ( typeof normalizedKey === "number" && Number.isInteger(normalizedKey) && normalizedKey >= 1 ) { const idx = normalizedKey - 1; // Sparse writes (e.g. `a[7]=4` when length is 3) go to the hash // part so that `#a` does not jump across holes. if (idx <= this.arrayPart.length) { this.arrayPart[idx] = value; if (isLuaNumber(value) && numType) { if (!this.arrayTypes) this.arrayTypes = []; this.arrayTypes[idx] = numType; } else if (this.arrayTypes) { this.arrayTypes[idx] = undefined; } // If we extended the array by appending, we may now be able to // promote subsequent integer keys from the hash part. if (idx === this.arrayPart.length - 1) { this.promoteIntegerKeysFromHash(); } // Trailing nil shrink if (value === null || value === undefined) { let n = this.arrayPart.length; while (n > 0) { const v = this.arrayPart[n - 1]; if (v === null || v === undefined) { n--; continue; } break; } if (n !== this.arrayPart.length) { this.arrayPart.length = n; if (this.arrayTypes) this.arrayTypes.length = n; } } return; } // Sparse numeric key if (!this.otherKeys) { this.otherKeys = new Map(); } if (!this.otherKeyTypes) { this.otherKeyTypes = new Map(); } if (value === null || value === undefined) { this.otherKeys.delete(normalizedKey); this.otherKeyTypes.delete(normalizedKey); } else { this.otherKeys.set(normalizedKey, value); if (isLuaNumber(value) && numType) { this.otherKeyTypes.set(normalizedKey, numType); } else { this.otherKeyTypes.delete(normalizedKey); } } return; } if (!this.otherKeys) { this.otherKeys = new Map(); } if (!this.otherKeyTypes) { this.otherKeyTypes = new Map(); } if (value === null || value === undefined) { this.otherKeys.delete(normalizedKey); this.otherKeyTypes.delete(normalizedKey); } else { this.otherKeys.set(normalizedKey, value); if (isLuaNumber(value) && numType) { this.otherKeyTypes.set(normalizedKey, numType); } else { this.otherKeyTypes.delete(normalizedKey); } } } set( key: LuaValue, value: LuaValue, sf?: LuaStackFrame, numType?: NumericType, ): Promise | void { if (key === null || key === undefined) { throw new LuaRuntimeError( "table index is nil", sf || LuaStackFrame.lostFrame, ); } if (typeof key === "number" && Number.isNaN(key)) { throw new LuaRuntimeError( "table index is NaN", sf || LuaStackFrame.lostFrame, ); } // Fast path: no metatable — skip has() check and metamethod machinery if (this.metatable === null) { return this.rawSet(key, value, numType); } // Key exists — rawSet directly, no metamethod needed if (this.has(key)) { return this.rawSet(key, value, numType); } const newIndexMM = this.metatable.rawGet("__newindex"); if (newIndexMM === undefined || newIndexMM === null) { return this.rawSet(key, value, numType); } // Slow path: __newindex metamethod chain (rare) const errSf = sf || LuaStackFrame.lostFrame; const ctx = sf?.astCtx ?? EMPTY_CTX; const k: LuaValue = key; const v: LuaValue = value; const nt: NumericType | undefined = numType; let target: LuaValue | null = null; for (let loop = 0; loop < MAX_TAG_LOOP; loop++) { const t = target === null ? this : target; if (t instanceof LuaTable) { if (t.has(k)) { return t.rawSet(k, v, nt); } const mt = t.metatable; if (!mt) { return t.rawSet(k, v, nt); } const mm = mt.rawGet("__newindex"); const hasMM = !(mm === undefined || mm === null); if (!hasMM) { return t.rawSet(k, v, nt); } // Function metamethod: call and stop if (typeof mm === "function" || isILuaFunction(mm)) { return luaCall(mm, [t, k, v], ctx, errSf); } // Table/env metamethod: forward assignment if (mm instanceof LuaTable || mm instanceof LuaEnv) { target = mm; continue; } const ty = luaTypeOf(mm) as any as string; throw new LuaRuntimeError( `attempt to index a ${ty} value`, errSf.withCtx(ctx), ); } const ty = luaTypeOf(t) as any as string; throw new LuaRuntimeError( `attempt to index a ${ty} value`, errSf.withCtx(ctx), ); } throw new LuaRuntimeError( "'__newindex' chain too long; possible loop", errSf.withCtx(ctx), ); } getNumericType(key: LuaValue): NumericType | undefined { if (typeof key === "string") { return this.stringKeyTypes ? this.stringKeyTypes.get(key) : undefined; } // Fast path for plain integer keys if (typeof key === "number") { if (key >= 1 && (key | 0) === key) { return this.arrayTypes ? this.arrayTypes[key - 1] : undefined; } return this.otherKeyTypes ? this.otherKeyTypes.get(key) : undefined; } if (LuaTable.isIntegerKey(key)) { return this.arrayTypes ? this.arrayTypes[LuaTable.toIndex(key)] : undefined; } if (this.otherKeyTypes) { return this.otherKeyTypes.get(key); } return undefined; } rawGet(key: LuaValue): LuaValue | null { if (typeof key === "string") { return this.stringKeys[key]; } // Fast path for plain integer keys (common in for loops) if (typeof key === "number") { if (key >= 1 && (key | 0) === key) { const v = this.arrayPart[key - 1]; if (v !== undefined) return v; if (this.otherKeys) return this.otherKeys.get(key); return undefined; } // Non-integer or zero/negative number keys if (this.otherKeys) return this.otherKeys.get(key); return undefined; } const normalizedKey = LuaTable.normalizeNumericKey(key); if (typeof normalizedKey === "string") { return this.stringKeys[normalizedKey]; } if ( typeof normalizedKey === "number" && Number.isInteger(normalizedKey) && normalizedKey >= 1 ) { const idx = normalizedKey - 1; const v = this.arrayPart[idx]; if (v !== undefined) { return v; } // Sparse integer keys can live in the hash part. if (this.otherKeys) { return this.otherKeys.get(normalizedKey); } return undefined; } if (this.otherKeys) { return this.otherKeys.get(normalizedKey); } } get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise | null { // Fast path: no metatable means rawGet is the final answer (most tables in SilverBullet) if (this.metatable === null) { const raw = this.rawGet(key); return raw !== undefined ? raw : 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 = {}; // Direct access to stringKeys avoids keys() allocation and get() metatable checks for (const k in this.stringKeys) { if (Object.hasOwn(this.stringKeys, k)) { result[k] = luaValueToJS(this.stringKeys[k], sf); } } // Include array part with 1-based keys for (let i = 0; i < this.arrayPart.length; i++) { result[i + 1] = luaValueToJS(this.arrayPart[i], sf); } // Include other keys if (this.otherKeys) { for (const [key, val] of this.otherKeys) { result[key] = luaValueToJS(val, 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); } return this.toJSObject(sf); } async toStringAsync(): Promise { const metatable = getMetatable(this); if (metatable) { const mm = metatable.rawGet("__tostring"); if (!(mm === undefined || mm === null)) { const ctx = EMPTY_CTX; const sf = LuaStackFrame.lostFrame.withCtx(ctx); const r = luaCall(mm, [this], ctx, sf); const v = isPromise(r) ? await r : r; const s = singleResult(v); if (typeof s !== "string") { throw new LuaRuntimeError("'__tostring' must return a string", sf); } return s; } } 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 { // `nil` handling is done by luaGet() which has better context; // keep this defensive for direct callers. if (value === null || value === undefined) { return null; } const errSf = sf || LuaStackFrame.lostFrame; const ctx = sf?.astCtx ?? EMPTY_CTX; let t: LuaValue = value; for (let loop = 0; loop < MAX_TAG_LOOP; loop++) { // Primitive get when table if (t instanceof LuaTable) { const raw = t.rawGet(key); if (raw !== undefined) { if (isSqlNull(raw)) return null; return raw; } // If no metatable, raw miss => nil if (t.metatable === null) { return null; } } const mt = getMetatable(t, errSf); const mm = mt ? mt.rawGet("__index") : null; if (mm === undefined || mm === null) { // Strict Lua: only tables are indexable without a metamethod. // For a table, raw miss yields nil; for non-table, it's a type error. if (t instanceof LuaTable) { return null; } const ty = luaTypeOf(t) as any as string; throw new LuaRuntimeError( `attempt to index a ${ty} value`, errSf.withCtx(ctx), ); } // Function metamethod if (typeof mm === "function" || isILuaFunction(mm)) { return luaCall(mm, [t, key], ctx, errSf); } // Table/metatable delegation: repeat with mm as new "t" if (mm instanceof LuaTable || mm instanceof LuaEnv) { t = mm; continue; } // Bad metamethod type: make it a Lua-like type error const ty = luaTypeOf(mm) as any as string; throw new LuaRuntimeError( `attempt to index a ${ty} value`, errSf.withCtx(ctx), ); } throw new LuaRuntimeError( "'__index' chain too long; possible loop", errSf.withCtx(ctx), ); } export type LuaLValueContainer = { env: ILuaSettable; key: LuaValue }; export function luaSet( obj: any, key: any, value: any, sf: LuaStackFrame, numType?: NumericType, ): void | Promise { if (!obj) { throw new LuaRuntimeError(`Not a settable object: nil`, sf); } const normKey = isTaggedFloat(key) ? key.value : key; if (obj instanceof LuaTable || obj instanceof LuaEnv) { return obj.set(normKey, value, sf, numType); } else { const k = toNumKey(normKey); const jsVal = luaValueToJS(value, sf); if (isPromise(jsVal)) { return (jsVal as Promise).then((v) => { (obj as Record)[k] = v; }); } (obj as Record)[k] = jsVal; } } export function luaGet( obj: any, key: any, ctx: ASTCtx | null, sf: LuaStackFrame, ): Promise | any { if (obj === null || obj === undefined) { throw new LuaRuntimeError( `attempt to index a nil value`, ctx ? sf.withCtx(ctx) : sf, ); } // In Lua reading with a nil key returns nil silently if (key === null || key === undefined) { return null; } if (obj instanceof LuaTable || obj instanceof LuaEnv) { return obj.get(key, sf); } // Native JS array access: normalize undefined → null (Lua nil). // Without this, accessing an out-of-bounds index on a JS array // leaks JS undefined into the Lua runtime, breaking nil checks // such as `tags[1] ~= nil` on an empty array. if (typeof key === "number") { return (obj as any[])[key - 1] ?? null; } if (isTaggedFloat(key)) { return (obj as any[])[key.value - 1] ?? null; } // 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); } if (val === undefined) { return null; } return val; } export function luaLen( obj: any, sf?: LuaStackFrame, raw = false, ): number | Promise { if (typeof obj === "string") { return obj.length; } if (Array.isArray(obj)) { return obj.length; } if (obj instanceof LuaTable) { // Check __len metamethod unless raw access is requested if (!raw) { const mt = getMetatable(obj, sf || LuaStackFrame.lostFrame); const mm = mt ? mt.rawGet("__len") : null; if (mm !== undefined && mm !== null) { const r = luaCall(mm, [obj], (sf?.astCtx ?? {}) as ASTCtx, sf); if (isPromise(r)) { return (r as Promise).then((v: any) => Number(singleResult(v))); } return Number(singleResult(r)); } } return obj.rawLength; } 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), ); } // Fast path: native JS function if (typeof callee === "function") { const baseSf = sf || LuaStackFrame.lostFrame; const len = args.length; let hasAsync = false; for (let i = 0; i < len; i++) { const converted = luaValueToJS(args[i], baseSf); args[i] = converted; if (!hasAsync && isPromise(converted)) { hasAsync = true; } } if (hasAsync) { return Promise.all(args).then((resolved) => (callee as (...a: any[]) => any)(...resolved), ); } return (callee as (...a: any[]) => any)(...args); } // Lua table: may be callable via __call metamethod if (callee instanceof LuaTable) { const metatable = getMetatable(callee, sf); // Metamethod lookup must be raw (no __index involvement). const mm = metatable ? metatable.rawGet("__call") : null; if (!(mm === undefined || mm === null)) { const isCallable = (v: any): boolean => { if (v === null || v === undefined) return false; if (typeof v === "function") return true; if (isILuaFunction(v)) return true; if (v instanceof LuaTable) { const mt2 = getMetatable(v, sf); const mm2 = mt2 ? mt2.rawGet("__call") : null; return !(mm2 === undefined || mm2 === null); } return false; }; if (!isCallable(mm)) { throw new LuaRuntimeError( `attempt to call a ${luaTypeOf(mm)} value`, (sf || LuaStackFrame.lostFrame).withCtx(ctx), ); } return luaCall(mm, [callee, ...args], ctx, sf); } } // ILuaFunction (LuaFunction/LuaBuiltinFunction/LuaNativeJSFunction/etc.) if (isILuaFunction(callee)) { // Fast path for builtins: skip withFunction, defer withCtx if (callee instanceof LuaBuiltinFunction) { const base = sf || LuaStackFrame.lostFrame; return callee.call(base, ...args); } // Fast path for native JS functions: skip withCtx/withFunction if (callee instanceof LuaNativeJSFunction) { const base = sf || LuaStackFrame.lostFrame; return callee.call(base, ...args); } const base = (sf || LuaStackFrame.lostFrame).withCtx(ctx); const frameForCall = callee instanceof LuaFunction ? base.withFunction(callee) : base; return callee.call(frameForCall, ...args); } 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 { // Normalize nil variants (null, undefined, SQL NULL) to null const an = a === null || a === undefined || isSqlNull(a) ? null : isTaggedFloat(a) ? a.value : a; const bn = b === null || b === undefined || isSqlNull(b) ? null : isTaggedFloat(b) ? b.value : b; return an === bn; } export function luaKeys(val: any): any[] { if (val instanceof LuaTable) { return val.keys(); } if (Array.isArray(val)) { return val.map((_, i) => i + 1); } return Object.keys(val); } export function luaTypeOf(val: any): LuaType | Promise { if (val === null || val === undefined || isSqlNull(val)) { return "nil"; } if (isPromise(val)) { return (val as Promise).then((v) => luaTypeOf(v)); } if (typeof val === "boolean") { return "boolean"; } if (typeof val === "number") { return "number"; } if (isTaggedFloat(val)) { return "number"; } if (typeof val === "string") { return "string"; } if (val instanceof LuaTable) { return "table"; } if (Array.isArray(val)) { return "table"; } if (typeof val === "function" || isILuaFunction(val)) { return "function"; } if (typeof val === "object" && (val as any).constructor === Object) { return "table"; } 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 || isSqlNull(value)) { return "nil"; } if (isPromise(value)) { return (value as Promise).then((v) => luaToString(v, visited)); } if (isTaggedFloat(value)) { return luaFormatNumber(value.value, "float"); } 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); } export function luaFormatNumber(n: number, kind?: "int" | "float"): string { if (kind !== "float" && Number.isInteger(n) && Number.isFinite(n)) { return String(n); } if (n !== n) return "-nan"; if (n === Infinity) return "inf"; if (n === -Infinity) return "-inf"; if (n === 0) { return 1 / n === -Infinity ? "-0.0" : "0.0"; } // Delegate to luaFormat for `%.14g` const s = luaFormat("%.14g", n); // Guarantee `.01 suffix for integer-valued floats if (s.indexOf(".") === -1 && s.indexOf("e") === -1) { return `${s}.0`; } return s; } 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(); void 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; } return null; } export function jsToLuaValue(value: any): any { if (value === null || value === undefined) { return value; } if (isPromise(value)) { return (value as Promise).then(jsToLuaValue); } if (value instanceof LuaTable) { return value; } if (value instanceof Uint8Array || value instanceof ArrayBuffer) { return value; } 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++) { void regexMatchTable.set(i + 1, regexMatch[i]); } void regexMatchTable.set("index", regexMatch.index); void regexMatchTable.set("input", regexMatch.input); void regexMatchTable.set("groups", regexMatch.groups); return regexMatchTable; } if (Array.isArray(value)) { const converted = new Array(value.length); for (let i = 0; i < value.length; i++) { converted[i] = jsToLuaValue(value[i]); } return new LuaTable(converted); } if (typeof value === "object") { const converted: Record = {}; for (const key in value) { converted[key] = jsToLuaValue((value as any)[key]); } return new LuaTable(converted); } if (typeof value === "function") { return new LuaNativeJSFunction(value); } 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); } 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, ); } return luaValueToJS((value as ILuaFunction).call(sf, ...jsArgs), sf); }; } if (isTaggedFloat(value)) { return value.value; } return value; }