diff --git a/client/codemirror/lua_directive.ts b/client/codemirror/lua_directive.ts index 8313f540..2a677d28 100644 --- a/client/codemirror/lua_directive.ts +++ b/client/codemirror/lua_directive.ts @@ -17,6 +17,7 @@ import { luaValueToJS, singleResult, } from "../space_lua/runtime.ts"; +import { isTaggedFloat } from "../space_lua/numeric.ts"; import { encodeRef, getNameFromPath, @@ -109,16 +110,21 @@ export function luaDirectivePlugin(client: Client) { client.clientSystem.spaceLuaEnv.env, ); threadLocalizedEnv.setLocal("_CTX", tl); - return luaValueToJS( - singleResult( - await evalExpression( - expr, - threadLocalizedEnv, - sf, - ), + const rawResult = singleResult( + await evalExpression( + expr, + threadLocalizedEnv, + sf, ), - sf, ); + // keep tagged floats as-is for proper formatting + if ( + isTaggedFloat(rawResult) || typeof rawResult === "number" + ) { + return rawResult; + } + // everything else needs luaValueToJS for widget support + return luaValueToJS(rawResult, sf); } catch (e: any) { if (e instanceof LuaRuntimeError) { if (e.sf?.astCtx) { diff --git a/client/codemirror/lua_widget.ts b/client/codemirror/lua_widget.ts index cecfc742..eff36158 100644 --- a/client/codemirror/lua_widget.ts +++ b/client/codemirror/lua_widget.ts @@ -13,7 +13,8 @@ import { moveCursorIntoText, } from "./widget_util.ts"; import { expandMarkdown } from "../markdown_renderer/inline.ts"; -import { LuaTable } from "../space_lua/runtime.ts"; +import { luaFormatNumber, LuaTable } from "../space_lua/runtime.ts"; +import { isTaggedFloat } from "../space_lua/numeric.ts"; import { isBlockMarkdown, jsonToMDTable, @@ -346,10 +347,17 @@ export function renderExpressionResult(result: any): Promise { if (result instanceof LuaTable) { result = result.toJS(); } + // Must check before object/array checks — tagged floats are plain objects + if (isTaggedFloat(result)) { + return Promise.resolve(luaFormatNumber(result.value, "float")); + } + if (typeof result === "number") { + return Promise.resolve(luaFormatNumber(result)); + } if ( Array.isArray(result) && result.length > 0 && typeof result[0] === "object" ) { - // If result is an array of objects, render as a markdown table + // If result is an array of objects, render as a Markdown table try { return jsonToMDTable(result, refCellTransformer); } catch (e: any) { @@ -361,10 +369,10 @@ export function renderExpressionResult(result: any): Promise { return Promise.resolve(JSON.stringify(result)); } } else if (typeof result === "object" && result.constructor === Object) { - // if result is a plain object, render as a markdown table + // If result is a plain object, render as a Markdown table return jsonToMDTable([result], refCellTransformer); } else if (Array.isArray(result)) { - // Not-object array, let's render it as a markdown list + // Not-object array, let's render it as a Markdown list return Promise.resolve(result.map((item) => `- ${item}`).join("\n")); } else { return Promise.resolve("" + result); diff --git a/client/markdown_renderer/result_render.ts b/client/markdown_renderer/result_render.ts index adc6af73..d27dc353 100644 --- a/client/markdown_renderer/result_render.ts +++ b/client/markdown_renderer/result_render.ts @@ -1,4 +1,9 @@ -import { LuaTable, luaToString } from "../space_lua/runtime.ts"; +import { + luaFormatNumber, + LuaTable, + luaToString, +} from "../space_lua/runtime.ts"; +import { isTaggedFloat } from "../space_lua/numeric.ts"; export function defaultTransformer(v: any, _k: string): Promise { if (v === undefined) { @@ -10,6 +15,9 @@ export function defaultTransformer(v: any, _k: string): Promise { if (v && typeof v === "object") { return Promise.resolve(luaToString(v)); } + if (typeof v === "number") { + return Promise.resolve(luaFormatNumber(v)); + } return Promise.resolve("" + v); } @@ -109,10 +117,17 @@ export function renderExpressionResult(result: any): Promise { if (result instanceof LuaTable) { result = result.toJS(); } + // Must check before object/array checks — tagged floats are plain objects + if (isTaggedFloat(result)) { + return Promise.resolve(luaFormatNumber(result.value, "float")); + } + if (typeof result === "number") { + return Promise.resolve(luaFormatNumber(result)); + } if ( Array.isArray(result) && result.length > 0 && typeof result[0] === "object" ) { - // If result is an array of objects, render as a markdown table + // If result is an array of objects, render as a Markdown table try { return jsonToMDTable(result); } catch (e: any) { @@ -124,10 +139,10 @@ export function renderExpressionResult(result: any): Promise { return Promise.resolve(JSON.stringify(result)); } } else if (typeof result === "object" && result.constructor === Object) { - // if result is a plain object, render as a markdown table + // If result is a plain object, render as a Markdown table return jsonToMDTable([result]); } else if (Array.isArray(result)) { - // Not-object array, let's render it as a markdown list + // Not-object array, let's render it as a Markdown list return Promise.resolve(result.map((item) => `- ${item}`).join("\n")); } else { return Promise.resolve("" + result); diff --git a/client/plugos/plug_compile.ts b/client/plugos/plug_compile.ts index 8e25bf10..58aa1e9d 100644 --- a/client/plugos/plug_compile.ts +++ b/client/plugos/plug_compile.ts @@ -97,8 +97,6 @@ setupMessageListener(functionMapping, manifest, self.postMessage); const outFile = `${destPath}/${manifest.name}.plug.js`; await Deno.writeTextFile(inFile, jsFile); - // console.log("JS FILE", inFile); - const result = await esbuild.build({ entryPoints: [inFile], bundle: true, @@ -114,7 +112,6 @@ setupMessageListener(functionMapping, manifest, self.postMessage); denoPlugin({ configPath: options.configPath && path.resolve(Deno.cwd(), options.configPath), - // importMapURL: options.importMap, }), ], }); diff --git a/client/space_lua/arithmetic_test.lua b/client/space_lua/arithmetic_test.lua index 11aa772f..0ac06b34 100644 --- a/client/space_lua/arithmetic_test.lua +++ b/client/space_lua/arithmetic_test.lua @@ -49,6 +49,35 @@ assert_eq(1/-(1.0-1.0) == -1/0.0, true, 'unary minus: float expr (-Inf)') assert_eq(-2^2, -4, 'precedence: -2^2 == -(2^2)') assert_eq((-2)^2, 4, 'precedence: (-2)^2 == 4') +-- 2.1.1. Unary minus with float-typed exponentiation results +do + local function mt(x) + return math.type(x) + end + + assert_eq(mt(-("1.0" ^ 1)), "float", "math.type(-('1.0'^1)) => float") + assert_eq(mt(-"1.0" ^ 1), "float", "math.type(-'1.0'^1) => float") + assert_eq(mt(-("2.0" ^ 1)), "float", "math.type(-('2.0'^1)) => float") + assert_eq(mt(-("2.0" ^ 2)), "float", "math.type(-('2.0'^2)) => float") +end + +-- 2.2. Unary minus uses table metadata for dynamic keys +local dyn_tbl = {} +local dyn_key = 'zf' +dyn_tbl[dyn_key] = 0.0 +assert_eq(1/-(dyn_tbl[dyn_key]) == -1/0.0, true, 'unary minus: table dyn key float (-Inf)') + +-- 2.3. Unary minus uses table metadata for property access +local prop_tbl = { zf = 0.0, zi = 0 } +assert_eq(1/-(prop_tbl.zf) == -1/0.0, true, 'unary minus: table prop float (-Inf)') +assert_eq(1/-(prop_tbl.zi) == 1/0, true, 'unary minus: table prop int (+Inf)') + +-- 2.4. Unary minus uses env metadata for locals +local u_zi, u_zf, u_zfn = 0, 0.0, -0.0 +assert_eq(1/-(u_zi) == 1/0, true, 'var: unary minus zi (+Inf)') +assert_eq(1/-(u_zfn) == 1/0.0, true, 'var: unary minus zfn (+Inf)') +assert_eq(1/u_zf == 1/0.0, true, 'var: zf (+Inf)') + -- 3. Integer operations (must not produce -0) assert_eq(1/(1-1) == 1/0, true, 'int: sub (+0)') assert_eq(1/(0*-1) == 1/0, true, 'int: mul (+0)') @@ -103,6 +132,11 @@ assert_eq(1/(0.0*-1 ) == -1/0.0, true, 'mixed: mul float*int (-0.0)') assert_eq(1/(1.0+(-1)) == 1/0.0, true, 'mixed: add (+0.0)') assert_eq(1/(-(1-1.0)) == -1/0.0, true, 'mixed: sub then unary minus (-0.0)') +-- 5.1. Dynamic key table metadata affects binary ops +local dyn_t, dyn_k = {}, 'zf' +dyn_t[dyn_k] = 0.0 +assert_eq(1/dyn_t[dyn_k] == 1/0.0, true, 'binary: table dyn key float (+Inf)') + -- 6. Variables local zi, zf, zfn = 0, 0.0, -0.0 @@ -112,6 +146,14 @@ assert_eq(1/zfn == -1/0.0, true, 'var: zfn (-Inf)') assert_eq(1/-(zi) == 1/0, true, 'var: unary minus zi (+Inf)') assert_eq(1/-(zfn) == 1/0.0, true, 'var: unary minus zfn (+Inf)') +-- 6.1. Variables: metadata must flow through reassignment +local zswap = 0.0 +assert_eq(1/zswap == 1/0.0, true, 'var: zswap float (+Inf)') +zswap = 0 +assert_eq(1/zswap == 1/0, true, 'var: zswap reassigned int (+Inf)') +zswap = 0.0 +assert_eq(1/zswap == 1/0.0, true, 'var: zswap reassigned float (+Inf)') + -- 7. Functions returning zeros and unary minus local function ret_zi() return 0 @@ -144,6 +186,12 @@ assert_eq(1/t.zfn == -1/0.0, true, 'table: t.zfn (-Inf)') assert_eq(1/arr[1] == 1/0, true, 'array: arr[1]=zi (+Inf)') assert_eq(1/arr[2] == -1/0.0, true, 'array: arr[2]=zfn (-Inf)') +-- 8.3. Arrays: dynamic numeric index key metadata +local arr2 = {} +local idx = 1 +arr2[idx] = 0.0 +assert_eq(1/arr2[idx] == 1/0.0, true, 'array: dyn index float (+Inf)') + -- 9. Deeply nested parentheses and expressions local xi, xf = 1-1, 1.0-1.0 local deepi = -((((0+0)-(1-1))+(zi-xi))) -- int path (+0) @@ -360,6 +408,63 @@ assertThrows("attempt to add a 'string' with a 'number'", end ) +-- 14.2 Numeric string coercion preserves int/float kind +do + local function mt(x) + return math.type(x) + end + + assert_eq(mt("0" + 0), "integer", "string '0' + 0 => integer") + assert_eq(mt("-0" + 0), "integer", "string '-0' + 0 => integer") + + assert_eq(mt("0.0" + 0), "float", "string '0.0' + 0 => float") + assert_eq(mt("-0.0" + 0), "float", "string '-0.0' + 0 => float") + + assert_eq(mt("0" + 0.0), "float", "string '0' + 0.0 => float") + assert_eq(mt("0.0" + 0.0), "float", "string '0.0' + 0.0 => float") + + assert_eq(mt("0" + -0), "integer", "string '0' + -0 => integer") + assert_eq(mt("0" + -0.0), "float", "string '0' + -0.0 => float") + + -- Regression coverage: numeric strings for all arithmetic ops and operand order + assert_eq(mt("0" - 0), "integer", "string '0' - 0 => integer") + assert_eq(mt("0.0" - 0), "float", "string '0.0' - 0 => float") + assert_eq(mt("0" * 1), "integer", "string '0' * 1 => integer") + assert_eq(mt("0.0" * 1), "float", "string '0.0' * 1 => float") + assert_eq(mt("0" / 1), "float", "string '0' / 1 => float") + assert_eq(mt("0.0" / 1), "float", "string '0.0' / 1 => float") + assert_eq(mt("0" // 1), "integer", "string '0' // 1 => integer") + assert_eq(mt("0.0" // 1), "float", "string '0.0' // 1 => float") + assert_eq(mt("0" % 1), "integer", "string '0' % 1 => integer") + assert_eq(mt("0.0" % 1), "float", "string '0.0' % 1 => float") + assert_eq(mt("2" ^ 1), "float", "string '2' ^ 1 => float") + assert_eq(mt("2.0" ^ 1), "float", "string '2.0' ^ 1 => float") + + assert_eq(mt(0 + "0"), "integer", "0 + string '0' => integer") + assert_eq(mt(0 + "0.0"), "float", "0 + string '0.0' => float") + + assert_eq(mt(0 - "0"), "integer", "0 - string '0' => integer") + assert_eq(mt(0 - "0.0"), "float", "0 - string '0.0' => float") + + assert_eq(mt(1 * "0"), "integer", "1 * string '0' => integer") + assert_eq(mt(1 * "0.0"), "float", "1 * string '0.0' => float") + + assert_eq(mt(0 / "1"), "float", "0 / string '1' => float") + assert_eq(mt(0 / "1.0"), "float", "0 / string '1.0' => float") + + assert_eq(mt(0 // "1"), "integer", "0 // string '1' => integer") + assert_eq(mt(0 // "1.0"), "float", "0 // string '1.0' => float") + + assert_eq(mt(0 % "1"), "integer", "0 % string '1' => integer") + assert_eq(mt(0 % "1.0"), "float", "0 % string '1.0' => float") + + assert_eq(mt(2 ^ "1"), "float", "2 ^ string '1' => float") + assert_eq(mt(2 ^ "1.0"), "float", "2 ^ string '1.0' => float") + + assert_eq((1 // "2"), 0, "1 // '2' == 0") + assert_eq((1 // "2.0"), 0.0, "1 // '2.0' == 0.0") +end + -- 15. Recursive function producing int zero (and unary minus) local function rec_zero(n) if n == 0 then diff --git a/client/space_lua/ast.ts b/client/space_lua/ast.ts index e607c34c..f622197f 100644 --- a/client/space_lua/ast.ts +++ b/client/space_lua/ast.ts @@ -22,6 +22,8 @@ export type LuaBlock = { hasLabelHere?: boolean; // true if this block itself (not nested ones) may create to-be-closed values hasCloseHere?: boolean; + // true if this block's subtree contains any function definition + hasFunctionDef?: boolean; } & ASTContext; // STATEMENTS @@ -91,6 +93,8 @@ export type LuaForStatement = { end: LuaExpression; step?: LuaExpression; block: LuaBlock; + // function definition in the block captures the loop variable + capturesLoopVar?: boolean; } & ASTContext; export type LuaForInStatement = { @@ -98,6 +102,8 @@ export type LuaForInStatement = { names: string[]; expressions: LuaExpression[]; block: LuaBlock; + // function definition in the block captures any loop variable + capturesLoopVar?: boolean; } & ASTContext; export type LuaFunctionStatement = { diff --git a/client/space_lua/context_errors.test.ts b/client/space_lua/context_errors.test.ts index 47e6a6ec..5c421fb9 100644 --- a/client/space_lua/context_errors.test.ts +++ b/client/space_lua/context_errors.test.ts @@ -109,7 +109,7 @@ Deno.test("Context error: __index metamethod must be function or table", async ( e, code, ref, - "Meta table __index must be a function or table", + "attempt to index a number value", ); }); @@ -126,7 +126,7 @@ Deno.test("Context error: __call metamethod must be a function", async () => { e, code, ref, - "Meta table __call must be a function", + "attempt to call a number value", ); }); diff --git a/client/space_lua/eval.test.ts b/client/space_lua/eval.test.ts index 840b7c19..fc0eb2b5 100644 --- a/client/space_lua/eval.test.ts +++ b/client/space_lua/eval.test.ts @@ -110,6 +110,144 @@ Deno.test("Evaluator test", async () => { assertEquals(fn.body.parameters, ["a", "b"]); }); +Deno.test("Parser rejects unary plus - parenthesized", () => { + for ( + const src of [ + "return +(1)", + "return +((1))", + "return +(1 + 2)", + "return +({})", + ] + ) { + let err: any = null; + try { + parse(src); + } catch (e: any) { + err = e; + } + assertEquals(err !== null, true, `expected parse error for: ${src}`); + assertEquals( + String(err?.message ?? err).includes("unexpected symbol near '+'"), + true, + `expected unary-plus message for: ${src}`, + ); + } +}); + +Deno.test("Parser rejects unary plus - variables and calls", () => { + for ( + const src of [ + "return +(a)", + "return +(a.b)", + "return +(a[1])", + "return +f()", + "return +(f())", + ] + ) { + let err: any = null; + try { + parse(src); + } catch (e: any) { + err = e; + } + assertEquals(err !== null, true, `expected parse error for: ${src}`); + assertEquals( + String(err?.message ?? err).includes("unexpected symbol near '+'"), + true, + `expected unary-plus message for: ${src}`, + ); + } +}); + +Deno.test("Parser rejects unary plus - whitespace/newlines", () => { + for ( + const src of [ + "return + 1", + "return +\n1", + "return +\t(1)", + "return \n+\n(1)", + ] + ) { + let err: any = null; + try { + parse(src); + } catch (e: any) { + err = e; + } + assertEquals(err !== null, true, `expected parse error for: ${src}`); + assertEquals( + String(err?.message ?? err).includes("unexpected symbol near '+'"), + true, + `expected unary-plus message for: ${src}`, + ); + } +}); + +Deno.test("Comparison metamethods: __lt", async () => { + const env = new LuaEnv(luaBuildStandardEnv()); + + await evalBlock( + ` + local mt = { + __lt = function(a, b) return a.v < b.v end + } + local a = setmetatable({ v = 1 }, mt) + local b = setmetatable({ v = 2 }, mt) + res = (a < b) + `, + env, + ); + + assertEquals(env.get("res"), true); +}); + +Deno.test("Comparison metamethods: __le does not fallback to __lt", async () => { + const env = new LuaEnv(luaBuildStandardEnv()); + + let threw = false; + try { + await evalBlock( + ` + local mt = { + __lt = function(a, b) return a.v < b.v end + } + local a = setmetatable({ v = 2 }, mt) + local b = setmetatable({ v = 2 }, mt) + res = (a <= b) + `, + env, + ); + } catch (e: any) { + threw = true; + const msg = String(e?.message ?? e); + if (!msg.includes("attempt to compare")) { + throw e; + } + } + + assertEquals(threw, true); +}); + +Deno.test("Equality metamethod: __eq requires same function", async () => { + const env = new LuaEnv(luaBuildStandardEnv()); + + await evalBlock( + ` + local f1 = function(a, b) return true end + local f2 = function(a, b) return true end + local mt1 = { __eq = f1 } + local mt2 = { __eq = f2 } -- different function object + local a = setmetatable({}, mt1) + local b = setmetatable({}, mt2) + res = (a == b) + `, + env, + ); + + // Lua calls __eq even when the functions differ + assertEquals(env.get("res"), true); +}); + Deno.test("Statement evaluation", async () => { const env = new LuaEnv(); env.set("test", new LuaNativeJSFunction((n) => n)); @@ -482,3 +620,20 @@ Deno.test("Thread local _CTX - advanced cases", async () => { `Some JSON {"name":"Pete"}!`, ); }); + +Deno.test("Length: rawlen ignores __len", async () => { + const env = new LuaEnv(luaBuildStandardEnv()); + + await evalBlock( + ` + local t = {1,2,3} + setmetatable(t, { __len = function() return 99 end }) + a = #t + b = rawlen(t) + `, + env, + ); + + assertEquals(env.get("a"), 99); + assertEquals(env.get("b"), 3); +}); diff --git a/client/space_lua/eval.ts b/client/space_lua/eval.ts index 997c36a3..18e9928b 100644 --- a/client/space_lua/eval.ts +++ b/client/space_lua/eval.ts @@ -12,7 +12,7 @@ import { getMetatable, type ILuaFunction, type ILuaGettable, - type ILuaSettable, + isILuaFunction, jsToLuaValue, luaCall, luaCloseFromMark, @@ -42,17 +42,16 @@ import { type LuaCollectionQuery, } from "./query_collection.ts"; import { - boxZero, - coerceNumeric, coerceNumericPair, - floatLiteral, - getNumericKind, - getZeroBoxKind, - isFloatTag, + coerceToNumber, + inferNumericType, isNegativeZero, - type LuaFloatTag, + isTaggedFloat, luaStringCoercionError, - type OpHints, + makeLuaFloat, + makeLuaZero, + normalizeArithmeticResult, + toInteger, untagNumber, } from "./numeric.ts"; import { isPromise, rpAll, rpThen } from "./rp.ts"; @@ -87,6 +86,55 @@ import { } from "./ast_narrow.ts"; import { getBlockGotoMeta } from "./labels.ts"; +const astNumberKindCache = new WeakMap(); + +function astNumberKind(e: LuaExpression | undefined): NumericType | undefined { + if (!e) return undefined; + + const cached = astNumberKindCache.get(e); + if (cached) return cached; + + let unwrapped = e; + while (unwrapped.type === "Parenthesized") { + unwrapped = unwrapped.expression; + } + + let result: NumericType | undefined; + + if (unwrapped.type === "Unary" && unwrapped.operator === "-") { + result = astNumberKind(unwrapped.argument); + } else if (unwrapped.type === "Number") { + result = unwrapped.numericType === "int" ? "int" : "float"; + } else if (unwrapped.type === "Binary") { + const op = unwrapped.operator; + const numericOp = op === "+" || op === "-" || op === "*" || op === "/" || + op === "//" || op === "%" || op === "^"; + + if (numericOp) { + const lk = astNumberKind(unwrapped.left); + const rk = astNumberKind(unwrapped.right); + + if (lk === "float" || rk === "float") { + result = "float"; + } else if (lk === "int" && rk === "int") { + result = "int"; + } else { + return undefined; + } + } else { + return undefined; + } + } else { + return undefined; + } + + if (result !== undefined) { + astNumberKindCache.set(e, result); + } + + return result; +} + type GotoSignal = { ctrl: "goto"; target: string }; type ReturnSignal = { ctrl: "return"; values: LuaValue[] }; type BreakSignal = { ctrl: "break" }; @@ -163,6 +211,214 @@ function arithVerbFromOperator(op: string): string | null { } } +function isNumericBinaryOp(op: string): boolean { + return ( + op === "+" || + op === "-" || + op === "*" || + op === "/" || + op === "//" || + op === "%" || + op === "^" + ); +} + +function arithCoercionErrorOrThrow( + op: string, + left: any, + right: any, + ctx: ASTCtx, + sf: LuaStackFrame, + e: any, +): never { + if (e === luaStringCoercionError) { + const mapped = maybeLuaArithStringError(op, left, right, ctx, sf); + if (mapped) { + throw mapped; + } + throw new LuaRuntimeError( + "attempt to perform arithmetic on a string value", + sf.withCtx(ctx), + ); + } + + const mapped = maybeLuaArithStringError(op, left, right, ctx, sf); + if (mapped) { + throw mapped; + } + + throw e; +} + +function luaOp( + op: string, + left: any, + right: any, + leftType: NumericType | undefined, + rightType: NumericType | undefined, + ctx: ASTCtx, + sf: LuaStackFrame, +): any { + switch (op) { + case "+": + case "-": + case "*": + case "/": + case "^": { + const ar = numericArith[op as NumericArithOp]; + try { + const { left: l, right: r, resultType } = coerceNumericPair( + left, + right, + leftType, + rightType, + op, + ); + + let result = ar.f(l, r); + + if ( + ar.special === "sub" && + result === 0 && + isNegativeZero(result) && + resultType === "float" + ) { + const rhsIsIntZero = r === 0 && rightType === "int"; + if (rhsIsIntZero) { + result = 0; + } + } + + const normalized = normalizeArithmeticResult(result, resultType); + + // Operators `/` and `^` produce float, wrap only if needed. + if (op === "/" || op === "^") { + if (normalized === 0) { + return makeLuaZero(normalized, "float"); + } + if (!Number.isFinite(normalized)) { + return normalized; + } + if (!Number.isInteger(normalized)) { + return normalized; + } + return makeLuaFloat(normalized); + } + + if (normalized === 0) { + return makeLuaZero(normalized, resultType); + } + if (resultType === "float" && Number.isInteger(normalized)) { + return makeLuaFloat(normalized); + } + return normalized; + } catch (e: any) { + const meta = evalMetamethod(left, right, ar.metaMethod, ctx, sf); + if (meta !== undefined) { + return meta; + } + return arithCoercionErrorOrThrow(op, left, right, ctx, sf, e); + } + } + case "..": { + try { + const coerce = (v: any): string => { + if (v === null || v === undefined) { + throw new LuaRuntimeError( + "attempt to concatenate a nil value", + sf.withCtx(ctx), + ); + } + if (typeof v === "string") { + return v as string; + } + if (typeof v === "number") { + return String(v); + } + if (isTaggedFloat(v)) { + return String(v.value); + } + const t = luaTypeName(v); + throw new LuaRuntimeError( + `attempt to concatenate a ${t} value`, + sf.withCtx(ctx), + ); + }; + return coerce(left) + coerce(right); + } catch (e: any) { + const meta = evalMetamethod(left, right, "__concat", ctx, sf); + if (meta !== undefined) { + return meta; + } + throw e; + } + } + case "==": { + if (luaEquals(left, right)) return true; + return luaEqWithMetamethod(left, right, ctx, sf); + } + case "~=": + case "!=": { + if (luaEquals(left, right)) { + return false; + } + return !luaEqWithMetamethod(left, right, ctx, sf); + } + case "<": { + return luaRelWithMetamethod("<", left, right, ctx, sf); + } + case "<=": { + return luaRelWithMetamethod("<=", left, right, ctx, sf); + } + // Lua: `a>b` is `b=b` is `b<=a` + case ">": { + return luaRelWithMetamethod("<", right, left, ctx, sf); + } + case ">=": { + return luaRelWithMetamethod("<=", right, left, ctx, sf); + } + } + + // Remaining operators: //, %, bitwise + const handler = operatorsMetaMethods[op]; + if (!handler) { + throw new LuaRuntimeError(`Unknown operator ${op}`, sf.withCtx(ctx)); + } + + try { + return handler.nativeImplementation( + left, + right, + leftType, + rightType, + ctx, + sf, + ); + } catch (e: any) { + if (handler.metaMethod) { + const meta = evalMetamethod(left, right, handler.metaMethod, ctx, sf); + if (meta !== undefined) { + return meta; + } + } + return arithCoercionErrorOrThrow(op, left, right, ctx, sf, e); + } +} + +type NumericArithOp = "+" | "-" | "*" | "/" | "^"; + +const numericArith: Record number; + special?: "sub"; +}> = { + "+": { metaMethod: "__add", f: (l, r) => l + r }, + "-": { metaMethod: "__sub", f: (l, r) => l - r, special: "sub" }, + "*": { metaMethod: "__mul", f: (l, r) => l * r }, + "/": { metaMethod: "__div", f: (l, r) => l / r }, + "^": { metaMethod: "__pow", f: (l, r) => l ** r }, +}; + function maybeLuaArithStringError( op: string, a: any, @@ -188,211 +444,170 @@ function maybeLuaArithStringError( return null; } -// Normalize arithmetic result based on operation mode: -// * integer operations: -0 becomes +0, integer results stay plain, -// * float operations: -0 stays -0, integer results tagged as float. -function normalizeArithmeticResult( - r: number, - bothInt: boolean, -): number | LuaFloatTag { - if (r === 0) { - if (isNegativeZero(r)) { - return bothInt ? 0 : -0; - } - return bothInt ? 0 : boxZero("float"); - } - - if (!bothInt && Number.isInteger(r)) { - return floatLiteral(r); - } - - return r; -} - function luaFloorDiv( a: unknown, b: unknown, + leftType: NumericType | undefined, + rightType: NumericType | undefined, ctx: ASTCtx, sf: LuaStackFrame, - hints?: OpHints, -): number | LuaFloatTag { - const { ax, bx, bothInt } = coerceNumericPair(a, b, hints); - if (bothInt && bx === 0) { +): any { + const { left, right, resultType } = coerceNumericPair( + a, + b, + leftType, + rightType, + "//", + ); + + if (resultType === "int" && right === 0) { throw new LuaRuntimeError( `attempt to divide by zero`, sf.withCtx(ctx), ); } - const q = Math.floor(ax / bx); - if (q === 0) { - if (bothInt) { - return 0; - } - return isNegativeZero(q) ? -0 : boxZero("float"); + const result = Math.floor(left / right); + const normalized = normalizeArithmeticResult(result, resultType); + if (normalized === 0) { + return makeLuaZero(normalized, resultType); } - - // Tag integer-valued float results - if (!bothInt && Number.isInteger(q)) { - return floatLiteral(q); + if (resultType === "float" && Number.isInteger(normalized)) { + return makeLuaFloat(normalized); } - - return q; + return normalized; } function luaMod( a: unknown, b: unknown, + leftType: NumericType | undefined, + rightType: NumericType | undefined, ctx: ASTCtx, sf: LuaStackFrame, - hints?: OpHints, -): number | LuaFloatTag { - const { ax, bx, bothInt } = coerceNumericPair(a, b, hints); - if (bothInt && bx === 0) { +): any { + const { left, right, resultType } = coerceNumericPair( + a, + b, + leftType, + rightType, + "%", + ); + + if (resultType === "int" && right === 0) { throw new LuaRuntimeError( `attempt to perform 'n%0'`, sf.withCtx(ctx), ); } - const q = Math.floor(ax / bx); - const r = ax - q * bx; - if (r === 0) { - if (isNegativeZero(ax)) { - return -0; - } - return bothInt ? 0 : boxZero("float"); + const q = Math.floor(left / right); + const result = left - q * right; + + // Preserve -0.0 from left operand in float mode + if (result === 0 && resultType === "float" && isNegativeZero(left)) { + return makeLuaZero(-0, "float"); } - // Tag integer-valued float results - if (!bothInt && Number.isInteger(r)) { - return floatLiteral(r); + const normalized = normalizeArithmeticResult(result, resultType); + if (normalized === 0) { + return makeLuaZero(normalized, resultType); } - - return r; + if (resultType === "float" && Number.isInteger(normalized)) { + return makeLuaFloat(normalized); + } + return normalized; } -function luaLess( - a: any, - b: any, - ctx: ASTCtx, - sf: LuaStackFrame, -): boolean { - const { ta, tb, av, bv } = luaRelOperands(a, b); +function luaUnaryMinus( + v: number, + numType: NumericType | undefined, +): number { + const vType = numType ?? inferNumericType(v); - if (ta === "number" && tb === "number") { - return av < bv; - } - - if (ta === "string" && tb === "string") { - return av < bv; - } - - throw new LuaRuntimeError( - `attempt to compare ${ta} with ${tb}`, - sf.withCtx(ctx), - ); -} - -function luaLessEqual( - a: any, - b: any, - ctx: ASTCtx, - sf: LuaStackFrame, -): boolean { - const { ta, tb, av, bv } = luaRelOperands(a, b); - - if (ta === "number" && tb === "number") { - return av <= bv; - } - - if (ta === "string" && tb === "string") { - return av <= bv; - } - - throw new LuaRuntimeError( - `attempt to compare ${ta} with ${tb}`, - sf.withCtx(ctx), - ); -} - -function luaUnaryMinus(v: any): number | LuaFloatTag { - const { n, zeroKind } = coerceNumeric(v); - - if (n === 0) { - const origKind = getZeroBoxKind(v); - const isFloat = origKind === "float" || zeroKind === "float" || - isFloatTag(v); - - // If it's a float zero for sure, flip the sign - if (isFloat) { - return isNegativeZero(v) || isNegativeZero(n) ? 0 : -0; - } + if (v === 0 && vType === "int") { return 0; } - // Preserve float type for non-zero values. Without this code -5.0 - // would become plain integer -5 (it would loose its float type). - if (isFloatTag(v) || zeroKind === "float" || getNumericKind(v) === "float") { - const result = -n; - if (Number.isInteger(result)) { - return floatLiteral(result); - } - return result; + if (v === 0 && vType === "float") { + return isNegativeZero(v) ? 0 : -0; } - return -n; + return -v; } -async function handleTableFieldSync( - table: LuaTable, - field: any, - env: LuaEnv, - sf: LuaStackFrame, -): Promise { - switch (field.type) { - case "PropField": { - const value = await evalExpression(field.value, env, sf); - table.set(field.key, singleResult(value), sf); - break; - } - case "DynamicField": { - const key = await evalExpression(field.key, env, sf); - const value = await evalExpression(field.value, env, sf); - - table.set(singleResult(key), singleResult(value), sf); - break; - } - case "ExpressionField": { - const value = await evalExpression(field.value, env, sf); - if (value instanceof LuaMultiRes) { - for (const val of value.values) { - table.set(table.length + 1, val, sf); - } - } else { - table.set(table.length + 1, singleResult(value), sf); - } - break; - } - } -} - -// Unwrap parentheses and unary +/- around a numeric literal -function astNumberKind(e: LuaExpression | undefined): NumericType | undefined { - if (!e) { - return undefined; - } - while (e.type === "Parenthesized") { - e = e.expression; - } - if (e.type === "Unary" && (e.operator === "-" || e.operator === "+")) { - return astNumberKind(e.argument); - } - if (e.type === "Number") { - return e.numericType === "int" ? "int" : "float"; - } - return undefined; -} +const operatorsMetaMethods: Record LuaValue; +}> = { + "//": { + metaMethod: "__idiv", + nativeImplementation: (a, b, lt, rt, ctx, sf) => + luaFloorDiv(a, b, lt, rt, ctx, sf), + }, + "%": { + metaMethod: "__mod", + nativeImplementation: (a, b, lt, rt, ctx, sf) => + luaMod(a, b, lt, rt, ctx, sf), + }, + "&": { + metaMethod: "__band", + nativeImplementation: (a, b, _lt, _rt, ctx, sf) => { + const aInt = toInteger(a); + const bInt = toInteger(b); + if (aInt === null) throw createBitwiseError(a, ctx, sf); + if (bInt === null) throw createBitwiseError(b, ctx, sf); + return aInt & bInt; + }, + }, + "|": { + metaMethod: "__bor", + nativeImplementation: (a, b, _lt, _rt, ctx, sf) => { + const aInt = toInteger(a); + const bInt = toInteger(b); + if (aInt === null) throw createBitwiseError(a, ctx, sf); + if (bInt === null) throw createBitwiseError(b, ctx, sf); + return aInt | bInt; + }, + }, + "~": { + metaMethod: "__bxor", + nativeImplementation: (a, b, _lt, _rt, ctx, sf) => { + const aInt = toInteger(a); + const bInt = toInteger(b); + if (aInt === null) throw createBitwiseError(a, ctx, sf); + if (bInt === null) throw createBitwiseError(b, ctx, sf); + return aInt ^ bInt; + }, + }, + "<<": { + metaMethod: "__shl", + nativeImplementation: (a, b, _lt, _rt, ctx, sf) => { + const aInt = toInteger(a); + const bInt = toInteger(b); + if (aInt === null) throw createBitwiseError(a, ctx, sf); + if (bInt === null) throw createBitwiseError(b, ctx, sf); + return aInt << bInt; + }, + }, + ">>": { + metaMethod: "__shr", + nativeImplementation: (a, b, _lt, _rt, ctx, sf) => { + const aInt = toInteger(a); + const bInt = toInteger(b); + if (aInt === null) throw createBitwiseError(a, ctx, sf); + if (bInt === null) throw createBitwiseError(b, ctx, sf); + return aInt >> bInt; + }, + }, +}; export function evalExpression( e: LuaExpression, @@ -405,11 +620,11 @@ export function evalExpression( return e.value; } case "Number": { - if (e.value === 0 && !isNegativeZero(e.value)) { - return e.numericType === "int" ? 0 : boxZero("float"); + if (e.value === 0) { + return makeLuaZero(e.value, e.numericType); } if (e.numericType === "float" && Number.isInteger(e.value)) { - return floatLiteral(e.value); + return makeLuaFloat(e.value); } return e.value; } @@ -422,14 +637,11 @@ export function evalExpression( case "Binary": { const b = asBinary(e); if (b.operator === "or") { - // Special case: eagerly evaluate left before even attempting right return evalLogical("or", b.left, b.right, env, sf); - } else if (b.operator === "and") { - // Special case: eagerly evaluate left before even attempting right + } + if (b.operator === "and") { return evalLogical("and", b.left, b.right, env, sf); } - // Enforce left-to-right evaluation - const hints: OpHints = opHintsFromBinary(b); return evalBinaryWithLR( b.operator, b.left, @@ -437,53 +649,92 @@ export function evalExpression( b.ctx, env, sf, - hints, ); } case "Unary": { const u = asUnary(e); + + // Fast path: negation of numeric literal if (u.operator === "-" && u.argument.type === "Number") { const num = u.argument; - if (num.value === 0 && !isNegativeZero(num.value)) { - if (num.numericType === "float") { - return -0; - } else { - return 0; - } + if (num.value === 0) { + const z = num.numericType === "int" ? 0 : -0; + return makeLuaZero(z, num.numericType); } - return num.numericType === "float" && Number.isInteger(-num.value) - ? floatLiteral(-num.value) - : -num.value; + if (num.numericType === "float" && Number.isInteger(num.value)) { + return makeLuaFloat(-num.value); + } + return -num.value; } + + if (u.operator === "-") { + const tv = evalExprWithNumericType(u.argument, env, sf, true); + + const applyTyped = (typed: TypedValue) => { + const arg = singleResult(typed.value); + + return unaryWithMeta( + arg, + "__unm", + u.ctx, + sf, + () => { + // Numeric-string coercion for unary minus + if (typeof arg === "string") { + const n = coerceToNumber(arg); + if (n === null) { + throw new LuaRuntimeError( + "attempt to unm a 'string' with a 'string'", + sf.withCtx(u.ctx), + ); + } + if (n === 0) { + return 0; + } + return -n; + } + + const plain = untagNumber(arg); + if (typeof plain !== "number") { + throw new LuaRuntimeError( + "attempt to perform arithmetic on a table value", + sf.withCtx(u.ctx), + ); + } + + const argType = isTaggedFloat(arg) + ? "float" + : astNumberKind(u.argument); + + const out = luaUnaryMinus(plain, argType); + + // If the operand is a float-tagged boxed number, unary + // minus must keep the result float-typed. + if (isTaggedFloat(arg)) { + if (out === 0) { + return makeLuaZero(out, "float"); + } + return makeLuaFloat(out); + } + + // Preserve numeric kind for zero results + if (out === 0) { + const outType = argType ?? inferNumericType(plain); + return makeLuaZero(out, outType); + } + + return out; + }, + ); + }; + + return rpThen(tv as any, applyTyped); + } + const value = evalExpression(u.argument, env, sf); - const apply = (value: LuaValue) => { + const applyUnary = (value: LuaValue) => { switch (u.operator) { - case "-": { - const arg = singleResult(value); - return unaryWithMeta( - arg, - "__unm", - u.ctx, - sf, - () => { - if (typeof arg === "string") { - try { - coerceNumeric(arg); - } catch (_e: any) { - throw new LuaRuntimeError( - "attempt to unm a 'string' with a 'string'", - sf.withCtx(u.ctx), - ); - } - } - return luaUnaryMinus(arg); - }, - ); - } - case "+": { - return +singleResult(value); - } case "not": { return !luaTruthy(value); } @@ -494,7 +745,29 @@ export function evalExpression( "__bnot", u.ctx, sf, - () => ~exactInt(arg, u.ctx, sf), + () => { + const intVal = toInteger(arg); + if (intVal === null) { + if (typeof arg === "string") { + throw new LuaRuntimeError( + `attempt to perform bitwise operation on a string value (constant '${arg}')`, + sf.withCtx(u.ctx), + ); + } + const t = luaTypeName(arg); + if (t === "number") { + throw new LuaRuntimeError( + `number has no integer representation`, + sf.withCtx(u.ctx), + ); + } + throw new LuaRuntimeError( + `attempt to perform bitwise operation on a ${t} value`, + sf.withCtx(u.ctx), + ); + } + return ~intVal; + }, ); } case "#": { @@ -509,9 +782,8 @@ export function evalExpression( } }; - return rpThen(value, apply); + return rpThen(value, applyUnary); } - case "Variable": case "FunctionCall": case "TableAccess": @@ -522,11 +794,39 @@ export function evalExpression( const tc = asTableConstructor(e); return Promise.resolve().then(async () => { const table = new LuaTable(); - + // Expression fields assign consecutive integer keys starting + // at 1 and advance even when the value is `nil`. + let nextArrayIndex = 1; for (const field of tc.fields) { - await handleTableFieldSync(table, field, env, sf); - } + switch (field.type) { + case "PropField": { + const value = await evalExpression(field.value, env, sf); + table.set(field.key, singleResult(value), sf); + break; + } + case "DynamicField": { + const key = await evalExpression(field.key, env, sf); + const value = await evalExpression(field.value, env, sf); + table.set(singleResult(key), singleResult(value), sf); + break; + } + case "ExpressionField": { + const value = await evalExpression(field.value, env, sf); + if (value instanceof LuaMultiRes) { + const flat = value.flatten(); + for (let i = 0; i < flat.values.length; i++) { + table.rawSetArrayIndex(nextArrayIndex, flat.values[i]); + nextArrayIndex++; + } + } else { + table.rawSetArrayIndex(nextArrayIndex, singleResult(value)); + nextArrayIndex++; + } + break; + } + } + } return table; }); } @@ -642,14 +942,15 @@ function evalPrefixExpression( const value = env.get(v.name); if (value === undefined) { return null; - } else { - return value; } + return value; } + case "Parenthesized": { const p = asParenthesized(e); return evalExpression(p.expression, env, sf); } + // <>[<>] case "TableAccess": { const ta = asTableAccess(e); @@ -672,6 +973,7 @@ function evalPrefixExpression( ), ); } + // .property case "PropertyAccess": { const pa = asPropertyAccess(e); @@ -682,6 +984,7 @@ function evalPrefixExpression( } return rpThen(objV, (obj) => luaGet(obj, pa.property, pa.ctx, sf)); } + case "FunctionCall": { const fc = asFunctionCall(e); const prefixValue = evalPrefixExpression(fc.prefix, env, sf); @@ -722,6 +1025,7 @@ function evalPrefixExpression( return rpThen(prefixValue, handleFunctionCall); } + default: { throw new LuaRuntimeError( `Unknown prefix expression type ${e.type}`, @@ -740,13 +1044,19 @@ function evalMetamethod( sf: LuaStackFrame, ): LuaValue | undefined { const leftMetatable = getMetatable(left, sf); + if (leftMetatable) { + const fn = leftMetatable.rawGet(metaMethod); + if (!(fn === undefined || fn === null)) { + return luaCall(fn, [left, right], ctx, sf); + } + } + const rightMetatable = getMetatable(right, sf); - if (leftMetatable?.has(metaMethod)) { - const fn = leftMetatable.get(metaMethod); - return luaCall(fn, [left, right], ctx, sf); - } else if (rightMetatable?.has(metaMethod)) { - const fn = rightMetatable.get(metaMethod); - return luaCall(fn, [left, right], ctx, sf); + if (rightMetatable) { + const fn = rightMetatable.rawGet(metaMethod); + if (!(fn === undefined || fn === null)) { + return luaCall(fn, [left, right], ctx, sf); + } } } @@ -758,11 +1068,14 @@ function evalUnaryMetamethod( sf: LuaStackFrame, ): LuaValue | Promise | undefined { const mt = getMetatable(value, sf); - if (mt?.has(metaMethod)) { - const fn = mt.get(metaMethod); - return luaCall(fn, [value], ctx, sf); + if (!mt) { + return undefined; } - return undefined; + const fn = mt.rawGet(metaMethod); + if (fn === undefined || fn === null) { + return undefined; + } + return luaCall(fn, [value], ctx, sf); } // Unary metamethod handling (with fallback) @@ -802,31 +1115,47 @@ function evalLogical( return isPromise(rv) ? (rv as Promise).then(singleResult) : singleResult(rv); - } else { - if (!luaTruthy(lv)) { - return singleResult(lv); - } - const rv = evalExpression(rightExpr, env, sf); - return isPromise(rv) - ? (rv as Promise).then(singleResult) - : singleResult(rv); } + if (!luaTruthy(lv)) { + return singleResult(lv); + } + const rv = evalExpression(rightExpr, env, sf); + return isPromise(rv) + ? (rv as Promise).then(singleResult) + : singleResult(rv); }; if (isPromise(left)) { return (left as Promise).then(decide); - } else { - return decide(left); } + return decide(left); } -function opHintsFromBinary( - e: Extract, -): OpHints { - return { - leftKind: astNumberKind(e.left), - rightKind: astNumberKind(e.right), - }; +type TypedValue = { value: LuaValue }; + +function evalExprWithNumericType( + expr: LuaExpression, + env: LuaEnv, + sf: LuaStackFrame, + _wantNumericType: boolean, +): TypedValue | Promise { + const v = evalExpression(expr, env, sf); + const apply = (vv: any): TypedValue => ({ value: vv }); + return rpThen(v, apply) as any; +} + +function getSimpleLiteralType(expr: LuaExpression): NumericType | undefined { + if (expr.type === "Number") { + return expr.numericType === "int" ? "int" : "float"; + } + if ( + expr.type === "Unary" && + (expr.operator === "+" || expr.operator === "-") && + expr.argument.type === "Number" + ) { + return expr.argument.numericType === "int" ? "int" : "float"; + } + return undefined; } function evalBinaryWithLR( @@ -836,279 +1165,161 @@ function evalBinaryWithLR( ctx: ASTCtx, env: LuaEnv, sf: LuaStackFrame, - hints?: OpHints, ): any { + const wantNumericType = isNumericBinaryOp(op); + const leftType = wantNumericType ? getSimpleLiteralType(leftExpr) : undefined; + const rightType = wantNumericType + ? getSimpleLiteralType(rightExpr) + : undefined; const leftVal = evalExpression(leftExpr, env, sf); - if (!isPromise(leftVal)) { + const applyLeft = (lv: any) => { const rightVal = evalExpression(rightExpr, env, sf); - if (!isPromise(rightVal)) { - return luaOp( - op, - singleResult(leftVal), - singleResult(rightVal), - ctx, - sf, - hints, - ); - } - return rpThen(rightVal, (rv) => - luaOp( - op, - singleResult(leftVal), - singleResult(rv), - ctx, - sf, - hints, - )); - } - - return rpThen(leftVal, (lv) => { - const rightVal = evalExpression(rightExpr, env, sf); - if (!isPromise(rightVal)) { + const applyRight = (rv: any) => { return luaOp( - op, - singleResult(lv), - singleResult(rightVal), - ctx, - sf, - hints, - ); - } - return rpThen(rightVal, (rv) => - luaOp( op, singleResult(lv), singleResult(rv), + leftType, + rightType, ctx, sf, - hints, - )); - }); -} - -// Relational comparison "prelude" -function luaRelOperands( - a: any, - b: any, -): { - ta: string; - tb: string; - av: any; - bv: any; -} { - const ta = luaTypeName(a); - const tb = luaTypeName(b); - const av = (a instanceof Number) ? Number(a) : a; - const bv = (b instanceof Number) ? Number(b) : b; - - return { ta, tb, av, bv }; -} - -// Simplified operator definitions -const operatorsMetaMethods: Record LuaValue; -}> = { - "+": { - metaMethod: "__add", - nativeImplementation: (a, b, _ctx, _sf, hints) => { - const { ax, bx, bothInt } = coerceNumericPair(a, b, hints); - return normalizeArithmeticResult(ax + bx, bothInt); - }, - }, - "-": { - metaMethod: "__sub", - nativeImplementation: (a, b, _ctx, _sf, hints) => { - const { ax, bx, bothInt, bZeroKind } = coerceNumericPair( - a, - b, - hints, ); - const result = ax - bx; + }; + return rpThen(rightVal, applyRight); + }; + return rpThen(leftVal, applyLeft); +} - if (result === 0 && isNegativeZero(result) && !bothInt) { - const rightIsIntZero = bx === 0 && - (bZeroKind === undefined || bZeroKind === "int"); - if (rightIsIntZero) { - return boxZero("float"); // Normalize to +0.0 - } - } - - return normalizeArithmeticResult(result, bothInt); - }, - }, - "*": { - metaMethod: "__mul", - nativeImplementation: (a, b, _ctx, _sf, hints) => { - const { ax, bx, bothInt } = coerceNumericPair(a, b, hints); - return normalizeArithmeticResult(ax * bx, bothInt); - }, - }, - "/": { - metaMethod: "__div", - nativeImplementation: (a, b, _ctx, _sf, hints) => { - const { ax, bx, bothInt, bZeroKind } = coerceNumericPair(a, b, hints); - - // In integer division mode, or if divisor is an integer zero, - // normalize -0 to +0. - let divisor = bx; - if ((bothInt || bZeroKind === "int") && isNegativeZero(bx)) { - divisor = 0; - } - - const result = ax / divisor; - return normalizeArithmeticResult(result, false); - }, - }, - "//": { - metaMethod: "__idiv", - nativeImplementation: (a, b, ctx, sf, hints) => - luaFloorDiv(a, b, ctx, sf, hints), - }, - "%": { - metaMethod: "__mod", - nativeImplementation: (a, b, ctx, sf, hints) => - luaMod(a, b, ctx, sf, hints), - }, - "^": { - metaMethod: "__pow", - nativeImplementation: (a, b, _ctx, _sf, hints) => { - const { ax, bx } = coerceNumericPair(a, b, hints); - const result = ax ** bx; - - // Always treat as potentially float - return normalizeArithmeticResult(result, false); - }, - }, - "&": { - metaMethod: "__band", - nativeImplementation: (a, b, ctx, sf) => - exactInt(a, ctx, sf) & exactInt(b, ctx, sf), - }, - "|": { - metaMethod: "__bor", - nativeImplementation: (a, b, ctx, sf) => - exactInt(a, ctx, sf) | exactInt(b, ctx, sf), - }, - "~": { - metaMethod: "__bxor", - nativeImplementation: (a, b, ctx, sf) => - exactInt(a, ctx, sf) ^ exactInt(b, ctx, sf), - }, - "<<": { - metaMethod: "__shl", - nativeImplementation: (a, b, ctx, sf) => - exactInt(a, ctx, sf) << exactInt(b, ctx, sf), - }, - ">>": { - metaMethod: "__shr", - nativeImplementation: (a, b, ctx, sf) => - exactInt(a, ctx, sf) >> exactInt(b, ctx, sf), - }, - "..": { - metaMethod: "__concat", - nativeImplementation: (a, b, ctx, sf) => { - const coerce = (v: any): string => { - if (v === null || v === undefined) { - throw new LuaRuntimeError( - "attempt to concatenate a nil value", - sf.withCtx(ctx), - ); - } - if (typeof v === "string") { - return v as string; - } - if (typeof v === "number" || v instanceof Number) { - return String(v instanceof Number ? Number(v) : v); - } - const t = luaTypeName(v); - throw new LuaRuntimeError( - `attempt to concatenate a ${t} value`, - sf.withCtx(ctx), - ); - }; - return coerce(a) + coerce(b); - }, - }, - "==": { - metaMethod: "__eq", - nativeImplementation: (a, b) => luaEquals(a, b), - }, - "~=": { - metaMethod: "__ne", - nativeImplementation: (a, b) => !luaEquals(a, b), - }, - "!=": { - metaMethod: "__ne", - nativeImplementation: (a, b) => !luaEquals(a, b), - }, - "<": { - metaMethod: "__lt", - nativeImplementation: (a, b, ctx, sf) => luaLess(a, b, ctx, sf), - }, - "<=": { - metaMethod: "__le", - nativeImplementation: (a, b, ctx, sf) => luaLessEqual(a, b, ctx, sf), - }, - // Lua does not define `>`/`>=` as logical negations of `<`/`<=` but - // as swapped operands (`a>b` evaluated as `b=b` as `b<=a`). - ">": { - nativeImplementation: (a, b, ctx, sf) => luaOp("<", b, a, ctx, sf), - }, - ">=": { - nativeImplementation: (a, b, ctx, sf) => luaOp("<=", b, a, ctx, sf), - }, -}; - -function luaOp( - op: string, - left: any, - right: any, +function createBitwiseError( + val: any, ctx: ASTCtx, sf: LuaStackFrame, - hints?: OpHints, -): any { - const handler = operatorsMetaMethods[op]; - if (!handler) { - throw new LuaRuntimeError(`Unknown operator ${op}`, sf.withCtx(ctx)); +): LuaRuntimeError { + if (typeof val === "string") { + return new LuaRuntimeError( + `attempt to perform bitwise operation on a string value (constant '${val}')`, + sf.withCtx(ctx), + ); } + const t = luaTypeName(val); + if (t === "number") { + return new LuaRuntimeError( + `number has no integer representation`, + sf.withCtx(ctx), + ); + } + return new LuaRuntimeError( + `attempt to perform bitwise operation on a ${t} value`, + sf.withCtx(ctx), + ); +} - if (handler.metaMethod) { - const metaResult = evalMetamethod(left, right, handler.metaMethod, ctx, sf); - if (metaResult !== undefined) { - return metaResult; +function getBinaryMM( + a: any, + b: any, + mmName: string, + sf: LuaStackFrame, +): any | null { + // Look in a's metatable first; if absent, look in b's. + const ma = getMetatable(a, sf); + if (ma) { + const mmA = ma.rawGet(mmName); + if (!(mmA === undefined || mmA === null)) { + return mmA; } } - - try { - return handler.nativeImplementation(left, right, ctx, sf, hints); - } catch (e: any) { - // If numeric coercion failed on a string produce the Lua message - if (e === luaStringCoercionError) { - const mapped = maybeLuaArithStringError(op, left, right, ctx, sf); - if (mapped) { - throw mapped; - } - // Fallback - throw new LuaRuntimeError( - "attempt to perform arithmetic on a string value", - sf.withCtx(ctx), - ); + const mb = getMetatable(b, sf); + if (mb) { + const mmB = mb.rawGet(mmName); + if (!(mmB === undefined || mmB === null)) { + return mmB; } - - const mapped = maybeLuaArithStringError(op, left, right, ctx, sf); - if (mapped) { - throw mapped; - } - throw e; } + return null; +} + +function luaEqWithMetamethod( + a: any, + b: any, + ctx: ASTCtx, + sf: LuaStackFrame, +): boolean | Promise { + if (luaEquals(a, b)) { + return true; + } + + const ta = luaTypeName(a); + const tb = luaTypeName(b); + + // __eq only applies to tables/userdata + const aOk = ta === "table" || ta === "userdata"; + const bOk = tb === "table" || tb === "userdata"; + if (!aOk || !bOk) { + return false; + } + + const getEqMM = (obj: any): any | null => { + const mt = getMetatable(obj, sf); + if (!mt) return null; + + const mm = mt.rawGet("__eq"); + if (mm === undefined || mm === null) return null; + + if (typeof mm === "function" || isILuaFunction(mm)) { + return mm; + } + + const ty = luaTypeName(mm); + throw new LuaRuntimeError( + `attempt to call a ${ty} value`, + sf.withCtx(ctx), + ); + }; + + // Try left __eq first, then right. + const mm = getEqMM(a) ?? getEqMM(b); + if (!mm) { + return false; + } + + const r = luaCall(mm, [a, b], ctx, sf); + return isPromise(r) + ? (r as Promise).then((v) => !!singleResult(v)) + : !!singleResult(r); +} + +function luaRelWithMetamethod( + op: "<" | "<=", + a: any, + b: any, + ctx: ASTCtx, + sf: LuaStackFrame, +): boolean | Promise { + const an = isTaggedFloat(a) ? a.value : a; + const bn = isTaggedFloat(b) ? b.value : b; + + if (typeof an === "number" && typeof bn === "number") { + return op === "<" ? an < bn : an <= bn; + } + if (typeof an === "string" && typeof bn === "string") { + return op === "<" ? an < bn : an <= bn; + } + + const mmName = op === "<" ? "__lt" : "__le"; + const mm = getBinaryMM(a, b, mmName, sf); + if (mm) { + const r = luaCall(mm, [a, b], ctx, sf); + if (isPromise(r)) { + return (r as Promise).then((v) => !!singleResult(v)); + } + return !!singleResult(r); + } + + throw new LuaRuntimeError( + `attempt to compare ${luaTypeName(a)} with ${luaTypeName(b)}`, + sf.withCtx(ctx), + ); } /** @@ -1133,9 +1344,11 @@ function luaLengthOp( // Tables: prefer metatable `__len` to raw length if (val instanceof LuaTable) { const mt = getMetatable(val, sf); - if (mt && mt.has("__len")) { - const fn = mt.get("__len"); - return luaCall(fn, [val], ctx, sf); + if (mt) { + const fn = mt.rawGet("__len"); + if (!(fn === undefined || fn === null)) { + return luaCall(fn, [val], ctx, sf); + } } return val.length; } @@ -1143,9 +1356,11 @@ function luaLengthOp( // Other values: allow metatable `__len` first { const mt = getMetatable(val, sf); - if (mt && mt.has("__len")) { - const fn = mt.get("__len"); - return luaCall(fn, [val], ctx, sf); + if (mt) { + const fn = mt.rawGet("__len"); + if (!(fn === undefined || fn === null)) { + return luaCall(fn, [val], ctx, sf); + } } } @@ -1171,8 +1386,7 @@ function evalExpressions( const parts = es.map((arg) => evalExpression(arg, env, sf)); const argsVal = rpAll(parts); - // In Lua multi-returns propagate only in tail position of an expression - // list. + // In Lua multi-returns propagate only in tail position of an expression list. const finalize = (argsResolved: any[]) => { if (argsResolved.length === 0) { return []; @@ -1324,10 +1538,9 @@ function evalBlockNoClose( if (res !== undefined) return res; return runFrom(i + 1); }); - } else { - if (isGotoSignal(r)) return r; - if (r !== undefined) return r; } + if (isGotoSignal(r)) return r; + if (r !== undefined) return r; } return; }; @@ -1425,6 +1638,7 @@ export function evalStatement( values[i], sf.withCtx(a.ctx), ); + if (isPromise(r)) { ps.push(r); } @@ -1440,25 +1654,26 @@ export function evalStatement( valuesRP as LuaValue[], lvaluesRP as LuaLValueContainer[], ); - } else if ( + } + if ( isPromise(valuesRP) && !isPromise(lvaluesRP) ) { return (valuesRP as Promise).then((values: LuaValue[]) => apply(values, lvaluesRP as LuaLValueContainer[]) ); - } else if ( + } + if ( !isPromise(valuesRP) && isPromise(lvaluesRP) ) { return (lvaluesRP as Promise).then((lvalues: any[]) => apply(valuesRP as LuaValue[], lvalues) ); - } else { - return (valuesRP as Promise).then((values: LuaValue[]) => - (lvaluesRP as Promise).then((lvalues: any[]) => - apply(values, lvalues) - ) - ); } + return (valuesRP as Promise).then((values: LuaValue[]) => + (lvaluesRP as Promise).then((lvalues: any[]) => + apply(values, lvalues) + ) + ); } case "Local": { const l = asLocal(s); @@ -1506,7 +1721,10 @@ export function evalStatement( const bindAvailable = () => { while (boundCount < l.names.length && boundCount < out.length) { - bindOne(l.names[boundCount], out[boundCount] ?? null); + bindOne( + l.names[boundCount], + out[boundCount] ?? null, + ); boundCount++; } }; @@ -1530,8 +1748,9 @@ export function evalStatement( const onValue = (v: LuaValue) => { if (isLastExpr) { if (v instanceof LuaMultiRes) { - for (const x of v.values) { - out.push(x); + const flat = v.flatten(); + for (let k = 0; k < flat.values.length; k++) { + out.push(flat.values[k]); } } else { out.push(v); @@ -1624,12 +1843,11 @@ export function evalStatement( } return runFrom(i + 1); }); - } else { - if (luaTruthy(cv)) { - return evalStatement(conds[i].block, env, sf, returnOnReturn); - } - return runFrom(i + 1); } + if (luaTruthy(cv)) { + return evalStatement(conds[i].block, env, sf, returnOnReturn); + } + return runFrom(i + 1); }; return runFrom(0); @@ -1674,15 +1892,14 @@ export function evalStatement( } return runAsync(); }); - } else { - if (r !== undefined) { - if (isBreakSignal(r)) { - return; - } - return r; - } - return runAsync(); } + if (r !== undefined) { + if (isBreakSignal(r)) { + return; + } + return r; + } + return runAsync(); } catch (e: any) { throw e; } @@ -1702,13 +1919,12 @@ export function evalStatement( } return runAsync(); }); - } else { - if (r !== undefined) { - if (isBreakSignal(r)) { - break; - } - return r; + } + if (r !== undefined) { + if (isBreakSignal(r)) { + break; } + return r; } } return; @@ -1746,13 +1962,12 @@ export function evalStatement( } return runAsync(); }); - } else { - if (rr !== undefined) { - if (isBreakSignal(rr)) { - return; - } - return rr; + } + if (rr !== undefined) { + if (isBreakSignal(rr)) { + return; } + return rr; } const c = evalExpression(r.condition, env, sf); @@ -1760,10 +1975,9 @@ export function evalStatement( return (c as Promise).then((cv) => luaTruthy(cv) ? undefined : runAsync() ); - } else { - if (luaTruthy(c)) { - break; - } + } + if (luaTruthy(c)) { + break; } } return; @@ -1791,7 +2005,7 @@ export function evalStatement( }; propNames = [...fn.name.propNames, fn.name.colonName]; } - let settable: ILuaSettable & ILuaGettable = env; + let settable: ILuaGettable = env; for (let i = 0; i < propNames.length - 1; i++) { settable = (settable as any).get(propNames[i]); if (!settable) { @@ -1817,24 +2031,46 @@ export function evalStatement( } case "Return": { const ret = asReturn(s); - // Sync-first collection of return expressions, no extra Promise - // if all are sync. + const parts = ret.expressions.map((value: LuaExpression) => evalExpression(value, env, sf) ); const valuesRP = rpAll(parts); - if (isPromise(valuesRP)) { - return (valuesRP as Promise).then((vals) => ({ - ctrl: "return", - values: vals as LuaValue[], - })); - } else { + const finalize = (vals: any[]): ReturnSignal => { + const outVals: LuaValue[] = []; + + if (vals.length === 0) { + return { ctrl: "return", values: outVals }; + } + + for (let i = 0; i < vals.length; i++) { + const isLast = i === vals.length - 1; + const v = vals[i]; + + if (!isLast) { + outVals.push(singleResult(v)); + continue; + } + + if (v instanceof LuaMultiRes) { + const flat = v.flatten(); + outVals.push(...flat.values); + } else { + outVals.push(v); + } + } + return { - ctrl: "return", - values: valuesRP as LuaValue[], + ctrl: "return" as const, + values: outVals, }; + }; + + if (isPromise(valuesRP)) { + return (valuesRP as Promise).then((vals) => finalize(vals)); } + return finalize(valuesRP as any[]); } case "For": { const fr = asFor(s); @@ -1842,98 +2078,91 @@ export function evalStatement( const endV = evalExpression(fr.end, env, sf); const stepV = fr.step ? evalExpression(fr.step, env, sf) : 1; - const classifyMode = ( - start: any, - _end: any, - step: any, - ): "int" | "float" => { - const s0 = untagNumber(start); - const st0 = untagNumber(step); - - if (!Number.isInteger(s0) || !Number.isInteger(st0)) { - return "float"; - } - - const startKind = getNumericKind(start); - const stepKind = getNumericKind(step); - - if (startKind === "float" || stepKind === "float") { - return "float"; - } - - return "int"; + const determineLoopType = (): NumericType => { + const startType = astNumberKind(fr.start); + const stepType = fr.step ? astNumberKind(fr.step) : "int"; + return (startType === "float" || stepType === "float") + ? "float" + : "int"; }; - const wrapLoopVar = (i: number, mode: "int" | "float") => { - if (mode === "float") { - return floatLiteral(i); + const wrapLoopVar = (i: number, loopType: NumericType) => { + if (loopType === "float") { + return makeLuaFloat(i); } return i; }; - // Extract the common iteration logic - const executeIteration = ( - i: number, - mode: "int" | "float", - ): void | ControlSignal | Promise => { - const localEnv = new LuaEnv(env); - localEnv.setLocal(fr.name, wrapLoopVar(i, mode)); - return evalStatement(fr.block, localEnv, sf, returnOnReturn); - }; + const canReuseEnv = !fr.block.hasFunctionDef || + fr.capturesLoopVar === false; + + const executeIteration = canReuseEnv + ? ( + loopEnv: LuaEnv, + i: number, + loopType: NumericType, + ): void | ControlSignal | Promise => { + loopEnv.setLocal(fr.name, wrapLoopVar(i, loopType)); + return evalStatement(fr.block, loopEnv, sf, returnOnReturn); + } + : ( + _loopEnv: LuaEnv, + i: number, + loopType: NumericType, + ): void | ControlSignal | Promise => { + const localEnv = new LuaEnv(env); + localEnv.setLocal(fr.name, wrapLoopVar(i, loopType)); + return evalStatement(fr.block, localEnv, sf, returnOnReturn); + }; const runAsync = async ( - _start: any, - end: any, - step: any, - mode: "int" | "float", + loopEnv: LuaEnv, + end: number, + step: number, startIndex: number, + loopType: NumericType, ) => { - const e0 = untagNumber(end); - const st0 = untagNumber(step); - - if (st0 === 0) { + if (step === 0) { throw new LuaRuntimeError("'for' step is zero", sf.withCtx(fr.ctx)); } - // Condition changes based on direction - const shouldContinue = st0 > 0 - ? (i: number) => i <= e0 - : (i: number) => i >= e0; + const shouldContinue = step > 0 + ? (i: number) => i <= end + : (i: number) => i >= end; - for (let i = startIndex; shouldContinue(i); i += st0) { - const r = executeIteration(i, mode); + for (let i = startIndex; shouldContinue(i); i += step) { + const r = executeIteration(loopEnv, i, loopType); const res = isPromise(r) ? await r : r; if (res !== undefined) { if (isBreakSignal(res)) { - break; + return; } return res; } } - return; }; const runSyncFirst = ( - start: any, - end: any, - step: any, - mode: "int" | "float", - ): void | ControlSignal | Promise => { - const s0 = untagNumber(start); - const e0 = untagNumber(end); - const st0 = untagNumber(step); - - if (st0 === 0) { + start: number, + end: number, + step: number, + loopType: NumericType, + ): + | void + | ControlSignal + | Promise => { + if (step === 0) { throw new LuaRuntimeError("'for' step is zero", sf.withCtx(fr.ctx)); } - // Condition changes based on direction - const shouldContinue = st0 > 0 - ? (i: number) => i <= e0 - : (i: number) => i >= e0; + const shouldContinue = step > 0 + ? (i: number) => i <= end + : (i: number) => i >= end; - for (let i = s0; shouldContinue(i); i += st0) { - const r = executeIteration(i, mode); + const loopEnv = new LuaEnv(env); + + for (let i = start; shouldContinue(i); i += step) { + const r = executeIteration(loopEnv, i, loopType); if (isPromise(r)) { return (r as Promise).then((res) => { if (res !== undefined) { @@ -1942,34 +2171,45 @@ export function evalStatement( } return res; } - return runAsync(start, end, step, mode, i + st0); + return runAsync(loopEnv, end, step, i + step, loopType); }); - } else if (r !== undefined) { + } + if (r !== undefined) { if (isBreakSignal(r)) { - break; + return; } return r; } } return; }; + + const loopType = determineLoopType(); + if ( !isPromise(startV) && !isPromise(endV) && !isPromise(stepV) ) { - const mode = classifyMode(startV, endV, stepV); - return runSyncFirst(startV, endV, stepV ?? 1, mode); - } else { - return Promise.all([ - isPromise(startV) ? startV : Promise.resolve(startV), - isPromise(endV) ? endV : Promise.resolve(endV), - isPromise(stepV) ? stepV : Promise.resolve(stepV), - ]).then(([start, end, step]) => { - const mode = classifyMode(start, end, step ?? 1); - return runSyncFirst(start, end, step ?? 1, mode); - }); + return runSyncFirst( + untagNumber(startV) as number, + untagNumber(endV) as number, + untagNumber(stepV ?? 1) as number, + loopType, + ); } + return Promise.all([ + isPromise(startV) ? startV : Promise.resolve(startV), + isPromise(endV) ? endV : Promise.resolve(endV), + isPromise(stepV) ? stepV : Promise.resolve(stepV), + ]).then(([start, end, step]) => { + return runSyncFirst( + untagNumber(start) as number, + untagNumber(end) as number, + untagNumber(step ?? 1) as number, + loopType, + ); + }); } case "ForIn": { const fi = asForIn(s); @@ -1977,6 +2217,18 @@ export function evalStatement( fi.expressions.map((e: LuaExpression) => evalExpression(e, env, sf)), ); + const canReuseEnv = !fi.block.hasFunctionDef || + fi.capturesLoopVar === false; + const setIterVars = ( + localEnv: LuaEnv, + names: string[], + values: LuaValue[], + ) => { + for (let i = 0; i < names.length; i++) { + localEnv.setLocal(names[i], values[i]); + } + }; + const afterExprs = (resolved: any[]) => { const iteratorMultiRes = new LuaMultiRes(resolved).flatten(); let iteratorValue: ILuaFunction | any = iteratorMultiRes.values[0]; @@ -2001,13 +2253,9 @@ export function evalStatement( let control: LuaValue = iteratorMultiRes.values[2] ?? null; const closing: LuaValue = iteratorMultiRes.values[3] ?? null; - // The closing value in a generic-for has loop scope. Close it - // when the loop ends, not only when the surrounding block ends. const closeStack = luaEnsureCloseStack(sf); const mark = closeStack.length; - // The closing value is a "to-be-closed" value, and only `nil` - // means "no close"; `false` is NOT special here. if (closing !== null) { luaMarkToBeClosed(sf, closing, fi.ctx); } @@ -2031,6 +2279,16 @@ export function evalStatement( throw e; }; + // Allocate the reusable env once before the loop + const loopEnv = canReuseEnv ? new LuaEnv(env) : null; + + const makeIterEnv = (): LuaEnv => { + if (loopEnv) { + return loopEnv; + } + return new LuaEnv(env); + }; + try { const runAsync = async () => { while (true) { @@ -2049,10 +2307,8 @@ export function evalStatement( } control = nextControl; - const localEnv = new LuaEnv(env); - for (let i = 0; i < fi.names.length; i++) { - localEnv.setLocal(fi.names[i], iterResult.values[i]); - } + const localEnv = makeIterEnv(); + setIterVars(localEnv, fi.names, iterResult.values); const r = evalStatement(fi.block, localEnv, sf, returnOnReturn); const res = isPromise(r) ? await r : r; @@ -2084,10 +2340,8 @@ export function evalStatement( } control = nextControl; - const localEnv = new LuaEnv(env); - for (let i = 0; i < fi.names.length; i++) { - localEnv.setLocal(fi.names[i], iterResult.values[i]); - } + const localEnv = makeIterEnv(); + setIterVars(localEnv, fi.names, iterResult.values); const r = evalStatement( fi.block, @@ -2105,15 +2359,14 @@ export function evalStatement( } return runAsync(); }); - } else { - if (r !== undefined) { - if (isBreakSignal(r)) { - return finish(undefined); - } - return rpThen(finish(undefined), () => r); - } - return runAsync(); } + if (r !== undefined) { + if (isBreakSignal(r)) { + return finish(undefined); + } + return rpThen(finish(undefined), () => r); + } + return runAsync(); }).catch((e: any) => finishErr(e)); } @@ -2128,10 +2381,8 @@ export function evalStatement( } control = nextControl; - const localEnv = new LuaEnv(env); - for (let i = 0; i < fi.names.length; i++) { - localEnv.setLocal(fi.names[i], iterResult.values[i]); - } + const localEnv = makeIterEnv(); + setIterVars(localEnv, fi.names, iterResult.values); const r = evalStatement(fi.block, localEnv, sf, returnOnReturn); if (isPromise(r)) { @@ -2144,7 +2395,8 @@ export function evalStatement( } return runAsync(); }).catch((e: any) => finishErr(e)); - } else if (r !== undefined) { + } + if (r !== undefined) { if (isBreakSignal(r)) { return finish(undefined); } @@ -2158,9 +2410,8 @@ export function evalStatement( if (isPromise(exprVals)) { return (exprVals as Promise).then(afterExprs); - } else { - return afterExprs(exprVals as any[]); } + return afterExprs(exprVals as any[]); } } } @@ -2197,12 +2448,11 @@ function evalLValue( env: singleResult(objValue), key: singleResult(keyValue), })); - } else { - return { - env: singleResult(objValue), - key: singleResult(keyValue), - }; } + return { + env: singleResult(objValue), + key: singleResult(keyValue), + }; } case "PropertyAccess": { const pa = asLValuePropertyAccess(lval); @@ -2218,48 +2468,11 @@ function evalLValue( key: pa.property, }; }); - } else { - return { - env: objValue, - key: pa.property, - }; } + return { + env: objValue, + key: pa.property, + }; } } } - -function exactInt( - num: any, - ctx: ASTCtx, - sf: LuaStackFrame, -): number { - // See conversion from float to integer https://www.lua.org/manual/5.4/manual.html#3.4.3 - let n: number; - - if (isFloatTag(num)) { - n = num.value; - } else if (typeof num === "number") { - n = num; - } else if (num instanceof Number) { - n = Number(num); - } else if (typeof num === "string") { - throw new LuaRuntimeError( - `attempt to perform bitwise operation on a string value (constant '${num}')`, - sf.withCtx(ctx), - ); - } else { - const t = luaTypeName(num); - throw new LuaRuntimeError( - `attempt to perform bitwise operation on a ${t} value`, - sf.withCtx(ctx), - ); - } - - if (!Number.isInteger(n)) { - throw new LuaRuntimeError( - `number has no integer representation`, - sf.withCtx(ctx), - ); - } - return n; -} diff --git a/client/space_lua/len_test.lua b/client/space_lua/len_test.lua index e738301b..d9d9f3fb 100644 --- a/client/space_lua/len_test.lua +++ b/client/space_lua/len_test.lua @@ -104,3 +104,43 @@ assertThrows( return #f end ) + +-- 4. rawlen() semantics +do + local t = { 1, 2, 3 } + + setmetatable(t, { + __len = function(_) return 99 end, + }) + + assert_eq(#t, 99, "#t uses __len") + assert_eq(rawlen(t), 3, "rawlen(t) ignores __len") +end + +-- 5. Trailing-nil shrinking behavior +do + local t = { 1, 2, 3 } + assert_eq(#t, 3, "initial sequence length") + + t[3] = nil + assert_eq(#t, 2, "sequence length shrinks when last element is set to nil") + assert_eq(rawlen(t), 2, "rawlen shrinks with trailing nil (array part shrinks)") + + t[2] = nil + assert_eq(#t, 1, "sequence length shrinks again after removing new last element") + assert_eq(rawlen(t), 1, "rawlen shrinks again after trailing nil") +end + +-- 6. rawlen on strings +assert_eq(rawlen("abc"), 3, "rawlen on strings") + +-- 7. rawlen(t) must ignore the __len metamethod +local t = {1,2,3} + +setmetatable(t, { + __len = function() + return 99 end +}) + +assert(#t == 99) +assert(rawlen(t) == 3) diff --git a/client/space_lua/lua.test.ts b/client/space_lua/lua.test.ts index 5ee4f4d1..4f4ca82b 100644 --- a/client/space_lua/lua.test.ts +++ b/client/space_lua/lua.test.ts @@ -25,12 +25,20 @@ Deno.test("[Lua] Core language (arithmetic)", async () => { await runLuaTest("./arithmetic_test.lua"); }); +Deno.test("[Lua] Core language (metamethods)", async () => { + await runLuaTest("./metamethods_test.lua"); +}); + Deno.test("[Lua] Load tests", async () => { await runLuaTest("./stdlib/load_test.lua"); }); -Deno.test("[Lua] Table tests", async () => { - await runLuaTest("./stdlib/table_test.lua"); +Deno.test("[Lua] Core language (length)", async () => { + await runLuaTest("./len_test.lua"); +}); + +Deno.test("[Lua] Format tests", async () => { + await runLuaTest("./stdlib/format_test.lua"); }); Deno.test("[Lua] String to number tests", async () => { diff --git a/client/space_lua/metamethods_test.lua b/client/space_lua/metamethods_test.lua new file mode 100644 index 00000000..9978f6fb --- /dev/null +++ b/client/space_lua/metamethods_test.lua @@ -0,0 +1,576 @@ +local function assert_eq(actual, expected, message) + if actual ~= expected then + error('Assertion failed: ' .. message + .. ' (expected ' .. tostring(expected) + .. ', got ' .. tostring(actual) .. ')') + end +end + +local function assert_true(v, message) + if v ~= true then + error('Assertion failed: ' .. message .. ' (expected true)') + end +end + +local function assert_false(v, message) + if v ~= false then + error('Assertion failed: ' .. message .. ' (expected false)') + end +end + +local function assertThrows(msg_substr, fn) + local ok, err = pcall(fn) + + if ok then + error('Assertion failed: expected error containing "' + .. msg_substr .. '"') + end + + if type(err) ~= 'string' then + err = tostring(err) + end + + if not string.find(err, msg_substr, 1, true) then + error('Assertion failed: expected error message to contain "' + .. msg_substr .. '", got: "' .. err .. '"') + end +end + +local function assert_first_only(expr_fn, expected, message) + local v = expr_fn() + assert_eq(v, expected, message) +end + +-- 1. Arithmetic + bitwise metamethods: values + multi-return trimming +do + local mt = {} + + mt.__add = function(a, b) return a.tag .. '+' .. b.tag end + mt.__sub = function(a, b) return a.tag .. '-' .. b.tag end + mt.__mul = function(a, b) return a.tag .. '*' .. b.tag end + mt.__div = function(a, b) return a.tag .. '/' .. b.tag end + mt.__pow = function(a, b) return a.tag .. '^' .. b.tag end + mt.__idiv = function(a, b) return a.tag .. '//' .. b.tag end + mt.__mod = function(a, b) return a.tag .. '%' .. b.tag end + mt.__unm = function(a) return '(-' .. a.tag .. ')' end + mt.__bnot = function(a) return '(~' .. a.tag .. ')' end + mt.__band = function(a, b) return a.tag .. '&' .. b.tag end + mt.__bor = function(a, b) return a.tag .. '|' .. b.tag end + mt.__bxor = function(a, b) return a.tag .. '~' .. b.tag end + mt.__shl = function(a, b) return a.tag .. '<<' .. b.tag end + mt.__shr = function(a, b) return a.tag .. '>>' .. b.tag end + + local a = setmetatable({ tag = 'A' }, mt) + local b = setmetatable({ tag = 'B' }, mt) + + assert_eq(a + b, 'A+B', '__add produced correct value') + assert_eq(a - b, 'A-B', '__sub produced correct value') + assert_eq(a * b, 'A*B', '__mul produced correct value') + assert_eq(a / b, 'A/B', '__div produced correct value') + assert_eq(a ^ b, 'A^B', '__pow produced correct value') + assert_eq(a // b, 'A//B', '__idiv produced correct value') + assert_eq(a % b, 'A%B', '__mod produced correct value') + + assert_eq(-a, '(-A)', '__unm produced correct value') + assert_eq(~a, '(~A)', '__bnot produced correct value') + + assert_eq(a & b, 'A&B', '__band produced correct value') + assert_eq(a | b, 'A|B', '__bor produced correct value') + assert_eq(a ~ b, 'A~B', '__bxor produced correct value') + assert_eq(a << b, 'A<> b, 'A>>B', '__shr produced correct value') + + -- Multi-return from metamethod: must take only the first return + do + local mt_mr = { + __add = function(_, _) return 'first', 'second' end, + __unm = function(_) return 7, 8 end, + __bnot = function(_) return 9, 10 end, + __concat = function(_, _) return "c1", "c2" end, + __len = function(_) return 123, 999 end, + } + + local x = setmetatable({}, mt_mr) + + assert_eq(x + x, 'first', 'binary operator uses first return value') + assert_eq(-x, 7, 'unary operator uses first return value') + assert_eq(~x, 9, 'unary operator uses first return value') + assert_eq(x .. x, "c1", 'concat uses first return value') + assert_eq(#x, 123, 'len uses first return value') + end +end + +-- 2. Comparison operators +do + -- 2.1. No ordering for plain tables + assertThrows("attempt to compare", function() return ({} < {}) end) + assertThrows("attempt to compare", function() return ({} <= {}) end) + assertThrows("attempt to compare", function() return ({} > {}) end) + assertThrows("attempt to compare", function() return ({} >= {}) end) + + -- 2.2. With metamethods: `__lt` / `__le` + do + local mt = { + __lt = function(a, b) return a.v < b.v end, + __le = function(a, b) return a.v <= b.v end, + } + local function O(v) return setmetatable({ v = v }, mt) end + + assert_true(O(1) < O(2), "__lt true") + assert_false(O(2) < O(1), "__lt false") + assert_true(O(2) <= O(2), "__le true") + assert_false(O(3) <= O(2), "__le false") + end + +-- 2.3. `__eq` semantics +do + do + local a, b = {}, {} + assert_false(a == b, "raw equality for distinct tables without __eq is false") + assert_true(a == a, "raw equality for same table is true") + end + + do + local calls = 0 + local mt = { + __eq = function(_, _) + calls = calls + 1 + return true + end + } + local x = setmetatable({}, mt) + local y = {} -- no metatable + + assert_true(x == y, "__eq may be used from left operand metatable") + assert_true(y == x, "__eq may be used from right operand metatable") + assert_true(calls >= 1, "__eq was called at least once") + end + + do + local a = setmetatable({ v = 1 }, { __eq = function() return true end }) + local b = setmetatable({ v = 2 }, { __eq = function() return true end }) + assert_true(a == b, "__eq true/true => true") + assert_false(a ~= b, "~= is negation of ==") + end + + do + local a = setmetatable({ v = 1 }, { __eq = function() return false end }) + local b = setmetatable({ v = 2 }, { __eq = function() return false end }) + assert_false(a == b, "__eq false/false => false") + assert_true(a ~= b, "~= is negation of ==") + end +end + +-- 2.4. Operand swap rules: `a > b` is `b < a` and `a >= b` is `b <= a` +do + local mt2 = { __lt = function(a, b) return a.v < b.v end } + local function P(v) return setmetatable({ v = v }, mt2) end + + local a = P(3) + local b = P(2) + + assert_true(b < a, "sanity: b < a uses __lt") + assert_true(a > b, '">" uses swapped __lt') + assert_false(b > a, '">" uses swapped __lt (false case)') +end + +do + local mt3 = { __le = function(a, b) return a.v <= b.v end } + local function P(v) return setmetatable({ v = v }, mt3) end + + local a = P(3) + local b = P(2) + + assert_true(b <= a, "sanity: b <= a uses __le") + assert_true(a >= b, '">=" uses swapped __le') + assert_false(b >= a, '">=" uses swapped __le (false case)') +end + +-- 2.5. No `__le` fallback via `__lt` in Lua for metamethod comparisons +do + local mt = { __lt = function(a, b) return a.v < b.v end } + local function P(v) return setmetatable({ v = v }, mt) end + + local a = P(2) + local b = P(2) + + assert_false(a < b, "sanity: 2 < 2 is false via __lt") + assertThrows("attempt to compare", function() return a <= b end) +end + +do + local mt = { + __lt = function(a, b) return a.v < b.v end, + __le = function(a, b) return a.v <= b.v end, + } + local function P(v) return setmetatable({ v = v }, mt) end + local a = P(2) + local b = P(2) + assert_true(a <= b, "__le works when provided") +end +end + +-- 3. `rawlen` vs `#` when `__len` exists +do + local t = setmetatable({ 1, 2, 3 }, { __len = function() return 10 end }) + assert_eq(#t, 10, '# uses __len') + assert_eq(rawlen(t), 3, 'rawlen ignores __len on tables') + assert_eq(rawlen("abc"), 3, 'rawlen works on strings') +end + +-- 4. `__len`: tables use metamethod; strings are raw length +do + local t = setmetatable({ x = 20 }, { __len = function(tt) return tt.x end }) + assert_eq(#t, 20, '__len dispatched for tables') + t.x = "234" + assert_eq(#t, "234", '__len can return non-number') +end + +do + local s = "abc" + assert_eq(#s, 3, 'strings ignore __len metamethod') +end + +-- 5. `__concat`: produced value and number/table interop +do + local a = setmetatable({ x = "u" }, { + __concat = function(l, r) return l.x .. "." .. r.x end + }) + assert_eq(a .. a, "u.u", '__concat dispatched') +end + +do + local c = {} + setmetatable(c, { + __concat = function(a, b) + if type(a) == "number" then + assert_eq(b, c, '__concat rhs is c when lhs number') + return "n..c" + else + assert_eq(a, c, '__concat lhs is c when rhs number') + return "c..n" + end + end + }) + assert_eq(c .. 5, "c..n", "__concat handles table .. number") + assert_eq(5 .. c, "n..c", "__concat handles number .. table") +end + +-- 6. `__call`: produced values +do + local t = setmetatable({}, { + __call = function(self, a, b) + assert_eq(type(self), 'table', '__call self is table') + return a + b + end + }) + assert_eq(t(2, 3), 5, '__call dispatched') +end + +do + local t = setmetatable({}, { + __call = function(_, ...) + return "a", "b", select('#', ...) + end + }) + local a, b, n = t(10, 20) + assert_eq(a, "a", "__call multi-return first") + assert_eq(b, "b", "__call multi-return second") + assert_eq(n, 2, "__call sees arguments") +end + +do + local n = 200 + local function leaf() + if n == 0 then return 1023 end + n = n - 1 + return leaf() + end + + local f = leaf + for _ = 1, 50 do + f = setmetatable({}, { __call = f }) + end + + assert_eq(f(), 1023, 'chain of __call metamethods works') +end + +do + local i = 0 + local tt = { + __call = function(t, ...) + i = i + 1 + if t.f then return t.f(...) end + return {...} + end + } + + local a = setmetatable({}, tt) + local b = setmetatable({ f = a }, tt) + local c = setmetatable({ f = b }, tt) + + local x = c(3, 4, 5) + assert_eq(i, 3, "__call nested chain increments i") + assert_eq(x[1], 3, "__call nested chain arg1") + assert_eq(x[2], 4, "__call nested chain arg2") + assert_eq(x[3], 5, "__call nested chain arg3") +end + +do + local t = setmetatable({}, { __call = 123 }) + assertThrows("attempt to call a number value", function() + return t() + end) +end + +-- 7. `__index` / `__newindex`: function + table forms +do + local backing = { x = 10 } + local t = setmetatable({}, { + __index = function(_, k) return backing[k] end, + __newindex = function(_, k, v) backing[k] = v end, + }) + + assert_eq(t.x, 10, '__index function dispatched') + t.y = 20 + assert_eq(backing.y, 20, '__newindex function dispatched') +end + +do + local backing = { a = 1, b = 2 } + local t = setmetatable({}, { __index = backing }) + assert_eq(t.a, 1, '__index table dispatched (a)') + assert_eq(t.b, 2, '__index table dispatched (b)') + assert_eq(t.c, nil, '__index table missing key yields nil') +end + +do + -- __newindex as table target (Lua 5.4) + local backing = {} + local t = setmetatable({}, { __newindex = backing }) + t.k = 99 + assert_eq(backing.k, 99, '__newindex table dispatched') +end + +do + local calls = 0 + local t = { present = 1 } + setmetatable(t, { + __newindex = function(tt, k, v) + calls = calls + 1 + rawset(tt, k, v) + end + }) + + t.present = 2 + assert_eq(calls, 0, '__newindex not called for existing key') + + t.absent = 3 + assert_eq(calls, 1, '__newindex called for absent key') + assert_eq(t.absent, 3, '__newindex stored value') +end + +do + local a = {} + setmetatable(a, a) + a.__index = a + a.__newindex = a + + assertThrows("chain too long", function() + local _ = a[10] + end) + + assertThrows("chain too long", function() + a[10] = true + end) +end + +-- 8. `__pairs` and `ipairs` via `__index`-backed table +do + local a = {} + local out = {} + + local function foo(e, i) + assert_eq(e, a, "__pairs sees original table") + if i < 10 then + return i + 1, i + 2 + end + end + + setmetatable(a, { __pairs = function(x) return foo, x, 0 end }) + + local i = 0 + for k, v in pairs(a) do + i = i + 1 + out[i] = { k, v } + end + + assert_eq(i, 10, "__pairs loop count") + assert_eq(out[1][1], 1, "__pairs k1") + assert_eq(out[1][2], 2, "__pairs v1") + assert_eq(out[10][1], 10, "__pairs k10") + assert_eq(out[10][2], 11, "__pairs v10") +end + +do + local a = { n = 5 } + setmetatable(a, { + __index = function(t, k) + if type(k) == "number" and k <= t.n then + return k * 10 + end + end + }) + + local out = {} + for k, v in ipairs(a) do + out[#out + 1] = k .. ":" .. v + end + + assert_eq(#out, 5, "ipairs iterates n entries via __index") + assert_eq(out[1], "1:10", "ipairs entry 1") + assert_eq(out[5], "5:50", "ipairs entry 5") +end + +-- 9. `table` library interaction with metamethods +do + local function check_proxy(proxy, backing) + for i = 1, 10 do + table.insert(proxy, 1, i) + end + assert_eq(#proxy, 10, "proxy length after inserts") + assert_eq(#backing, 10, "backing length after inserts") + + for i = 1, 10 do + assert_eq(backing[i], 11 - i, "backing reversed insert " .. i) + end + + table.sort(proxy) + for i = 1, 10 do + assert_eq(backing[i], i, "backing sorted " .. i) + assert_eq(proxy[i], i, "proxy sorted " .. i) + end + + assert_eq(table.concat(proxy, ","), "1,2,3,4,5,6,7,8,9,10", "table.concat(proxy)") + + for i = 1, 8 do + assert_eq(table.remove(proxy, 1), i, "table.remove(proxy,1) yields i") + end + assert_eq(#proxy, 2, "proxy length after removes") + assert_eq(#backing, 2, "backing length after removes") + + local a, b, c = table.unpack(proxy) + assert_eq(a, 9, "table.unpack(proxy) first") + assert_eq(b, 10, "table.unpack(proxy) second") + assert_eq(c, nil, "table.unpack(proxy) third nil") + end + + local backing = {} + local proxy = setmetatable({}, { + __len = function() return #backing end, + __index = backing, + __newindex = backing, + }) + check_proxy(proxy, backing) +end + +-- 10. Comparison compatibility corner: metamethod dispatch present +do + local mt1 = { + __eq = function() return true end, + __lt = function() return true end, + __le = function() return false end, + } + + local c = setmetatable({}, mt1) + local d = setmetatable({}, mt1) + + assert_true(c == d, "comparison compat: __eq true (same mt)") + assert_true(c < d, "comparison compat: __lt true (same mt)") + assert_false(d <= c, "comparison compat: __le false (same mt)") + + local mt2 = { + __eq = mt1.__eq, + __lt = mt1.__lt, + __le = mt1.__le, + } + local e = setmetatable({}, mt1) + local f = setmetatable({}, mt2) + + assert_true(e == f, "comparison compat across metatables: __eq") + assert_true(e < f, "comparison compat across metatables: __lt") + assert_false(f <= e, "comparison compat across metatables: __le") +end + +-- 11. Concat chain associativity + returning table from `__concat` +do + local t = {} + t.__concat = function(a, b) + local av = (type(a) == "table") and a.val or tostring(a) + local bv = (type(b) == "table") and b.val or tostring(b) + return setmetatable({ val = av .. bv }, t) + end + + local c = setmetatable({ val = "c" }, t) + local d = setmetatable({ val = "d" }, t) + + local x = c .. d .. c .. d + assert_eq(type(x), "table", "__concat chain returns table") + assert_eq(x.val, "cdcd", "__concat chain value") +end + +-- 12. `__eq` is not used for table indexing / hashing +do + local mt = {} + mt.__eq = function(_, _) return true end + + local function Set(x) + local t = {} + for _, k in pairs(x) do t[k] = true end + return setmetatable(t, mt) + end + + local k1 = Set{1, 2, 3} + local k2 = Set{1, 2, 3} + + assert_true(k1 == k2, "__eq says equal") + assert_false(rawequal(k1, k2), "not the same object") + + local t = {} + t[k1] = 123 + + assert_eq(t[k2], nil, "table indexing ignores __eq") + assert_eq(t[k1], 123, "table indexing by same object works") +end + +-- 13. operator metamethod lookup must be raw (no `__index` involvement) +do + local mt = { + __index = function(_, _) + return function() return "BAD" end + end + } + + local a = setmetatable({}, mt) + + assertThrows("attempt to perform arithmetic on a table value", function() + local _ = a + a + end) +end + +-- 14. `__tostring`: must return a string +do + local m = setmetatable({ name = "NAME" }, { + __tostring = function(x) return x.name end + }) + assert_eq(tostring(m), "NAME", "__tostring used by tostring()") +end + +do + local m = setmetatable({}, { + __tostring = function() return {} end + }) + assertThrows("'__tostring' must return a string", function() + return tostring(m) + end) +end diff --git a/client/space_lua/numeric.ts b/client/space_lua/numeric.ts index 8f3e787a..0891e447 100644 --- a/client/space_lua/numeric.ts +++ b/client/space_lua/numeric.ts @@ -2,147 +2,138 @@ import type { NumericType } from "./ast.ts"; import { luaToNumberDetailed } from "./tonumber.ts"; import { luaTypeName } from "./runtime.ts"; -export const ZeroBoxKind = Symbol("ZeroBox"); - -export type LuaFloatTag = { - readonly type: "float"; +export interface LuaTaggedFloat { readonly value: number; -}; - -export function isBoxedZero(x: any): boolean { - return x instanceof Number && Number(x) === 0 && !isNegativeZero(Number(x)); + readonly isFloat: true; } -export function getZeroBoxKind(x: any): NumericType | undefined { - return isBoxedZero(x) - ? (x as any)[ZeroBoxKind] as NumericType | undefined - : undefined; -} - -export function boxZero(kind: NumericType): number { - const z = new Number(0); - (z as any)[ZeroBoxKind] = kind; - return z as unknown as number; -} - -export function isFloatTag(v: any): v is LuaFloatTag { - return v && typeof v === "object" && v.type === "float"; -} - -// Tag an integer-valued number as a float: -// * zero: returns boxed float zero, -// * integer: returns tagged float, -// * non-integer: returns plain number. -export function floatLiteral(n: number): number | LuaFloatTag { - if (n === 0 && !isNegativeZero(n)) { - return boxZero("float"); - } - if (Number.isInteger(n)) { - return { type: "float" as const, value: n }; - } - return n; -} - -export function untagNumber(n: any): number { - if (isFloatTag(n)) { - return n.value; - } - if (n instanceof Number) { - return Number(n); - } - return n; -} - -export function getNumericKind( - n: number | LuaFloatTag | any, -): NumericType | undefined { - if (isFloatTag(n)) { - return "float"; - } - if (typeof n === "number") { - return Number.isInteger(n) ? "int" : "float"; - } - if (n instanceof Number) { - const kind = getZeroBoxKind(n); - if (kind) { - return kind; - } - return Number.isInteger(Number(n)) ? "int" : "float"; - } - return undefined; -} - -export type OpHints = { - leftKind?: NumericType; - rightKind?: NumericType; -}; +// Pre-allocated singletons for float zeros +const FLOAT_POS_ZERO: LuaTaggedFloat = { value: 0, isFloat: true }; +const FLOAT_NEG_ZERO: LuaTaggedFloat = { value: -0, isFloat: true }; export const luaStringCoercionError: Error = new Error( "LuaStringCoercionError", ); -export function isNegativeZero(x: number): boolean { - // faster than `Object.is(, -0)` - return x === 0 && 1 / x === -Infinity; +export function isNegativeZero(n: number): boolean { + return n === 0 && 1 / n === -Infinity; } -function produceNegativeZero() { - return { n: -0, isInt: false, zeroKind: "float" as const }; +export function isTaggedFloat(v: unknown): v is LuaTaggedFloat { + return v !== null && typeof v === "object" && (v as any).isFloat === true; } +function makeFloat(n: number): LuaTaggedFloat { + if (n === 0) { + return isNegativeZero(n) ? FLOAT_NEG_ZERO : FLOAT_POS_ZERO; + } + return { value: n, isFloat: true }; +} + +// Box a zero with a given kind tag. +export function makeLuaZero( + n: number, + numericType: NumericType, +): any { + if (n !== 0) { + return n; + } + if (numericType !== "float") { + return 0; + } + return isNegativeZero(n) ? FLOAT_NEG_ZERO : FLOAT_POS_ZERO; +} + +// Tag an integer-valued number as float. +// Only allocates for integer-valued results; non-integer floats +// (1.5, NaN, Inf) are already unambiguously float as plain `number`. +export function makeLuaFloat(n: number): any { + if (!Number.isInteger(n)) { + return n; + } + return makeFloat(n); +} + +export function getZeroBoxKind(x: any): NumericType | undefined { + if (isTaggedFloat(x)) { + return "float"; + } + return undefined; +} + +// Unwrap a potentially tagged or boxed Number to a plain number. +export function untagNumber(n: any): number { + if (typeof n === "number") return n; + if (isTaggedFloat(n)) { + return n.value; + } + return n; +} + +export function coerceToNumber(v: unknown): number | null { + if (typeof v === "number") { + return v; + } + if (isTaggedFloat(v)) { + return v.value; + } + if (typeof v === "string") { + const det = luaToNumberDetailed(v); + if (!det) { + return null; + } + return det.value; + } + return null; +} + +export function inferNumericType(n: number): NumericType { + if (!Number.isFinite(n)) { + return "float"; + } + if (isNegativeZero(n)) { + return "float"; + } + return Number.isInteger(n) ? "int" : "float"; +} + +export function combineNumericTypes( + a: NumericType | undefined, + b: NumericType | undefined, +): NumericType { + if (a === "float" || b === "float") { + return "float"; + } + return "int"; +} + +export function getNumericKind( + n: unknown, +): NumericType | undefined { + if (typeof n === "number") { + return inferNumericType(n); + } + if (isTaggedFloat(n)) { + return "float"; + } + return undefined; +} + +export type CoerceNumericResult = { + n: number; + type: NumericType; +}; + export function coerceNumeric( val: unknown, -): { - n: number; - isInt: boolean; - zeroKind?: NumericType; -} { - if (isFloatTag(val)) { - const n = val.value; - if (n === 0) { - if (isNegativeZero(n)) { - return produceNegativeZero(); - } - return { n: 0, isInt: false, zeroKind: "float" }; - } - // Non-zero float tag: always mark as float - return { n, isInt: false, zeroKind: "float" }; - } - - if (isBoxedZero(val)) { - return { n: 0, isInt: true, zeroKind: getZeroBoxKind(val)! }; - } - + hint?: NumericType, +): CoerceNumericResult { if (typeof val === "number") { - const n = val; - if (n === 0) { - if (isNegativeZero(n)) { - return produceNegativeZero(); - } - return { - n, - isInt: true, - }; - } - return { - n, - isInt: Number.isInteger(n), - }; + return { n: val, type: hint ?? inferNumericType(val) }; } - if (val instanceof Number) { - const n = Number(val); - if (n === 0 && !isNegativeZero(n)) { - return { - n, - isInt: true, - zeroKind: getZeroBoxKind(val), - }; - } - return { - n, - isInt: Number.isInteger(n), - }; + if (isTaggedFloat(val)) { + return { n: val.value, type: hint ?? "float" }; } if (typeof val === "string") { @@ -150,25 +141,7 @@ export function coerceNumeric( if (!det) { throw luaStringCoercionError; } - - const n = det.value; - const isInt = det.numericType === "int"; - - if (n === 0) { - if (isNegativeZero(n)) { - return produceNegativeZero(); - } - return { - n, - isInt, - zeroKind: det.numericType, - }; - } - - return { - n, - isInt, - }; + return { n: det.value, type: hint ?? det.numericType }; } throw new Error( @@ -176,37 +149,92 @@ export function coerceNumeric( ); } +export type CoerceNumericPairResult = { + left: number; + right: number; + resultType: NumericType; +}; + export function coerceNumericPair( a: unknown, b: unknown, - hints?: OpHints, -): { - ax: number; - bx: number; - bothInt: boolean; - aZeroKind?: NumericType; - bZeroKind?: NumericType; -} { - const A = coerceNumeric(a); - const B = coerceNumeric(b); + leftType?: NumericType, + rightType?: NumericType, + op?: string, +): CoerceNumericPairResult { + const forceFloat = op === "/" || op === "^"; - const aIsInt = hints?.leftKind !== undefined - ? hints.leftKind === "int" - : (A.zeroKind === "float" - ? false - : (A.zeroKind === "int" ? true : A.isInt)); + // Both plain numbers + if (typeof a === "number" && typeof b === "number") { + const lt = leftType ?? inferNumericType(a); + const rt = rightType ?? inferNumericType(b); - const bIsInt = hints?.rightKind !== undefined - ? hints.rightKind === "int" - : (B.zeroKind === "float" - ? false - : (B.zeroKind === "int" ? true : B.isInt)); + return { + left: a, + right: b, + resultType: forceFloat + ? "float" + : ((lt === "float" || rt === "float") ? "float" : "int"), + }; + } + + // One tagged float, one plain number + if (typeof a === "number" && isTaggedFloat(b)) { + return { + left: a, + right: b.value, + resultType: "float", + }; + } + + if (isTaggedFloat(a) && typeof b === "number") { + return { + left: a.value, + right: b, + resultType: "float", + }; + } + + // Both tagged floats + if (isTaggedFloat(a) && isTaggedFloat(b)) { + return { + left: a.value, + right: b.value, + resultType: "float", + }; + } + + // General fallback + const A = coerceNumeric(a, leftType); + const B = coerceNumeric(b, rightType); return { - ax: A.n, - bx: B.n, - bothInt: aIsInt && bIsInt, - aZeroKind: A.zeroKind, - bZeroKind: B.zeroKind, + left: A.n, + right: B.n, + resultType: forceFloat ? "float" : combineNumericTypes(A.type, B.type), }; } + +export function normalizeArithmeticResult( + n: number, + resultType: NumericType, +): number { + if (n === 0) { + if (resultType === "int") { + return 0; + } + return isNegativeZero(n) ? -0 : 0; + } + return n; +} + +export function toInteger(v: unknown): number | null { + if (typeof v === "number") { + return Number.isInteger(v) ? v : null; + } + if (isTaggedFloat(v)) { + const n = v.value; + return Number.isInteger(n) ? n : null; + } + return null; +} diff --git a/client/space_lua/parse.ts b/client/space_lua/parse.ts index 4a35496f..16a48cdb 100644 --- a/client/space_lua/parse.ts +++ b/client/space_lua/parse.ts @@ -1,4 +1,5 @@ import { lezerToParseTree } from "../../client/markdown_parser/parse_tree.ts"; +import type { SyntaxNode } from "@lezer/common"; import { cleanTree, type ParseTree, @@ -83,6 +84,455 @@ function hasCloseLocal(names: LuaAttName[] | undefined): boolean { return false; } +function expressionHasFunctionDef(e: LuaExpression): boolean { + if (!e) return false; + switch (e.type) { + case "FunctionDefinition": + return true; + case "FunctionCall": + if (expressionHasFunctionDef(e.prefix)) return true; + for (let i = 0; i < e.args.length; i++) { + if (expressionHasFunctionDef(e.args[i])) return true; + } + return false; + case "Binary": + return expressionHasFunctionDef(e.left) || + expressionHasFunctionDef(e.right); + case "Unary": + return expressionHasFunctionDef(e.argument); + case "Parenthesized": + return expressionHasFunctionDef(e.expression); + case "TableConstructor": + for (let i = 0; i < e.fields.length; i++) { + const f = e.fields[i]; + switch (f.type) { + case "DynamicField": + if (expressionHasFunctionDef(f.key)) return true; + if (expressionHasFunctionDef(f.value)) return true; + break; + case "PropField": + case "ExpressionField": + if (expressionHasFunctionDef(f.value)) return true; + break; + } + } + return false; + case "TableAccess": + return expressionHasFunctionDef(e.object) || + expressionHasFunctionDef(e.key); + case "PropertyAccess": + return expressionHasFunctionDef(e.object); + case "Query": + for (let i = 0; i < e.clauses.length; i++) { + const c = e.clauses[i]; + switch (c.type) { + case "From": + if (expressionHasFunctionDef(c.expression)) return true; + break; + case "Where": + case "Select": + if (expressionHasFunctionDef(c.expression)) return true; + break; + case "Limit": + if (expressionHasFunctionDef(c.limit)) return true; + if (c.offset && expressionHasFunctionDef(c.offset)) return true; + break; + case "OrderBy": + for (let j = 0; j < c.orderBy.length; j++) { + if (expressionHasFunctionDef(c.orderBy[j].expression)) { + return true; + } + } + break; + } + } + return false; + default: + return false; + } +} + +function expressionsHaveFunctionDef( + exprs: LuaExpression[] | undefined, +): boolean { + if (!exprs) return false; + for (let i = 0; i < exprs.length; i++) { + if (expressionHasFunctionDef(exprs[i])) return true; + } + return false; +} + +// Does the expression reference any of `names`? +// Note: It DOES NOT descend into `FunctionDefinition`. +function exprReferencesNames(e: LuaExpression, names: Set): boolean { + if (!e) return false; + switch (e.type) { + case "Variable": + return names.has(e.name); + case "Binary": + return exprReferencesNames(e.left, names) || + exprReferencesNames(e.right, names); + case "Unary": + return exprReferencesNames(e.argument, names); + case "Parenthesized": + return exprReferencesNames(e.expression, names); + case "FunctionCall": + if (exprReferencesNames(e.prefix, names)) return true; + for (let i = 0; i < e.args.length; i++) { + if (exprReferencesNames(e.args[i], names)) return true; + } + return false; + case "TableAccess": + return exprReferencesNames(e.object, names) || + exprReferencesNames(e.key, names); + case "PropertyAccess": + return exprReferencesNames(e.object, names); + case "TableConstructor": + for (let i = 0; i < e.fields.length; i++) { + const f = e.fields[i]; + switch (f.type) { + case "DynamicField": + if (exprReferencesNames(f.key, names)) return true; + if (exprReferencesNames(f.value, names)) return true; + break; + case "PropField": + case "ExpressionField": + if (exprReferencesNames(f.value, names)) return true; + break; + } + } + return false; + case "FunctionDefinition": + return false; + case "Query": + for (let i = 0; i < e.clauses.length; i++) { + const c = e.clauses[i]; + switch (c.type) { + case "From": + if (exprReferencesNames(c.expression, names)) return true; + break; + case "Where": + case "Select": + if (exprReferencesNames(c.expression, names)) return true; + break; + case "Limit": + if (exprReferencesNames(c.limit, names)) return true; + if (c.offset && exprReferencesNames(c.offset, names)) return true; + break; + case "OrderBy": + for (let j = 0; j < c.orderBy.length; j++) { + if (exprReferencesNames(c.orderBy[j].expression, names)) { + return true; + } + } + break; + } + } + return false; + default: + return false; + } +} + +function lvalueReferencesNames(lv: LuaLValue, names: Set): boolean { + switch (lv.type) { + case "Variable": + return names.has(lv.name); + case "PropertyAccess": + return exprReferencesNames(lv.object as LuaExpression, names); + case "TableAccess": + return exprReferencesNames(lv.object as LuaExpression, names) || + exprReferencesNames(lv.key, names); + } +} + +// Does a function body reference any of `names` NOT shadowed by its +// parameters? +function functionBodyCapturesNames( + body: LuaFunctionBody, + names: Set, +): boolean { + let unshadowed: Set | null = null; + for (let i = 0; i < body.parameters.length; i++) { + if (names.has(body.parameters[i])) { + if (!unshadowed) unshadowed = new Set(names); + unshadowed.delete(body.parameters[i]); + } + } + const check = unshadowed ?? names; + if (check.size === 0) return false; + return blockReferencesNames(body.block, check); +} + +// Walk block using `exprReferencesNames` (inside a function body). +function blockReferencesNames(block: LuaBlock, names: Set): boolean { + for (let i = 0; i < block.statements.length; i++) { + if (statementReferencesNames(block.statements[i], names)) return true; + } + return false; +} + +function statementReferencesNames( + s: LuaStatement, + names: Set, +): boolean { + switch (s.type) { + case "Local": { + const exprs = (s as any).expressions as LuaExpression[] | undefined; + if (exprs) { + for (let i = 0; i < exprs.length; i++) { + if (exprReferencesNames(exprs[i], names)) return true; + } + } + return false; + } + case "LocalFunction": { + const lf = s as any; + return functionBodyCapturesNames(lf.body as LuaFunctionBody, names); + } + case "Function": { + const fn = s as any; + return functionBodyCapturesNames(fn.body as LuaFunctionBody, names); + } + case "FunctionCallStatement": { + const call = (s as any).call as LuaFunctionCallExpression; + if (exprReferencesNames(call.prefix, names)) return true; + for (let i = 0; i < call.args.length; i++) { + if (exprReferencesNames(call.args[i], names)) return true; + } + return false; + } + case "Assignment": { + const a = s as any; + const vars = a.variables as LuaLValue[]; + if (vars) { + for (let i = 0; i < vars.length; i++) { + if (lvalueReferencesNames(vars[i], names)) return true; + } + } + const exprs = a.expressions as LuaExpression[]; + for (let i = 0; i < exprs.length; i++) { + if (exprReferencesNames(exprs[i], names)) return true; + } + return false; + } + case "Return": { + const exprs = (s as any).expressions as LuaExpression[]; + for (let i = 0; i < exprs.length; i++) { + if (exprReferencesNames(exprs[i], names)) return true; + } + return false; + } + case "Block": + return blockReferencesNames(s as LuaBlock, names); + case "If": { + const iff = s as LuaIfStatement; + for (const c of iff.conditions) { + if (exprReferencesNames(c.condition, names)) return true; + if (blockReferencesNames(c.block, names)) return true; + } + if (iff.elseBlock && blockReferencesNames(iff.elseBlock, names)) { + return true; + } + return false; + } + case "While": { + const w = s as any; + if (exprReferencesNames(w.condition, names)) return true; + return blockReferencesNames(w.block as LuaBlock, names); + } + case "Repeat": { + const r = s as any; + if (blockReferencesNames(r.block as LuaBlock, names)) return true; + if (exprReferencesNames(r.condition, names)) return true; + return false; + } + case "For": { + const fr = s as any; + if (exprReferencesNames(fr.start, names)) return true; + if (exprReferencesNames(fr.end, names)) return true; + if (fr.step && exprReferencesNames(fr.step, names)) return true; + return blockReferencesNames(fr.block as LuaBlock, names); + } + case "ForIn": { + const fi = s as any; + const exprs = fi.expressions as LuaExpression[]; + for (let i = 0; i < exprs.length; i++) { + if (exprReferencesNames(exprs[i], names)) return true; + } + return blockReferencesNames(fi.block as LuaBlock, names); + } + default: + return false; + } +} + +// Walk block looking for `FunctionDefinition` nodes that capture `names`. +function blockCapturesNames(block: LuaBlock, names: Set): boolean { + for (let i = 0; i < block.statements.length; i++) { + if (statementCapturesNames(block.statements[i], names)) return true; + } + return false; +} + +function statementCapturesNames( + s: LuaStatement, + names: Set, +): boolean { + switch (s.type) { + case "Local": { + const exprs = (s as any).expressions as LuaExpression[] | undefined; + if (exprs) { + for (let i = 0; i < exprs.length; i++) { + if (exprCapturesNames(exprs[i], names)) return true; + } + } + return false; + } + case "LocalFunction": { + const lf = s as any; + return functionBodyCapturesNames(lf.body as LuaFunctionBody, names); + } + case "Function": { + const fn = s as any; + return functionBodyCapturesNames(fn.body as LuaFunctionBody, names); + } + case "FunctionCallStatement": { + const call = (s as any).call as LuaFunctionCallExpression; + if (exprCapturesNames(call.prefix, names)) return true; + for (let i = 0; i < call.args.length; i++) { + if (exprCapturesNames(call.args[i], names)) return true; + } + return false; + } + case "Assignment": { + const exprs = (s as any).expressions as LuaExpression[]; + for (let i = 0; i < exprs.length; i++) { + if (exprCapturesNames(exprs[i], names)) return true; + } + return false; + } + case "Return": { + const exprs = (s as any).expressions as LuaExpression[]; + for (let i = 0; i < exprs.length; i++) { + if (exprCapturesNames(exprs[i], names)) return true; + } + return false; + } + case "Block": + return blockCapturesNames(s as LuaBlock, names); + case "If": { + const iff = s as LuaIfStatement; + for (const c of iff.conditions) { + if (exprCapturesNames(c.condition, names)) return true; + if (blockCapturesNames(c.block, names)) return true; + } + if (iff.elseBlock && blockCapturesNames(iff.elseBlock, names)) { + return true; + } + return false; + } + case "While": { + const w = s as any; + if (exprCapturesNames(w.condition, names)) return true; + return blockCapturesNames(w.block as LuaBlock, names); + } + case "Repeat": { + const r = s as any; + if (blockCapturesNames(r.block as LuaBlock, names)) return true; + if (exprCapturesNames(r.condition, names)) return true; + return false; + } + case "For": { + const fr = s as any; + if (exprCapturesNames(fr.start, names)) return true; + if (exprCapturesNames(fr.end, names)) return true; + if (fr.step && exprCapturesNames(fr.step, names)) return true; + return blockCapturesNames(fr.block as LuaBlock, names); + } + case "ForIn": { + const fi = s as any; + const exprs = fi.expressions as LuaExpression[]; + for (let i = 0; i < exprs.length; i++) { + if (exprCapturesNames(exprs[i], names)) return true; + } + return blockCapturesNames(fi.block as LuaBlock, names); + } + default: + return false; + } +} + +// At loop block level find `FunctionDefinition` and check if it +// captures `names`. +function exprCapturesNames(e: LuaExpression, names: Set): boolean { + if (!e) return false; + switch (e.type) { + case "FunctionDefinition": + return functionBodyCapturesNames(e.body, names); + case "Binary": + return exprCapturesNames(e.left, names) || + exprCapturesNames(e.right, names); + case "Unary": + return exprCapturesNames(e.argument, names); + case "Parenthesized": + return exprCapturesNames(e.expression, names); + case "FunctionCall": + if (exprCapturesNames(e.prefix, names)) return true; + for (let i = 0; i < e.args.length; i++) { + if (exprCapturesNames(e.args[i], names)) return true; + } + return false; + case "TableAccess": + return exprCapturesNames(e.object, names) || + exprCapturesNames(e.key, names); + case "PropertyAccess": + return exprCapturesNames(e.object, names); + case "TableConstructor": + for (let i = 0; i < e.fields.length; i++) { + const f = e.fields[i]; + switch (f.type) { + case "DynamicField": + if (exprCapturesNames(f.key, names)) return true; + if (exprCapturesNames(f.value, names)) return true; + break; + case "PropField": + case "ExpressionField": + if (exprCapturesNames(f.value, names)) return true; + break; + } + } + return false; + case "Query": + for (let i = 0; i < e.clauses.length; i++) { + const c = e.clauses[i]; + switch (c.type) { + case "From": + if (exprCapturesNames(c.expression, names)) return true; + break; + case "Where": + case "Select": + if (exprCapturesNames(c.expression, names)) return true; + break; + case "Limit": + if (exprCapturesNames(c.limit, names)) return true; + if (c.offset && exprCapturesNames(c.offset, names)) return true; + break; + case "OrderBy": + for (let j = 0; j < c.orderBy.length; j++) { + if (exprCapturesNames(c.orderBy[j].expression, names)) { + return true; + } + } + break; + } + } + return false; + default: + return false; + } +} + function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { if (t.type !== "Block") { throw new Error(`Expected Block, got ${t.type}`); @@ -97,6 +547,7 @@ function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { let dup: { name: string; ctx: ASTCtx } | undefined; let hasLabelHere = false; let hasCloseHere = false; + let hasFunctionDef = false; const seen = new Set(); @@ -125,10 +576,44 @@ function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { if (!hasCloseHere) { hasCloseHere = hasCloseLocal((s as any).names as LuaAttName[]); } + if (!hasFunctionDef) { + hasFunctionDef = expressionsHaveFunctionDef( + (s as any).expressions as LuaExpression[] | undefined, + ); + } break; } case "LocalFunction": { hasLocalDecl = true; + hasFunctionDef = true; + break; + } + case "Function": { + hasFunctionDef = true; + break; + } + case "FunctionCallStatement": { + if (!hasFunctionDef) { + const call = (s as any).call as LuaFunctionCallExpression; + hasFunctionDef = expressionHasFunctionDef(call.prefix) || + expressionsHaveFunctionDef(call.args); + } + break; + } + case "Assignment": { + if (!hasFunctionDef) { + hasFunctionDef = expressionsHaveFunctionDef( + (s as any).expressions as LuaExpression[], + ); + } + break; + } + case "Return": { + if (!hasFunctionDef) { + hasFunctionDef = expressionsHaveFunctionDef( + (s as any).expressions as LuaExpression[], + ); + } break; } case "Block": { @@ -136,6 +621,7 @@ function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { hasLabel = hasLabel || !!child.hasLabel; hasGoto = hasGoto || !!child.hasGoto; hasCloseHere = hasCloseHere || !!child.hasCloseHere; + hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef; break; } case "If": { @@ -144,21 +630,44 @@ function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { hasLabel = hasLabel || !!c.block.hasLabel; hasGoto = hasGoto || !!c.block.hasGoto; hasCloseHere = hasCloseHere || !!c.block.hasCloseHere; + hasFunctionDef = hasFunctionDef || !!c.block.hasFunctionDef; + if (!hasFunctionDef) { + hasFunctionDef = expressionHasFunctionDef(c.condition); + } } if (iff.elseBlock) { hasLabel = hasLabel || !!iff.elseBlock.hasLabel; hasGoto = hasGoto || !!iff.elseBlock.hasGoto; hasCloseHere = hasCloseHere || !!iff.elseBlock.hasCloseHere; + hasFunctionDef = hasFunctionDef || !!iff.elseBlock.hasFunctionDef; } break; } case "While": - case "Repeat": + case "Repeat": { + const child = (s as any).block as LuaBlock; + hasLabel = hasLabel || !!child.hasLabel; + hasGoto = hasGoto || !!child.hasGoto; + hasCloseHere = hasCloseHere || !!child.hasCloseHere; + hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef; + if (!hasFunctionDef) { + hasFunctionDef = expressionHasFunctionDef((s as any).condition); + } + break; + } case "For": { const child = (s as any).block as LuaBlock; hasLabel = hasLabel || !!child.hasLabel; hasGoto = hasGoto || !!child.hasGoto; hasCloseHere = hasCloseHere || !!child.hasCloseHere; + hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef; + if (!hasFunctionDef) { + hasFunctionDef = expressionHasFunctionDef((s as any).start) || + expressionHasFunctionDef((s as any).end) || + ((s as any).step + ? expressionHasFunctionDef((s as any).step) + : false); + } break; } case "ForIn": { @@ -166,6 +675,12 @@ function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { hasLabel = hasLabel || !!child.hasLabel; hasGoto = hasGoto || !!child.hasGoto; hasCloseHere = true; + hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef; + if (!hasFunctionDef) { + hasFunctionDef = expressionsHaveFunctionDef( + (s as any).expressions as LuaExpression[], + ); + } break; } default: { @@ -192,6 +707,9 @@ function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { if (hasCloseHere) { block.hasCloseHere = true; } + if (hasFunctionDef) { + block.hasFunctionDef = true; + } return block; } @@ -276,30 +794,44 @@ function parseStatement(t: ParseTree, ctx: ASTCtx): LuaStatement { ctx: context(t, ctx), }; } - case "ForStatement": + case "ForStatement": { if (t.children![1].type === "ForNumeric") { const forNumeric = t.children![1]; - return { + const name = forNumeric.children![0].children![0].text!; + const block = parseBlock(t.children![3], ctx); + const node: LuaStatement = { type: "For", - name: forNumeric.children![0].children![0].text!, + name, start: parseExpression(forNumeric.children![2], ctx), end: parseExpression(forNumeric.children![4], ctx), step: forNumeric.children![5] ? parseExpression(forNumeric.children![6], ctx) : undefined, - block: parseBlock(t.children![3], ctx), - ctx: context(t, ctx), - }; - } else { - const forGeneric = t.children![1]; - return { - type: "ForIn", - names: parseNameList(forGeneric.children![0]), - expressions: parseExpList(forGeneric.children![2], ctx), - block: parseBlock(t.children![3], ctx), + block, ctx: context(t, ctx), }; + if (block.hasFunctionDef) { + const names = new Set([name]); + (node as any).capturesLoopVar = blockCapturesNames(block, names); + } + return node; } + const forGeneric = t.children![1]; + const names = parseNameList(forGeneric.children![0]); + const block = parseBlock(t.children![3], ctx); + const node: LuaStatement = { + type: "ForIn", + names, + expressions: parseExpList(forGeneric.children![2], ctx), + block, + ctx: context(t, ctx), + }; + if (block.hasFunctionDef) { + const nameSet = new Set(names); + (node as any).capturesLoopVar = blockCapturesNames(block, nameSet); + } + return node; + } case "Function": return { type: "Function", @@ -584,13 +1116,20 @@ function parseExpression(t: ParseTree, ctx: ASTCtx): LuaExpression { right: parseExpression(t.children![2], ctx), ctx: context(t, ctx), }; - case "UnaryExpression": + case "UnaryExpression": { + const op = t.children![0].children![0].text!; + if (op === "+") { + const err = new Error("unexpected symbol near '+'"); + (err as any).astCtx = context(t.children![0], ctx); + throw err; + } return { type: "Unary", - operator: t.children![0].children![0].text!, + operator: op, argument: parseExpression(t.children![1], ctx), ctx: context(t, ctx), }; + } case "Property": return { type: "PropertyAccess", @@ -672,13 +1211,12 @@ function parseQueryClause(t: ParseTree, ctx: ASTCtx): LuaQueryClause { expression: parseExpression(t.children![3], ctx), ctx: context(t, ctx), }; - } else { - return { - type: "From", - expression: parseExpression(t.children![1], ctx), - ctx: context(t, ctx), - }; } + return { + type: "From", + expression: parseExpression(t.children![1], ctx), + ctx: context(t, ctx), + }; } case "WhereClause": return { @@ -940,10 +1478,39 @@ export function parse(s: string, ctx: ASTCtx = {}): LuaBlock { } export function parseToAST(t: string): ParseTree { - const n = lezerToParseTree(t, parser.parse(t).topNode); + const tree = parser.parse(t); + + const errNode = findFirstParseError(tree.topNode); + if (errNode) { + const err = new Error(luaUnexpectedSymbolMessage(t, errNode.from)); + (err as any).astCtx = { from: errNode.from, to: errNode.to }; + throw err; + } + + const n = lezerToParseTree(t, tree.topNode); return cleanTree(n, true); } +function findFirstParseError(node: SyntaxNode): SyntaxNode | null { + if (node.type.isError) { + return node; + } + for (let ch = node.firstChild; ch; ch = ch.nextSibling) { + const hit = findFirstParseError(ch); + if (hit) { + return hit; + } + } + return null; +} + +function luaUnexpectedSymbolMessage(src: string, from: number): string { + let i = from; + while (i < src.length && /\s/.test(src[i])) i++; + const sym = i < src.length ? src[i] : "?"; + return `unexpected symbol near '${sym}'`; +} + /** * Helper function to parse a Lua expression string */ diff --git a/client/space_lua/runtime.ts b/client/space_lua/runtime.ts index 862bd037..517379fc 100644 --- a/client/space_lua/runtime.ts +++ b/client/space_lua/runtime.ts @@ -1,8 +1,9 @@ -import type { ASTCtx, LuaFunctionBody } from "./ast.ts"; +import type { ASTCtx, LuaFunctionBody, NumericType } 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"; +import { isNegativeZero, isTaggedFloat } from "./numeric.ts"; +import { luaFormat } from "./stdlib/format.ts"; export type LuaType = | "nil" @@ -25,11 +26,17 @@ export interface ILuaFunction { } export interface ILuaSettable { - set(key: LuaValue, value: LuaValue, sf?: LuaStackFrame): void | Promise; + set( + key: LuaValue, + value: LuaValue, + sf?: LuaStackFrame, + numType?: NumericType, + ): void | Promise; } export interface ILuaGettable { get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise | null; + getNumericType?(key: LuaValue): NumericType | undefined; } // Small helpers for type safety/readability @@ -42,8 +49,8 @@ export function isLuaTable(v: unknown): v is LuaTable { } export function toNumKey(key: unknown): string | number { - if (key instanceof Number) { - return Number(key); + if (isTaggedFloat(key)) { + return key.value; } if (typeof key === "number" || typeof key === "string") { return key; @@ -58,6 +65,8 @@ export function ctxOrNull(sf?: LuaStackFrame): ASTCtx | null { // Reuse a single empty context to avoid allocating `{}` in hot paths const EMPTY_CTX = {} as ASTCtx; +const MAX_TAG_LOOP = 200; + // Close-stack support export type LuaCloseEntry = { value: LuaValue; ctx: ASTCtx }; @@ -65,9 +74,13 @@ type LuaThreadState = { closeStack?: LuaCloseEntry[]; }; +function isLuaNumber(v: any): boolean { + return typeof v === "number" || isTaggedFloat(v); +} + export function luaTypeName(val: any): LuaType { - if (isFloatTag(val)) { - return "number"; + if (val === null || val === undefined) { + return "nil"; } const t = luaTypeOf(val); @@ -75,12 +88,9 @@ export function luaTypeName(val: any): LuaType { if (typeof t === "string") { return t; } - if (val === null || val === undefined) { - return "nil"; - } const ty = typeof val; - if (ty === "number" || val instanceof Number) { + if (ty === "number") { return "number"; } if (ty === "string") { @@ -247,21 +257,38 @@ export function luaCloseFromMark( export class LuaEnv implements ILuaSettable, ILuaGettable { variables = new Map(); + private readonly consts = new Set(); + private readonly numericTypes = new Map(); constructor(readonly parent?: LuaEnv) { } - setLocal(name: string, value: LuaValue) { + setLocal(name: string, value: LuaValue, numType?: NumericType) { this.variables.set(name, value); + if (isLuaNumber(value) && numType) { + this.numericTypes.set(name, numType); + } else { + this.numericTypes.delete(name); + } } - setLocalConst(name: string, value: LuaValue) { + setLocalConst(name: string, value: LuaValue, numType?: NumericType) { this.variables.set(name, value); this.consts.add(name); + if (isLuaNumber(value) && numType) { + this.numericTypes.set(name, numType); + } else { + this.numericTypes.delete(name); + } } - set(key: string, value: LuaValue, sf?: LuaStackFrame): void { + set( + key: string, + value: LuaValue, + sf?: LuaStackFrame, + numType?: NumericType, + ): void { if (this.variables.has(key) || !this.parent) { if (this.consts.has(key)) { throw new LuaRuntimeError( @@ -270,11 +297,26 @@ export class LuaEnv implements ILuaSettable, ILuaGettable { ); } this.variables.set(key, value); + if (isLuaNumber(value) && numType) { + this.numericTypes.set(key, numType); + } else { + this.numericTypes.delete(key); + } } else { - this.parent.set(key, value, sf); + this.parent.set(key, value, sf, numType); } } + getNumericType(name: string): NumericType | undefined { + if (this.numericTypes.has(name)) { + return this.numericTypes.get(name); + } + if (this.parent) { + return this.parent.getNumericType(name); + } + return undefined; + } + has(key: string): boolean { if (this.variables.has(key)) { return true; @@ -392,6 +434,7 @@ export class LuaMultiRes { // 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); @@ -399,6 +442,7 @@ export class LuaMultiRes { result.push(value); } } + return new LuaMultiRes(result); } } @@ -406,9 +450,8 @@ export class LuaMultiRes { export function singleResult(value: any): any { if (value instanceof LuaMultiRes) { return value.unwrap(); - } else { - return value; } + return value; } export class LuaFunction implements ILuaFunction { @@ -474,16 +517,14 @@ export class LuaFunction implements ILuaFunction { if (isPromise(r)) { return r.then(map); - } else { - return map(r); } + return map(r); }; if (isPromise(argsRP)) { return argsRP.then(resolveArgs); - } else { - return resolveArgs(argsRP); } + return resolveArgs(argsRP); } asString(): string { @@ -498,11 +539,13 @@ export class LuaFunction implements ILuaFunction { function mapFunctionReturnValue(values: any[]): any { if (values.length === 0) { return; - } else if (values.length === 1) { - return values[0]; - } else { - return new LuaMultiRes(values); } + + if (values.length === 1) { + return values[0]; + } + + return new LuaMultiRes(values); } export class LuaNativeJSFunction implements ILuaFunction { @@ -515,9 +558,8 @@ export class LuaNativeJSFunction implements ILuaFunction { const resolved = rpAll(jsArgsRP); if (isPromise(resolved)) { return resolved.then((jsArgs) => this.fn(...jsArgs)); - } else { - return this.fn(...resolved); } + return this.fn(...resolved); } asString(): string { @@ -560,10 +602,10 @@ export class LuaTable implements ILuaSettable, ILuaGettable { // 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; + // Numeric type metadata at storage boundaries + private readonly stringKeyTypes = new Map(); + private otherKeyTypes: Map | null = null; + private readonly arrayTypes: (NumericType | undefined)[] = []; constructor(init?: any[] | Record) { // For efficiency and performance reasons we pre-allocate these (modern JS engines are very good at optimizing this) @@ -582,12 +624,9 @@ export class LuaTable implements ILuaSettable, ILuaGettable { } private static numKeyValue(key: any): number | null { - if (isFloatTag(key)) { + if (isTaggedFloat(key)) { return key.value; } - if (key instanceof Number) { - return Number(key); - } if (typeof key === "number") { return key; } @@ -598,7 +637,11 @@ export class LuaTable implements ILuaSettable, ILuaGettable { // * negative zero becomes positive zero, // * integer-valued floats become plain integers, // * non-integer floats stay as-is. - private static normalizeNumericKey(key: any): any { + static normalizeNumericKey(key: any): any { + if (typeof key === "string") { + return key; + } + const numVal = LuaTable.numKeyValue(key); if (numVal !== null) { // Normalize -0 to +0 @@ -626,10 +669,23 @@ export class LuaTable implements ILuaSettable, ILuaGettable { return k - 1; } - get length(): number { + get rawLength(): number { return this.arrayPart.length; } + get length(): number { + let n = this.arrayPart.length; + while (n > 0) { + const v = this.arrayPart[n - 1]; + if (v === null || v === undefined) { + n--; + continue; + } + break; + } + return n; + } + keys(): any[] { const keys: any[] = []; for (const k in this.stringKeys) { @@ -664,24 +720,101 @@ export class LuaTable implements ILuaSettable, ILuaGettable { } has(key: LuaValue) { + if (typeof key === "string") { + return this.stringKeys[key] !== undefined; + } + const normalizedKey = LuaTable.normalizeNumericKey(key); + if ( + typeof normalizedKey === "number" && Number.isInteger(normalizedKey) && + normalizedKey >= 1 + ) { + const idx = normalizedKey - 1; + const v = this.arrayPart[idx]; + if (v !== undefined) { + return true; + } + return this.otherKeys ? this.otherKeys.has(normalizedKey) : false; + } 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) { + } + if (this.otherKeys) { return this.otherKeys.has(normalizedKey); } + return false; } - rawSet(key: LuaValue, value: LuaValue): void | Promise { + // Used by table constructors to preserve positional semantics + // including nils and ensure the array part grows to the final + // constructor size. + rawSetArrayIndex( + index1: number, + value: LuaValue, + numType?: NumericType, + ): void { + const idx = index1 - 1; + + this.arrayPart[idx] = value; + if (isLuaNumber(value) && numType) { + this.arrayTypes[idx] = numType; + } else { + this.arrayTypes[idx] = undefined; + } + } + + private promoteIntegerKeysFromHash(): void { + if (!this.otherKeys) return; + + while (true) { + const nextIndex1 = this.arrayPart.length + 1; + if (!this.otherKeys.has(nextIndex1)) { + break; + } + + const v = this.otherKeys.get(nextIndex1); + const nt = this.otherKeyTypes + ? this.otherKeyTypes.get(nextIndex1) + : undefined; + + this.otherKeys.delete(nextIndex1); + if (this.otherKeyTypes) { + this.otherKeyTypes.delete(nextIndex1); + } + + this.arrayPart.push(v); + this.arrayTypes.push(nt); + } + } + + rawSet( + key: LuaValue, + value: LuaValue, + numType?: NumericType, + ): void | Promise { if (isPromise(key)) { - return key.then((key) => this.rawSet(key, value)); + return key.then((key) => this.rawSet(key, value, numType)); } if (isPromise(value)) { - return value.then((v) => this.rawSet(key, v)); + return value.then((v) => this.rawSet(key, v, numType)); + } + + // Fast path: string keys (the dominant case) + if (typeof key === "string") { + if (value === null || value === undefined) { + delete this.stringKeys[key]; + this.stringKeyTypes.delete(key); + } else { + this.stringKeys[key] = value; + if (isLuaNumber(value) && numType) { + this.stringKeyTypes.set(key, numType); + } else { + this.stringKeyTypes.delete(key); + } + } + return; } const normalizedKey = LuaTable.normalizeNumericKey(key); @@ -689,22 +822,99 @@ export class LuaTable implements ILuaSettable, ILuaGettable { if (typeof normalizedKey === "string") { if (value === null || value === undefined) { delete this.stringKeys[normalizedKey]; + this.stringKeyTypes.delete(normalizedKey); } else { this.stringKeys[normalizedKey] = value; + if (isLuaNumber(value) && numType) { + this.stringKeyTypes.set(normalizedKey, numType); + } else { + this.stringKeyTypes.delete(normalizedKey); + } } return; } - if (LuaTable.isIntegerKey(normalizedKey)) { - this.arrayPart[LuaTable.toIndex(normalizedKey)] = value; + + if ( + typeof normalizedKey === "number" && Number.isInteger(normalizedKey) && + normalizedKey >= 1 + ) { + const idx = normalizedKey - 1; + + // Sparse writes (e.g. `a[7]=4` when length is 3) go to the hash + // part so that `#a` does not jump across holes. + if (idx <= this.arrayPart.length) { + this.arrayPart[idx] = value; + if (isLuaNumber(value) && numType) { + this.arrayTypes[idx] = numType; + } else { + this.arrayTypes[idx] = undefined; + } + + // If we extended the array by appending, we may now be able to + // promote subsequent integer keys from the hash part. + if (idx === this.arrayPart.length - 1) { + this.promoteIntegerKeysFromHash(); + } + + // Trailing nil shrink + if (value === null || value === undefined) { + let n = this.arrayPart.length; + while (n > 0) { + const v = this.arrayPart[n - 1]; + if (v === null || v === undefined) { + n--; + continue; + } + break; + } + if (n !== this.arrayPart.length) { + this.arrayPart.length = n; + this.arrayTypes.length = n; + } + } + + return; + } + + // Sparse numeric key + if (!this.otherKeys) { + this.otherKeys = new Map(); + } + if (!this.otherKeyTypes) { + this.otherKeyTypes = new Map(); + } + + if (value === null || value === undefined) { + this.otherKeys.delete(normalizedKey); + this.otherKeyTypes.delete(normalizedKey); + } else { + this.otherKeys.set(normalizedKey, value); + if (isLuaNumber(value) && numType) { + this.otherKeyTypes.set(normalizedKey, numType); + } else { + this.otherKeyTypes.delete(normalizedKey); + } + } return; } + if (!this.otherKeys) { this.otherKeys = new Map(); } + if (!this.otherKeyTypes) { + this.otherKeyTypes = new Map(); + } + if (value === null || value === undefined) { this.otherKeys.delete(normalizedKey); + this.otherKeyTypes.delete(normalizedKey); } else { this.otherKeys.set(normalizedKey, value); + if (isLuaNumber(value) && numType) { + this.otherKeyTypes.set(normalizedKey, numType); + } else { + this.otherKeyTypes.delete(normalizedKey); + } } } @@ -712,52 +922,123 @@ export class LuaTable implements ILuaSettable, ILuaGettable { key: LuaValue, value: LuaValue, sf?: LuaStackFrame, + numType?: NumericType, ): Promise | 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); + const errSf = sf || LuaStackFrame.lostFrame; + const ctx = sf?.astCtx ?? EMPTY_CTX; - 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).then((mv: any) => { - return luaCall( - mv, - [this, key, value], - callCtx, - sf, - ); - }); - } else { - return luaCall( - metaValue, - [this, key, value], - callCtx, - sf, + if (this.has(key)) { + return this.rawSet(key, value, numType); + } + + if (this.metatable === null) { + return this.rawSet(key, value, numType); + } + + const newIndexMM = this.metatable.rawGet("__newindex"); + + if (newIndexMM === undefined || newIndexMM === null) { + return this.rawSet(key, value, numType); + } + + const k: LuaValue = key; + const v: LuaValue = value; + const nt: NumericType | undefined = numType; + + let target: LuaValue | null = null; + + for (let loop = 0; loop < MAX_TAG_LOOP; loop++) { + const t = target === null ? this : target; + + if (t instanceof LuaTable) { + if (t.has(k)) { + return t.rawSet(k, v, nt); + } + + const mt = t.metatable; + if (!mt) { + return t.rawSet(k, v, nt); + } + + const mm = mt.rawGet("__newindex"); + const hasMM = !(mm === undefined || mm === null); + + if (!hasMM) { + return t.rawSet(k, v, nt); + } + + // Function metamethod: call and stop + if (typeof mm === "function" || isILuaFunction(mm)) { + return luaCall(mm, [t, k, v], ctx, errSf); + } + + // Table/env metamethod: forward assignment + if (mm instanceof LuaTable || mm instanceof LuaEnv) { + target = mm; + continue; + } + + const ty = luaTypeOf(mm) as any as string; + throw new LuaRuntimeError( + `attempt to index a ${ty} value`, + errSf.withCtx(ctx), ); } + + const ty = luaTypeOf(t) as any as string; + throw new LuaRuntimeError( + `attempt to index a ${ty} value`, + errSf.withCtx(ctx), + ); } - // Just set the value - return this.rawSet(key, value); + throw new LuaRuntimeError( + "'__newindex' chain too long; possible loop", + errSf.withCtx(ctx), + ); + } + + getNumericType(key: LuaValue): NumericType | undefined { + if (typeof key === "string") { + return this.stringKeyTypes.get(key); + } + if (LuaTable.isIntegerKey(key)) { + return this.arrayTypes[LuaTable.toIndex(key)]; + } + if (this.otherKeyTypes) { + return this.otherKeyTypes.get(key); + } + return undefined; } rawGet(key: LuaValue): LuaValue | null { + if (typeof key === "string") { + return this.stringKeys[key]; + } + 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) { + } + + if ( + typeof normalizedKey === "number" && Number.isInteger(normalizedKey) && + normalizedKey >= 1 + ) { + const idx = normalizedKey - 1; + const v = this.arrayPart[idx]; + if (v !== undefined) { + return v; + } + // Sparse integer keys can live in the hash part. + if (this.otherKeys) { + return this.otherKeys.get(normalizedKey); + } + return undefined; + } + + if (this.otherKeys) { return this.otherKeys.get(normalizedKey); } } @@ -799,21 +1080,32 @@ export class LuaTable implements ILuaSettable, ILuaGettable { toJS(sf = LuaStackFrame.lostFrame): Record | any[] { if (this.length > 0) { return this.toJSArray(sf); - } else { - return this.toJSObject(sf); } + return this.toJSObject(sf); } async toStringAsync(): Promise { 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"); + if (metatable) { + const mm = metatable.rawGet("__tostring"); + if (!(mm === undefined || mm === null)) { + const ctx = EMPTY_CTX; + const sf = LuaStackFrame.lostFrame.withCtx(ctx); + + const r = luaCall(mm, [this], ctx, sf); + const v = isPromise(r) ? await r : r; + + const s = singleResult(v); + if (typeof s !== "string") { + throw new LuaRuntimeError( + "'__tostring' must return a string", + sf, + ); + } + return s; } } + let result = "{"; let first = true; for (const key of this.keys()) { @@ -846,70 +1138,69 @@ export function luaIndexValue( key: LuaValue, sf?: LuaStackFrame, ): LuaValue | Promise | null { + // `nil` handling is done by luaGet() which has better context; + // keep this defensive for direct callers. 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).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, - ); - } + + const errSf = sf || LuaStackFrame.lostFrame; + const ctx = sf?.astCtx ?? EMPTY_CTX; + + let t: LuaValue = value; + + for (let loop = 0; loop < MAX_TAG_LOOP; loop++) { + // Primitive get when table + if (t instanceof LuaTable) { + const raw = t.rawGet(key); + if (raw !== undefined) { + return raw; + } + // If no metatable, raw miss => nil + if (t.metatable === null) { + return null; } } + + const mt = getMetatable(t, errSf); + const mm = mt ? mt.rawGet("__index") : null; + + if (mm === undefined || mm === null) { + // Strict Lua: only tables are indexable without a metamethod. + // For a table, raw miss yields nil; for non-table, it's a type error. + if (t instanceof LuaTable) { + return null; + } + const ty = luaTypeOf(t) as any as string; + throw new LuaRuntimeError( + `attempt to index a ${ty} value`, + errSf.withCtx(ctx), + ); + } + + // Function metamethod + if (typeof mm === "function" || isILuaFunction(mm)) { + return luaCall(mm, [t, key], ctx, errSf); + } + + // Table/metatable delegation: repeat with mm as new "t" + if (mm instanceof LuaTable || mm instanceof LuaEnv) { + t = mm; + continue; + } + + // Bad metamethod type: make it a Lua-like type error + const ty = luaTypeOf(mm) as any as string; + throw new LuaRuntimeError( + `attempt to index a ${ty} value`, + errSf.withCtx(ctx), + ); } - // 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)[k]; - if (objValue === undefined || objValue === null) { - return null; - } else { - return objValue; - } + + throw new LuaRuntimeError( + "'__index' chain too long; possible loop", + errSf.withCtx(ctx), + ); } export type LuaLValueContainer = { env: ILuaSettable; key: LuaValue }; @@ -919,6 +1210,7 @@ export async function luaSet( key: any, value: any, sf: LuaStackFrame, + numType?: NumericType, ): Promise { if (!obj) { throw new LuaRuntimeError( @@ -927,11 +1219,12 @@ export async function luaSet( ); } + const normKey = isTaggedFloat(key) ? key.value : key; + if (obj instanceof LuaTable || obj instanceof LuaEnv) { - await obj.set(key, value, sf); + await obj.set(normKey, value, sf, numType); } else { - const k = toNumKey(key); - // Writing into a "native" JavaScript object, need to do value conversion + const k = toNumKey(normKey); (obj as Record)[k] = await luaValueToJS(value, sf); } } @@ -959,34 +1252,39 @@ export function luaGet( 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)[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; - } } + if (typeof key === "number") { + return (obj as any[])[key - 1]; + } + if (isTaggedFloat(key)) { + return (obj as any[])[key.value - 1]; + } + // Native JS object + const k = toNumKey(key); + const val = (obj as Record)[k]; + if (typeof val === "function") { + // Automatically bind the function to the object + return val.bind(obj); + } + if (val === undefined) { + return null; + } + 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) - ) { + if (typeof obj === "string") { return obj.length; } + if (Array.isArray(obj)) { + return obj.length; + } + if (obj instanceof LuaTable) { + return obj.rawLength; + } const t = luaTypeOf(obj) as LuaType; throw new LuaRuntimeError( @@ -1008,6 +1306,7 @@ export function luaCall( ); } + // Fast path: native JS function if (typeof callee === "function") { const jsArgs = rpAll( args.map((v) => luaValueToJS(v, sf || LuaStackFrame.lostFrame)), @@ -1017,37 +1316,43 @@ export function luaCall( return jsArgs.then((resolved) => (callee as (...a: any[]) => any)(...resolved) ); - } else { - return (callee as (...a: any[]) => any)(...jsArgs); } - } else if (callee instanceof LuaTable) { + return (callee as (...a: any[]) => any)(...jsArgs); + } + + // Lua table: may be callable via __call metamethod + if (callee instanceof LuaTable) { const metatable = getMetatable(callee, sf); - if (metatable && metatable.has("__call")) { - // Invoke the meta table - const metaValue = metatable.get("__call", sf); + // Metamethod lookup must be raw (no __index involvement). + const mm = metatable ? metatable.rawGet("__call") : null; - 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 (!(mm === undefined || mm === null)) { + const isCallable = (v: any): boolean => { + if (v === null || v === undefined) return false; + if (typeof v === "function") return true; + if (isILuaFunction(v)) return true; + if (v instanceof LuaTable) { + const mt2 = getMetatable(v, sf); + const mm2 = mt2 ? mt2.rawGet("__call") : null; + return !(mm2 === undefined || mm2 === null); } + return false; }; - if (isPromise(metaValue)) { - // Got a promise, we need to wait for it - return (metaValue as Promise).then((value: any) => { - return callMetaValue(value); - }); - } else { - return callMetaValue(metaValue); + if (!isCallable(mm)) { + throw new LuaRuntimeError( + `attempt to call a ${luaTypeOf(mm)} value`, + (sf || LuaStackFrame.lostFrame).withCtx(ctx), + ); } + + return luaCall(mm, [callee, ...args], ctx, sf); } - } else if (isILuaFunction(callee)) { + } + + // ILuaFunction (LuaFunction/LuaBuiltinFunction/LuaNativeJSFunction/etc.) + if (isILuaFunction(callee)) { const base = (sf || LuaStackFrame.lostFrame).withCtx(ctx); const frameForCall = callee instanceof LuaFunction ? base.withFunction(callee) @@ -1056,37 +1361,28 @@ export function luaCall( frameForCall, ...args, ); - } else { - throw new LuaRuntimeError( - `attempt to call a non-callable value of type: ${luaTypeOf(callee)}`, - (sf || LuaStackFrame.lostFrame).withCtx(ctx), - ); } + + 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; - } + const an = isTaggedFloat(a) ? a.value : a; + const bn = isTaggedFloat(b) ? b.value : b; 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); } + if (Array.isArray(val)) { + return val.map((_, i) => i + 1); + } + return Object.keys(val); } export function luaTypeOf(val: any): LuaType | Promise { @@ -1095,25 +1391,32 @@ export function luaTypeOf(val: any): LuaType | Promise { } if (isPromise(val)) { return (val as Promise).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"; } + if (typeof val === "boolean") { + return "boolean"; + } + if (typeof val === "number") { + return "number"; + } + if (isTaggedFloat(val)) { + return "number"; + } + if (typeof val === "string") { + return "string"; + } + if (val instanceof LuaTable) { + return "table"; + } + if (Array.isArray(val)) { + return "table"; + } + if (typeof val === "function" || isILuaFunction(val)) { + return "function"; + } + if (typeof val === "object" && (val as any).constructor === Object) { + return "table"; + } + return "userdata"; } export class LuaRuntimeError extends Error { @@ -1201,22 +1504,10 @@ export function luaToString( return (value as Promise).then((v) => luaToString(v, visited)); } - if (value && typeof value === "object" && value.type === "float") { + if (isTaggedFloat(value)) { 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); } @@ -1284,37 +1575,23 @@ export function luaToString( 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)) { +export function luaFormatNumber(n: number, kind?: "int" | "float"): string { + if (kind !== "float" && Number.isInteger(n) && isFinite(n)) { return String(n); } - - return String(n); + if (n !== n) return "-nan"; + if (n === Infinity) return "inf"; + if (n === -Infinity) return "-inf"; + if (n === 0) { + return (1 / n === -Infinity) ? "-0.0" : "0.0"; + } + // Delegate to luaFormat for `%.14g` + const s = luaFormat("%.14g", n); + // Guarantee `.01 suffix for integer-valued floats + if (s.indexOf(".") === -1 && s.indexOf("e") === -1) { + return s + ".0"; + } + return s; } export function getMetatable( @@ -1347,9 +1624,8 @@ export function getMetatable( if ((value as any).metatable) { return (value as any).metatable as LuaTable; - } else { - return null; } + return null; } export function jsToLuaValue(value: any): any { @@ -1358,9 +1634,11 @@ export function jsToLuaValue(value: any): any { } if (value instanceof LuaTable) { return value; - } else if (value instanceof Uint8Array || value instanceof ArrayBuffer) { + } + if (value instanceof Uint8Array || value instanceof ArrayBuffer) { return value; - } else if (Array.isArray(value) && "index" in value && "input" in value) { + } + if (Array.isArray(value) && "index" in value && "input" in value) { // This is a RegExpMatchArray const regexMatch = value as RegExpMatchArray; const regexMatchTable = new LuaTable(); @@ -1371,23 +1649,25 @@ export function jsToLuaValue(value: any): any { regexMatchTable.set("input", regexMatch.input); regexMatchTable.set("groups", regexMatch.groups); return regexMatchTable; - } else if (Array.isArray(value)) { + } + 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") { + } + 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; } + if (typeof value === "function") { + return new LuaNativeJSFunction(value); + } + return value; } // Inverse of jsToLuaValue @@ -1397,7 +1677,8 @@ export function luaValueToJS(value: any, sf: LuaStackFrame): any { } if (value instanceof LuaTable) { return value.toJS(sf); - } else if ( + } + if ( value instanceof LuaNativeJSFunction || value instanceof LuaFunction || value instanceof LuaBuiltinFunction ) { @@ -1410,13 +1691,12 @@ export function luaValueToJS(value: any, sf: LuaStackFrame): any { jsArgs.then((jsArgs) => (value as ILuaFunction).call(sf, ...jsArgs)), sf, ); - } else { - return luaValueToJS((value as ILuaFunction).call(sf, ...jsArgs), sf); } + return luaValueToJS((value as ILuaFunction).call(sf, ...jsArgs), sf); }; - } else if (value instanceof Number) { - return Number(value); - } else { - return value; } + if (isTaggedFloat(value)) { + return value.value; + } + return value; } diff --git a/client/space_lua/stdlib.ts b/client/space_lua/stdlib.ts index 27b9d511..a112103e 100644 --- a/client/space_lua/stdlib.ts +++ b/client/space_lua/stdlib.ts @@ -31,7 +31,7 @@ import { luaToNumberDetailed } from "./tonumber.ts"; import { luaLoad } from "./stdlib/load.ts"; import { cryptoApi } from "./stdlib/crypto.ts"; import { netApi } from "./stdlib/net.ts"; -import { floatLiteral } from "./numeric.ts"; +import { isTaggedFloat, makeLuaFloat } from "./numeric.ts"; const printFunction = new LuaBuiltinFunction(async (_sf, ...args) => { console.log( @@ -48,18 +48,18 @@ const assertFunction = new LuaBuiltinFunction( }, ); -const ipairsFunction = new LuaBuiltinFunction((sf, ar: LuaTable | any[]) => { - let i = 1; +const ipairsFunction = new LuaBuiltinFunction((sf, t: LuaTable | any[]) => { + let i = 0; + return async () => { - if (i > ar.length) { + i = i + 1; + + const v = await luaGet(t, i, sf.astCtx ?? null, sf); + if (v === null || v === undefined) { return; } - const result = new LuaMultiRes([ - i, - await luaGet(ar, i, sf.astCtx ?? null, sf), - ]); - i++; - return result; + + return new LuaMultiRes([i, v]); }; }); @@ -150,7 +150,10 @@ const tonumberFunction = new LuaBuiltinFunction( } } - if (typeof value === "number" || value instanceof Number) { + if (typeof value === "number") { + return value; + } + if (isTaggedFloat(value)) { return value; } @@ -164,7 +167,7 @@ const tonumberFunction = new LuaBuiltinFunction( } if (result.numericType === "float") { - return floatLiteral(result.value); + return makeLuaFloat(result.value); } return result.value; @@ -276,7 +279,7 @@ const rawgetFunction = new LuaBuiltinFunction( typeName = "nil"; } else if (typeof table === "boolean") { typeName = "boolean"; - } else if (typeof table === "number" || table instanceof Number) { + } else if (typeof table === "number" || isTaggedFloat(table)) { typeName = "number"; } else if (typeof table === "string") { typeName = "string"; @@ -299,16 +302,15 @@ const rawgetFunction = new LuaBuiltinFunction( return v === undefined ? null : v; } - const k = key instanceof Number ? Number(key) : key; + 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; - } else { - const v = (table as Record)[k]; - return v === undefined ? null : v; } + const v = (table as Record)[k]; + return v === undefined ? null : v; } const v = (table as Record)[k as any]; @@ -318,8 +320,8 @@ const rawgetFunction = new LuaBuiltinFunction( const rawequalFunction = new LuaBuiltinFunction( (_sf, a: any, b: any) => { - const av = a instanceof Number ? Number(a) : a; - const bv = b instanceof Number ? Number(b) : b; + const av = isTaggedFloat(a) ? a.value : a; + const bv = isTaggedFloat(b) ? b.value : b; return av === bv; }, ); @@ -361,12 +363,12 @@ const selectFunction = new LuaBuiltinFunction( (_sf, index: number | "#", ...args: LuaValue[]) => { if (index === "#") { return args.length; - } else if (typeof index === "number") { + } + if (typeof index === "number") { if (index >= 0) { return new LuaMultiRes(args.slice(index - 1)); - } else { - return new LuaMultiRes(args.slice(args.length + index)); } + return new LuaMultiRes(args.slice(args.length + index)); } }, ); @@ -408,19 +410,18 @@ const nextFunction = new LuaBuiltinFunction( // Return the first key, value const key = keys[0]; return new LuaMultiRes([key, luaGet(table, key, sf.astCtx ?? null, sf)]); - } else { - // 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)]); } + // 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)]); }, ); diff --git a/client/space_lua/stdlib/format.ts b/client/space_lua/stdlib/format.ts new file mode 100644 index 00000000..a4cb3489 --- /dev/null +++ b/client/space_lua/stdlib/format.ts @@ -0,0 +1,770 @@ +// Supported specifiers set: [diuoxXaAfeEgGcspq%] +// Supported flags set: [-+0# ] +// Width and precision via digits or `*` + +type FormatSpec = { + flags: number; // FLAG_* + width: number; + hasPrec: boolean; + prec: number; + spec: number; +}; + +const FLAG_MINUS = 1; +const FLAG_PLUS = 2; +const FLAG_ZERO = 4; +const FLAG_HASH = 8; +const FLAG_SPACE = 16; + +function isDigit(c: number): boolean { + return c >= 48 && c <= 57; // '0'..'9' +} + +// Parse a format spec starting after '%' and return index of specifier. +function parseSpec( + fmt: string, + start: number, +): { spec: FormatSpec; end: number } { + let i = start; + const len = fmt.length; + let flags = 0; + + outer: while (i < len) { + switch (fmt.charCodeAt(i)) { + case 45: + flags |= FLAG_MINUS; + i++; + break; // '-' + case 43: + flags |= FLAG_PLUS; + i++; + break; // '+' + case 48: + flags |= FLAG_ZERO; + i++; + break; // '0' + case 35: + flags |= FLAG_HASH; + i++; + break; // '#' + case 32: + flags |= FLAG_SPACE; + i++; + break; // ' ' + default: + break outer; + } + } + + // Parse width + let width = 0; + if (i < len && fmt.charCodeAt(i) === 42) { // '*' + width = -1; + i++; + } else { + while (i < len && isDigit(fmt.charCodeAt(i))) { + width = width * 10 + (fmt.charCodeAt(i) - 48); + i++; + } + } + + // Parse precision + let hasPrec = false; + let prec = 0; + if (i < len && fmt.charCodeAt(i) === 46) { // '.' + hasPrec = true; + i++; + if (i < len && fmt.charCodeAt(i) === 42) { // '*' + prec = -1; + i++; + } else { + while (i < len && isDigit(fmt.charCodeAt(i))) { + prec = prec * 10 + (fmt.charCodeAt(i) - 48); + i++; + } + } + } + + // Skip length modifiers [hlL] ignored in Lua + while ( + i < len && + (fmt.charCodeAt(i) === 104 || // 'h' + fmt.charCodeAt(i) === 108 || // 'l' + fmt.charCodeAt(i) === 76) // 'L' + ) { + i++; + } + + if (i >= len) { + throw new Error("invalid format (missing specifier)"); + } + + return { + spec: { flags, width, hasPrec, prec, spec: fmt.charCodeAt(i) }, + end: i, + }; +} + +// pad a string to `width` respecting `FLAG_MINUS` and `FLAG_ZERO` +function pad(s: string, width: number, flags: number, numPad: boolean): string { + if (width <= 0 || s.length >= width) return s; + const n = width - s.length; + if (numPad && (flags & FLAG_ZERO) && !(flags & FLAG_MINUS)) { + let signLen = 0; + if (s.charCodeAt(0) === 45 || s.charCodeAt(0) === 43) { // '-' or '+' + signLen = 1; + } else if ( + s.charCodeAt(0) === 48 && + (s.charCodeAt(1) === 120 || s.charCodeAt(1) === 88) + ) { + signLen = 2; // '0x' or '0X' + } + return s.slice(0, signLen) + "0".repeat(n) + s.slice(signLen); + } + if (flags & FLAG_MINUS) { + return s + " ".repeat(n); + } + return " ".repeat(n) + s; +} + +function addSign(s: string, flags: number): string { + if (flags & FLAG_PLUS) return "+" + s; + if (flags & FLAG_SPACE) return " " + s; + return s; +} + +function formatInt(n: number, spec: FormatSpec): string { + const code = spec.spec; + const v = Math.trunc(n); + + let base = 10; + let unsigned = false; + let upper = false; + + switch (code) { + case 100: + case 105: // 'd', 'i' + break; + case 117: // 'u' + unsigned = true; + break; + case 111: // 'o' + base = 8; + unsigned = true; + break; + case 120: // 'x' + base = 16; + unsigned = true; + break; + case 88: // 'X' + base = 16; + unsigned = true; + upper = true; + break; + } + + let neg = false; + let digits: string; + + if (unsigned && v < 0) { + // Reinterpret as 64-bit unsigned + const bv = BigInt(v) + (1n << 64n); + digits = bv.toString(base); + } else if (unsigned) { + digits = v.toString(base); + } else { + neg = v < 0; + digits = (neg ? -v : v).toString(base); + } + + if (upper) digits = digits.toUpperCase(); + + // Precision + if (spec.hasPrec) { + if (spec.prec === 0 && v === 0) { + digits = ""; + } else if (digits.length < spec.prec) { + digits = "0".repeat(spec.prec - digits.length) + digits; + } + } + + // Alt flag + let prefix = ""; + if (spec.flags & FLAG_HASH) { + if (base === 8 && (digits.length === 0 || digits.charCodeAt(0) !== 48)) { + prefix = "0"; + } else if (base === 16 && v !== 0) { + prefix = upper ? "0X" : "0x"; + } + } + + let result: string; + if (neg) { + result = "-" + prefix + digits; + } else { + result = addSign(prefix + digits, spec.flags); + } + + const numPad = !spec.hasPrec; + return pad(result, spec.width, spec.flags, numPad); +} + +function formatFloat(n: number, spec: FormatSpec): string { + const code = spec.spec; + const upper = code === 69 || code === 71 || code === 70; + // 'E'=69 'G'=71 'F'=70 'e'=101 'g'=103 'f'=102 + const lower = code | 32; // to lowercase + + // Lua convention + if (!isFinite(n)) { + let s: string; + if (n !== n) { + s = upper ? "-NAN" : "-nan"; + } else if (n > 0) { + s = upper ? "INF" : "inf"; + s = addSign(s, spec.flags); + } else { + s = upper ? "-INF" : "-inf"; + } + return pad(s, spec.width, spec.flags, false); + } + + const neg = n < 0 || (n === 0 && 1 / n === -Infinity); + const abs = neg ? -n : n; + const prec = spec.hasPrec ? spec.prec : 6; + + let body: string; + + if (lower === 102) { // 'f' + body = abs.toFixed(prec); + } else if (lower === 101) { // 'e' + body = abs.toExponential(prec); + // Ensure exponent has at least 2 digits + body = ensureExpTwoDigits(body); + } else { // 'g' + const gPrec = (prec === 0) ? 1 : prec; + if (abs === 0) { + body = "0"; + } else { + // C rule: use 'e' if exponent < -4 or exponent >= precision + const exp = Math.floor(Math.log10(abs)); + if (exp < -4 || exp >= gPrec) { + body = abs.toExponential(gPrec - 1); + body = ensureExpTwoDigits(body); + } else { + // Number of decimals = precision - (exponent + 1) + const decimals = gPrec - (exp + 1); + body = abs.toFixed(decimals); + } + } + // Strip trailing zeros unless '#' flag + if (!(spec.flags & FLAG_HASH)) { + body = stripTrailingZerosG(body); + } + } + + if (upper) { + body = body.toUpperCase(); + } + + // Alt flag for 'f'/'e': ensure decimal point exists + if ((spec.flags & FLAG_HASH) && lower !== 103) { + if (body.indexOf(".") === -1) { + // Insert dot before 'e' if present, else append + const eIdx = body.indexOf("e"); + const EIdx = body.indexOf("E"); + const expIdx = eIdx !== -1 ? eIdx : EIdx; + if (expIdx !== -1) { + body = body.slice(0, expIdx) + "." + body.slice(expIdx); + } else { + body = body + "."; + } + } + } + + // Alt flag for 'g': keep trailing zeros but ensure decimal point + if ((spec.flags & FLAG_HASH) && lower === 103) { + if (body.indexOf(".") === -1) { + const expIdx = findExpIndex(body); + if (expIdx !== -1) { + body = body.slice(0, expIdx) + "." + body.slice(expIdx); + } else { + body = body + "."; + } + } + } + + let result: string; + if (neg) { + result = "-" + body; + } else { + result = addSign(body, spec.flags); + } + + return pad(result, spec.width, spec.flags, true); +} + +function findExpIndex(s: string): number { + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c === 101 || c === 69) return i; // 'e' or 'E' + } + return -1; +} + +// Ensure exponent part has at least 2 digits +function ensureExpTwoDigits(s: string): string { + const idx = findExpIndex(s); + if (idx === -1) return s; + // idx+1 is sign, idx+2... are digits + const signIdx = idx + 1; + if (signIdx >= s.length) return s; + const digitStart = signIdx + 1; + const expLen = s.length - digitStart; + if (expLen < 2) { + return s.slice(0, digitStart) + "0" + s.slice(digitStart); + } + return s; +} + +// Strip trailing zeros from '%g' output +function stripTrailingZerosG(s: string): string { + const expIdx = findExpIndex(s); + const mantissa = expIdx !== -1 ? s.slice(0, expIdx) : s; + const exp = expIdx !== -1 ? s.slice(expIdx) : ""; + + const dotIdx = mantissa.indexOf("."); + if (dotIdx === -1) return s; // nothing to strip + + let end = mantissa.length; + while (end > dotIdx + 1 && mantissa.charCodeAt(end - 1) === 48) { // '0' + end--; + } + // Remove dot if nothing after it + if (end === dotIdx + 1) { + end = dotIdx; + } + + return mantissa.slice(0, end) + exp; +} + +// Format a number as hexadecimal floating-point (%a/%A) +function formatHexFloat(n: number, spec: FormatSpec): string { + const code = spec.spec; + const upper = code === 65; // 'A' + + if (!isFinite(n)) { + let s: string; + if (n !== n) { + s = upper ? "-NAN" : "-nan"; + } else if (n > 0) { + s = upper ? "INF" : "inf"; + s = addSign(s, spec.flags); + } else { + s = upper ? "-INF" : "-inf"; + } + return pad(s, spec.width, spec.flags, false); + } + + const neg = n < 0 || (n === 0 && 1 / n === -Infinity); + const abs = neg ? -n : n; + + let body: string; + if (abs === 0) { + const prec = spec.hasPrec ? spec.prec : 0; + if (prec > 0) { + body = "0x0." + "0".repeat(prec) + "p+0"; + } else { + body = "0x0p+0"; + } + } else { + body = hexFloatBody(abs, spec); + } + + if (upper) body = body.toUpperCase(); + + // Alt flag: ensure decimal point + if (spec.flags & FLAG_HASH) { + const pIdx = findPIndex(body); + if (pIdx !== -1) { + let hasDot = false; + for (let k = 0; k < pIdx; k++) { + if (body.charCodeAt(k) === 46) { // '.' + hasDot = true; + break; + } + } + if (!hasDot) { + body = body.slice(0, pIdx) + "." + body.slice(pIdx); + } + } + } + + let result: string; + if (neg) { + result = "-" + body; + } else { + result = addSign(body, spec.flags); + } + + return pad(result, spec.width, spec.flags, true); +} + +// Find index of 'p' or 'P' in hex float string +function findPIndex(s: string): number { + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c === 112 || c === 80) return i; // 'p' or 'P' + } + return -1; +} + +// Number of bits needed to represent a positive bigint +function bitLength(n: bigint): number { + let bits = 0; + let v = n; + while (v > 0n) { + bits++; + v >>= 1n; + } + return bits; +} + +// Decompose a positive non-zero finite float into `0xH.HHHpN` form +function hexFloatBody(abs: number, spec: FormatSpec): string { + const buf = new Float64Array(1); + const view = new DataView(buf.buffer); + view.setFloat64(0, abs); + const bits = view.getBigUint64(0); + const biasedExp = Number((bits >> 52n) & 0x7FFn); + const frac = bits & 0xFFFFFFFFFFFFFn; + + let exponent: number; + let mantBits: bigint; + + if (biasedExp === 0) { + // Subnormal + if (frac === 0n) return "0x0p+0"; + const shift = 52 - bitLength(frac) + 1; + mantBits = frac << BigInt(shift); + exponent = -1022 - shift; + } else { + // Normal + exponent = biasedExp - 1023; + mantBits = frac | (1n << 52n); + } + + let firstDigit = Number(mantBits >> 52n); + const restBits = mantBits & ((1n << 52n) - 1n); + + // 13 hex digits from 52 bits + let fracHex = hexDigits52(restBits); + + if (spec.hasPrec) { + if (spec.prec < 13) { + const carry = roundHexInPlace(fracHex, spec.prec); + if (carry) firstDigit++; + fracHex = truncHexDigits(fracHex, spec.prec); + } else { + fracHex = padHexRight(fracHex, spec.prec); + } + } else { + fracHex = stripHexTrailingZeros(fracHex); + } + + const expSign = exponent >= 0 ? "+" : ""; + if (fracHex.length > 0) { + return "0x" + firstDigit + "." + fracHex + "p" + expSign + exponent; + } + return "0x" + firstDigit + "p" + expSign + exponent; +} + +// Convert 52-bit value to 13 hex digits, zero-padded +function hexDigits52(bits: bigint): string { + const s = bits.toString(16); + if (s.length >= 13) return s; + return "0".repeat(13 - s.length) + s; +} + +// Parse one hex char to its numeric value +function hexVal(c: number): number { + if (c >= 48 && c <= 57) return c - 48; // '0'..'9' + if (c >= 97 && c <= 102) return c - 87; // 'a'..'f' + if (c >= 65 && c <= 70) return c - 55; // 'A'..'F' + return 0; +} + +function roundHexInPlace(digits: string, prec: number): boolean { + if (prec >= digits.length) return false; + + const nextVal = hexVal(digits.charCodeAt(prec)); + if (nextVal < 8) return false; + + if (prec === 0) return true; + + const arr = new Array(prec); + for (let i = 0; i < prec; i++) { + arr[i] = hexVal(digits.charCodeAt(i)); + } + + let carry = 1; + for (let i = prec - 1; i >= 0 && carry; i--) { + arr[i] += carry; + if (arr[i] >= 16) { + arr[i] = 0; + carry = 1; + } else { + carry = 0; + } + } + + return carry === 1; +} + +function truncHexDigits(digits: string, prec: number): string { + if (prec === 0) return ""; + + const nextVal = hexVal(digits.charCodeAt(prec)); + if (nextVal < 8) return digits.slice(0, prec); + + const arr = new Array(prec); + for (let i = 0; i < prec; i++) { + arr[i] = hexVal(digits.charCodeAt(i)); + } + + let carry = 1; + for (let i = prec - 1; i >= 0 && carry; i--) { + arr[i] += carry; + if (arr[i] >= 16) { + arr[i] = 0; + } else { + carry = 0; + } + } + + let out = ""; + for (let i = 0; i < prec; i++) { + out += arr[i].toString(16); + } + + return out; +} + +function padHexRight(s: string, len: number): string { + if (s.length >= len) return s; + return s + "0".repeat(len - s.length); +} + +function stripHexTrailingZeros(s: string): string { + let end = s.length; + while (end > 0 && s.charCodeAt(end - 1) === 48) { // '0' + end--; + } + if (end === s.length) return s; + return s.slice(0, end); +} + +function quoteString(s: string): string { + let out = '"'; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c === 34 || c === 92 || c === 10) { + // '"', '\\', '\n': backslash + literal char + out += "\\"; + out += String.fromCharCode(c); + } else if (c < 32) { + const next = i + 1 < s.length ? s.charCodeAt(i + 1) : -1; + const isNextDigit = next >= 48 && next <= 57; + if (isNextDigit) { + const ds = c.toString(); + out += "\\"; + if (ds.length < 3) out += "0".repeat(3 - ds.length); + out += ds; + } else { + out += "\\" + c.toString(); + } + } else { + out += String.fromCharCode(c); + } + } + out += '"'; + return out; +} + +// Format a float for %q: hex representation preserving full precision +function quoteFloat(n: number): string { + if (n !== n) return "(0/0)"; + if (n === Infinity) return "1e9999"; + if (n === -Infinity) return "-1e9999"; + + const spec: FormatSpec = { + flags: 0, + width: 0, + hasPrec: false, + prec: 0, + spec: 97, // 'a' + }; + return formatHexFloat(n, spec); +} + +function formatQ(v: unknown): string { + if (v === null || v === undefined) return "nil"; + if (v === true) return "true"; + if (v === false) return "false"; + + if (typeof v === "number") { + if (v === 0 && 1 / v === -Infinity) return quoteFloat(v); + if (Number.isInteger(v) && Number.isFinite(v)) { + return v.toString(); + } + return quoteFloat(v); + } + + return quoteString(String(v)); +} + +function formatChar(n: number): string { + return String.fromCharCode(n & 0x7f); +} + +const objectIds = new WeakMap(); +const stringIds = new Map(); +let nextId = 1; + +function toPointer(v: unknown): string { + if (v === null || v === undefined) return "(null)"; + if (typeof v === "boolean" || typeof v === "number") return "(null)"; + + // Primitives (strings, symbols, etc.) cannot be `WeakMap` keys + if (typeof v !== "object" && typeof v !== "function") { + const key = String(v); + let id = stringIds.get(key); + if (id === undefined) { + id = nextId++; + stringIds.set(key, id); + } + return "0x" + id.toString(16).padStart(14, "0"); + } + + const obj = v as object; + let id = objectIds.get(obj); + if (id === undefined) { + id = nextId++; + objectIds.set(obj, id); + } + return "0x" + id.toString(16).padStart(14, "0"); +} + +function formatPointer(v: unknown, spec: FormatSpec): string { + const s = toPointer(v); + // `%p` only supports width and '-' flag, no precision + return pad(s, spec.width, spec.flags, false); +} + +export function luaFormat(fmt: string, ...args: any[]): string { + let out = ""; + let ai = 0; // arg index + const len = fmt.length; + let i = 0; + + while (i < len) { + const c = fmt.charCodeAt(i); + if (c !== 37) { // not '%' + // Fast path: scan for next '%' or end + let j = i + 1; + while (j < len && fmt.charCodeAt(j) !== 37) j++; + out += fmt.slice(i, j); + i = j; + continue; + } + + // '%' found + i++; + if (i >= len) { + throw new Error("invalid format (ends with '%')"); + } + + // '%%' into literal '%' + if (fmt.charCodeAt(i) === 37) { + out += "%"; + i++; + continue; + } + + const { spec, end } = parseSpec(fmt, i); + i = end + 1; + + // Resolve `*` width and precision from args + let width = spec.width; + if (width === -1) { + width = Number(args[ai++]) || 0; + if (width < 0) { + spec.flags |= FLAG_MINUS; + width = -width; + } + spec.width = width; + } + if (spec.prec === -1) { + let p = Number(args[ai++]) || 0; + if (p < 0) { + spec.hasPrec = false; + p = 0; + } + spec.prec = p; + } + + const code = spec.spec; + switch (code) { + case 97: + case 65: // 'a', 'A' + out += formatHexFloat(Number(args[ai++]), spec); + break; + case 100: + case 105: + case 117: // 'd', 'i', 'u' + case 111: + case 120: + case 88: // 'o', 'x', 'X' + out += formatInt(Number(args[ai++]), spec); + break; + case 102: + case 101: + case 69: // 'f', 'e', 'E' + case 103: + case 71: + case 70: // 'g', 'G', 'F' + out += formatFloat(Number(args[ai++]), spec); + break; + case 99: // 'c' + out += pad( + formatChar(Number(args[ai++])), + spec.width, + spec.flags, + false, + ); + break; + case 112: { // 'p' + out += formatPointer(args[ai++], spec); + break; + } + case 113: { // 'q' + out += formatQ(args[ai++]); + break; + } + case 115: { // 's' + let s = String(args[ai++]); + if (spec.hasPrec && s.length > spec.prec) { + s = s.slice(0, spec.prec); + } + out += pad(s, spec.width, spec.flags, false); + break; + } + default: + throw new Error( + `invalid format specifier '${String.fromCharCode(code)}'`, + ); + } + } + + return out; +} diff --git a/client/space_lua/stdlib/format_test.lua b/client/space_lua/stdlib/format_test.lua new file mode 100644 index 00000000..dc5220d3 --- /dev/null +++ b/client/space_lua/stdlib/format_test.lua @@ -0,0 +1,503 @@ +local function assert_eq(actual, expected, message) + if actual ~= expected then + error('Assertion failed: ' .. message + .. '\n actual: ' .. tostring(actual) + .. '\n expected: ' .. tostring(expected), 2) + end +end + +local function assertThrows(msg_substr, fn) + local ok, err = pcall(fn) + if ok then + error('Assertion failed: expected error containing "' + .. msg_substr .. '"', 2) + end + if type(err) ~= 'string' then + err = tostring(err) + end + if not string.find(err, msg_substr, 1, true) then + error('Assertion failed: expected error message to contain "' + .. msg_substr .. '", got: "' .. err .. '"', 2) + end +end + +-- %%: literal percent +assert_eq(string.format("%%"), "%", "%%: literal") +assert_eq(string.format("100%%"), "100%", "%%: 100%%") +assert_eq(string.format("%%%%"), "%%", "%%: %%%%") +assert_eq(string.format("a%%b%%c"), "a%b%c", "%%: a%%b%%c") + +-- %d: basic integers +assert_eq(string.format("%d", 0), "0", "%d: 0") +assert_eq(string.format("%d", 1), "1", "%d: 1") +assert_eq(string.format("%d", -1), "-1", "%d: -1") +assert_eq(string.format("%d", 42), "42", "%d: 42") +assert_eq(string.format("%d", -42), "-42", "%d: -42") +assert_eq(string.format("%d", 2147483647), "2147483647", "%d: INT_MAX") +assert_eq(string.format("%d", -2147483648), "-2147483648", "%d: INT_MIN") + +-- %i: alias for %d +assert_eq(string.format("%i", 42), "42", "%i: 42") +assert_eq(string.format("%i", -7), "-7", "%i: -7") + +-- %d: width +assert_eq(string.format("%5d", 42), " 42", "%d: width 5") +assert_eq(string.format("%5d", -42), " -42", "%d: width 5 neg") +assert_eq(string.format("%1d", 42), "42", "%d: width 1 no pad") + +-- %d: left-justify +assert_eq(string.format("%-5d", 42), "42 ", "%d: left 42") +assert_eq(string.format("%-5d", -42), "-42 ", "%d: left -42") + +-- %d: zero-pad +assert_eq(string.format("%05d", 42), "00042", "%d: zero-pad 42") +assert_eq(string.format("%05d", -42), "-0042", "%d: zero-pad -42") +assert_eq(string.format("%05d", 0), "00000", "%d: zero-pad 0") + +-- %d: plus flag +assert_eq(string.format("%+d", 42), "+42", "%d: +42") +assert_eq(string.format("%+d", -42), "-42", "%d: + -42") +assert_eq(string.format("%+d", 0), "+0", "%d: +0") + +-- %d: space flag +assert_eq(string.format("% d", 42), " 42", "%d: space 42") +assert_eq(string.format("% d", -42), "-42", "%d: space -42") +assert_eq(string.format("% d", 0), " 0", "%d: space 0") + +-- %d: precision +assert_eq(string.format("%.5d", 42), "00042", "%d: prec5 42") +assert_eq(string.format("%.5d", -42), "-00042", "%d: prec5 -42") +assert_eq(string.format("%.0d", 0), "", "%d: prec0 0") +assert_eq(string.format("%.0d", 1), "1", "%d: prec0 1") +assert_eq(string.format("%.1d", 0), "0", "%d: prec1 0") + +-- %d: width and precision +assert_eq(string.format("%8.5d", 42), " 00042", "%d: w8 p5 42") +assert_eq(string.format("%-8.5d", 42), "00042 ", "%d: left w8 p5 42") + +-- %u: unsigned integers +assert_eq(string.format("%u", 0), "0", "%u: 0") +assert_eq(string.format("%u", 42), "42", "%u: 42") +assert_eq(string.format("%u", -1), "18446744073709551615", "%u: -1") +assert_eq(string.format("%u", -42), "18446744073709551574", "%u: -42") + +-- %o: octal +assert_eq(string.format("%o", 0), "0", "%o: 0") +assert_eq(string.format("%o", 8), "10", "%o: 8") +assert_eq(string.format("%o", 255), "377", "%o: 255") +assert_eq(string.format("%o", -1), "1777777777777777777777", "%o: -1") + +-- %o: alt flag +assert_eq(string.format("%#o", 0), "0", "%#o: 0") +assert_eq(string.format("%#o", 8), "010", "%#o: 8") +assert_eq(string.format("%#o", 255), "0377", "%#o: 255") + +-- %x: hex lowercase +assert_eq(string.format("%x", 0), "0", "%x: 0") +assert_eq(string.format("%x", 255), "ff", "%x: 255") +assert_eq(string.format("%x", 4095), "fff", "%x: 4095") +assert_eq(string.format("%x", -1), "ffffffffffffffff", "%x: -1") + +-- %X: hex uppercase +assert_eq(string.format("%X", 255), "FF", "%X: 255") +assert_eq(string.format("%X", -1), "FFFFFFFFFFFFFFFF", "%X: -1") + +-- %x: alt flag +assert_eq(string.format("%#x", 0), "0", "%#x: 0") +assert_eq(string.format("%#x", 255), "0xff", "%#x: 255") +assert_eq(string.format("%#X", 255), "0XFF", "%#X: 255") + +-- %x: width and zero-pad +assert_eq(string.format("%08x", 255), "000000ff", "%x: zero-pad 255") +assert_eq(string.format("%#08x", 255), "0x0000ff", "%#x: zero-pad 255") + +-- %c: character +assert_eq(string.format("%c", 65), "A", "%c: 65") +assert_eq(string.format("%c", 97), "a", "%c: 97") +assert_eq(string.format("%c", 48), "0", "%c: 48") + +-- %c: width +assert_eq(string.format("%3c", 65), " A", "%c: width 3") +assert_eq(string.format("%-3c", 65), "A ", "%c: left width 3") + +-- %s: strings +assert_eq(string.format("%s", "hello"), "hello", "%s: hello") +assert_eq(string.format("%s", ""), "", "%s: empty") +assert_eq(string.format("%s %s", "hello", "world"), "hello world", "%s: two") + +-- %s: width +assert_eq(string.format("%10s", "hello"), " hello", "%s: width 10") +assert_eq(string.format("%-10s", "hello"), "hello ", "%s: left width 10") + +-- %s: precision truncates +assert_eq(string.format("%.3s", "hello"), "hel", "%s: prec3") +assert_eq(string.format("%.10s", "hello"), "hello", "%s: prec10") +assert_eq(string.format("%.0s", "hello"), "", "%s: prec0") + +-- %s: width and precision +assert_eq(string.format("%10.3s", "hello"), " hel", "%s: w10 p3") +assert_eq(string.format("%-10.3s", "hello"), "hel ", "%s: left w10 p3") + +-- %f: basic floats +assert_eq(string.format("%f", 0), "0.000000", "%f: 0") +assert_eq(string.format("%f", 1), "1.000000", "%f: 1") +assert_eq(string.format("%f", -1), "-1.000000", "%f: -1") +assert_eq(string.format("%f", 3.14), "3.140000", "%f: 3.14") +assert_eq(string.format("%f", 0.1), "0.100000", "%f: 0.1") + +-- %f: precision +assert_eq(string.format("%.2f", 3.14159), "3.14", "%f: prec2") +assert_eq(string.format("%.0f", 3.14159), "3", "%f: prec0") +assert_eq(string.format("%.10f", 1.0), "1.0000000000", "%f: prec10") + +-- %f: width and precision +assert_eq(string.format("%10.2f", 3.14), " 3.14", "%f: w10 p2") +assert_eq(string.format("%-10.2f", 3.14), "3.14 ", "%f: left w10 p2") +assert_eq(string.format("%010.2f", 3.14), "0000003.14", "%f: zero-pad w10 p2") + +-- %f: sign flags +assert_eq(string.format("%+f", 3.14), "+3.140000", "%f: +3.14") +assert_eq(string.format("%+f", -3.14), "-3.140000", "%f: + -3.14") +assert_eq(string.format("% f", 3.14), " 3.140000", "%f: space 3.14") +assert_eq(string.format("% f", -3.14), "-3.140000", "%f: space -3.14") + +-- %f: non-finite +assert_eq(string.format("%f", 1/0), "inf", "%f: inf") +assert_eq(string.format("%f", -1/0), "-inf", "%f: -inf") +assert_eq(string.format("%f", 0/0), "-nan", "%f: nan") +assert_eq(string.format("%+f", 1/0), "+inf", "%f: +inf") + +-- %f: width with non-finite +assert_eq(string.format("%10f", 1/0), " inf", "%f: w10 inf") +assert_eq(string.format("%-10f", 1/0), "inf ", "%f: left w10 inf") + +-- %e: basic +assert_eq(string.format("%e", 0), "0.000000e+00", "%e: 0") +assert_eq(string.format("%e", 1), "1.000000e+00", "%e: 1") +assert_eq(string.format("%e", 100), "1.000000e+02", "%e: 100") +assert_eq(string.format("%e", 0.001), "1.000000e-03", "%e: 0.001") +assert_eq(string.format("%e", -42), "-4.200000e+01", "%e: -42") + +-- %E: uppercase +assert_eq(string.format("%E", 100), "1.000000E+02", "%E: 100") + +-- %e: precision +assert_eq(string.format("%.2e", 100), "1.00e+02", "%e: prec2") +assert_eq(string.format("%.0e", 100), "1e+02", "%e: prec0") +assert_eq(string.format("%.14e", 1), "1.00000000000000e+00", "%e: prec14") + +-- %e: non-finite +assert_eq(string.format("%e", 1/0), "inf", "%e: inf") +assert_eq(string.format("%e", -1/0), "-inf", "%e: -inf") +assert_eq(string.format("%e", 0/0), "-nan", "%e: nan") + +-- %e: width and flags +assert_eq(string.format("%15e", 100), " 1.000000e+02", "%e: w15") +assert_eq(string.format("%-15e", 100), "1.000000e+02 ", "%e: left w15") +assert_eq(string.format("%+e", 100), "+1.000000e+02", "%e: +100") + +-- %g: basic +assert_eq(string.format("%g", 0), "0", "%g: 0") +assert_eq(string.format("%g", 1), "1", "%g: 1") +assert_eq(string.format("%g", 100), "100", "%g: 100") +assert_eq(string.format("%g", 100000), "100000", "%g: 100000") +assert_eq(string.format("%g", 1000000), "1e+06", "%g: 1000000") +assert_eq(string.format("%g", 0.0001), "0.0001", "%g: 0.0001") +assert_eq(string.format("%g", 0.00001), "1e-05", "%g: 0.00001") +assert_eq(string.format("%g", -42), "-42", "%g: -42") + +-- %G: uppercase +assert_eq(string.format("%G", 1e6), "1E+06", "%G: 1e6") +assert_eq(string.format("%G", 1e-5), "1E-05", "%G: 1e-5") + +-- %g: precision +assert_eq(string.format("%.1g", 3.14), "3", "%g: prec1") +assert_eq(string.format("%.2g", 3.14), "3.1", "%g: prec2") +assert_eq(string.format("%.4g", 3.14159), "3.142", "%g: prec4") +assert_eq(string.format("%.0g", 3.14), "3", "%g: prec0") +assert_eq(string.format("%.10g", 1), "1", "%g: prec10 int") + +-- %g: strips trailing zeros +assert_eq(string.format("%.6g", 1.0), "1", "%g: strip 1.0") +assert_eq(string.format("%.6g", 1.5), "1.5", "%g: strip 1.5") +assert_eq(string.format("%.6g", 1.50), "1.5", "%g: strip 1.50") +assert_eq(string.format("%.14g", 1.0), "1", "%g: strip prec14 1.0") +assert_eq(string.format("%.14g", 1.5), "1.5", "%g: strip prec14 1.5") + +-- %g: alt flag keeps trailing zeros and dot +assert_eq(string.format("%#g", 1.0), "1.00000", "%#g: 1.0") +assert_eq(string.format("%#.2g", 1.0), "1.0", "%#g: prec2 1.0") +assert_eq(string.format("%#.4g", 100), "100.0", "%#g: prec4 100") + +-- %g: non-finite +assert_eq(string.format("%g", 1/0), "inf", "%g: inf") +assert_eq(string.format("%g", -1/0), "-inf", "%g: -inf") +assert_eq(string.format("%g", 0/0), "-nan", "%g: nan") +assert_eq(string.format("%+g", 1/0), "+inf", "%g: +inf") + +-- %g: width and flags +assert_eq(string.format("%10g", 42), " 42", "%g: w10 42") +assert_eq(string.format("%-10g", 42), "42 ", "%g: left w10 42") +assert_eq(string.format("%010g", 42), "0000000042", "%g: zero-pad w10 42") +assert_eq(string.format("%+g", 42), "+42", "%g: +42") +assert_eq(string.format("% g", 42), " 42", "%g: space 42") + +-- %g: Lua 5.4 %.14g reference values +assert_eq(string.format("%.14g", 0.0), "0", "%.14g: 0.0") +assert_eq(string.format("%.14g", 1.0), "1", "%.14g: 1.0") +assert_eq(string.format("%.14g", 1/3), "0.33333333333333", "%.14g: 1/3") +assert_eq(string.format("%.14g", math.pi), "3.1415926535898", "%.14g: pi") +assert_eq(string.format("%.14g", 1e-10), "1e-10", "%.14g: 1e-10") +assert_eq(string.format("%.14g", 1e18), "1e+18", "%.14g: 1e18") +assert_eq(string.format("%.14g", 2^63), "9.2233720368548e+18", "%.14g: 2^63") +assert_eq(string.format("%.14g", 2^53), "9.007199254741e+15", "%.14g: 2^53") +assert_eq(string.format("%.14g", 1.7976931348623e+308), "1.7976931348623e+308", "%.14g: max") +assert_eq(string.format("%.14g", 5e-324), "4.9406564584125e-324", "%.14g: min subnormal") +assert_eq(string.format("%.14g", 0/0), "-nan", "%.14g: nan") +assert_eq(string.format("%.14g", 1/0), "inf", "%.14g: inf") +assert_eq(string.format("%.14g", -1/0), "-inf", "%.14g: -inf") + +-- mixed: multiple specifiers +assert_eq(string.format("%d + %d = %d", 1, 2, 3), "1 + 2 = 3", "mixed: d+d=d") +assert_eq(string.format("%s is %d", "age", 42), "age is 42", "mixed: s is d") +assert_eq(string.format("[%05d] %s (%.2f)", 7, "test", 3.14), "[00007] test (3.14)", "mixed: complex") + +-- mixed: no specifiers +assert_eq(string.format("hello world"), "hello world", "mixed: no spec") +assert_eq(string.format(""), "", "mixed: empty") + +-- error: invalid specifier (Lua 5.4 says "invalid conversion", 5.5 may differ) +do + local ok = pcall(string.format, "%z", 1) + assert_eq(ok, false, "%z: should error") +end + +-- error: trailing percent +do + local ok = pcall(string.format, "abc%") + assert_eq(ok, false, "trailing %%: should error") +end + +-- %f: negative zero +assert_eq(string.format("%f", -0.0), "-0.000000", "%f: -0.0") +assert_eq(string.format("%e", -0.0), "-0.000000e+00", "%e: -0.0") +assert_eq(string.format("%g", -0.0), "-0", "%g: -0.0") + +-- %f: large precision +do + local s = string.format("%.20f", 1.0) + assert_eq(string.find(s, "%.") ~= nil, true, "%f: large prec has dot") + assert_eq(#s, 22, "%f: large prec length") +end + +-- zero width is no-op +assert_eq(string.format("%0d", 42), "42", "%0d: no-op") + +-- %a: basic hex floats +assert_eq(string.format("%a", 0), "0x0p+0", "%a: 0") +assert_eq(string.format("%a", 1), "0x1p+0", "%a: 1") +assert_eq(string.format("%a", -1), "-0x1p+0", "%a: -1") +assert_eq(string.format("%a", 2), "0x1p+1", "%a: 2") +assert_eq(string.format("%a", 0.5), "0x1p-1", "%a: 0.5") +assert_eq(string.format("%a", 1.5), "0x1.8p+0", "%a: 1.5") +assert_eq(string.format("%a", -0.0), "-0x0p+0", "%a: -0.0") + +-- %A: uppercase +assert_eq(string.format("%A", 1.5), "0X1.8P+0", "%A: 1.5") +assert_eq(string.format("%A", 255), "0X1.FEP+7", "%A: 255") + +-- %a: precision +assert_eq(string.format("%.0a", 1.5), "0x2p+0", "%a: prec0 1.5") +assert_eq(string.format("%.1a", 1.5), "0x1.8p+0", "%a: prec1 1.5") +assert_eq(string.format("%.4a", 1.5), "0x1.8000p+0", "%a: prec4 1.5") +assert_eq(string.format("%.4a", 1), "0x1.0000p+0", "%a: prec4 1") + +-- %a: width and flags +assert_eq(string.format("%20a", 1.5), " 0x1.8p+0", "%a: w20 1.5") +assert_eq(string.format("%-20a", 1.5), "0x1.8p+0 ", "%a: left w20 1.5") +assert_eq(string.format("%+a", 1.5), "+0x1.8p+0", "%a: +1.5") +assert_eq(string.format("% a", 1.5), " 0x1.8p+0", "%a: space 1.5") + +-- %a: non-finite +assert_eq(string.format("%a", 1/0), "inf", "%a: inf") +assert_eq(string.format("%a", -1/0), "-inf", "%a: -inf") +assert_eq(string.format("%a", 0/0), "-nan", "%a: nan") +assert_eq(string.format("%A", 1/0), "INF", "%A: inf") + +-- %a: Math.PI +assert_eq(string.format("%a", math.pi), "0x1.921fb54442d18p+1", "%a: pi") + +-- %a: 0.1 +assert_eq(string.format("%a", 0.1), "0x1.999999999999ap-4", "%a: 0.1") + +-- %q: nil and booleans +assert_eq(string.format("%q", nil), "nil", "%q: nil") +assert_eq(string.format("%q", true), "true", "%q: true") +assert_eq(string.format("%q", false), "false", "%q: false") + +-- %q: integers +assert_eq(string.format("%q", 0), "0", "%q: 0") +assert_eq(string.format("%q", 1), "1", "%q: 1") +assert_eq(string.format("%q", -1), "-1", "%q: -1") +assert_eq(string.format("%q", 42), "42", "%q: 42") +assert_eq(string.format("%q", -42), "-42", "%q: -42") + +-- %q: floats as hex +assert_eq(string.format("%q", 1.5), "0x1.8p+0", "%q: 1.5") +assert_eq(string.format("%q", 0.1), "0x1.999999999999ap-4", "%q: 0.1") +assert_eq(string.format("%q", math.pi), "0x1.921fb54442d18p+1", "%q: pi") +assert_eq(string.format("%q", -0.5), "-0x1p-1", "%q: -0.5") +assert_eq(string.format("%q", -0.0), "-0x0p+0", "%q: -0.0") + +-- %q: non-finite numbers +assert_eq(string.format("%q", 0/0), "(0/0)", "%q: nan") +assert_eq(string.format("%q", 1/0), "1e9999", "%q: inf") +assert_eq(string.format("%q", -1/0), "-1e9999", "%q: -inf") + +-- %q: simple strings +assert_eq(string.format("%q", "hello"), '"hello"', "%q: hello") +assert_eq(string.format("%q", ""), '""', "%q: empty") + +-- %q: strings with special characters +assert_eq(string.format("%q", 'say "hi"'), '"say \\"hi\\""', '%q: quotes') +assert_eq(string.format("%q", "back\\slash"), '"back\\\\slash"', "%q: backslash") +assert_eq(string.format("%q", "line\nbreak"), '"line\\\nbreak"', "%q: newline") +assert_eq(string.format("%q", "cr\rreturn"), '"cr\\13return"', "%q: cr") +assert_eq(string.format("%q", "tab\there"), '"tab\\9here"', "%q: tab") + +-- %q: Lua reference example +do + local result = string.format("%q", 'a string with "quotes" and \n new line') + assert_eq(result, '"a string with \\"quotes\\" and \\\n new line"', "%q: reference") +end + +-- %q: mixed with other specifiers +assert_eq( + string.format("name=%q age=%d", "Alice", 30), + 'name="Alice" age=30', + "%q: mixed" +) + +-- %p: pointer-like identity for tables +do + local t1 = {} + local t2 = {} + local s1 = string.format("%p", t1) + local s2 = string.format("%p", t2) + -- starts with 0x + assert_eq(s1:sub(1, 2), "0x", "%p: table prefix") + -- same object gives same result + assert_eq(string.format("%p", t1), s1, "%p: stable identity") + -- different objects give different results + assert_eq(s1 ~= s2, true, "%p: distinct tables differ") +end + +-- %p: functions get an identity too +do + local f1 = function() end + local f2 = function() end + local s1 = string.format("%p", f1) + local s2 = string.format("%p", f2) + assert_eq(s1:sub(1, 2), "0x", "%p: function prefix") + assert_eq(s1 ~= s2, true, "%p: distinct functions differ") +end + +-- %p: nil and primitives yield (null) +assert_eq(string.format("%p", nil), "(null)", "%p: nil") +assert_eq(string.format("%p", true), "(null)", "%p: boolean") +assert_eq(string.format("%p", 42), "(null)", "%p: number") + +-- %p: strings get an identity (they are GC objects in Lua) +do + local s = string.format("%p", "hello") + assert_eq(s:sub(1, 2), "0x", "%p: string prefix") +end + +-- %p: width +do + local t = {} + local s = string.format("%20p", t) + assert_eq(#s >= 20, true, "%p: width pads") +end + +-- %p: left-justify +do + local t = {} + local s = string.format("%-20p", t) + assert_eq(#s >= 20, true, "%p: left width pads") + -- trailing spaces + assert_eq(s:sub(#s, #s), " ", "%p: left-justify trailing space") +end + +-- %p: pointer-like identity for tables +do + local t1 = {} + local t2 = {} + local s1 = string.format("%p", t1) + local s2 = string.format("%p", t2) + assert_eq(s1:sub(1, 2), "0x", "%p: table prefix") + assert_eq(string.format("%p", t1), s1, "%p: table stable identity") + assert_eq(s1 ~= s2, true, "%p: distinct tables differ") +end + +-- %p: functions get an identity too +do + local f1 = function() end + local f2 = function() end + local s1 = string.format("%p", f1) + local s2 = string.format("%p", f2) + assert_eq(s1:sub(1, 2), "0x", "%p: function prefix") + assert_eq(string.format("%p", f1), s1, "%p: function stable identity") + assert_eq(s1 ~= s2, true, "%p: distinct functions differ") +end + +-- %p: strings get an identity (interned: same content = same id) +do + local s1 = string.format("%p", "hello") + local s2 = string.format("%p", "hello") + local s3 = string.format("%p", "world") + assert_eq(s1:sub(1, 2), "0x", "%p: string prefix") + assert_eq(s1, s2, "%p: same string content = same id") + assert_eq(s1 ~= s3, true, "%p: different string content differs") +end + +-- %p: nil and primitives yield (null) +assert_eq(string.format("%p", nil), "(null)", "%p: nil") +assert_eq(string.format("%p", true), "(null)", "%p: boolean") +assert_eq(string.format("%p", 42), "(null)", "%p: number") + +-- %p: repeated calls on same table are stable +do + local t = {} + local ids = {} + for i = 1, 5 do + ids[i] = string.format("%p", t) + end + for i = 2, 5 do + assert_eq(ids[i], ids[1], "%p: repeated call #" .. i .. " stable") + end +end + +-- %p: table stored in another table keeps identity +do + local inner = {} + local outer = { ref = inner } + local s1 = string.format("%p", inner) + local s2 = string.format("%p", outer.ref) + assert_eq(s1, s2, "%p: table via reference stable") +end + +-- %p: width +do + local t = {} + local s = string.format("%20p", t) + assert_eq(#s >= 20, true, "%p: width pads") +end + +-- %p: left-justify +do + local t = {} + local s = string.format("%-20p", t) + assert_eq(#s >= 20, true, "%p: left width pads") + assert_eq(s:sub(#s, #s), " ", "%p: left-justify trailing space") +end diff --git a/client/space_lua/stdlib/math.ts b/client/space_lua/stdlib/math.ts index 533ffa27..f83c0d3e 100644 --- a/client/space_lua/stdlib/math.ts +++ b/client/space_lua/stdlib/math.ts @@ -1,64 +1,45 @@ -import { LuaBuiltinFunction, LuaRuntimeError, LuaTable } from "../runtime.ts"; -import { getZeroBoxKind, isFloatTag, isNegativeZero } from "../numeric.ts"; +import { + LuaBuiltinFunction, + LuaMultiRes, + LuaRuntimeError, + LuaTable, +} from "../runtime.ts"; +import { isNegativeZero, isTaggedFloat } from "../numeric.ts"; + +// Fast unwrap: avoids function call overhead for the common plain-number case +function untagNumber(x: any): number { + if (typeof x === "number") return x; + if (isTaggedFloat(x)) return x.value; + return Number(x); +} export const mathApi = new LuaTable({ // math constants huge: 1 / 0, + pi: Math.PI, // math.type(x) => "integer" | "float" | nil type: new LuaBuiltinFunction((_sf, x?: any) => { - // arg is mandatory if (x === undefined) { throw new LuaRuntimeError( "bad argument #1 to 'math.type' (value expected)", _sf, ); } - - if (isFloatTag(x)) { + if (isTaggedFloat(x)) { return "float"; } - - if (x instanceof Number) { - const kind = getZeroBoxKind(x); - if (kind === "float") { - return "float"; - } - if (kind === "int") { - return "integer"; - } - - const n = Number(x); - if (!Number.isFinite(n)) { - return "float"; - } - - return Number.isInteger(n) ? "integer" : "float"; - } - if (typeof x === "number") { - const n = Number(x); - - // Negative zero is a float - if (n === 0 && isNegativeZero(n)) { + if (!Number.isFinite(x) || isNegativeZero(x)) { return "float"; } - - // NaN and +Inf/-Inf are floats - if (!Number.isFinite(n)) { - return "float"; - } - - return Number.isInteger(n) ? "integer" : "float"; + return Number.isInteger(x) ? "integer" : "float"; } - if (typeof x === "bigint") { return "integer"; } - return null; }), - /** * When called without arguments, returns a pseudo-random float with * uniform distribution in the range [0,1). When called with two @@ -69,8 +50,10 @@ export const mathApi = new LuaTable({ * with all bits (pseudo)random. */ random: new LuaBuiltinFunction((_sf, m?: number, n?: number) => { + if (m !== undefined) m = untagNumber(m); + if (n !== undefined) n = untagNumber(n); + if (m === undefined && n === undefined) { - // random() returns [0,1) return Math.random(); } @@ -83,27 +66,21 @@ export const mathApi = new LuaTable({ if (n === undefined) { if (m! == 0) { - // random(0) returns a random integer const high = Math.floor(Math.random() * 0x100000000); const low = Math.floor(Math.random() * 0x100000000); - let result = (BigInt(high) << 32n) | BigInt(low); - if (result & (1n << 63n)) { result -= 1n << 64n; } - return result; - } else { - // random(m) returns [1,m] - if (m! < 1) { - throw new LuaRuntimeError( - "bad argument #1 to 'math.random' (interval is empty)", - _sf, - ); - } - return Math.floor(Math.random() * m!) + 1; } + if (m! < 1) { + throw new LuaRuntimeError( + "bad argument #1 to 'math.random' (interval is empty)", + _sf, + ); + } + return Math.floor(Math.random() * m!) + 1; } if (!Number.isInteger(n!)) { @@ -113,7 +90,6 @@ export const mathApi = new LuaTable({ ); } - // random(m,n) returns [m,n] if (n! < m!) { throw new LuaRuntimeError( "bad argument #1 to 'math.random' (interval is empty)", @@ -124,61 +100,72 @@ export const mathApi = new LuaTable({ }), // Basic functions - abs: new LuaBuiltinFunction((_sf, x: number) => Math.abs(x)), - ceil: new LuaBuiltinFunction((_sf, x: number) => Math.ceil(x)), - floor: new LuaBuiltinFunction((_sf, x: number) => Math.floor(x)), - max: new LuaBuiltinFunction((_sf, ...args: number[]) => Math.max(...args)), - min: new LuaBuiltinFunction((_sf, ...args: number[]) => Math.min(...args)), + abs: new LuaBuiltinFunction((_sf, x: number) => Math.abs(untagNumber(x))), + ceil: new LuaBuiltinFunction((_sf, x: number) => Math.ceil(untagNumber(x))), + floor: new LuaBuiltinFunction((_sf, x: number) => Math.floor(untagNumber(x))), + max: new LuaBuiltinFunction((_sf, ...args: number[]) => + Math.max(...args.map(untagNumber)) + ), + min: new LuaBuiltinFunction((_sf, ...args: number[]) => + Math.min(...args.map(untagNumber)) + ), // Rounding and remainder - fmod: new LuaBuiltinFunction((_sf, x: number, y: number) => x % y), + fmod: new LuaBuiltinFunction((_sf, x: number, y: number) => + untagNumber(x) % untagNumber(y) + ), modf: new LuaBuiltinFunction((_sf, x: number) => { - const int = Math.floor(x); - const frac = x - int; - return new LuaTable([int, frac]); + const xn = untagNumber(x); + const int = Math.trunc(xn); + const frac = xn - int; + return new LuaMultiRes([int, frac]); }), // Power and logarithms - exp: new LuaBuiltinFunction((_sf, x: number) => Math.exp(x)), + exp: new LuaBuiltinFunction((_sf, x: number) => Math.exp(untagNumber(x))), log: new LuaBuiltinFunction((_sf, x: number, base?: number) => { if (base === undefined) { - return Math.log(x); + return Math.log(untagNumber(x)); } - return Math.log(x) / Math.log(base); + return Math.log(untagNumber(x)) / Math.log(untagNumber(base)); }), - pow: new LuaBuiltinFunction((_sf, x: number, y: number) => Math.pow(x, y)), - sqrt: new LuaBuiltinFunction((_sf, x: number) => Math.sqrt(x)), + pow: new LuaBuiltinFunction((_sf, x: number, y: number) => + Math.pow(untagNumber(x), untagNumber(y)) + ), + sqrt: new LuaBuiltinFunction((_sf, x: number) => Math.sqrt(untagNumber(x))), // Trigonometric functions - cos: new LuaBuiltinFunction((_sf, x: number) => Math.cos(x)), - sin: new LuaBuiltinFunction((_sf, x: number) => Math.sin(x)), - tan: new LuaBuiltinFunction((_sf, x: number) => Math.tan(x)), - acos: new LuaBuiltinFunction((_sf, x: number) => Math.acos(x)), - asin: new LuaBuiltinFunction((_sf, x: number) => Math.asin(x)), + cos: new LuaBuiltinFunction((_sf, x: number) => Math.cos(untagNumber(x))), + sin: new LuaBuiltinFunction((_sf, x: number) => Math.sin(untagNumber(x))), + tan: new LuaBuiltinFunction((_sf, x: number) => Math.tan(untagNumber(x))), + acos: new LuaBuiltinFunction((_sf, x: number) => Math.acos(untagNumber(x))), + asin: new LuaBuiltinFunction((_sf, x: number) => Math.asin(untagNumber(x))), atan: new LuaBuiltinFunction((_sf, y: number, x?: number) => { if (x === undefined) { - return Math.atan(y); + return Math.atan(untagNumber(y)); } - return Math.atan2(y, x); + return Math.atan2(untagNumber(y), untagNumber(x)); }), // Hyperbolic functions - cosh: new LuaBuiltinFunction((_sf, x: number) => Math.cosh(x)), - sinh: new LuaBuiltinFunction((_sf, x: number) => Math.sinh(x)), - tanh: new LuaBuiltinFunction((_sf, x: number) => Math.tanh(x)), + cosh: new LuaBuiltinFunction((_sf, x: number) => Math.cosh(untagNumber(x))), + sinh: new LuaBuiltinFunction((_sf, x: number) => Math.sinh(untagNumber(x))), + tanh: new LuaBuiltinFunction((_sf, x: number) => Math.tanh(untagNumber(x))), // Additional utility - deg: new LuaBuiltinFunction((_sf, x: number) => x * 180 / Math.PI), - rad: new LuaBuiltinFunction((_sf, x: number) => x * Math.PI / 180), + deg: new LuaBuiltinFunction((_sf, x: number) => + untagNumber(x) * 180 / Math.PI + ), + rad: new LuaBuiltinFunction((_sf, x: number) => + untagNumber(x) * Math.PI / 180 + ), ult: new LuaBuiltinFunction((_sf, m: number, n: number) => { - // Unsigned less than comparison - return (m >>> 0) < (n >>> 0); + return (untagNumber(m) >>> 0) < (untagNumber(n) >>> 0); }), // Keep the cosineSimilarity utility function cosineSimilarity: new LuaBuiltinFunction( (sf, vecA: LuaTable | number[], vecB: LuaTable | number[]) => { - // Convert LuaTable to number[] if (vecA instanceof LuaTable) { vecA = vecA.toJSArray(); } diff --git a/client/space_lua/stdlib/math_test.lua b/client/space_lua/stdlib/math_test.lua index ac06e240..5c74eb9f 100644 --- a/client/space_lua/stdlib/math_test.lua +++ b/client/space_lua/stdlib/math_test.lua @@ -1,48 +1,163 @@ -local function assertEqual(a, b) +local function assert_eq(a, b, msg) if a ~= b then - error("Assertion failed: " .. a .. " is not equal to " .. b) + error((msg or "assert_eq failed") .. ": expected " .. tostring(b) .. " got " .. tostring(a)) end end --- Trigonometric functions -assertEqual(math.cos(0), 1) -assertEqual(math.sin(0), 0) -assertEqual(math.tan(0), 0) -assertEqual(math.acos(1), 0) -assertEqual(math.asin(0), 0) -assertEqual(math.atan(0), 0) +local function assert_true(v, msg) + if not v then + error(msg or "assert_true failed") + end +end --- Hyperbolic functions -assertEqual(math.cosh(0), 1) -assertEqual(math.sinh(0), 0) -assertEqual(math.tanh(0), 0) +local function assert_close(a, b, eps, msg) + eps = eps or 1e-12 + if a ~= a and b ~= b then + return + end + if a == b then + return + end + local d = a - b + if d < 0 then d = -d end + if d > eps then + error((msg or "assert_close failed") .. ": expected " .. tostring(b) .. " got " .. tostring(a)) + end +end --- Basic functions -assertEqual(math.abs(-5), 5) -assertEqual(math.ceil(3.3), 4) -assertEqual(math.floor(3.7), 3) -assertEqual(math.max(1, 2, 3, 4), 4) -assertEqual(math.min(1, 2, 3, 4), 1) +-- math.type (basic) +do + assert_eq(math.type(0), "integer", "math.type(0)") + assert_eq(math.type(1), "integer", "math.type(1)") + assert_eq(math.type(0.0), "float", "math.type(0.0)") + assert_eq(math.type(-0.0), "float", "math.type(-0.0)") + assert_eq(math.type(1.5), "float", "math.type(1.5)") +end --- Rounding and remainder -assertEqual(math.fmod(7, 3), 1) +-- constants +do + assert_true(type(math.pi) == "number", "math.pi exists") + assert_true(type(math.huge) == "number", "math.huge exists") +end --- Power and logarithms -assertEqual(math.exp(0), 1) -assertEqual(math.log(math.exp(1)), 1) -assertEqual(math.log(8, 2), 3) -- log base 2 of 8 -assertEqual(math.pow(2, 3), 8) -assertEqual(math.sqrt(9), 3) +-- abs +do + assert_eq(math.abs(-5), 5, "abs(-5)") + assert_eq(math.abs(-3), 3, "abs(-3)") + assert_eq(math.abs(3), 3, "abs(3)") + assert_close(math.abs(-3.25), 3.25, 1e-12, "abs(-3.25)") +end +-- floor/ceil (rounding correctness) +do + assert_eq(math.floor(3.7), 3, "floor(3.7)") + assert_eq(math.floor(3), 3, "floor(3)") + assert_eq(math.floor(3.0), 3, "floor(3.0)") + assert_eq(math.floor(3.1), 3, "floor(3.1)") + assert_eq(math.floor(3.9), 3, "floor(3.9)") + assert_eq(math.floor(-3.1), -4, "floor(-3.1)") + assert_eq(math.floor(-3.9), -4, "floor(-3.9)") --- Random number tests (basic range checks) -local rand = math.random() -assertEqual(rand >= 0 and rand < 1, true) -local randN = math.random(10) -assertEqual(randN >= 1 and randN <= 10, true) -local randRange = math.random(5, 10) -assertEqual(randRange >= 5 and randRange <= 10, true) + assert_eq(math.ceil(3.3), 4, "ceil(3.3)") + assert_eq(math.ceil(3), 3, "ceil(3)") + assert_eq(math.ceil(3.0), 3, "ceil(3.0)") + assert_eq(math.ceil(3.1), 4, "ceil(3.1)") + assert_eq(math.ceil(3.9), 4, "ceil(3.9)") + assert_eq(math.ceil(-3.1), -3, "ceil(-3.1)") + assert_eq(math.ceil(-3.9), -3, "ceil(-3.9)") +end --- Unsigned less than comparison -assertEqual(math.ult(1, 2), true) -assertEqual(math.ult(2, 1), false) +-- min/max +do + assert_eq(math.max(1, 2, 3, 4), 4, "max(1,2,3,4)") + assert_eq(math.min(1, 2, 3, 4), 1, "min(1,2,3,4)") + + assert_eq(math.max(1, 2, 3), 3, "max") + assert_eq(math.min(1, 2, 3), 1, "min") + assert_eq(math.max(-1, -2), -1, "max negative") + assert_eq(math.min(-1, -2), -2, "min negative") +end + +-- modf +do + local i, f = math.modf(3.5) + assert_eq(i, 3, "modf(3.5) int") + assert_close(f, 0.5, 1e-12, "modf(3.5) frac") + + local i2, f2 = math.modf(-3.5) + assert_eq(i2, -3, "modf(-3.5) int") + assert_close(f2, -0.5, 1e-12, "modf(-3.5) frac") +end + +-- sqrt/exp/log/pow +do + assert_eq(math.exp(0), 1, "exp(0)") + assert_close(math.log(math.exp(1)), 1, 1e-12, "log(exp(1))") + assert_close(math.log(8, 2), 3, 1e-12, "log base 2 of 8") + assert_eq(math.pow(2, 3), 8, "pow(2,3)") + assert_eq(math.sqrt(9), 3, "sqrt(9)") +end + +-- trigonomic +do + assert_eq(math.cos(0), 1, "cos(0)") + assert_eq(math.sin(0), 0, "sin(0)") + assert_eq(math.tan(0), 0, "tan(0)") + assert_eq(math.acos(1), 0, "acos(1)") + assert_eq(math.asin(0), 0, "asin(0)") + assert_eq(math.atan(0), 0, "atan(0)") + assert_eq(math.atan(0, 1), 0, "atan(0,1)") +end + +-- hyperbolic +do + assert_eq(math.cosh(0), 1, "cosh(0)") + assert_eq(math.sinh(0), 0, "sinh(0)") + assert_eq(math.tanh(0), 0, "tanh(0)") +end + +-- remainder +do + assert_eq(math.fmod(7, 3), 1, "fmod(7,3)") +end + +-- random +do + local a1 = math.random() + assert_true(a1 >= 0 and a1 < 1, "random() range") + + local aN = math.random(10) + assert_true(aN >= 1 and aN <= 10, "random(10) range") + + local aR = math.random(5, 10) + assert_true(aR >= 5 and aR <= 10, "random(5,10) range") +end + +-- unsigned less-than +do + assert_eq(math.ult(1, 2), true, "ult(1,2)") + assert_eq(math.ult(2, 1), false, "ult(2,1)") +end + +-- reported regression +do + local x1 = 8 * 0.5 + local x2 = 8 * 0.49 + local x3 = 9 * 0.5 + + assert_close(x1, 4.0, 0, "8*0.5 == 4.0") + assert_close(x2, 3.92, 1e-12, "8*0.49 == 3.92") + assert_close(x3, 4.5, 1e-12, "9*0.5 == 4.5") + + local f1 = math.floor(x1) + local f2 = math.floor(x2) + local f3 = math.floor(x3) + + assert_true(f1 == f1, "floor(8*0.5) not NaN") + assert_true(f2 == f2, "floor(8*0.49) not NaN") + assert_true(f3 == f3, "floor(9*0.5) not NaN") + + assert_eq(f1, 4, "floor(8*0.5)") + assert_eq(f2, 3, "floor(8*0.49)") + assert_eq(f3, 4, "floor(9*0.5)") +end diff --git a/client/space_lua/stdlib/printf.ts b/client/space_lua/stdlib/printf.ts deleted file mode 100644 index d524c2dc..00000000 --- a/client/space_lua/stdlib/printf.ts +++ /dev/null @@ -1,645 +0,0 @@ -// Adapted and simplified from https://github.com/adaltas/node-printf - -/** - * Tokenizes a string by a regular expression with the ability to capture the delimiters - */ -const tokenize = function ( - str: string, - re: RegExp, - parseDelim?: ( - ...args: any[] - ) => any, - instance?: any, -): Array { - // Split a string by a regular expression with the ability to capture the delimiters - // parseDelim: Each group (excluding the 0 group) is passed as a parameter. If the function returns - // a value, it's added to the list of tokens. - // instance: Used as the "this' instance when calling parseDelim - const tokens: Array = []; - let match: RegExpExecArray | null; - let content: string; - let lastIndex = 0; - - while ((match = re.exec(str))) { - content = str.slice(lastIndex, re.lastIndex - match[0].length); - if (content.length) { - tokens.push(content); - } - if (parseDelim) { - const parsed = parseDelim.apply( - instance, - [...match.slice(1), tokens.length], - ); - if (typeof parsed !== "undefined") { - if (parsed.specifier === "%") { - tokens.push("%"); - } else { - tokens.push(parsed); - } - } - } - lastIndex = re.lastIndex; - } - content = str.slice(lastIndex); - if (content.length) { - tokens.push(content); - } - return tokens; -}; - -interface TokenInfo { - mapping?: string; - intmapping?: string; - flags: string; - _minWidth?: string; - period?: string; - _precision?: string; - specifier: string; - arg?: string; - compiled?: boolean; - sign?: string; - zeroPad?: boolean; - rightJustify?: boolean; - alternative?: boolean; - minWidth?: number; - maxWidth?: number; - toUpper?: boolean; - isUnsigned?: boolean; - isInt?: boolean; - isDouble?: boolean; - isObject?: boolean; - precision?: number; - base?: number; - doubleNotation?: string; - setArg?: (token: TokenInfo) => void; - setMaxWidth?: (token: TokenInfo) => void; - extend?: string[]; - - [key: string]: any; // Allow dynamic property access -} - -interface SpecifierInfo { - base?: number; - isInt?: boolean; - extend?: string[]; - toUpper?: boolean; - isUnsigned?: boolean; - setArg?: (token: TokenInfo) => void; - setMaxWidth?: (token: TokenInfo) => void; - isDouble?: boolean; - doubleNotation?: string; - isObject?: boolean; - - [key: string]: any; // Allow dynamic property access -} - -class Formatter { - private _mapped: boolean; - private _format: string; - private _tokens: Array; - private _zeros10: string = "0000000000"; - private _spaces10: string = " "; - private _re: RegExp = - /\%(?:\(([\w_.]+)\)|([1-9]\d*)\$)?([0 +\-\#]*)(\*|\d+)?(?:(\.)(\*|\d+)?)?[hlL]?([\%bscdeEfFgGioOuxX])/g; - private _specifiers: Record = { - b: { - base: 2, - isInt: true, - }, - o: { - base: 8, - isInt: true, - }, - x: { - base: 16, - isInt: true, - }, - X: { - extend: ["x"], - toUpper: true, - }, - d: { - base: 10, - isInt: true, - }, - i: { - extend: ["d"], - }, - u: { - extend: ["d"], - isUnsigned: true, - }, - c: { - setArg: function (token: TokenInfo): void { - if (!isNaN(Number(token.arg))) { - const num = parseInt(token.arg as string); - if (num < 0 || num > 127) { - throw new Error("invalid character code passed to %c in printf"); - } - token.arg = isNaN(num) ? "" + num : String.fromCharCode(num); - } - }, - }, - s: { - setMaxWidth: function (token: TokenInfo): void { - token.maxWidth = (token.period === ".") ? token.precision ?? -1 : -1; - }, - }, - e: { - isDouble: true, - doubleNotation: "e", - }, - E: { - extend: ["e"], - toUpper: true, - }, - f: { - isDouble: true, - doubleNotation: "f", - }, - F: { - extend: ["f"], - }, - g: { - isDouble: true, - doubleNotation: "g", - }, - G: { - extend: ["g"], - toUpper: true, - }, - O: { - isObject: true, - }, - }; - - constructor(format: string) { - this._mapped = false; - this._format = format; - this._tokens = tokenize(format, this._re, this._parseDelim, this); - } - - // The old regexp `/\%(?:\(([\w_.]+)\)|([1-9]\d*)\$)?([0 +\-\#]*)(\*|\d+)?(\.)?(\*|\d+)?[hlL]?([\%bscdeEfFgGioOuxX])/` has a cubic worst-case time complexity behavior due to overlapping capture groups `([0 +\-\#]*)(\*|\d+)?(\.)?(\*|\d+)?`. And a pump string of 0 can be consumed by `([0 +\-\#]*), (\*|\d+)?, or (\*|\d+)?`. - // The solution replace the sub-regexp (\*|\d+)?(\.)?(\*|\d+)? with the sub-regexp `(\*|\d+)?(?:(\.)(\*|\d+)?)?`, see the figure in [#32](https://github.com/adaltas/node-printf/pull/32) - // There are also performance improvement, see in [#31](https://github.com/adaltas/node-printf/issues/31#issuecomment-776731490) - - format(...args: any[]): string { - if (this._mapped && typeof args[0] !== "object") { - throw new Error("format requires a mapping"); - } - - let str = ""; - let position = 0; - for (let i = 0; i < this._tokens.length; i++) { - const token = this._tokens[i]; - - if (typeof token === "string") { - str += token; - } else { - if (this._mapped) { - // Identify value of property defined in `token.mapping` - const tokens = token.mapping!.split("."); - let value = args[0]; - for (let j = 0, c = tokens.length; j < c; j++) { - value = value[tokens[j]]; - if (typeof value === "undefined") { - break; - } - } - if (typeof value === "undefined") { - throw new Error("missing key '" + token.mapping + "'"); - } - token.arg = String(value); - } else { - if (token.intmapping) { - position = parseInt(token.intmapping) - 1; - } - if (position >= args.length) { - throw new Error( - "got " + args.length + - " printf arguments, insufficient for '" + this._format + "'", - ); - } - token.arg = String(args[position++]); - } - - if (!token.compiled) { - token.compiled = true; - token.sign = ""; - token.zeroPad = false; - token.rightJustify = false; - token.alternative = false; - - const flags: Record = {}; - for (let fi = token.flags.length; fi--;) { - const flag = token.flags.charAt(fi); - flags[flag] = true; - switch (flag) { - case " ": - token.sign = " "; - break; - case "+": - token.sign = "+"; - break; - case "0": - token.zeroPad = (flags["-"]) ? false : true; - break; - case "-": - token.rightJustify = true; - token.zeroPad = false; - break; - case "#": - token.alternative = true; - break; - default: - throw Error( - "bad formatting flag '" + token.flags.charAt(fi) + "'", - ); - } - } - - token.minWidth = (token._minWidth) ? parseInt(token._minWidth) : 0; - token.maxWidth = -1; - token.toUpper = false; - token.isUnsigned = false; - token.isInt = false; - token.isDouble = false; - token.isObject = false; - token.precision = 1; - if (token.period === ".") { - if (token._precision) { - token.precision = parseInt(token._precision); - } else { - token.precision = 0; - } - } - - const mixins = this._specifiers[token.specifier]; - if (typeof mixins === "undefined") { - throw new Error("unexpected specifier '" + token.specifier + "'"); - } - if (mixins.extend) { - const s = this._specifiers[mixins.extend[0]]; - for (const k in s) { - mixins[k] = s[k]; - } - delete mixins.extend; - } - for (const l in mixins) { - token[l] = mixins[l]; - } - } - - if (typeof token.setArg === "function") { - token.setArg(token); - } - - if (typeof token.setMaxWidth === "function") { - token.setMaxWidth(token); - } - - if (token._minWidth === "*") { - if (this._mapped) { - throw new Error("* width not supported in mapped formats"); - } - token.minWidth = parseInt(String(args[position++])); - if (isNaN(token.minWidth)) { - throw new Error( - "the argument for * width at position " + position + - " is not a number in " + this._format, - ); - } - // negative width means rightJustify - if (token.minWidth < 0) { - token.rightJustify = true; - token.minWidth = -token.minWidth; - } - } - - if (token._precision === "*" && token.period === ".") { - if (this._mapped) { - throw new Error("* precision not supported in mapped formats"); - } - token.precision = parseInt(String(args[position++])); - if (isNaN(token.precision)) { - throw Error( - "the argument for * precision at position " + position + - " is not a number in " + this._format, - ); - } - // negative precision means unspecified - if (token.precision < 0) { - token.precision = 1; - token.period = ""; - } - } - if (token.isInt) { - // a specified precision means no zero padding - if (token.period === ".") { - token.zeroPad = false; - } - this.formatInt(token); - } else if (token.isDouble) { - if (token.period !== ".") { - token.precision = 6; - } - this.formatDouble(token); - } else if (token.isObject) { - this.formatObject(token); - } - this.fitField(token); - str += "" + token.arg; - } - } - - return str; - } - - formatInt(token: TokenInfo): void { - let i = parseInt(token.arg as string); - if (!isFinite(i)) { // isNaN(f) || f == Number.POSITIVE_INFINITY || f == Number.NEGATIVE_INFINITY) - // allow this only if arg is number - if (typeof token.arg !== "number" && typeof token.arg !== "string") { - throw new Error( - "format argument '" + token.arg + - "' not an integer; parseInt returned " + i, - ); - } - //return '' + i; - i = 0; - } - - // if not base 10, make negatives be positive - // otherwise, (-10).toString(16) is '-a' instead of 'fffffff6' - if (i < 0 && (token.isUnsigned || token.base !== 10)) { - i = 0xffffffff + i + 1; - } - - if (i < 0) { - token.arg = (-i).toString(token.base); - this.zeroPad(token); - token.arg = "-" + token.arg; - } else { - token.arg = i.toString(token.base); - // need to make sure that argument 0 with precision==0 is formatted as '' - if (!i && !token.precision) { - token.arg = ""; - } else { - this.zeroPad(token); - } - if (token.sign) { - token.arg = token.sign + token.arg; - } - } - if (token.base === 16) { - if (token.alternative) { - token.arg = "0x" + token.arg; - } - token.arg = token.toUpper - ? token.arg.toUpperCase() - : token.arg.toLowerCase(); - } - if (token.base === 8) { - if (token.alternative && token.arg.charAt(0) !== "0") { - token.arg = "0" + token.arg; - } - } - } - - formatDouble(token: TokenInfo): void { - let f = parseFloat(token.arg as string); - if (!isFinite(f)) { // isNaN(f) || f == Number.POSITIVE_INFINITY || f == Number.NEGATIVE_INFINITY) - // allow this only if arg is number - if (typeof token.arg !== "number" && typeof token.arg !== "string") { - throw new Error( - "format argument '" + token.arg + - "' not a float; parseFloat returned " + f, - ); - } - // C99 says that for 'f': - // infinity -> '[-]inf' or '[-]infinity' ('[-]INF' or '[-]INFINITY' for 'F') - // NaN -> a string starting with 'nan' ('NAN' for 'F') - // this is not commonly implemented though. - //return '' + f; - f = 0; - } - - switch (token.doubleNotation) { - case "e": { - token.arg = f.toExponential(token.precision ?? 1); - break; - } - case "f": { - token.arg = f.toFixed(token.precision ?? 1); - break; - } - case "g": { - // C says use 'e' notation if exponent is < -4 or is >= prec - // ECMAScript for toPrecision says use exponential notation if exponent is >= prec, - // though step 17 of toPrecision indicates a test for < -6 to force exponential. - if (Math.abs(f) < 0.0001) { - //print('forcing exponential notation for f=' + f); - token.arg = f.toExponential( - (token.precision ?? 1) > 0 - ? (token.precision ?? 1) - 1 - : (token.precision ?? 1), - ); - } else { - token.arg = f.toPrecision(token.precision ?? 1); - } - - // In C, unlike 'f', 'gG' removes trailing 0s from fractional part, unless alternative format flag ('#'). - // But ECMAScript formats toPrecision as 0.00100000. So remove trailing 0s. - if (!token.alternative) { - //print('replacing trailing 0 in \'' + s + '\''); - token.arg = token.arg.replace(/(\..*[^0])0*e/, "$1e"); - // if fractional part is entirely 0, remove it and decimal point - token.arg = token.arg.replace(/\.0*e/, "e").replace(/\.0$/, ""); - } - break; - } - default: - throw new Error( - "unexpected double notation '" + token.doubleNotation + "'", - ); - } - - // C says that exponent must have at least two digits. - // But ECMAScript does not; toExponential results in things like '1.000000e-8' and '1.000000e+8'. - // Note that s.replace(/e([\+\-])(\d)/, 'e$10$2') won't work because of the '$10' instead of '$1'. - // And replace(re, func) isn't supported on IE50 or Safari1. - token.arg = token.arg.replace(/e\+(\d)$/, "e+0$1").replace( - /e\-(\d)$/, - "e-0$1", - ); - - // if alt, ensure a decimal point - if (token.alternative) { - token.arg = token.arg.replace(/^(\d+)$/, "$1."); - token.arg = token.arg.replace(/^(\d+)e/, "$1.e"); - } - - if (f >= 0 && token.sign) { - token.arg = token.sign + token.arg; - } - - token.arg = token.toUpper - ? token.arg.toUpperCase() - : token.arg.toLowerCase(); - } - - formatObject(token: TokenInfo): void { - // Simple object formatting without util.inspect - if (token.arg === null) { - token.arg = "null"; - return; - } - - if (token.arg === undefined) { - token.arg = "undefined"; - return; - } - - // Use JSON.stringify with indentation for objects - try { - // Limit depth based on precision if specified - const maxDepth = token.period === "." && token.precision !== undefined - ? token.precision - : 2; - - // Create a simplified version of the object with limited depth - const simplifiedObj = this.limitObjectDepth(token.arg, maxDepth); - - // Format with indentation if alternative flag is set - token.arg = JSON.stringify( - simplifiedObj, - null, - token.alternative ? 2 : 0, - ); - } catch { - // Fallback for circular references or other JSON.stringify errors - token.arg = String(token.arg); - } - } - - zeroPad(token: TokenInfo, length?: number): void { - length = (arguments.length === 2) ? length : token.precision; - let negative = false; - if (typeof token.arg !== "string") { - token.arg = "" + token.arg; - } - if (token.arg.substr(0, 1) === "-") { - negative = true; - token.arg = token.arg.substr(1); - } - - const tenless = (length || 0) - 10; - while (token.arg.length < tenless) { - token.arg = (token.rightJustify) - ? token.arg + this._zeros10 - : this._zeros10 + token.arg; - } - const pad = (length || 0) - token.arg.length; - token.arg = (token.rightJustify) - ? token.arg + this._zeros10.substring(0, pad) - : this._zeros10.substring(0, pad) + token.arg; - if (negative) token.arg = "-" + token.arg; - } - - fitField(token: TokenInfo): void { - if ( - token.maxWidth !== undefined && token.maxWidth >= 0 && - token.arg!.length > token.maxWidth - ) { - token.arg = token.arg!.substring(0, token.maxWidth); - } - if (token.zeroPad) { - this.zeroPad(token, token.minWidth); - return; - } - this.spacePad(token); - } - - spacePad(token: TokenInfo, length?: number): void { - length = (arguments.length === 2) ? length : token.minWidth; - if (typeof token.arg !== "string") { - token.arg = "" + token.arg; - } - const tenless = (length || 0) - 10; - while (token.arg.length < tenless) { - token.arg = (token.rightJustify) - ? token.arg + this._spaces10 - : this._spaces10 + token.arg; - } - const pad = (length || 0) - token.arg.length; - token.arg = (token.rightJustify) - ? token.arg + this._spaces10.substring(0, pad) - : this._spaces10.substring(0, pad) + token.arg; - } - - private _parseDelim( - mapping?: string, - intmapping?: string, - flags?: string, - minWidth?: string, - period?: string, - precision?: string, - specifier?: string, - ): TokenInfo { - if (mapping) { - this._mapped = true; - } - return { - mapping, - intmapping, - flags: flags || "", - _minWidth: minWidth, - period, - _precision: precision, - specifier: specifier || "", - }; - } - - // Helper method to limit object depth for formatObject - private limitObjectDepth(obj: any, maxDepth: number, currentDepth = 0): any { - if (currentDepth >= maxDepth) { - if (Array.isArray(obj)) { - return "[Array]"; - } else if (typeof obj === "object" && obj !== null) { - return "[Object]"; - } - return obj; - } - - if (Array.isArray(obj)) { - return obj.map((item) => - this.limitObjectDepth(item, maxDepth, currentDepth + 1) - ); - } else if (typeof obj === "object" && obj !== null) { - const result: Record = {}; - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - result[key] = this.limitObjectDepth( - obj[key], - maxDepth, - currentDepth + 1, - ); - } - } - return result; - } - - return obj; - } -} - -/** - * Printf-style string formatting function - * @param format The format string - * @param args Values to be formatted - * @returns Formatted string - */ -export default function (format: string, ...args: any[]): string { - const formatter = new Formatter(format); - return formatter.format(...args); -} diff --git a/client/space_lua/stdlib/space_lua.ts b/client/space_lua/stdlib/space_lua.ts index 8695ead5..b5747ae1 100644 --- a/client/space_lua/stdlib/space_lua.ts +++ b/client/space_lua/stdlib/space_lua.ts @@ -84,10 +84,9 @@ export async function interpolateLuaString( try { const parsedExpr = parseExpressionString(expr); const env = createAugmentedEnv(sf, envAugmentation); - const luaResult = await luaValueToJS( - singleResult(await evalExpression(parsedExpr, env, sf)), - sf, - ); + // Do `luaToString` before `luaValueToJS` to preserve tagged float + // formatting. + const luaResult = singleResult(await evalExpression(parsedExpr, env, sf)); result += await luaToString(luaResult); } catch (e: any) { throw new LuaRuntimeError( diff --git a/client/space_lua/stdlib/string.ts b/client/space_lua/stdlib/string.ts index bbf9d1fe..43a945d8 100644 --- a/client/space_lua/stdlib/string.ts +++ b/client/space_lua/stdlib/string.ts @@ -6,7 +6,8 @@ import { LuaTable, luaToString, } from "../runtime.ts"; -import printf from "./printf.ts"; +import { untagNumber } from "../numeric.ts"; +import { luaFormat } from "./format.ts"; // Bits and pieces borrowed from https://github.com/paulcuth/starlight/blob/master/src/runtime/lib/string.js @@ -112,7 +113,8 @@ export const stringApi = new LuaTable({ }, ), format: new LuaBuiltinFunction((_sf, format: string, ...args: any[]) => { - return printf(format, ...args); + // Unwrap tagged floats so `luaFormat` sees plain numbers + return luaFormat(format, ...args.map(untagNumber)); }), gmatch: new LuaBuiltinFunction((_sf, s: string, pattern: string) => { pattern = translatePattern(pattern); @@ -216,7 +218,8 @@ export const stringApi = new LuaTable({ if (!matches) { return null; - } else if (matches[1] === undefined) { + } + if (matches[1] === undefined) { // No captures return matches[0]; } diff --git a/client/space_lua/stdlib/table.ts b/client/space_lua/stdlib/table.ts index 2f3f8b37..c0aba789 100644 --- a/client/space_lua/stdlib/table.ts +++ b/client/space_lua/stdlib/table.ts @@ -1,4 +1,5 @@ import { + getMetatable, type ILuaFunction, LuaBuiltinFunction, luaCall, @@ -7,11 +8,45 @@ import { 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({ /** @@ -23,20 +58,58 @@ export const tableApi = new LuaTable({ * @returns The concatenated string. */ concat: new LuaBuiltinFunction( - (_sf, tbl: LuaTable | any[], sep?: string, i?: number, j?: number) => { + async (sf, tbl: LuaTable | any[], sep?: string, i?: number, j?: number) => { sep = sep ?? ""; i = i ?? 1; - j = j ?? tbl.length; + 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 String(v); + } + if (isTaggedFloat(v)) { + return String(v.value); + } + + 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)) { - return tbl.slice(i - 1, j).join(sep); + 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 result = []; + + const out: string[] = []; for (let k = i; k <= j; k++) { - result.push(tbl.get(k)); + const v = await luaGet(tbl, k, sf.astCtx ?? null, sf); + out.push(luaConcatElemToString(v, k)); } - return result.join(sep); + return out.join(sep); }, ), + /** * Inserts an element into a table at a specified position. * @param tbl - The table to insert the element into. @@ -44,7 +117,12 @@ export const tableApi = new LuaTable({ * @param value - The value to insert. */ insert: new LuaBuiltinFunction( - (sf, tbl: LuaTable | any[], posOrValue: number | any, value?: any) => { + 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 @@ -53,28 +131,79 @@ export const tableApi = new LuaTable({ } else { tbl.splice(posOrValue - 1, 0, luaValueToJS(value, sf)); } - } else if (tbl instanceof LuaTable) { - if (value === undefined) { - value = posOrValue; - posOrValue = tbl.length + 1; - } - tbl.insert(value, posOrValue); + 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); }, ), + /** * 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((_sf, tbl: LuaTable | any[], pos?: number) => { - pos = pos ?? tbl.length; - if (Array.isArray(tbl)) { - tbl.splice(pos - 1, 1); - } else if (tbl instanceof LuaTable) { - tbl.remove(pos); - } - }), + remove: new LuaBuiltinFunction( + 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; + }, + ), + /** * Sorts a table. * @param tbl - The table to sort. @@ -84,19 +213,59 @@ export const tableApi = new LuaTable({ sort: new LuaBuiltinFunction( async (sf, tbl: LuaTable | any[], comp?: ILuaFunction) => { if (Array.isArray(tbl)) { - tbl = await asyncQuickSort(tbl, async (a, b) => { + return await asyncQuickSort(tbl, async (a, b) => { if (comp) { - return (await comp.call(sf, a, b)) ?? 0; - } else { - return a - b; + return (await comp.call(sf, a, b)) ? -1 : 1; } + return (a as any) < (b as any) ? -1 : 1; }); - } else { - await tbl.sort(comp, sf); } + + 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 : 1; + } + + 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; }, ), + /** * Returns the keys of a table. * Note: Space Lua specific @@ -106,10 +275,10 @@ export const tableApi = new LuaTable({ keys: new LuaBuiltinFunction((_sf, tbl: LuaTable | LuaEnv | any) => { if (tbl.keys) { return tbl.keys(); - } else { - return Object.keys(tbl); } + return Object.keys(tbl); }), + /** * Checks if a table (used as an array) contains a value. * Note: Space Lua specific @@ -130,16 +299,17 @@ export const tableApi = new LuaTable({ } } return false; - } else if (Array.isArray(tbl)) { - return !!tbl.find((item) => luaEquals(item, value)); - } else { - throw new LuaRuntimeError( - `Cannot use includes on a non-table or non-array value`, - sf, - ); } + 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, + ); }, ), + /** * Returns a new table from an old one, only with selected keys * @param tbl a Lua table or JS object @@ -163,11 +333,11 @@ export const tableApi = new LuaTable({ const promised = evalPromiseValues(setPromises); if (promised instanceof Promise) { return promised.then(() => resultTable); - } else { - return resultTable; } + return resultTable; }, ), + pack: new LuaBuiltinFunction((_sf, ...args: any[]) => { const tbl = new LuaTable(); for (let i = 0; i < args.length; i++) { @@ -176,25 +346,24 @@ export const tableApi = new LuaTable({ tbl.set("n", args.length); return tbl; }), + unpack: new LuaBuiltinFunction( - (_sf, tbl: LuaTable | any[], i?: number, j?: number) => { - if (Array.isArray(tbl)) { - i = i ?? 1; - j = j ?? tbl.length; - const result = []; - for (let k = i; k <= j; k++) { - result.push(tbl[k - 1]); - } - return new LuaMultiRes(result); - } else { - i = i ?? 1; - j = j ?? tbl.length; - const result = []; - for (let k = i; k <= j; k++) { - result.push(tbl.get(k)); - } - return new LuaMultiRes(result); + async (sf, tbl: LuaTable | any[], i?: number, j?: number) => { + i = i ?? 1; + if (j === undefined || j === null) { + j = Array.isArray(tbl) + ? tbl.length + : await luaLenForTableLibAsync(sf, tbl); } + + 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); }, ), @@ -217,7 +386,10 @@ export const tableApi = new LuaTable({ return null; } const startIndex = fromIndex < 1 ? 1 : fromIndex; - for (let i = startIndex; i <= tbl.length; i++) { + 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]); diff --git a/client/space_lua/stdlib/table_test.lua b/client/space_lua/stdlib/table_test.lua index dda64a1e..b0e09e2b 100644 --- a/client/space_lua/stdlib/table_test.lua +++ b/client/space_lua/stdlib/table_test.lua @@ -1,26 +1,21 @@ -function deepCompare(t1, t2) +local function deepCompare(t1, t2, seen) if t1 == t2 then return true end - -- If they are the same object, return true if type(t1) ~= "table" or type(t2) ~= "table" then return false end - -- If not both tables, return false - -- Check if both tables have the same number of keys - local t1_keys = 0 - local t2_keys = 0 - for k in pairs(t1) do - t1_keys = t1_keys + 1 - end - for k in pairs(t2) do - t2_keys = t2_keys + 1 - end - if t1_keys ~= t2_keys then return false end - -- Recursively compare each key-value pair + seen = seen or {} + if seen[t1] and seen[t1] == t2 then return true end + seen[t1] = t2 + + local n1, n2 = 0, 0 + for _ in pairs(t1) do n1 = n1 + 1 end + for _ in pairs(t2) do n2 = n2 + 1 end + if n1 ~= n2 then return false end + for k, v in pairs(t1) do - if not deepCompare(v, t2[k]) then + if not deepCompare(v, t2[k], seen) then return false end end - return true end @@ -30,163 +25,233 @@ local function assertEqual(a, b) end end - - --- Basic table operations -local t = { 1, 2, 3 } -table.insert(t, 4) -assertEqual(t[4], 4) -table.remove(t, 1) -table.remove(t) -assertEqual(t[2], 3) -assertEqual(#t, 2) - -t = js.tojs({ 1, 2, 3 }) -table.insert(t, 4) -assertEqual(t[4], 4) -table.remove(t, 1) -table.remove(t) -assertEqual(t[2], 3) -assertEqual(#t, 2) - - --- Test concat -assertEqual(table.concat({ "Hello", "world" }, " "), "Hello world") -assertEqual(table.concat({ "Hello", "world", "three" }, " ", 2, 3), "world three") - --- Test with JavaScript array -assertEqual(table.concat(js.tojs({ "Hello", "world", "three" }), " ", 2, 3), "world three") - --- Table sorting -local t = { 3, 1, 2 } -table.sort(t) -assertEqual(t[1], 1) -assertEqual(t[2], 2) -assertEqual(t[3], 3) - --- Table sorting with custom comparator -table.sort(t, function(a, b) - return a > b -end) -assertEqual(t[1], 3) -assertEqual(t[2], 2) -assertEqual(t[3], 1) - --- Table sorting with complex objects -local data = { { name = "John", age = 30 }, { name = "Jane", age = 25 } } -table.sort(data, function(a, b) - return a.age < b.age -end) -assertEqual(data[1].name, "Jane") -assertEqual(data[2].name, "John") - --- Now the same with js.tojs -local data = js.tojs { 1, 3, 2 } -table.sort(data) -assertEqual(data[1], 1) -assertEqual(data[2], 2) -assertEqual(data[3], 3) - -local data = js.tojs { { name = "John", age = 30 }, { name = "Jane", age = 25 } } -table.sort(data, function(a, b) - return a.age < b.age -end) -assertEqual(data[1].name, "Jane") -assertEqual(data[2].name, "John") - - --- ipairs tests -local p = ipairs({ 3, 2, 1 }) -local idx, value = p() -assert(idx == 1 and value == 3) -idx, value = p() -assert(idx == 2 and value == 2) -idx, value = p() -assert(idx == 3 and value == 1) -idx, value = p() -assert(idx == nil and value == nil) - -for index, value in ipairs({ 1, 2, 3 }) do - assert(index == value) +local function assertTrue(v, msg) + if not v then error(msg or "Assertion failed") end end --- pairs tests -local p = pairs({ a = 1, b = 2, c = 3 }) -local key, value = p() -assert(key == "a" and value == 1) -key, value = p() -assert(key == "b" and value == 2) -key, value = p() -assert(key == "c" and value == 3) -key, value = p() -assert(key == nil and value == nil) - -for key, value in pairs({ a = "a", b = "b" }) do - assertEqual(key, value) +local function assertFalse(v, msg) + if v then error(msg or "Assertion failed (expected false)") end end --- for in over tables directly -local cnt = 1 -for val in { 1, 2, 3 } do - assertEqual(val, cnt) - cnt = cnt + 1 +local function assertError(fn, msg) + local ok, err = pcall(fn) + if ok then + error(msg or "Assertion failed (expected error)") + end + return err end -assertEqual(cnt, 4) -local cnt = 1 -for val in js.tojs({ 1, 2, 3 }) do - assertEqual(val, cnt) - cnt = cnt + 1 +-- Basic table operations (insert/remove) +do + local t = { 1, 2, 3 } + table.insert(t, 4) + assertEqual(t[4], 4) + + table.insert(t, 1, 99) + assertEqual(t[1], 99) + assertEqual(#t, 5) + + local x = table.remove(t, 1) + assertEqual(x, 99) + + table.remove(t) -- remove last + assertEqual(#t, 3) + assertEqual(t, { 1, 2, 3 }) + + -- remove out of range should error + assertError(function() table.remove(t, 0) end) + assertError(function() table.remove(t, #t + 2) end) end -assertEqual(cnt, 4) --- Table keys tests -local t = { a = 1, b = 2, c = 3 } -local keys = table.keys(t) -assert(table.includes(keys, "a")) -assert(table.includes(keys, "b")) -assert(table.includes(keys, "c")) +-- Reference semantics / aliasing (tables are mutable references) +do + local a = { x = 1 } + local b = a + b.x = 2 + assertEqual(a.x, 2) --- Table includes tests with different value types -local t = { 1, 2, "three", true } -assert(table.includes(t, 1)) -assert(table.includes(t, "three")) -assert(table.includes(t, true)) -assert(not table.includes(t, "missing")) + local nested = { inner = { 1, 2 } } + local alias = nested.inner + alias[1] = 10 + assertEqual(nested.inner[1], 10) +end --- Test using table as set -local s = {} -s["something"] = true -assertEqual(#table.keys(s), 1) --- Remove from the map -s["something"] = nil -assertEqual(#table.keys(s), 0) +-- rawget/rawset/rawequal ignore metamethods +do + local backing = {} + local t = setmetatable({}, { + __index = function(_, k) + return backing[k] + end, + __newindex = function(_, k, v) + backing[k] = v + end, + }) --- Error cases -local success, error = pcall(function() - table.includes("not a table", 1) -end) -assert(not success) -assert(string.find(error, "Cannot use includes")) + t.a = 1 + assertEqual(backing.a, 1) + assertEqual(t.a, 1) --- Test pack and unpack -local t = { 1, 2, 3 } -local packed = table.pack(table.unpack(t)) -assertEqual(packed[1], 1) -assertEqual(packed[2], 2) -assertEqual(packed[3], 3) -assertEqual(packed.n, 3) + rawset(t, "a", 2) + assertEqual(rawget(t, "a"), 2) + assertEqual(backing.a, 1) --- Test table.find -local t = { 0, 1, 2, 3 } -local idx, two = table.find(t, function(v) return v == 2 end) -assertEqual(idx, 3) -assertEqual(two, 2) -assertEqual(table.find(t, function(v) return v == 4 end), nil) + assertEqual(rawget(t, "missing"), nil) --- Test table.select -local tbl = {name = "Pete", age = 100, parents = {"John", "Jane"}} -assertEqual(table.select(tbl, "name"), {name = "Pete"}) -assertEqual(table.select(tbl, {"name"}), {name = "Pete"}) -assertEqual(table.select(tbl, "name", "age"), { name = "Pete", age = 100}) -assertEqual(table.select(tbl, "name", "age", "non-existing"), { name = "Pete", age = 100}) + local t2 = t + local t3 = {} + assertTrue(rawequal(t2, t)) + assertFalse(rawequal(t3, t)) +end + +-- Length operator semantics +do + local seq = { 1, 2, 3 } + assertEqual(#seq, 3) + + local holes = { 1, 2, 3, nil, nil, nil, 4 } + assertEqual(rawlen(holes), 7) + + local t = { 1, 2, 3 } + t[5] = 5 + local collected = {} + for i, v in ipairs(t) do + collected[i] = v + end + assertEqual(collected, { 1, 2, 3 }) +end + +-- __len metamethod can override #t +do + local t = setmetatable({ 1, 2, 3 }, { + __len = function(_) return 123 end + }) + assertEqual(#t, 123) +end + +-- pairs / __pairs +do + local t = setmetatable({ a = 1, b = 2 }, { + __pairs = function(_) + local yielded = false + return function() + if yielded then return nil end + yielded = true + return "only", 42 + end + end + }) + + local out = {} + for k, v in pairs(t) do + out[k] = v + end + assertEqual(out, { only = 42 }) +end + +-- next +do + local t = { a = 1, b = 2, c = 3 } + local k, v = next(t, nil) + assertTrue(k ~= nil) + assertTrue(v ~= nil) + + local seen = {} + while k do + seen[k] = v + k, v = next(t, k) + end + assertEqual(seen.a, 1) + assertEqual(seen.b, 2) + assertEqual(seen.c, 3) +end + +-- table.concat +do + assertEqual(table.concat({ "Hello", "world" }, " "), "Hello world") + assertEqual(table.concat({ "Hello", "world", "three" }, " ", 2, 3), "world three") + assertEqual(table.concat({ "a", "b", "c" }), "abc") + assertEqual(table.concat({ "a", "b", "c" }, "", 2, 1), "") + + assertError(function() table.concat({ "a", {} }, "") end) +end + +-- table.sort +do + local t = { 3, 1, 2 } + table.sort(t) + assertEqual(t, { 1, 2, 3 }) + + table.sort(t, function(a, b) return a > b end) + assertEqual(t, { 3, 2, 1 }) + + local data = { { name = "John", age = 30 }, { name = "Jane", age = 25 } } + table.sort(data, function(a, b) return a.age < b.age end) + assertEqual(data[1].name, "Jane") + assertEqual(data[2].name, "John") + + assertError(function() table.sort({ 1, "x" }) end) + + -- Portable error test: comparator itself throws -> sort must raise + assertError(function() + table.sort({ 2, 1 }, function() + error("boom") + end) + end) + + -- NOTE: Do NOT require an error for comparator returning nil; Lua may treat it as false. +end + +-- table.pack / table.unpack +do + local t = { 1, 2, 3 } + local packed = table.pack(table.unpack(t)) + assertEqual(packed[1], 1) + assertEqual(packed[2], 2) + assertEqual(packed[3], 3) + assertEqual(packed.n, 3) + + local p = table.pack(1, nil, 3, nil) + assertEqual(p.n, 4) + assertEqual(p[1], 1) + assertEqual(p[2], nil) + assertEqual(p[3], 3) + assertEqual(p[4], nil) + + local a, b, c, d = table.unpack(p, 1, p.n) + assertEqual(a, 1) + assertEqual(b, nil) + assertEqual(c, 3) + assertEqual(d, nil) + + local seq = { "x", "y" } + local x, y = table.unpack(seq) + assertEqual(x, "x") + assertEqual(y, "y") +end + +-- __index / __newindex semantics +do + local backing = { a = 10 } + local t = setmetatable({}, { + __index = backing, + __newindex = function(_, k, v) + backing[k] = v * 2 + end, + }) + + assertEqual(t.a, 10) + + t.b = 5 + assertEqual(backing.b, 10) + assertEqual(rawget(t, "b"), nil) +end + +-- Equality semantics: tables compare by reference +do + local a = { 1 } + local b = { 1 } + assertFalse(a == b) + local c = a + assertTrue(a == c) +end diff --git a/client/space_lua/tonumber.ts b/client/space_lua/tonumber.ts index c84f9ac2..3f790d18 100644 --- a/client/space_lua/tonumber.ts +++ b/client/space_lua/tonumber.ts @@ -32,26 +32,24 @@ function charToDigitBase(c: number, base: number): number { const v = c - 48; if (v < base) { return v; - } else { - return -1; } - } else if (c >= 65 && c <= 90) { // 'A'..'Z' + return -1; + } + if (c >= 65 && c <= 90) { // 'A'..'Z' const v = 10 + (c - 65); if (v < base) { return v; - } else { - return -1; } - } else if (c >= 97 && c <= 122) { // 'a'..'z' + return -1; + } + if (c >= 97 && c <= 122) { // 'a'..'z' const v = 10 + (c - 97); if (v < base) { return v; - } else { - return -1; } - } else { return -1; } + return -1; } function parseIntWithBase( @@ -162,9 +160,8 @@ function parseInt(s: string): { ok: boolean; value: number } { i = skipSpace(s, i); if (!any || i !== n) { return { ok: false, value: 0 }; - } else { - return { ok: true, value: neg ? -acc : acc }; } + return { ok: true, value: neg ? -acc : acc }; } } @@ -182,9 +179,8 @@ function parseInt(s: string): { ok: boolean; value: number } { i = skipSpace(s, i); if (!any || i !== n) { return { ok: false, value: 0 }; - } else { - return { ok: true, value: neg ? -acc : acc }; } + return { ok: true, value: neg ? -acc : acc }; } function parseDecFloat(s: string): { ok: boolean; value: number } { @@ -459,9 +455,8 @@ export function luaToNumberDetailed( if (parsed.ok) { const v = parsed.value; return { value: v === 0 ? 0 : v, numericType: "int" }; - } else { - return null; } + return null; } { @@ -494,8 +489,9 @@ export function luaToNumber(s: unknown, base?: number): number | null { if (typeof s === "number") { return s; } - if (s instanceof Number) { - return Number(s); + // Tagged float: { value: number, isFloat: true } + if (s !== null && typeof s === "object" && (s as any).isFloat === true) { + return (s as any).value; } if (typeof s !== "string") { return null; diff --git a/deno.json b/deno.json index d63580a6..02d45ede 100644 --- a/deno.json +++ b/deno.json @@ -4,14 +4,14 @@ "tasks": { "build": "make clean && deno run -A build_plugs_libraries.ts && deno run -A build_client.ts", "build-production": "deno task build --production", - "build-plug-compile": "deno run -A build_plug_compile.ts", + "build-plug-compile": "deno run -A --no-lock build_plug_compile.ts", "check": "find . -name '*.ts*' -not -path \"*/node_modules/*\" | xargs deno check", "checks": "deno task check && deno task lint && deno task test", "lint": "deno lint --fix", "fmt": "deno fmt", "test": "deno test -A --fail-fast", "bench": "deno bench -A", - "plugs": "deno run -A build_plugs_libraries.ts" + "plugs": "deno run -A --no-lock build_plugs_libraries.ts" }, "publish": { "exclude": ["website", "CHANGELOG.md", "**/*.md"] diff --git a/deno.lock b/deno.lock index 5925032d..654f7d90 100644 --- a/deno.lock +++ b/deno.lock @@ -1,14 +1,24 @@ { "version": "5", "specifiers": { + "jsr:@cliffy/command@^1.0.0-rc.7": "1.0.0-rc.8", + "jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8", + "jsr:@cliffy/internal@1.0.0-rc.8": "1.0.0-rc.8", + "jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@deno/esbuild-plugin@^1.2.1": "1.2.1", "jsr:@deno/loader@~0.3.10": "0.3.12", + "jsr:@std/assert@^1.0.1": "1.0.17", + "jsr:@std/async@^1.1.0": "1.1.0", "jsr:@std/cli@^1.0.1": "1.0.26", + "jsr:@std/data-structures@^1.0.10": "1.0.10", + "jsr:@std/fmt@~1.0.2": "1.0.9", "jsr:@std/fs@^1.0.18": "1.0.22", "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/path@^1.0.8": "1.1.4", "jsr:@std/path@^1.1.1": "1.1.4", "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@std/testing@^1.0.14": "1.0.17", + "jsr:@std/text@~1.0.7": "1.0.17", "jsr:@std/yaml@^1.0.8": "1.0.11", "npm:@codemirror/autocomplete@6.20.0": "6.20.0", "npm:@codemirror/commands@6.10.1": "6.10.1", @@ -35,6 +45,7 @@ "npm:@replit/codemirror-vim@6.3.0": "6.3.0_@codemirror+commands@6.10.1_@codemirror+language@6.12.1_@codemirror+search@6.6.0_@codemirror+state@6.5.4_@codemirror+view@6.39.11", "npm:ajv@8.17.1": "8.17.1", "npm:crelt@1.0.6": "1.0.6", + "npm:esbuild@~0.25.5": "0.25.12", "npm:esbuild@~0.27.3": "0.27.3", "npm:fake-indexeddb@6.0.1": "6.0.1", "npm:fast-diff@1.3.0": "1.3.0", @@ -48,19 +59,60 @@ "npm:turndown@7.2.2": "7.2.2" }, "jsr": { + "@cliffy/command@1.0.0-rc.8": { + "integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557", + "dependencies": [ + "jsr:@cliffy/flags", + "jsr:@cliffy/internal", + "jsr:@cliffy/table", + "jsr:@std/fmt", + "jsr:@std/text" + ] + }, + "@cliffy/flags@1.0.0-rc.8": { + "integrity": "0f1043ce6ef037ba1cb5fe6b1bcecb25dc2f29371a1c17f278ab0f45e4b6f46c", + "dependencies": [ + "jsr:@std/text" + ] + }, + "@cliffy/internal@1.0.0-rc.8": { + "integrity": "34cdf2fad9b084b5aed493b138d573f52d4e988767215f7460daf0b918ff43d8" + }, + "@cliffy/table@1.0.0-rc.8": { + "integrity": "8bbcdc2ba5e0061b4b13810a24e6f5c6ab19c09f0cce9eb691ccd76c7c6c9db5", + "dependencies": [ + "jsr:@std/fmt" + ] + }, "@deno/esbuild-plugin@1.2.1": { "integrity": "df629467913adc1f960149fdfa3a3430ba8c20381c310fba096db244e6c3c9f6", "dependencies": [ "jsr:@deno/loader", - "jsr:@std/path@^1.1.1" + "jsr:@std/path@^1.1.1", + "npm:esbuild@~0.25.5" ] }, "@deno/loader@0.3.12": { "integrity": "52d3b3be0a32192efe07b0a4f1b3047077d2f2bba0f693e32f47421507f9fdb6" }, + "@std/assert@1.0.17": { + "integrity": "df5ebfffe77c03b3fa1401e11c762cc8f603d51021c56c4d15a8c7ab45e90dbe", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/async@1.1.0": { + "integrity": "72418df08d1be84668a53e48aab3520d68ae6882182f8a5ca75c6d1f087220d1" + }, "@std/cli@1.0.26": { "integrity": "53679a2b041406f15c3ff193986852595f3d11ba7b9d4b0c71a42589662e8fb5" }, + "@std/data-structures@1.0.10": { + "integrity": "f574f86b0e07c69b9edc555fcc814b57d29258bad39fd5a34ba8a80ecf033cfe" + }, + "@std/fmt@1.0.9": { + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" + }, "@std/fs@1.0.22": { "integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308", "dependencies": [ @@ -77,6 +129,16 @@ "jsr:@std/internal" ] }, + "@std/testing@1.0.17": { + "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", + "dependencies": [ + "jsr:@std/async", + "jsr:@std/data-structures" + ] + }, + "@std/text@1.0.17": { + "integrity": "4b2c4ef67ae5b6c1dfd447c81c83a43718f52e3c7e748d8b33f694aba9895f95" + }, "@std/yaml@1.0.11": { "integrity": "bd139ce77f2d06cbed3713c8b26d301d2a2e12ecc7986130a268107e212143ee" } @@ -196,131 +258,261 @@ "w3c-keyname" ] }, + "@esbuild/aix-ppc64@0.25.12": { + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "os": ["aix"], + "cpu": ["ppc64"] + }, "@esbuild/aix-ppc64@0.27.3": { "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "os": ["aix"], "cpu": ["ppc64"] }, + "@esbuild/android-arm64@0.25.12": { + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "os": ["android"], + "cpu": ["arm64"] + }, "@esbuild/android-arm64@0.27.3": { "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "os": ["android"], "cpu": ["arm64"] }, + "@esbuild/android-arm@0.25.12": { + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "os": ["android"], + "cpu": ["arm"] + }, "@esbuild/android-arm@0.27.3": { "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "os": ["android"], "cpu": ["arm"] }, + "@esbuild/android-x64@0.25.12": { + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "os": ["android"], + "cpu": ["x64"] + }, "@esbuild/android-x64@0.27.3": { "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "os": ["android"], "cpu": ["x64"] }, + "@esbuild/darwin-arm64@0.25.12": { + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, "@esbuild/darwin-arm64@0.27.3": { "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "os": ["darwin"], "cpu": ["arm64"] }, + "@esbuild/darwin-x64@0.25.12": { + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "os": ["darwin"], + "cpu": ["x64"] + }, "@esbuild/darwin-x64@0.27.3": { "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "os": ["darwin"], "cpu": ["x64"] }, + "@esbuild/freebsd-arm64@0.25.12": { + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, "@esbuild/freebsd-arm64@0.27.3": { "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "os": ["freebsd"], "cpu": ["arm64"] }, + "@esbuild/freebsd-x64@0.25.12": { + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, "@esbuild/freebsd-x64@0.27.3": { "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "os": ["freebsd"], "cpu": ["x64"] }, + "@esbuild/linux-arm64@0.25.12": { + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, "@esbuild/linux-arm64@0.27.3": { "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "os": ["linux"], "cpu": ["arm64"] }, + "@esbuild/linux-arm@0.25.12": { + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "os": ["linux"], + "cpu": ["arm"] + }, "@esbuild/linux-arm@0.27.3": { "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "os": ["linux"], "cpu": ["arm"] }, + "@esbuild/linux-ia32@0.25.12": { + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "os": ["linux"], + "cpu": ["ia32"] + }, "@esbuild/linux-ia32@0.27.3": { "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "os": ["linux"], "cpu": ["ia32"] }, + "@esbuild/linux-loong64@0.25.12": { + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "os": ["linux"], + "cpu": ["loong64"] + }, "@esbuild/linux-loong64@0.27.3": { "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "os": ["linux"], "cpu": ["loong64"] }, + "@esbuild/linux-mips64el@0.25.12": { + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "os": ["linux"], + "cpu": ["mips64el"] + }, "@esbuild/linux-mips64el@0.27.3": { "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "os": ["linux"], "cpu": ["mips64el"] }, + "@esbuild/linux-ppc64@0.25.12": { + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, "@esbuild/linux-ppc64@0.27.3": { "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "os": ["linux"], "cpu": ["ppc64"] }, + "@esbuild/linux-riscv64@0.25.12": { + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "os": ["linux"], + "cpu": ["riscv64"] + }, "@esbuild/linux-riscv64@0.27.3": { "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "os": ["linux"], "cpu": ["riscv64"] }, + "@esbuild/linux-s390x@0.25.12": { + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "os": ["linux"], + "cpu": ["s390x"] + }, "@esbuild/linux-s390x@0.27.3": { "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "os": ["linux"], "cpu": ["s390x"] }, + "@esbuild/linux-x64@0.25.12": { + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "os": ["linux"], + "cpu": ["x64"] + }, "@esbuild/linux-x64@0.27.3": { "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "os": ["linux"], "cpu": ["x64"] }, + "@esbuild/netbsd-arm64@0.25.12": { + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, "@esbuild/netbsd-arm64@0.27.3": { "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "os": ["netbsd"], "cpu": ["arm64"] }, + "@esbuild/netbsd-x64@0.25.12": { + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "os": ["netbsd"], + "cpu": ["x64"] + }, "@esbuild/netbsd-x64@0.27.3": { "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "os": ["netbsd"], "cpu": ["x64"] }, + "@esbuild/openbsd-arm64@0.25.12": { + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, "@esbuild/openbsd-arm64@0.27.3": { "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "os": ["openbsd"], "cpu": ["arm64"] }, + "@esbuild/openbsd-x64@0.25.12": { + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "os": ["openbsd"], + "cpu": ["x64"] + }, "@esbuild/openbsd-x64@0.27.3": { "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "os": ["openbsd"], "cpu": ["x64"] }, + "@esbuild/openharmony-arm64@0.25.12": { + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, "@esbuild/openharmony-arm64@0.27.3": { "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "os": ["openharmony"], "cpu": ["arm64"] }, + "@esbuild/sunos-x64@0.25.12": { + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "os": ["sunos"], + "cpu": ["x64"] + }, "@esbuild/sunos-x64@0.27.3": { "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "os": ["sunos"], "cpu": ["x64"] }, + "@esbuild/win32-arm64@0.25.12": { + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "os": ["win32"], + "cpu": ["arm64"] + }, "@esbuild/win32-arm64@0.27.3": { "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "os": ["win32"], "cpu": ["arm64"] }, + "@esbuild/win32-ia32@0.25.12": { + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "os": ["win32"], + "cpu": ["ia32"] + }, "@esbuild/win32-ia32@0.27.3": { "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "os": ["win32"], "cpu": ["ia32"] }, + "@esbuild/win32-x64@0.25.12": { + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "os": ["win32"], + "cpu": ["x64"] + }, "@esbuild/win32-x64@0.27.3": { "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "os": ["win32"], @@ -421,35 +613,68 @@ "crelt@1.0.6": { "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" }, + "esbuild@0.25.12": { + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "optionalDependencies": [ + "@esbuild/aix-ppc64@0.25.12", + "@esbuild/android-arm@0.25.12", + "@esbuild/android-arm64@0.25.12", + "@esbuild/android-x64@0.25.12", + "@esbuild/darwin-arm64@0.25.12", + "@esbuild/darwin-x64@0.25.12", + "@esbuild/freebsd-arm64@0.25.12", + "@esbuild/freebsd-x64@0.25.12", + "@esbuild/linux-arm@0.25.12", + "@esbuild/linux-arm64@0.25.12", + "@esbuild/linux-ia32@0.25.12", + "@esbuild/linux-loong64@0.25.12", + "@esbuild/linux-mips64el@0.25.12", + "@esbuild/linux-ppc64@0.25.12", + "@esbuild/linux-riscv64@0.25.12", + "@esbuild/linux-s390x@0.25.12", + "@esbuild/linux-x64@0.25.12", + "@esbuild/netbsd-arm64@0.25.12", + "@esbuild/netbsd-x64@0.25.12", + "@esbuild/openbsd-arm64@0.25.12", + "@esbuild/openbsd-x64@0.25.12", + "@esbuild/openharmony-arm64@0.25.12", + "@esbuild/sunos-x64@0.25.12", + "@esbuild/win32-arm64@0.25.12", + "@esbuild/win32-ia32@0.25.12", + "@esbuild/win32-x64@0.25.12" + ], + "scripts": true, + "bin": true + }, "esbuild@0.27.3": { "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "optionalDependencies": [ - "@esbuild/aix-ppc64", - "@esbuild/android-arm", - "@esbuild/android-arm64", - "@esbuild/android-x64", - "@esbuild/darwin-arm64", - "@esbuild/darwin-x64", - "@esbuild/freebsd-arm64", - "@esbuild/freebsd-x64", - "@esbuild/linux-arm", - "@esbuild/linux-arm64", - "@esbuild/linux-ia32", - "@esbuild/linux-loong64", - "@esbuild/linux-mips64el", - "@esbuild/linux-ppc64", - "@esbuild/linux-riscv64", - "@esbuild/linux-s390x", - "@esbuild/linux-x64", - "@esbuild/netbsd-arm64", - "@esbuild/netbsd-x64", - "@esbuild/openbsd-arm64", - "@esbuild/openbsd-x64", - "@esbuild/openharmony-arm64", - "@esbuild/sunos-x64", - "@esbuild/win32-arm64", - "@esbuild/win32-ia32", - "@esbuild/win32-x64" + "@esbuild/aix-ppc64@0.27.3", + "@esbuild/android-arm@0.27.3", + "@esbuild/android-arm64@0.27.3", + "@esbuild/android-x64@0.27.3", + "@esbuild/darwin-arm64@0.27.3", + "@esbuild/darwin-x64@0.27.3", + "@esbuild/freebsd-arm64@0.27.3", + "@esbuild/freebsd-x64@0.27.3", + "@esbuild/linux-arm@0.27.3", + "@esbuild/linux-arm64@0.27.3", + "@esbuild/linux-ia32@0.27.3", + "@esbuild/linux-loong64@0.27.3", + "@esbuild/linux-mips64el@0.27.3", + "@esbuild/linux-ppc64@0.27.3", + "@esbuild/linux-riscv64@0.27.3", + "@esbuild/linux-s390x@0.27.3", + "@esbuild/linux-x64@0.27.3", + "@esbuild/netbsd-arm64@0.27.3", + "@esbuild/netbsd-x64@0.27.3", + "@esbuild/openbsd-arm64@0.27.3", + "@esbuild/openbsd-x64@0.27.3", + "@esbuild/openharmony-arm64@0.27.3", + "@esbuild/sunos-x64@0.27.3", + "@esbuild/win32-arm64@0.27.3", + "@esbuild/win32-ia32@0.27.3", + "@esbuild/win32-x64@0.27.3" ], "scripts": true, "bin": true @@ -511,6 +736,9 @@ } }, "remote": { + "https://cdn.jsdelivr.net/gh/urielch/react-icons@1.0.8/lib/iconBase.tsx": "8ae4746f8fbe5e4e00bfe448e38876a6c5d0ca7edf0846b917b971bec5beaa6c", + "https://cdn.jsdelivr.net/gh/urielch/react-icons@1.0.8/lib/iconContext.ts": "a3ef1f1821598622472bf8aea52eaa4fdc7d27149e1757d569e1dc1e3f34d49f", + "https://cdn.jsdelivr.net/gh/urielch/react-icons@1.0.8/mod.ts": "faf8cbd7915c6b120c32649ba025c79a0207cbf71bac015eb8206336bd4a1a22", "https://deno.land/std@0.131.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", "https://deno.land/std@0.131.0/_util/os.ts": "49b92edea1e82ba295ec946de8ffd956ed123e2948d9bd1d3e901b04e4307617", "https://deno.land/std@0.131.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", @@ -556,7 +784,9 @@ "https://deno.land/x/denosass@1.0.6/src/types/module.types.ts": "7a5027482ded1d2967fbe690ef8f928446c5de8811c3333f9b09ae6e8122f9ba", "https://deno.land/x/denosass@1.0.6/src/wasm/grass.deno.js": "a72432ce8d6b8f9c31e31c71415fdca03fe36aa22417e414bc81e2e21a8a687b", "https://deno.land/x/mimetypes@v1.0.0/mod.ts": "abd1ea614b32cfa7fd50b46f6e070617696664698b8a34935dc807f3035ad408", - "https://deno.land/x/mimetypes@v1.0.0/src/mime.ts": "7cd7590eae6127dc1abfe710900fc749e94d5c715ffe8a148e59fe20ee21a9a5" + "https://deno.land/x/mimetypes@v1.0.0/src/mime.ts": "7cd7590eae6127dc1abfe710900fc749e94d5c715ffe8a148e59fe20ee21a9a5", + "https://deno.land/x/react_icons_md@1.0.9/deps.ts": "68cc4a569fad45a5956728ed79ee74b1290671aa8d048594b3256019a8da037f", + "https://deno.land/x/react_icons_md@1.0.9/mod.ts": "2cea4b838d89cbeaae3ee8e38b175736711fcd18cf561291d6104ad072d74455" }, "workspace": { "dependencies": [