Files
plainleaf/client/space_lua/stdlib/table.ts
T

617 lines
18 KiB
TypeScript

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<number> {
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<number> => {
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<string, any>,
...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<void>)[] = [];
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)",
},
],
}),
});