From 75cc79800fb7a661966b1dc270f5a9d95504c092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Jan=20Fialka?= Date: Thu, 12 Feb 2026 15:22:12 +0100 Subject: [PATCH] Space Lua: Align numeric/table semantics with Lua, align number formating, optimize loops allocations (#1823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Don't know what's going on with the Deno docker image, disabling the deno.lock file for now * Another try * Space Lua: Align numeric and table semantics with Lua This change improves Space Lua compatibility with standard Lua 5.4, focusing on numeric subtypes and table behavior. The test suite is extended to lock in the expected semantics and should pass under both Space Lua and a Lua interpreter. SUMMARY OF CHANGES ------------------ Tighten Lua compatibility across evaluator and runtime: - correct metamethod dispatch (`__index`, `__newindex`, `__call`, comparison metamethods), - loop limits, - raw metamethod lookups. Rework numeric semantics to preserve Lua **integer** vs **float** behavior: - $0$ vs $0.0$ and $-0.0$, - explicit zero kind representation, and - updates arithmetic/bitwise coercions accordingly. Improve parser correctness by rejecting **unary plus** with aligned Lua errors and better parsing errors reporting. Fix `stdlib` behavior to match that of Lua: - `table` function `concat`, `insert`, `remove`, `sort` and `unpack` gain metamethod awareness and enforce Lua errors, - `ipairs` iteration updated to stop on first nil and honor `__index`, - `tonumber` updated to Luae conversion using `luaToNumberDetailed`, - `math.modf` return corrected, - `math.type` accuracy improvements for float/integer and $-0.0$, - `math.pi` added. Fix numeric subtypes for `/` and `^` operators so `math.type` matches Lua results using tagging as well as unary `-`. Expand test coverage: - new `metamethods_test.lua` for Lua metamethod/operator semantics, - extend arithmetic and length tests for zero-kind propagation and `rawlen` vs `__len` metamethod, - Extend `math` test suite to test proper Lua alignment (`math.type` and more), and - update context error expectations for Lua error messages. RATIONALE --------- Lua differs from JavaScript by having two numeric subtypes: **integer** and **float**. Operators depend on the subtype: `+`, `-`, `*`, `//` and `%` use integer mode when both operands are integers and float mode otherwise. Bitwise operators require integers and `math.type(x)` reports "integer" or "float". JavaScript has one numeric primitive type (`number`) so a plain number value cannot record whether Lua considers a value to be a float when the value has no fractional part (for example $2.0$). Lua also differs from "everything is IEEE 754 double" because the rules are defined in terms of integer and float subtypes. Float operations preserve IEEE 754 behavior including `NaN`, infinities and signed zero ($-0.0$) which affects results like $1/0.0$ versus $1/-0.0$. Integer arithmetic does not preserve $-0$ and collapses it to $0$. Lua integer arithmetic is exact within its integer range while JavaScript `number` cannot exactly represent all integers in that range. Lua numbers are integers or floats. Numeric strings coerce to integer or float based on _lexical_ form. Each arithmetic operator selects the result subtype from the operator rules and operand subtypes. Integer only operators (bitwise and `//` as integer division) require integer representability. Mixed arithmetic promotes to float as needed. Two operators are **always float** typed: division (`/`) and exponentiation (`^`) produce floats even if both operands are integers and even if the numeric value has no fractional part. `math.type` reports that internal subtype. Tables are associative arrays and assigning `nil` removes a key. The length operator `#` uses `__len` metamethod if present otherwise it uses the raw length rule. `rawlen(table)` ignores `__len`. Without `__len` Lua defines `#` as some boundary `N` such that `table[N]` is not `nil` and `table[N+1]` is `nil`. If the table has holes (missing or `nil` entries in the positive integer key sequence) the boundary may be non unique so `#` is stable only for proper sequences without holes. PERFORMANCE NOTES ----------------- Numeric changes add small checks to preserve Lua integer and float subtype semantics and avoid allocations except when the subtype would otherwise be lost. Some table operations may be slower due to stricter Lua 5.4 behavior especially around length and sequence boundary handling which currently requires extra metadata tracking and scans and cannot be avoided without a completely different internal table representation. Signed-off-by: Matouš Jan Fialka * Another try * Replace `LuaFloatTag` plain objects with boxed Number for float tagging * Restore pre-merge eval/numeric architecture and fix regressions This commit restores the original branch architecture. On top of the restored foundation, float-typed integer results (e.g. `1.0 + 1.0` = `2.0`, `0.0 // 1.0` = `0.0`) are now correctly tagged via `makeLuaFloat` so that `tostring` and `math.type` report them as floats. The *unary minus* fast path for float literals and the `tonumber` function also preserve float tagging. Performance regressions from the merge are addressed by avoiding `Number` boxing for non-integer floats (`3.14` needs no tag — it is unambiguously float), adding *string key* fast paths in `LuaTable` `has`/`rawGet`/`rawSet` to skip numeric normalization for the dominant case, and inlining a `typeof` check in math standard library functions to avoid function call overhead on plain numbers. Signed-off-by: Matouš Jan Fialka * Replace boxed `Number` float tagging with plain tagged objects * Replace `new Number()` boxing with plain tagged float objects for Lua float type tracking. * Integer-valued floats that need type disambiguation are now represented as `{ value: number, isFloat: true }` instead of boxed `Number` objects with a symbol property. * Pre-allocated singletons are used for positive and negative float zeros to avoid allocation entirely in common cases. * Updated all detection, unwrapping, and coercion paths across `numeric.ts`, `runtime.ts`, `eval.ts`, `stdlib.ts`, and `stdlib/` modules to use the new `isTaggedFloat` type guard. * Removed all `instanceof Number` checks. * Deleted the `FloatKind` symbol and eliminated redundant helpers `isLuaFloat`, `isFloatTag`, `getZeroBoxKind` and `toPlainNumber` that became dead code. * Simplified `math.type`, `luaToString`, `luaEquals`, `luaTypeName` and various other key normalization paths. Signed-off-by: Matouš Jan Fialka * Add fast paths in `coerceNumericPair` for tagged float operands Signed-off-by: Matouš Jan Fialka * Fix copy/paste typo Signed-off-by: Matouš Jan Fialka * Avoid extra `LuaEnv` allocations in "For" and "ForIn" loops Signed-off-by: Matouš Jan Fialka * [Space Lua] Reuse loop variable environment in `for` and `for-in` loops Numeric `for` and generic `for-in` loops allocated a fresh `LuaEnv` on every iteration to hold loop variables. But this is only necessary when a closure inside the loop body captures the loop variable. The optimization uses a two-level check computed at parse time. If no function definition exists in the loop's subtree, environment reuse is safe. When a function definition is present a deeper analysis walks the block to determine whether any function body references the loop variable names without them being shadowed by its own parameters. When a closure captures a loop variable the loop fall back to per-iteration allocation. Signed-off-by: Matouš Jan Fialka * [Space Lua] Format numbers using standard Lua rules Standard Lua formats floats via C `sprintf("%.14g")` (14 significant digits, scientific notation when shorter, exponent padded to 2 digits, and a guaranteed `.0` suffix for integer-valued floats). * Replace the old `luaFormatNumber` with JS `toPrecision(14)`-based implementation that reproduces this behavior. * Integrates it so that `${}` expressions in the UI also display numbers correctly. * Fixes tagged floats (`{ value, isFloat }`) were being stripped by `luaValueToJS` or matched as plain objects before reaching the number formatter. This caused `${}` expressions to render raw JS numbers. Examples: ``` - ${tostring(2^63)} - ${2^63} - ${(2^63)} ``` All of the the above examples show correct `9.2233720368548e+18` now. Signed-off-by: Matouš Jan Fialka * [Space Lua] Fix `string.format` for floats and tagged numbers Unwrap tagged floats before `printf`, handle `inf`/`-inf`/`-nan` in `formatDouble`, and fix `%g` producing `0e+00` for zero. Hopefuly it's enough to gain Lua formatting. Tests: ``` - ${string.format("%.14g", 0.0)} - `0` - ${string.format("%.14g", 1.0)} - `1` - ${string.format("%.14g", 1/3)} - `0.33333333333333` - ${string.format("%.14g", math.pi)} - `3.1415926535898` - ${string.format("%.14g", 1e-10)} - `1e-10` - ${string.format("%.14g", 1e18)} - `1e+18` - ${string.format("%.14g", 2^63)} - `9.2233720368548e+18` - ${string.format("%.14g", 2^53)} - `9.007199254741e+15` - ${string.format("%.14g", 1.7976931348623e+308)} - `1.7976931348623e+308` - ${string.format("%.14g", 5e-324)} - `4.9406564584125e-324` - ${string.format("%.14g", 0/0)} - `-nan` - ${string.format("%.14g", 1/0)} - `inf` - ${string.format("%.14g", -1/0)} - `-inf` ``` Signed-off-by: Matouš Jan Fialka * [Space Lua] Add and integrate new `luaFormat` utility and test suite * Add `luaFormat` string formatting function compatible with Lua, and integrate it across the codebase as a replacement for prior formatting approaches. * Add extensive test suite. Signed-off-by: Matouš Jan Fialka * Fix check Signed-off-by: Matouš Jan Fialka * [Space Lua] Add `%a`/`%A` and `%q` format specifiers to `string.format` Implement hexadecimal floating-point (`%a`/`%A`) and quoted literal (`%q`) specifiers. * Add `%a`/`%A` as IEEE 754 double decomposition with full flag, width and precision support. * Add `%q` as producind valid Lua literals for strings, numbers, booleans and nil. * Use `Math.PI` for `math.pi` to preserve full double precision. * Remove Deno based test suite and replace it with native Lua test suite. Signed-off-by: Matouš Jan Fialka * [Space Lua] Add `%p` format specifier to `string.format` In standard Lua, `%p` formats the internal C heap address of a value, producing output like `0x55a3bc4e2f10`. It works on tables, functions, threads, strings, and userdata (GC-ed objects). For `nil`, booleans, and numbers it returns `(null)`. In Space Lua, there are no *raw memory addresses* since the runtime is JavaScript. Instead, `%p` assigns a *stable sequential integer* to each object via a `WeakMap`, formatted as a 14-digit zero-padded hex value. The key difference is that identifiers are deterministic and sequential rather than random-looking heap addresses: ```lua local t = {} print(string.format("identifier: %p", t)) -- 0x00000000000001 print(string.format("the same: %p", t)) -- 0x00000000000001 print(string.format("another: %p", {}) -- 0x00000000000002 ``` For strings, a regular `Map` is used so identical string content always produces the same identifier. Signed-off-by: Matouš Jan Fialka * Revert merge changes to the deno.json Signed-off-by: Matouš Jan Fialka * Replace `interface` with `type` Signed-off-by: Matouš Jan Fialka * Remove `has_math()` relict function test Signed-off-by: Matouš Jan Fialka * Refactor loop to map Signed-off-by: Matouš Jan Fialka * Remove `Deno.remove("deno.lock")` weirdness Signed-off-by: Matouš Jan Fialka * Refactor: Early return undefined in `astNumberKind` instead of assigning Signed-off-by: Matouš Jan Fialka --------- Signed-off-by: Matouš Jan Fialka Co-authored-by: Zef Hemel --- client/codemirror/lua_directive.ts | 22 +- client/codemirror/lua_widget.ts | 16 +- client/markdown_renderer/result_render.ts | 23 +- client/plugos/plug_compile.ts | 3 - client/space_lua/arithmetic_test.lua | 105 ++ client/space_lua/ast.ts | 6 + client/space_lua/context_errors.test.ts | 4 +- client/space_lua/eval.test.ts | 155 ++ client/space_lua/eval.ts | 1653 ++++++++++++--------- client/space_lua/len_test.lua | 40 + client/space_lua/lua.test.ts | 12 +- client/space_lua/metamethods_test.lua | 576 +++++++ client/space_lua/numeric.ts | 364 ++--- client/space_lua/parse.ts | 613 +++++++- client/space_lua/runtime.ts | 850 +++++++---- client/space_lua/stdlib.ts | 69 +- client/space_lua/stdlib/format.ts | 770 ++++++++++ client/space_lua/stdlib/format_test.lua | 503 +++++++ client/space_lua/stdlib/math.ts | 149 +- client/space_lua/stdlib/math_test.lua | 189 ++- client/space_lua/stdlib/printf.ts | 645 -------- client/space_lua/stdlib/space_lua.ts | 7 +- client/space_lua/stdlib/string.ts | 9 +- client/space_lua/stdlib/table.ts | 284 +++- client/space_lua/stdlib/table_test.lua | 389 +++-- client/space_lua/tonumber.ts | 30 +- deno.json | 4 +- deno.lock | 286 +++- 28 files changed, 5488 insertions(+), 2288 deletions(-) create mode 100644 client/space_lua/metamethods_test.lua create mode 100644 client/space_lua/stdlib/format.ts create mode 100644 client/space_lua/stdlib/format_test.lua delete mode 100644 client/space_lua/stdlib/printf.ts 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": [