Files

1897 lines
50 KiB
TypeScript

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<Pick<LuaFunctionInfo, "kind" | "name" | "source">>;
export type LuaFunctionDefinition<Callback> = {
callback: Callback;
} & LuaFunctionDefinitionMetadata;
export interface ILuaFunction {
call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue;
asString(): string;
info?: LuaFunctionInfo;
}
export interface ILuaSettable {
set(
key: LuaValue,
value: LuaValue,
sf?: LuaStackFrame,
numType?: NumericType,
): void | Promise<void>;
}
export interface ILuaGettable {
get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise<LuaValue> | 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> | void {
const closeStack = sf.threadState?.closeStack as LuaCloseEntry[] | undefined;
if (!closeStack) {
return;
}
if (closeStack.length <= mark) {
return;
}
const callClose = (entry: LuaCloseEntry): LuaValue | Promise<LuaValue> => {
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<void> => {
let firstErr: unknown | null = null;
const recordErr = (e: unknown) => {
if (firstErr === null) {
firstErr = e;
}
};
const next = (idx: number): void | Promise<void> => {
for (let j = idx; j >= mark; j--) {
let r: LuaValue | Promise<LuaValue>;
try {
r = callClose(closeStack[j]);
} catch (e) {
recordErr(e);
continue;
}
if (isPromise(r)) {
return (r as Promise<any>).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<string, LuaValue>();
private readonly consts = new Set<string>();
private readonly numericTypes = new Map<string, NumericType>();
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> | 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<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 {
// 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<LuaFunctionInfo> = {},
) {
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> | 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 `<lua function(${this.body.parameters.join(", ")})>`;
}
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> | 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 `<native js function: ${this.fn.name}>`;
}
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> | 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[];
// Numeric type metadata at storage boundaries (lazily allocated)
private stringKeyTypes: Map<string, NumericType> | null = null;
private otherKeyTypes: Map<any, NumericType> | null = null;
private arrayTypes: (NumericType | undefined)[] | null = null;
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 : {};
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<void> {
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> | 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<LuaValue> | 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<string, any> {
const result: Record<string, any> = {};
// 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<string, any> | any[] {
if (this.length > 0) {
return this.toJSArray(sf);
}
return this.toJSObject(sf);
}
async toStringAsync(): Promise<string> {
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<LuaValue> | 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<void> {
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<any>).then((v) => {
(obj as Record<string | number, any>)[k] = v;
});
}
(obj as Record<string | number, any>)[k] = jsVal;
}
}
export function luaGet(
obj: any,
key: any,
ctx: ASTCtx | null,
sf: LuaStackFrame,
): Promise<any> | 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<string | number, any>)[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<number> {
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<any>).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<LuaType> {
if (val === null || val === undefined || isSqlNull(val)) {
return "nil";
}
if (isPromise(val)) {
return (val as Promise<any>).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<any> = new Set(),
): string | Promise<string> {
if (value === null || value === undefined || isSqlNull(value)) {
return "nil";
}
if (isPromise(value)) {
return (value as Promise<any>).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 "<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 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<any>).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<string, any> = {};
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<any>).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;
}