Files
plainleaf/client/space_lua/runtime.ts
T
Matouš Jan FialkaandGitHub c38876b46f Space Lua: Improve numeric types semantics (#1803)
* Space Lua: Improve numeric types semantics

Add numeric result normalization for arithmetic operations:

* Integer operations preserve int type, collapse -0 to +0
* Float operations preserve float type, maintain -0.0
* Division always produces float results

Fix for-loop variable type tracking:

* Loop variable type determined by start/step parameters
* Integer loops produce integer variables
* Float/mixed loops produce float-tagged variables

Implement table key normalization:

* Integer-valued floats (1.0, 2.0) normalize to integer keys
* All zero variants (0, 0.0, -0.0) map to same key
* Non-integer floats (1.5) remain distinct keys

Add tests for:

* Arithmetic type preservation across operators
* For-loop type modes (int/float/mixed)
* Table key equivalence and normalization
* Edge cases with zero, -0.0, and special values
* Standard library `type` and `math.type`

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

* Code refactor and deduplication

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

---------

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
2026-02-02 15:52:03 +01:00

1423 lines
37 KiB
TypeScript

import type { ASTCtx, LuaFunctionBody } from "./ast.ts";
import { evalStatement } from "./eval.ts";
import { asyncQuickSort } from "./util.ts";
import { isPromise, rpAll } from "./rp.ts";
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> | LuaValue;
asString(): string;
}
export interface ILuaSettable {
set(key: LuaValue, value: LuaValue, sf?: LuaStackFrame): void | Promise<void>;
}
export interface ILuaGettable {
get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise<LuaValue> | 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> | 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>();
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> | 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<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();
} else {
return value;
}
}
export class LuaFunction implements ILuaFunction {
private capturedEnv: LuaEnv;
funcHasGotos?: boolean;
constructor(readonly body: LuaFunctionBody, closure: LuaEnv) {
this.capturedEnv = closure;
}
call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
// Create a new environment that chains to the captured environment
const env = new LuaEnv(this.capturedEnv);
if (!sf) {
console.trace(sf);
}
// Set _CTX to the thread local environment from the stack frame
env.setLocal("_CTX", sf.threadLocal);
// Eval using a stack frame that knows the current function
const sfWithFn = sf.currentFunction === this ? sf : sf.withFunction(this);
// Resolve args (sync-first)
const argsRP = rpAll(args as any[]);
const resolveArgs = (resolvedArgs: any[]) => {
// Assign parameter values to variable names in env
let varargs: LuaValue[] = [];
for (let i = 0; i < this.body.parameters.length; i++) {
const paramName = this.body.parameters[i];
if (paramName === "...") {
// Vararg parameter, let's collect the remainder of the resolved args into the varargs array
varargs = resolvedArgs.slice(i);
// Done, break out of this loop
break;
}
env.setLocal(paramName, resolvedArgs[i] ?? null);
}
env.setLocal("...", new LuaMultiRes(varargs));
// Evaluate the function body with returnOnReturn set to true
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 `<lua function(${this.body.parameters.join(", ")})>`;
}
toString(): string {
return this.asString();
}
}
function mapFunctionReturnValue(values: any[]): any {
if (values.length === 0) {
return;
} else if (values.length === 1) {
return values[0];
} else {
return new LuaMultiRes(values);
}
}
export class LuaNativeJSFunction implements ILuaFunction {
constructor(readonly fn: (...args: JSValue[]) => JSValue) {
}
// Performs automatic conversion between Lua and JS values for arguments, but not for return values
call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
const jsArgsRP = args.map((v) => luaValueToJS(v, sf));
const resolved = rpAll(jsArgsRP);
if (isPromise(resolved)) {
return resolved.then((jsArgs) => this.fn(...jsArgs));
} else {
return this.fn(...resolved);
}
}
asString(): string {
return `<native js function: ${this.fn.name}>`;
}
toString(): string {
return this.asString();
}
}
export class LuaBuiltinFunction implements ILuaFunction {
constructor(
readonly fn: (sf: LuaStackFrame, ...args: LuaValue[]) => LuaValue,
) {
}
call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
// _CTX is already available via the stack frame
return this.fn(sf, ...args);
}
asString(): string {
return `<builtin lua function>`;
}
toString(): string {
return this.asString();
}
}
export class LuaTable implements ILuaSettable, ILuaGettable {
// To optimize the table implementation we use a combination of different data structures
public metatable: LuaTable | null;
// When tables are used as maps, the common case is that they are string keys, so we use a simple object for that
private stringKeys: Record<string, any>;
// Other keys we can support using a Map as a fallback
private otherKeys: Map<any, any> | null;
// When tables are used as arrays, we use a native JavaScript array for that
private arrayPart: any[];
// Metamethod presence flags
hasIndexMM = false;
hasNewIndexMM = false;
hasLenMM = false;
constructor(init?: any[] | Record<string, any>) {
// For efficiency and performance reasons we pre-allocate these (modern JS engines are very good at optimizing this)
this.arrayPart = Array.isArray(init) ? init : [];
this.stringKeys = init && !Array.isArray(init) ? init : {};
if (init && !Array.isArray(init)) {
for (const k in init) {
if (Object.prototype.hasOwnProperty.call(init, k)) {
this.stringKeys[k] = (init as any)[k];
}
}
}
this.otherKeys = null; // Only create this when needed
this.metatable = null;
}
private static 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<void> {
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> | void {
const mt = this.metatable;
if (!mt) {
return this.rawSet(key, value);
}
// Refresh flags when used (fix: treat both undefined and null as absent)
const newIndexMM = mt.rawGet("__newindex");
this.hasNewIndexMM = !(newIndexMM === undefined || newIndexMM === null);
if (this.hasNewIndexMM && !this.has(key)) {
// Invoke the meta table
const metaValue = mt.get("__newindex", sf);
// Ensure we pass a non-null ASTCtx to luaCall
const callCtx: ASTCtx = sf?.astCtx ?? EMPTY_CTX;
if (isPromise(metaValue)) {
// This is a promise, we need to wait for it
return (metaValue as Promise<any>).then((mv: any) => {
return luaCall(
mv,
[this, key, value],
callCtx,
sf,
);
});
} else {
return luaCall(
metaValue,
[this, key, value],
callCtx,
sf,
);
}
}
// Just set the value
return this.rawSet(key, value);
}
rawGet(key: LuaValue): LuaValue | null {
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<LuaValue> | null {
return luaIndexValue(this, key, sf);
}
insert(value: LuaValue, pos: number) {
this.arrayPart.splice(pos - 1, 0, value);
}
remove(pos: number) {
this.arrayPart.splice(pos - 1, 1);
}
async sort(fn?: ILuaFunction, sf?: LuaStackFrame) {
if (fn && sf) {
this.arrayPart = await asyncQuickSort(this.arrayPart, async (a, b) => {
return (await fn.call(sf, a, b)) ? -1 : 1;
});
} else {
this.arrayPart.sort();
}
}
toJSObject(sf = LuaStackFrame.lostFrame): Record<string, any> {
const result: Record<string, any> = {};
for (const key of this.keys()) {
result[key] = luaValueToJS(this.get(key, sf), sf);
}
return result;
}
toJSArray(sf = LuaStackFrame.lostFrame): any[] {
return this.arrayPart.map((v) => luaValueToJS(v, sf));
}
toJS(sf = LuaStackFrame.lostFrame): Record<string, any> | any[] {
if (this.length > 0) {
return this.toJSArray(sf);
} else {
return this.toJSObject(sf);
}
}
async toStringAsync(): Promise<string> {
const metatable = getMetatable(this);
if (metatable && metatable.has("__tostring")) {
const metaValue = await metatable.get("__tostring");
if (metaValue.call) {
return metaValue.call(LuaStackFrame.lostFrame, this);
} else {
throw new Error("Meta table __tostring must be a function");
}
}
let result = "{";
let first = true;
for (const key of this.keys()) {
if (first) {
first = false;
} else {
result += ", ";
}
if (typeof key === "number") {
result += await luaToString(this.get(key));
continue;
}
if (typeof key === "string") {
result += key;
} else {
result += "[" + key + "]";
}
result += " = " + await luaToString(this.get(key));
}
result += "}";
return result;
}
}
/**
* Lookup a key in a table or a metatable
*/
export function luaIndexValue(
value: LuaValue,
key: LuaValue,
sf?: LuaStackFrame,
): LuaValue | Promise<LuaValue> | null {
if (value === null || value === undefined) {
return null;
}
// The value is a table, so we can try to get the value directly
if (value instanceof LuaTable) {
const rawValue = value.rawGet(key);
if (rawValue !== undefined) {
return rawValue;
}
// Skip metatable lookup fast path
if (value.metatable === null) {
return null;
}
}
// If not, let's see if the value has a metatable and if it has a __index metamethod
const metatable = getMetatable(value, sf);
if (metatable) {
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<any>).then((mv: any) => {
if (mv?.call) {
return luaCall(mv, [value, key], sf?.astCtx ?? EMPTY_CTX, sf);
} else if (mv instanceof LuaTable) {
return mv.get(key, sf);
} else {
throw new LuaRuntimeError(
"Meta table __index must be a function or table",
sf || LuaStackFrame.lostFrame,
);
}
});
} else {
const mv = metaValue;
if (mv?.call) {
return luaCall(mv, [value, key], sf?.astCtx ?? EMPTY_CTX, sf);
} else if (mv instanceof LuaTable) {
return mv.get(key, sf);
} else {
throw new LuaRuntimeError(
"Meta table __index must be a function or table",
sf || LuaStackFrame.lostFrame,
);
}
}
}
}
// If not, perhaps let's assume this is a plain JavaScript object and we just index into it
const k = toNumKey(key);
if (
typeof value === "object" && Array.isArray(value) &&
typeof k === "number"
) {
return (value as any[])[k - 1];
}
const objValue = (value as Record<string | number, any>)[k];
if (objValue === undefined || objValue === null) {
return null;
} else {
return objValue;
}
}
export type LuaLValueContainer = { env: ILuaSettable; key: LuaValue };
export async function luaSet(
obj: any,
key: any,
value: any,
sf: LuaStackFrame,
): Promise<void> {
if (!obj) {
throw new LuaRuntimeError(
`Not a settable object: nil`,
sf,
);
}
if (obj instanceof LuaTable || obj instanceof LuaEnv) {
await obj.set(key, value, sf);
} else {
const k = toNumKey(key);
// Writing into a "native" JavaScript object, need to do value conversion
(obj as Record<string | number, any>)[k] = await luaValueToJS(value, sf);
}
}
export function luaGet(
obj: any,
key: any,
ctx: ASTCtx | null,
sf: LuaStackFrame,
): Promise<any> | any {
const errSf = ctx ? sf.withCtx(ctx) : sf;
if (obj === null || obj === undefined) {
throw new LuaRuntimeError(
`attempt to index a nil value`,
errSf,
);
}
if (key === null || key === undefined) {
throw new LuaRuntimeError(
`attempt to index with a nil key`,
errSf,
);
}
if (obj instanceof LuaTable || obj instanceof LuaEnv) {
return obj.get(key, sf);
} else if (typeof key === "number" || key instanceof Number) {
const idx = key instanceof Number ? Number(key) : key;
return (obj as any[])[idx - 1];
} else {
// Native JS object
const k = toNumKey(key);
const val = (obj as Record<string | number, any>)[k];
if (typeof val === "function") {
// Automatically bind the function to the object
return val.bind(obj);
} else if (val === undefined) {
return null;
} else {
return val;
}
}
}
export function luaLen(
obj: any,
sf?: LuaStackFrame,
): number {
// Only strings, Lua tables and JS arrays
if (
typeof obj === "string" || obj instanceof LuaTable || Array.isArray(obj)
) {
return obj.length;
}
const t = luaTypeOf(obj) as LuaType;
throw new LuaRuntimeError(
`bad argument #1 to 'rawlen' (table or string expected, got ${t})`,
sf || LuaStackFrame.lostFrame,
);
}
export function luaCall(
callee: any,
args: any[],
ctx: ASTCtx,
sf?: LuaStackFrame,
): any {
if (!callee) {
throw new LuaRuntimeError(
`attempt to call a nil value`,
(sf || LuaStackFrame.lostFrame).withCtx(ctx),
);
}
if (typeof callee === "function") {
const jsArgs = rpAll(
args.map((v) => luaValueToJS(v, sf || LuaStackFrame.lostFrame)),
);
if (isPromise(jsArgs)) {
return jsArgs.then((resolved) =>
(callee as (...a: any[]) => any)(...resolved)
);
} else {
return (callee as (...a: any[]) => any)(...jsArgs);
}
} else if (callee instanceof LuaTable) {
const metatable = getMetatable(callee, sf);
if (metatable && metatable.has("__call")) {
// Invoke the meta table
const metaValue = metatable.get("__call", sf);
const callMetaValue = (value: any) => {
if (value && value.call) {
return luaCall(value, [callee, ...args], ctx, sf);
} else {
throw new LuaRuntimeError(
"Meta table __call must be a function",
(sf || LuaStackFrame.lostFrame).withCtx(ctx),
);
}
};
if (isPromise(metaValue)) {
// Got a promise, we need to wait for it
return (metaValue as Promise<any>).then((value: any) => {
return callMetaValue(value);
});
} else {
return callMetaValue(metaValue);
}
}
} else if (isILuaFunction(callee)) {
const base = (sf || LuaStackFrame.lostFrame).withCtx(ctx);
const frameForCall = callee instanceof LuaFunction
? base.withFunction(callee)
: base;
return callee.call(
frameForCall,
...args,
);
} else {
throw new LuaRuntimeError(
`attempt to call a non-callable value of type: ${luaTypeOf(callee)}`,
(sf || LuaStackFrame.lostFrame).withCtx(ctx),
);
}
}
export function luaEquals(a: any, b: any): boolean {
// 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<LuaType> {
if (val === null || val === undefined) {
return "nil";
}
if (isPromise(val)) {
return (val as Promise<any>).then((v) => luaTypeOf(v));
} else if (typeof val === "boolean") {
return "boolean";
} else if (typeof val === "number" || val instanceof Number) {
return "number";
} else if (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<any> = new Set(),
): string | Promise<string> {
if (value === null || value === undefined) {
return "nil";
}
if (isPromise(value)) {
return (value as Promise<any>).then((v) => luaToString(v, visited));
}
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 "<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);
}
// 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<any>).then(jsToLuaValue);
}
if (value instanceof LuaTable) {
return value;
} else if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
return value;
} else if (Array.isArray(value) && "index" in value && "input" in value) {
// This is a RegExpMatchArray
const regexMatch = value as RegExpMatchArray;
const regexMatchTable = new LuaTable();
for (let i = 0; i < regexMatch.length; i++) {
regexMatchTable.set(i + 1, regexMatch[i]);
}
regexMatchTable.set("index", regexMatch.index);
regexMatchTable.set("input", regexMatch.input);
regexMatchTable.set("groups", regexMatch.groups);
return regexMatchTable;
} else if (Array.isArray(value)) {
const table = new LuaTable();
for (let i = 0; i < value.length; i++) {
table.set(i + 1, jsToLuaValue(value[i]));
}
return table;
} else if (typeof value === "object") {
const table = new LuaTable();
for (const key in value) {
table.set(key, jsToLuaValue((value as any)[key]));
}
return table;
} else if (typeof value === "function") {
return new LuaNativeJSFunction(value);
} else {
return value;
}
}
// Inverse of jsToLuaValue
export function luaValueToJS(value: any, sf: LuaStackFrame): any {
if (isPromise(value)) {
return (value as Promise<any>).then((v) => luaValueToJS(v, sf));
}
if (value instanceof LuaTable) {
return value.toJS(sf);
} else if (
value instanceof LuaNativeJSFunction || value instanceof LuaFunction ||
value instanceof LuaBuiltinFunction
) {
return (...args: any[]) => {
const jsArgs = rpAll(
args.map((v) => luaValueToJS(v, sf)),
);
if (isPromise(jsArgs)) {
return luaValueToJS(
jsArgs.then((jsArgs) => (value as ILuaFunction).call(sf, ...jsArgs)),
sf,
);
} else {
return luaValueToJS((value as ILuaFunction).call(sf, ...jsArgs), sf);
}
};
} else if (value instanceof Number) {
return Number(value);
} else {
return value;
}
}