* Space Lua: Refactor control flow signals Removes "return throws an exception" failure mode. @zef :) Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Remove unused function Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Remove another unused function Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Remove unused `LuaBreak`/`LuaReturn` exception control flow Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> --------- Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
1312 lines
34 KiB
TypeScript
1312 lines
34 KiB
TypeScript
import type { ASTCtx, LuaFunctionBody } from "./ast.ts";
|
|
import { evalStatement } from "./eval.ts";
|
|
import { asyncQuickSort } from "./util.ts";
|
|
import { isPromise, rpAll } from "./rp.ts";
|
|
|
|
export type LuaType =
|
|
| "nil"
|
|
| "boolean"
|
|
| "number"
|
|
| "string"
|
|
| "table"
|
|
| "function"
|
|
| "userdata"
|
|
| "thread";
|
|
|
|
// These types are for documentation only
|
|
export type LuaValue = any;
|
|
export type JSValue = any;
|
|
|
|
export interface ILuaFunction {
|
|
call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue;
|
|
|
|
asString(): string;
|
|
}
|
|
|
|
export interface ILuaSettable {
|
|
set(key: LuaValue, value: LuaValue, sf?: LuaStackFrame): void | 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 {
|
|
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 isIntegerKey(key: any): boolean {
|
|
const k = key instanceof Number ? Number(key) : key;
|
|
return typeof k === "number" && Number.isInteger(k) && k >= 1;
|
|
}
|
|
private static toIndex(key: any): number {
|
|
return (key instanceof Number ? Number(key) : key) - 1;
|
|
}
|
|
|
|
get length(): number {
|
|
return this.arrayPart.length;
|
|
}
|
|
|
|
keys(): any[] {
|
|
const keys: any[] = [];
|
|
for (const k in this.stringKeys) {
|
|
if (Object.prototype.hasOwnProperty.call(this.stringKeys, k)) {
|
|
keys.push(k);
|
|
}
|
|
}
|
|
for (let i = 0; i < this.arrayPart.length; i++) {
|
|
keys.push(i + 1);
|
|
}
|
|
if (this.otherKeys) {
|
|
for (const key of this.otherKeys.keys()) {
|
|
keys.push(key);
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
empty(): boolean {
|
|
for (const k in this.stringKeys) {
|
|
if (Object.prototype.hasOwnProperty.call(this.stringKeys, k)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (this.arrayPart.length !== 0) {
|
|
return false;
|
|
}
|
|
if (this.otherKeys && this.otherKeys.size !== 0) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
has(key: LuaValue) {
|
|
if (typeof key === "string") {
|
|
return this.stringKeys[key] !== undefined;
|
|
} else if (LuaTable.isIntegerKey(key)) {
|
|
return this.arrayPart[LuaTable.toIndex(key)] !== undefined;
|
|
} else if (this.otherKeys) {
|
|
return this.otherKeys.has(key);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
rawSet(key: LuaValue, value: LuaValue): void | Promise<void> {
|
|
if (isPromise(key)) {
|
|
return key.then((key) => this.rawSet(key, value));
|
|
}
|
|
if (isPromise(value)) {
|
|
return value.then((v) => this.rawSet(key, v));
|
|
}
|
|
if (typeof key === "string") {
|
|
if (value === null || value === undefined) {
|
|
delete this.stringKeys[key];
|
|
} else {
|
|
this.stringKeys[key] = value;
|
|
}
|
|
return;
|
|
}
|
|
if (LuaTable.isIntegerKey(key)) {
|
|
this.arrayPart[LuaTable.toIndex(key)] = value;
|
|
return;
|
|
}
|
|
if (!this.otherKeys) {
|
|
this.otherKeys = new Map();
|
|
}
|
|
if (value === null || value === undefined) {
|
|
this.otherKeys.delete(key);
|
|
} else {
|
|
this.otherKeys.set(key, value);
|
|
}
|
|
}
|
|
|
|
set(
|
|
key: LuaValue,
|
|
value: LuaValue,
|
|
sf?: LuaStackFrame,
|
|
): Promise<void> | void {
|
|
const mt = this.metatable;
|
|
if (!mt) {
|
|
return this.rawSet(key, value);
|
|
}
|
|
// Refresh flags when used (fix: treat both undefined and null as absent)
|
|
const newIndexMM = mt.rawGet("__newindex");
|
|
this.hasNewIndexMM = !(newIndexMM === undefined || newIndexMM === null);
|
|
|
|
if (this.hasNewIndexMM && !this.has(key)) {
|
|
// Invoke the meta table
|
|
const metaValue = mt.get("__newindex", sf);
|
|
// Ensure we pass a non-null ASTCtx to luaCall
|
|
const callCtx: ASTCtx = sf?.astCtx ?? EMPTY_CTX;
|
|
if (isPromise(metaValue)) {
|
|
// This is a promise, we need to wait for it
|
|
return (metaValue as Promise<any>).then((mv: any) => {
|
|
return luaCall(
|
|
mv,
|
|
[this, key, value],
|
|
callCtx,
|
|
sf,
|
|
);
|
|
});
|
|
} else {
|
|
return luaCall(
|
|
metaValue,
|
|
[this, key, value],
|
|
callCtx,
|
|
sf,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Just set the value
|
|
return this.rawSet(key, value);
|
|
}
|
|
|
|
rawGet(key: LuaValue): LuaValue | null {
|
|
if (typeof key === "string") {
|
|
return this.stringKeys[key];
|
|
} else if (LuaTable.isIntegerKey(key)) {
|
|
return this.arrayPart[LuaTable.toIndex(key)];
|
|
} else if (this.otherKeys) {
|
|
return this.otherKeys.get(key);
|
|
}
|
|
}
|
|
|
|
get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise<LuaValue> | null {
|
|
return luaIndexValue(this, key, sf);
|
|
}
|
|
|
|
insert(value: LuaValue, pos: number) {
|
|
this.arrayPart.splice(pos - 1, 0, value);
|
|
}
|
|
|
|
remove(pos: number) {
|
|
this.arrayPart.splice(pos - 1, 1);
|
|
}
|
|
|
|
async sort(fn?: ILuaFunction, sf?: LuaStackFrame) {
|
|
if (fn && sf) {
|
|
this.arrayPart = await asyncQuickSort(this.arrayPart, async (a, b) => {
|
|
return (await fn.call(sf, a, b)) ? -1 : 1;
|
|
});
|
|
} else {
|
|
this.arrayPart.sort();
|
|
}
|
|
}
|
|
|
|
toJSObject(sf = LuaStackFrame.lostFrame): Record<string, any> {
|
|
const result: Record<string, any> = {};
|
|
for (const key of this.keys()) {
|
|
result[key] = luaValueToJS(this.get(key, sf), sf);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
toJSArray(sf = LuaStackFrame.lostFrame): any[] {
|
|
return this.arrayPart.map((v) => luaValueToJS(v, sf));
|
|
}
|
|
|
|
toJS(sf = LuaStackFrame.lostFrame): Record<string, any> | any[] {
|
|
if (this.length > 0) {
|
|
return this.toJSArray(sf);
|
|
} else {
|
|
return this.toJSObject(sf);
|
|
}
|
|
}
|
|
|
|
async toStringAsync(): Promise<string> {
|
|
const metatable = getMetatable(this);
|
|
if (metatable && metatable.has("__tostring")) {
|
|
const metaValue = await metatable.get("__tostring");
|
|
if (metaValue.call) {
|
|
return metaValue.call(LuaStackFrame.lostFrame, this);
|
|
} else {
|
|
throw new Error("Meta table __tostring must be a function");
|
|
}
|
|
}
|
|
let result = "{";
|
|
let first = true;
|
|
for (const key of this.keys()) {
|
|
if (first) {
|
|
first = false;
|
|
} else {
|
|
result += ", ";
|
|
}
|
|
if (typeof key === "number") {
|
|
result += await luaToString(this.get(key));
|
|
continue;
|
|
}
|
|
if (typeof key === "string") {
|
|
result += key;
|
|
} else {
|
|
result += "[" + key + "]";
|
|
}
|
|
result += " = " + await luaToString(this.get(key));
|
|
}
|
|
result += "}";
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lookup a key in a table or a metatable
|
|
*/
|
|
export function luaIndexValue(
|
|
value: LuaValue,
|
|
key: LuaValue,
|
|
sf?: LuaStackFrame,
|
|
): LuaValue | Promise<LuaValue> | null {
|
|
if (value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
// The value is a table, so we can try to get the value directly
|
|
if (value instanceof LuaTable) {
|
|
const rawValue = value.rawGet(key);
|
|
if (rawValue !== undefined) {
|
|
return rawValue;
|
|
}
|
|
// Skip metatable lookup fast path
|
|
if (value.metatable === null) {
|
|
return null;
|
|
}
|
|
}
|
|
// If not, let's see if the value has a metatable and if it has a __index metamethod
|
|
const metatable = getMetatable(value, sf);
|
|
if (metatable) {
|
|
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 {
|
|
const an = a instanceof Number ? Number(a) : a;
|
|
const bn = b instanceof Number ? Number(b) : b;
|
|
if ((typeof an === "number") && (typeof bn === "number")) {
|
|
return an === bn;
|
|
}
|
|
return a === b;
|
|
}
|
|
|
|
export function luaKeys(val: any): any[] {
|
|
if (val instanceof LuaTable) {
|
|
return val.keys();
|
|
} else if (Array.isArray(val)) {
|
|
return val.map((_, i) => i + 1);
|
|
} else {
|
|
return Object.keys(val);
|
|
}
|
|
}
|
|
|
|
export function luaTypeOf(val: any): LuaType | Promise<LuaType> {
|
|
if (val === null || val === undefined) {
|
|
return "nil";
|
|
}
|
|
if (isPromise(val)) {
|
|
return (val as Promise<any>).then((v) => luaTypeOf(v));
|
|
} else if (typeof val === "boolean") {
|
|
return "boolean";
|
|
} else if (typeof val === "number" || val instanceof Number) {
|
|
return "number";
|
|
} else if (typeof val === "string") {
|
|
return "string";
|
|
} else if (val instanceof LuaTable) {
|
|
return "table";
|
|
} else if (Array.isArray(val)) {
|
|
return "table";
|
|
} else if (typeof val === "function" || isILuaFunction(val)) {
|
|
return "function";
|
|
} else if (typeof val === "object" && (val as any).constructor === Object) {
|
|
return "table";
|
|
} else {
|
|
return "userdata";
|
|
}
|
|
}
|
|
|
|
export class LuaRuntimeError extends Error {
|
|
constructor(
|
|
override readonly message: string,
|
|
public sf: LuaStackFrame,
|
|
cause?: Error,
|
|
) {
|
|
super(message, cause);
|
|
}
|
|
|
|
toPrettyString(code: string): string {
|
|
if (!this.sf || !this.sf.astCtx?.from || !this.sf.astCtx?.to) {
|
|
return this.toString();
|
|
}
|
|
let traceStr = "";
|
|
let current: LuaStackFrame | undefined = this.sf;
|
|
while (current) {
|
|
const ctx = current.astCtx;
|
|
if (!ctx || !ctx.from || !ctx.to) {
|
|
break;
|
|
}
|
|
// Find the line and column
|
|
let line = 1;
|
|
let column = 0;
|
|
let lastNewline = -1;
|
|
for (let i = 0; i < ctx.from; i++) {
|
|
if (code[i] === "\n") {
|
|
line++;
|
|
lastNewline = i;
|
|
column = 0;
|
|
} else {
|
|
column++;
|
|
}
|
|
}
|
|
|
|
// Get the full line of code for context
|
|
const lineStart = lastNewline + 1;
|
|
const lineEnd = code.indexOf("\n", ctx.from);
|
|
const codeLine = code.substring(
|
|
lineStart,
|
|
lineEnd === -1 ? undefined : lineEnd,
|
|
);
|
|
|
|
// Add position indicator
|
|
const pointer = " ".repeat(column) + "^";
|
|
|
|
traceStr += `* ${ctx.ref || "(unknown source)"} @ ${line}:${column}:\n` +
|
|
` ${codeLine}\n` +
|
|
` ${pointer}\n`;
|
|
current = current.parent;
|
|
}
|
|
|
|
return `LuaRuntimeError: ${this.message}\nStack trace:\n${traceStr}`;
|
|
}
|
|
|
|
override toString() {
|
|
return `LuaRuntimeError: ${this.message} at ${this.sf.astCtx?.from}, ${this.sf.astCtx?.to}`;
|
|
}
|
|
}
|
|
|
|
export function luaTruthy(value: any): boolean {
|
|
if (value === undefined || value === null || value === false) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof value === "object" && value instanceof LuaMultiRes) {
|
|
// for multi-return values, only the first result determines truthiness
|
|
const first = value.unwrap();
|
|
return !(first === null || first === undefined || first === false);
|
|
}
|
|
|
|
// all non-`nil`/non-`false` values are truthy (including empty tables)
|
|
return true;
|
|
}
|
|
|
|
export function luaToString(
|
|
value: any,
|
|
visited: Set<any> = new Set(),
|
|
): string | Promise<string> {
|
|
if (value === null || value === undefined) {
|
|
return "nil";
|
|
}
|
|
if (isPromise(value)) {
|
|
return (value as Promise<any>).then((v) => luaToString(v, visited));
|
|
}
|
|
// Check for circular references
|
|
if (typeof value === "object" && visited.has(value)) {
|
|
return "<circular reference>";
|
|
}
|
|
if ((value as any).toStringAsync) {
|
|
// Add to visited before recursing
|
|
visited.add(value);
|
|
return (value as any).toStringAsync();
|
|
}
|
|
if ((value as any).asString) {
|
|
visited.add(value);
|
|
return (value as any).asString();
|
|
}
|
|
if (value instanceof LuaFunction) {
|
|
// Don't recurse into the function body, just show the function signature
|
|
return `<lua-function (${value.body.parameters.join(", ")})>`;
|
|
}
|
|
// Handle plain JavaScript objects in a Lua-like format
|
|
if (typeof value === "object") {
|
|
// Add to visited before recursing
|
|
visited.add(value);
|
|
return (async () => {
|
|
let result = "{";
|
|
let first = true;
|
|
|
|
// Handle arrays
|
|
if (Array.isArray(value)) {
|
|
for (const val of value) {
|
|
if (first) {
|
|
first = false;
|
|
} else {
|
|
result += ", ";
|
|
}
|
|
// Recursively stringify the value, passing the visited set
|
|
const strVal = await luaToString(val, visited);
|
|
result += strVal;
|
|
}
|
|
return result + "}";
|
|
}
|
|
|
|
// Handle objects
|
|
for (const [key, val] of Object.entries(value)) {
|
|
if (first) {
|
|
first = false;
|
|
} else {
|
|
result += ", ";
|
|
}
|
|
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
|
result += `${key} = `;
|
|
} else {
|
|
result += `["${key}"] = `;
|
|
}
|
|
// Recursively stringify the value, passing the visited set
|
|
const strVal = await luaToString(val, visited);
|
|
result += strVal;
|
|
}
|
|
result += "}";
|
|
return result;
|
|
})();
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
export function getMetatable(
|
|
value: LuaValue,
|
|
sf?: LuaStackFrame,
|
|
): 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;
|
|
}
|
|
}
|