import { getMetatable, type ILuaFunction, isILuaFunction, isLuaTable, LuaBuiltinFunction, luaCall, luaCloseFromMark, luaEnsureCloseStack, LuaEnv, luaGet, luaKeys, luaLen, LuaMultiRes, LuaRuntimeError, type LuaStackFrame, LuaTable, luaToString, luaTypeOf, type LuaValue, singleResult, } from "./runtime.ts"; import { stringApi } from "./stdlib/string.ts"; import { tableApi } from "./stdlib/table.ts"; import { osApi } from "./stdlib/os.ts"; import { jsApi } from "./stdlib/js.ts"; import { spaceluaApi } from "./stdlib/space_lua.ts"; import { mathApi } from "./stdlib/math.ts"; import { parseBlock } from "./parse.ts"; import { evalStatement } from "./eval.ts"; import { encodingApi } from "./stdlib/encoding.ts"; import { luaToNumberDetailed } from "./tonumber.ts"; import { luaLoad } from "./stdlib/load.ts"; import { cryptoApi } from "./stdlib/crypto.ts"; import { netApi } from "./stdlib/net.ts"; import { isTaggedFloat, makeLuaFloat } from "./numeric.ts"; import { isPromise } from "./rp.ts"; import { isSqlNull } from "./sliq_null.ts"; const printFunction = new LuaBuiltinFunction({ callback: async (_sf, ...args) => { console.log( "[Lua]", ...(await Promise.all(args.map((v) => luaToString(v)))), ); }, description: "Prints string representations of its arguments to the runtime log.", signatures: ["print(...)"], parameters: [{ name: "...", description: "Values to print." }], examples: [{ code: 'print("Hello, world!")' }], }); const assertFunction = new LuaBuiltinFunction({ callback: async (sf, value: any, message?: string) => { if (!(await value)) { throw new LuaRuntimeError(`Assertion failed: ${message}`, sf); } }, description: "Raises an error when a value is falsy; otherwise completes successfully.", parameters: [ { name: "value", description: "Condition to test." }, { name: "message", type: "string", description: "Error detail.", optional: true, }, ], examples: [{ code: 'assert(user ~= nil, "user is required")' }], }); const ipairsFunction = new LuaBuiltinFunction({ callback: (sf, t: LuaTable | any[]) => { let i = 0; return async () => { i = i + 1; const v = await luaGet(t, i, sf.astCtx ?? null, sf); if (v === null || v === undefined) { return; } return new LuaMultiRes([i, v]); }; }, description: "Returns an iterator over consecutive integer keys starting at 1 and stopping at the first `nil`.", parameters: [{ name: "table", type: "table" }], returns: [ { type: "function", description: "Iterator yielding index and value." }, ], examples: [ { code: 'for i, fruit in ipairs({"apple", "banana"}) do\n print(i, fruit)\nend', }, ], }); const pairsFunction = new LuaBuiltinFunction({ callback: (sf, t: LuaTable | any[] | Record) => { // Respect `__pairs` metamethod for Lua tables if (isLuaTable(t)) { const mt = (t as any).metatable as LuaTable | null | undefined; if (mt) { const mm = mt.get("__pairs", sf); if (mm && (typeof mm === "function" || isILuaFunction(mm))) { // __pairs must return (iter, state, control, closing) return luaCall(mm, [t], sf.astCtx ?? {}, sf); } } } let keys: (string | number)[]; if (Array.isArray(t)) { keys = Array.from({ length: t.length }, (_, i) => i + 1); // For arrays, generate 1-based indices } else if (isLuaTable(t) || t instanceof LuaEnv) { keys = t.keys(); } else { // For plain JavaScript objects case, note: this will also include keys from the prototype keys = []; for (const key in t) { keys.push(key); } } let i = 0; const iter = async () => { if (i >= keys.length) { return; } const key = keys[i]; i++; const value = await luaGet(t, key, sf.astCtx ?? null, sf); return new LuaMultiRes([key, value]); }; // Must return (iter, state, control) for generic for return new LuaMultiRes([iter, t, null]); }, description: "Returns an iterator over all table key-value pairs, respecting `__pairs`.", parameters: [{ name: "table", type: "table" }], returns: [ { type: "function", description: "Iterator plus its state and initial control value.", }, ], examples: [ { code: 'for key, value in pairs({name = "Ada", age = 36}) do\n print(key, value)\nend', }, ], }); export const eachFunction = new LuaBuiltinFunction({ callback: (sf, ar: LuaTable | any[]) => { let i = 1; const length = (ar as any).length; return async () => { if (i > length) { return; } const result = await luaGet(ar, i, sf.astCtx ?? null, sf); i++; return result; }; }, description: "Returns a Space Lua iterator over array-like values without yielding indices.", parameters: [{ name: "table", type: "table" }], returns: [{ type: "function", description: "Iterator yielding values." }], examples: [ { code: 'for fruit in each({"apple", "banana"}) do\n print(fruit)\nend', }, ], }); const typeFunction = new LuaBuiltinFunction({ callback: (_sf, value: LuaValue): string | Promise => { return luaTypeOf(value); }, description: "Returns the Lua type name of a value.", parameters: [{ name: "value" }], returns: [{ type: "string" }], }); // tostring() checks `__tostring` metamethod first (with live SF), then // falls back to the default `luaToString` representation. const tostringFunction = new LuaBuiltinFunction({ callback: (sf, value: any): string | Promise => { const mt = getMetatable(value, sf); if (mt) { const mm = mt.rawGet("__tostring"); if (mm !== undefined && mm !== null) { const ctx = sf.astCtx ?? {}; const r = luaCall(mm, [value], ctx as any, sf); const unwrap = (v: any): string => { const s = singleResult(v); if (typeof s !== "string") { throw new LuaRuntimeError("'__tostring' must return a string", sf); } return s; }; if (isPromise(r)) { return (r as Promise).then(unwrap); } return unwrap(r); } } return luaToString(value); }, description: "Converts a value to a string, respecting its `__tostring` metamethod.", parameters: [{ name: "value" }], returns: [{ type: "string" }], }); const tonumberFunction = new LuaBuiltinFunction({ callback: (sf, value: LuaValue, base?: number) => { if (base !== undefined) { if (!(typeof base === "number" && base >= 2 && base <= 36)) { throw new LuaRuntimeError( "bad argument #2 to 'tonumber' (base out of range)", sf, ); } } if (typeof value === "number") { return value; } if (isTaggedFloat(value)) { return value; } if (typeof value !== "string") { return null; } const result = luaToNumberDetailed(value, base); if (result === null) { return null; } if (result.numericType === "float") { return makeLuaFloat(result.value); } return result.value; }, description: "Converts a number or numeric string to a Lua number, optionally in a base from 2 through 36.", signatures: [ "tonumber(value): number|nil", "tonumber(value, base): integer|nil", ], parameters: [ { name: "value", type: "number|string" }, { name: "base", type: "integer", optional: true }, ], returns: [{ type: "number|nil" }], examples: [{ code: 'print(tonumber("2a", 16)) -- 42' }], }); const errorFunction = new LuaBuiltinFunction({ callback: (sf, message: string) => { throw new LuaRuntimeError(message, sf); }, description: "Raises a Lua runtime error with the supplied message.", parameters: [{ name: "message", type: "string" }], }); async function pcallBoundary( sf: LuaStackFrame, fn: ILuaFunction, args: LuaValue[], ): Promise<{ ok: true; values: LuaValue[] } | { ok: false; message: string }> { const closeStack = luaEnsureCloseStack(sf); const mark = closeStack.length; const errMsgOf = (e: any): string => e instanceof LuaRuntimeError ? e.message : (e?.message ?? String(e)); try { const r = await luaCall(fn, args, sf.astCtx!, sf); await luaCloseFromMark(sf, mark, null); const values = r instanceof LuaMultiRes ? r.flatten().values : [r]; return { ok: true, values }; } catch (e: any) { const msg = errMsgOf(e); try { await luaCloseFromMark(sf, mark, msg); return { ok: false, message: msg }; } catch (closeErr: any) { return { ok: false, message: errMsgOf(closeErr) }; } } } const pcallFunction = new LuaBuiltinFunction({ callback: async (sf, fn: ILuaFunction, ...args) => { // To-be-closed variables must be closed when unwinding to the // protected call boundary. Space Lua uses a per-thread close // stack, so we snapshot its length and close anything pushed // after that. // // The protected call boundary must be established *before* // evaluating the function and its arguments. Otherwise, any // `` locals created while evaluating `pcall`'s arguments // will be wrongly treated as "inside" the protected call, and // `pcall` may end up closing them (or affecting close ordering). // // `threadState` is read-only on the stack frame; do not reassign! const res = await pcallBoundary(sf, fn, args); if (res.ok) { return new LuaMultiRes([true, ...res.values]); } return new LuaMultiRes([false, res.message]); }, description: "Calls a function in protected mode and returns a success flag followed by results or an error message.", signatures: ["pcall(function, ...): boolean, ..."], parameters: [ { name: "function", type: "function" }, { name: "...", description: "Arguments passed to the function." }, ], returns: [ { type: "boolean", description: "Whether the call succeeded." }, { description: "Call results or error message." }, ], examples: [ { code: "local ok, result = pcall(function() return mightFail() end)" }, ], }); const xpcallFunction = new LuaBuiltinFunction({ callback: async ( sf, fn: ILuaFunction, errorHandler: ILuaFunction, ...args ) => { // Same semantic as `pcall` (see comments there) const res = await pcallBoundary(sf, fn, args); if (res.ok) { return new LuaMultiRes([true, ...res.values]); } const hr = await luaCall(errorHandler, [res.message], sf.astCtx!, sf); const outVals = hr instanceof LuaMultiRes ? hr.flatten().values : [hr]; return new LuaMultiRes([false, ...outVals]); }, description: "Calls a function in protected mode and transforms any error with an error handler.", signatures: ["xpcall(function, errorHandler, ...): boolean, ..."], parameters: [ { name: "function", type: "function" }, { name: "errorHandler", type: "function" }, { name: "...", description: "Arguments passed to the function." }, ], returns: [ { type: "boolean", description: "Whether the call succeeded." }, { description: "Call results or handler results." }, ], examples: [ { code: 'local ok, message = xpcall(riskyOperation, function(err)\n return "Operation failed: " .. tostring(err)\nend)', }, ], }); const setmetatableFunction = new LuaBuiltinFunction({ callback: (sf, table: LuaTable, metatable: LuaTable) => { if (!metatable) { throw new LuaRuntimeError("metatable cannot be set to nil", sf); } table.metatable = metatable; return table; }, description: "Sets a table's metatable and returns the table.", parameters: [ { name: "table", type: "table" }, { name: "metatable", type: "table" }, ], returns: [{ type: "table" }], }); const rawlenFunction = new LuaBuiltinFunction({ callback: (_sf, value: LuaValue) => luaLen(value, _sf, true), description: "Returns a string or table length without invoking `__len`.", parameters: [{ name: "value", type: "string|table" }], returns: [{ type: "integer" }], }); const rawsetFunction = new LuaBuiltinFunction({ callback: (_sf, table: LuaTable, key: LuaValue, value: LuaValue) => { return (table as any).rawSet(key, value); }, description: "Sets a table key without invoking `__newindex` and returns the table.", parameters: [ { name: "table", type: "table" }, { name: "key" }, { name: "value" }, ], returns: [{ type: "table" }], examples: [ { code: 'local t = setmetatable({}, {__newindex = function() error("blocked") end})\nrawset(t, "name", "Ada")', }, ], }); const rawgetFunction = new LuaBuiltinFunction({ callback: (_sf, table: any, key: LuaValue) => { const isArray = Array.isArray(table); const isPlainObj = typeof table === "object" && table !== null && (table as any).constructor === Object; if (!isLuaTable(table) && !isArray && !isPlainObj) { let typeName = "userdata"; if (table === null || table === undefined) { typeName = "nil"; } else if (typeof table === "boolean") { typeName = "boolean"; } else if (typeof table === "number" || isTaggedFloat(table)) { typeName = "number"; } else if (typeof table === "string") { typeName = "string"; } else if ( typeof table === "function" || (typeof table === "object" && table !== null && typeof (table as any).call === "function") ) { typeName = "function"; } throw new LuaRuntimeError( `bad argument #1 to 'rawget' (table expected, got ${typeName})`, _sf, ); } if (isLuaTable(table)) { const v = table.rawGet(key); return v === undefined || isSqlNull(v) ? null : v; } const k = isTaggedFloat(key) ? key.value : key; if (isArray) { if (typeof k === "number") { const v = (table as any[])[k - 1]; return v === undefined ? null : v; } const v = (table as Record)[k]; return v === undefined ? null : v; } const v = (table as Record)[k as any]; return v === undefined ? null : v; }, description: "Reads a table key without invoking `__index`.", parameters: [{ name: "table", type: "table" }, { name: "key" }], returns: [{ description: "Stored value or `nil`." }], }); const rawequalFunction = new LuaBuiltinFunction({ callback: (_sf, a: any, b: any) => { const av = isTaggedFloat(a) ? a.value : a; const bv = isTaggedFloat(b) ? b.value : b; return av === bv; }, description: "Tests two values for equality without invoking `__eq`.", parameters: [{ name: "a" }, { name: "b" }], returns: [{ type: "boolean" }], }); const getmetatableFunction = new LuaBuiltinFunction({ callback: (_sf, table: LuaTable) => (table as any).metatable, description: "Returns a table's metatable, or `nil` when none is set.", parameters: [{ name: "table", type: "table" }], returns: [{ type: "table|nil" }], }); const dofileFunction = new LuaBuiltinFunction({ callback: async (sf, filename: string) => { const global = sf.threadLocal.get("_GLOBAL") as LuaEnv; const file = (await luaCall( (global.get("space") as any).get("readFile"), [filename], sf.astCtx!, sf, )) as Uint8Array; const code = new TextDecoder().decode(file); try { const parsedExpr = parseBlock(code); const env = new LuaEnv(global); await evalStatement(parsedExpr, env, sf.withCtx(parsedExpr.ctx)); } catch (e: any) { throw new LuaRuntimeError( `Error evaluating "${filename}": ${e.message}`, sf, ); } }, description: "Reads and executes a Lua source file from the current space.", parameters: [ { name: "path", type: "string", description: "Space-relative Lua file path.", }, ], }); /** * From the Lua docs: * * If index is a number, returns all arguments after argument number * index; a negative number indexes from the end (-1 is the last * argument). Otherwise, index must be the string "#", and select * returns the total number of extra arguments it received. */ const selectFunction = new LuaBuiltinFunction({ callback: (_sf, index: number | "#", ...args: LuaValue[]) => { if (index === "#") { return args.length; } if (typeof index === "number") { if (index >= 0) { return new LuaMultiRes(args.slice(index - 1)); } return new LuaMultiRes(args.slice(args.length + index)); } }, description: "Returns the count of extra arguments or all arguments from a selected position onward.", signatures: ['select("#", ...): integer', "select(index, ...): ..."], parameters: [ { name: "index", type: "integer|string", description: "One-based index, negative index from the end, or `#`.", }, { name: "..." }, ], returns: [{ description: "Argument count or selected argument values." }], }); /** * From the Lua docs: * * Allows a program to traverse all fields of a table. Its first * argument is a table and its second argument is an index in this * table. A call to next returns the next index of the table and its * associated value. When called with nil as its second argument, next * returns an initial index and its associated value. When called with * the last index, or with nil in an empty table, next returns nil. If * the second argument is absent, then it is interpreted as nil. In * particular, you can use next(t) to check whether a table is empty. * * The order in which the indices are enumerated is not specified, even * for numeric indices. (To traverse a table in numerical order, use * a numerical for.) * * You should not assign any value to a non-existent field in a table * during its traversal. You may however modify existing fields. In * particular, you may set existing fields to nil. */ const nextFunction = new LuaBuiltinFunction({ callback: ( sf, table: LuaTable | Record, index: number | null = null, ) => { if (!table) { // When nil value return null; } const keys = luaKeys(table); // Empty table -> null return value if (keys.length === 0) { return null; } if (index === null) { // Return the first key, value const key = keys[0]; return new LuaMultiRes([key, luaGet(table, key, sf.astCtx ?? null, sf)]); } // Find index in the key list const idx = keys.indexOf(index); if (idx === -1) { // Not found throw new LuaRuntimeError("invalid key to 'next': key not found", sf); } const key = keys[idx + 1]; if (key === undefined) { // When called with the last key, should return nil return null; } return new LuaMultiRes([key, luaGet(table, key, sf.astCtx ?? null, sf)]); }, description: "Returns the next table key and value after a given key, or the first pair when the key is omitted.", parameters: [ { name: "table", type: "table" }, { name: "index", description: "Previous key.", optional: true }, ], returns: [ { description: "Next key or `nil`." }, { description: "Value at the next key." }, ], }); // Non-standard, but useful const someFunction = new LuaBuiltinFunction({ callback: async (_sf, value: any) => { switch (await luaTypeOf(value)) { case "number": if (!Number.isFinite(value)) return null; break; case "string": if (value.trim() === "") return null; break; case "table": if (luaKeys(value).length === 0) return null; } return value; }, description: "Returns `nil` for empty Space Lua values and otherwise returns the value unchanged.", parameters: [ { name: "value", description: "Value to normalize; blank strings, empty tables, infinities, and NaN are empty.", }, ], returns: [{ description: "Original value or `nil`." }], examples: [ { code: 'print(some(" ") or "empty")\nprint(some({}) or "empty")\nprint(some(0))', }, ], }); const loadFunction = new LuaBuiltinFunction({ callback: (sf, s) => luaLoad(s, sf), description: "Compiles Lua source into a callable chunk without executing it.", parameters: [ { name: "chunk", type: "string", description: "Lua source code." }, ], returns: [ { type: "function|nil", description: "Compiled chunk or `nil`." }, { type: "string", description: "Compilation error when unsuccessful." }, ], }); function annotateBuiltinApi( value: unknown, path: string, page: string, seen = new WeakSet(), ): void { if (!value || typeof value !== "object" || seen.has(value)) return; seen.add(value); if (isILuaFunction(value)) { value.info ??= { kind: "builtin" }; value.info.name ??= path; value.info.see ??= page; return; } if (value instanceof LuaTable) { for (const key of value.keys()) { if (typeof key !== "string") continue; annotateBuiltinApi(value.rawGet(key), `${path}.${key}`, page, seen); } } } export function luaBuildStandardEnv() { const env = new LuaEnv(); // _G global env.set("_G", env); // Lua version string - for now it signals Lua 5.4 compatibility with // selective 5.5 features; kept non-standard so callers can distinguish // Space Lua from a plain Lua runtime. env.set("_VERSION", "Lua 5.4+"); // Top-level builtins env.set("print", printFunction); env.set("assert", assertFunction); env.set("type", typeFunction); env.set("tostring", tostringFunction); env.set("tonumber", tonumberFunction); env.set("select", selectFunction); env.set("next", nextFunction); // Iterators env.set("pairs", pairsFunction); env.set("ipairs", ipairsFunction); // meta table stuff env.set("setmetatable", setmetatableFunction); env.set("getmetatable", getmetatableFunction); env.set("rawlen", rawlenFunction); env.set("rawset", rawsetFunction); env.set("rawget", rawgetFunction); env.set("rawequal", rawequalFunction); env.set("dofile", dofileFunction); // Error handling env.set("error", errorFunction); env.set("pcall", pcallFunction); env.set("xpcall", xpcallFunction); // Evaluation env.set("load", loadFunction); // APIs env.set("string", stringApi); env.set("table", tableApi); env.set("os", osApi); env.set("js", jsApi); env.set("math", mathApi); // Non-standard env.set("each", eachFunction); env.set("spacelua", spaceluaApi); env.set("encoding", encodingApi); env.set("crypto", cryptoApi); env.set("net", netApi); env.set("some", someFunction); for (const name of env.keys()) { const value = env.get(name); const page = value instanceof LuaTable ? `API/${name}` : "API/global"; annotateBuiltinApi(value, name, page); } return env; }