import { getMetatable, type ILuaFunction, LuaBuiltinFunction, luaCall, type LuaEnv, luaEquals, luaFormatNumber, luaGet, LuaMultiRes, LuaRuntimeError, luaSet, LuaTable, type LuaValue, luaValueToJS, singleResult, } from "../runtime.ts"; import { asyncQuickSort, evalPromiseValues } from "../util.ts"; import { isTaggedFloat } from "../numeric.ts"; // For `LuaTable` honor `__len` when present; otherwise use raw array // length. For JS arrays use `.length`. function luaLenForTableLib( sf: any, tbl: LuaTable | any[], ): number | Promise { if (Array.isArray(tbl)) { return tbl.length; } if (!(tbl instanceof LuaTable)) { return 0; } const mt = getMetatable(tbl, sf); const mm = mt ? mt.rawGet("__len") : null; if (!(mm === undefined || mm === null)) { const r = luaCall(mm, [tbl], sf.astCtx ?? {}, sf); if (r instanceof Promise) { return r.then((v: any) => Number(singleResult(v))); } return Number(singleResult(r)); } return tbl.length; } async function luaLenForTableLibAsync(sf: any, tbl: LuaTable | any[]) { const r = luaLenForTableLib(sf, tbl); return r instanceof Promise ? await r : r; } export const tableApi = new LuaTable({ /** * Concatenates the elements of a table into a string, using a separator. * @param tbl - The table to concatenate. * @param sep - The separator to use between elements. * @param i - The start index. * @param j - The end index. * @returns The concatenated string. */ concat: new LuaBuiltinFunction({ callback: async ( sf, tbl: LuaTable | any[], sep?: string, i?: number, j?: number, ) => { sep = sep ?? ""; i = i ?? 1; if (j === undefined || j === null) { j = await luaLenForTableLibAsync(sf, tbl); } const luaConcatElemToString = (v: any, idx: number): string => { // Concat errors on nil and non-string or non-number values. if (v === null || v === undefined) { throw new LuaRuntimeError( `invalid value (nil) at index ${idx} in table for 'concat'`, sf, ); } if (typeof v === "string") { return v; } if (typeof v === "number") { return luaFormatNumber(v); } if (isTaggedFloat(v)) { return luaFormatNumber(v.value, "float"); } const ty = typeof v === "object" && v instanceof LuaTable ? "table" : typeof v; throw new LuaRuntimeError( `invalid value (${ty}) at index ${idx} in table for 'concat'`, sf, ); }; if (Array.isArray(tbl)) { const out: string[] = []; for (let k = i; k <= j; k++) { const v = tbl[k - 1]; out.push(luaConcatElemToString(v, k)); } return out.join(sep); } const out: string[] = []; for (let k = i; k <= j; k++) { const v = await luaGet(tbl, k, sf.astCtx ?? null, sf); out.push(luaConcatElemToString(v, k)); } return out.join(sep); }, description: "Concatenates table elements from `i` through `j` using an optional separator.", parameters: [ { name: "table", type: "table" }, { name: "sep", type: "string", optional: true }, { name: "i", type: "integer", optional: true }, { name: "j", type: "integer", optional: true }, ], returns: [{ type: "string" }], }), /** * Inserts an element into a table at a specified position. * @param tbl - The table to insert the element into. * @param posOrValue - The position or value to insert. * @param value - The value to insert. */ insert: new LuaBuiltinFunction({ callback: async ( sf, tbl: LuaTable | any[], posOrValue: number | any, value?: any, ) => { if (Array.isArray(tbl)) { // Since we're inserting/appending to a native JS array, we'll also convert the value to a JS value on the fly // this seems like a reasonable heuristic if (value === undefined) { tbl.push(luaValueToJS(posOrValue, sf)); } else { tbl.splice(posOrValue - 1, 0, luaValueToJS(value, sf)); } return; } if (!(tbl instanceof LuaTable)) { return; } let pos: number; let v: any; if (value === undefined) { v = posOrValue; pos = (await luaLenForTableLibAsync(sf, tbl)) + 1; } else { pos = posOrValue; v = value; } const n = await luaLenForTableLibAsync(sf, tbl); // Shift up: for k = n, pos, -1 do t[k+1] = t[k] end for (let k = n; k >= pos; k--) { const cur = await luaGet(tbl, k, sf.astCtx ?? null, sf); await luaSet(tbl, k + 1, cur, sf); } await luaSet(tbl, pos, v, sf); }, description: "Inserts a value at a position, shifting later elements, or appends it when no position is supplied.", signatures: [ "table.insert(table, value)", "table.insert(table, pos, value)", ], parameters: [ { name: "table", type: "table" }, { name: "posOrValue", description: "Insertion position or appended value.", }, { name: "value", description: "Value for positional insertion.", optional: true, }, ], examples: [ { code: 'local fruits = {"apple", "orange"}\ntable.insert(fruits, 2, "banana")\nprint(table.concat(fruits, ", "))', }, ], }), /** * Removes an element from a table at a specified position. * @param tbl - The table to remove the element from. * @param pos - The position of the element to remove. */ remove: new LuaBuiltinFunction({ callback: async (sf, tbl: LuaTable | any[], pos?: number) => { if (Array.isArray(tbl)) { const n = tbl.length; const p = pos ?? n; if (p < 1 || p > n) { throw new LuaRuntimeError("position out of bounds", sf); } const idx = p - 1; const v = tbl[idx]; tbl.splice(idx, 1); return v; } if (!(tbl instanceof LuaTable)) { return null; } const n = await luaLenForTableLibAsync(sf, tbl); const p = pos ?? n; if (p < 1 || p > n) { throw new LuaRuntimeError("position out of bounds", sf); } const v = await luaGet(tbl, p, sf.astCtx ?? null, sf); // Shift down: for k = p, n-1 do t[k] = t[k+1] end; t[n] = nil for (let k = p; k < n; k++) { const next = await luaGet(tbl, k + 1, sf.astCtx ?? null, sf); await luaSet(tbl, k, next, sf); } await luaSet(tbl, n, null, sf); return v; }, description: "Removes and returns an element, shifting later elements down.", parameters: [ { name: "table", type: "table" }, { name: "pos", type: "integer", description: "Position; defaults to the last element.", optional: true, }, ], returns: [{ description: "Removed value." }], }), /** * Moves elements from table a1 into table a2 (defaults to a1). * Equivalent to: `for i = f, e do a2[t+(i-f)] = a1[i] end` * Handles overlapping ranges within the same table correctly. * @param a1 - Source table. * @param f - First source index (inclusive). * @param e - Last source index (inclusive). * @param t - Destination start index. * @param a2 - Destination table (defaults to a1). * @returns a2. */ move: new LuaBuiltinFunction({ callback: async ( sf, a1: LuaTable | any[], f: number, e: number, t: number, a2?: LuaTable | any[], ) => { // a2 defaults to a1 if (a2 === undefined || a2 === null) { a2 = a1; } // Empty range: nothing to do, return destination if (e < f) { return a2; } const count = e - f + 1; // When source and destination overlap and destination is ahead of // source then copy backwards to avoid clobbering unread values. if (t > f && a2 === a1) { for (let i = count - 1; i >= 0; i--) { const v = await luaGet(a1, f + i, sf.astCtx ?? null, sf); await luaSet(a2, t + i, v, sf); } } else { for (let i = 0; i < count; i++) { const v = await luaGet(a1, f + i, sf.astCtx ?? null, sf); await luaSet(a2, t + i, v, sf); } } return a2; }, description: "Moves an inclusive element range to a destination table while handling overlaps.", parameters: [ { name: "a1", type: "table", description: "Source table." }, { name: "f", type: "integer", description: "First source index." }, { name: "e", type: "integer", description: "Last source index." }, { name: "t", type: "integer", description: "Destination start index." }, { name: "a2", type: "table", description: "Destination table; defaults to `a1`.", optional: true, }, ], returns: [{ type: "table", description: "Destination table." }], }), /** * Sorts a table. * @param tbl - The table to sort. * @param comp - The comparison function. * @returns The sorted table. */ sort: new LuaBuiltinFunction({ callback: async (sf, tbl: LuaTable | any[], comp?: ILuaFunction) => { if (Array.isArray(tbl)) { return await asyncQuickSort(tbl, async (a, b) => { if (comp) { return (await comp.call(sf, a, b)) ? -1 : 0; } return (a as any) < (b as any) ? -1 : 1; }); } if (!(tbl instanceof LuaTable)) { return tbl; } const n = await luaLenForTableLibAsync(sf, tbl); const values: any[] = []; for (let i = 1; i <= n; i++) { values.push(await luaGet(tbl, i, sf.astCtx ?? null, sf)); } const cmp = async (a: any, b: any): Promise => { if (comp) { const r = await luaCall(comp, [a, b], sf.astCtx ?? {}, sf); return r ? -1 : 0; } const av = isTaggedFloat(a) ? a.value : a; const bv = isTaggedFloat(b) ? b.value : b; if (typeof av === "number" && typeof bv === "number") { return av < bv ? -1 : 1; } if (typeof av === "string" && typeof bv === "string") { return av < bv ? -1 : 1; } const ta = typeof av; const tb = typeof bv; throw new LuaRuntimeError(`attempt to compare ${ta} with ${tb}`, sf); }; const sorted = await asyncQuickSort(values, cmp); for (let i = 1; i <= n; i++) { await luaSet(tbl, i, sorted[i - 1], sf); } return tbl; }, description: "Sorts a table in place using ascending order or an optional comparison function.", parameters: [ { name: "table", type: "table" }, { name: "comp", type: "function", optional: true }, ], returns: [{ type: "table", description: "The sorted table in Space Lua." }], examples: [ { code: "local numbers = {3, 1, 2}\ntable.sort(numbers, function(a, b) return a > b end)", }, ], }), /** * Returns the keys of a table. * Note: Space Lua specific * @param tbl - The table to get the keys from. * @returns The keys of the table. */ keys: new LuaBuiltinFunction({ callback: (_sf, tbl: LuaTable | LuaEnv | any) => { if (tbl.keys) { return tbl.keys(); } return Object.keys(tbl); }, description: "Returns an array containing all keys of a table or JavaScript object.", parameters: [{ name: "table", type: "table" }], returns: [{ type: "table", description: "Array of keys." }], }), /** * Checks if a table (used as an array) contains a value. * Note: Space Lua specific * @param tbl - The table to check. * @param value - The value to check for. * @returns True if the value is in the table, false otherwise. */ includes: new LuaBuiltinFunction({ callback: (sf, tbl: LuaTable | any[], value: LuaValue) => { if (!tbl) { return false; } if (tbl instanceof LuaTable) { // Iterate over the table for (const key of tbl.keys()) { if (luaEquals(tbl.get(key), value)) { return true; } } return false; } if (Array.isArray(tbl)) { return !!tbl.find((item) => luaEquals(item, value)); } throw new LuaRuntimeError( `Cannot use includes on a non-table or non-array value`, sf, ); }, description: "Returns whether any table value is Lua-equal to a requested value.", parameters: [ { name: "table", type: "table" }, { name: "value", description: "Value to find." }, ], returns: [{ type: "boolean" }], }), /** * Returns a new table from an old one, only with selected keys * @param tbl a Lua table or JS object * @param keys a list of keys to select from the table, if keys[0] is a table or array, assumed to contain the keys to select * @returns a new table with only the selected keys */ select: new LuaBuiltinFunction({ callback: ( sf, tbl: LuaTable | Record, ...keys: LuaValue[] ) => { // Normalize arguments if (Array.isArray(keys[0])) { // First argument is key array, let's unpack keys = keys[0]; } else if (keys[0] instanceof LuaTable) { keys = keys[0].toJSArray(); } const resultTable = new LuaTable(); const setPromises: (void | Promise)[] = []; for (const key of keys) { setPromises.push(resultTable.set(key, luaGet(tbl, key, null, sf))); } const promised = evalPromiseValues(setPromises); if (promised instanceof Promise) { return promised.then(() => resultTable); } return resultTable; }, description: "Copies selected keys from a table into a new table.", signatures: [ "table.select(table, ...keys): table", "table.select(table, keys): table", ], parameters: [ { name: "table", type: "table" }, { name: "keys", description: "Individual keys or one array-like table of keys.", }, ], returns: [{ type: "table" }], examples: [ { code: '${query[[\n from p = index.pages()\n limit 3\n select table.select(p, "name", "lastModified")\n]]}', language: "markdown", }, ], }), /** * Returns a new table with all arguments stored in keys 1, 2, ..., n * and t.n = n (the total number of arguments). */ pack: new LuaBuiltinFunction({ callback: async (sf, ...args: any[]) => { const tbl = new LuaTable(); const n = args.length; for (let i = 0; i < n; i++) { await luaSet(tbl, i + 1, args[i], sf); } void tbl.rawSet("n", n); return tbl; }, description: "Packs all arguments into a table with a count stored in field `n`.", signatures: ["table.pack(...): table"], returns: [ { type: "table", description: "Arguments at integer keys plus field `n`.", }, ], }), /** * Returns all values t[i], t[i+1], ..., t[j]. * i defaults to 1, j defaults to #t (honours __len). * Empty range returns no values (null), not an empty multi-res. */ unpack: new LuaBuiltinFunction({ callback: async (sf, tbl: LuaTable | any[], i?: number, j?: number) => { i = i === undefined || i === null ? 1 : i; if (j === undefined || j === null) { j = Array.isArray(tbl) ? tbl.length : await luaLenForTableLibAsync(sf, tbl); } if (i > j) { return new LuaMultiRes([]); } const result: LuaValue[] = []; for (let k = i; k <= j; k++) { const v = Array.isArray(tbl) ? tbl[k - 1] : await luaGet(tbl, k, sf.astCtx ?? null, sf); result.push(v); } return new LuaMultiRes(result); }, description: "Returns the table values from index `i` through `j` as separate results.", parameters: [ { name: "table", type: "table" }, { name: "i", type: "integer", optional: true }, { name: "j", type: "integer", optional: true }, ], returns: [{ description: "One result per selected element." }], examples: [ { code: 'local second, third = table.unpack({"a", "b", "c"}, 2, 3)' }, ], }), // Non-standard Lua functions /** * Finds an element in a table that matches a criteria function. Returns the first matching element. * @param tbl - The table to search. * @param criteriaFn - The criteria function. * @param fromIndex - The index to start searching from. * @returns Lua multi value of index, value, or nil if no element is found. */ find: new LuaBuiltinFunction({ callback: async ( sf, tbl: LuaTable | any[], criteriaFn: ILuaFunction, fromIndex = 1, ) => { if (!tbl) { return null; } const startIndex = fromIndex < 1 ? 1 : fromIndex; const n = Array.isArray(tbl) ? tbl.length : await luaLenForTableLibAsync(sf, tbl); for (let i = startIndex; i <= n; i++) { const val = await luaGet(tbl, i, sf.astCtx ?? null, sf); if (await luaCall(criteriaFn, [val], sf.astCtx!, sf)) { return new LuaMultiRes([i, val]); } } return null; }, description: "Finds the first array element accepted by a predicate and returns its index and value.", parameters: [ { name: "table", type: "table" }, { name: "criteriaFn", type: "function", description: "Predicate called with each value.", }, { name: "fromIndex", type: "integer", optional: true }, ], returns: [ { type: "integer|nil", description: "Matching index or `nil`." }, { description: "Matching value." }, ], examples: [ { code: "local index, value = table.find({1, 2, 3, 4}, function(n) return n % 2 == 0 end)", }, ], }), });