diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..902182b --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,8 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 4 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" +# never allow `if x then y end` on one line — always expand after then/do +collapse_simple_statement = "Never" diff --git a/README.md b/README.md index 71c2b89..ec38e93 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # `a` -`a` embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the network, a database, the filesystem, and the terminal without additional setup. +`a` embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the outside world — today an HTTP client and SQLite, with more of the surface below planned — without additional setup. ```sh a file.lua # run a script @@ -38,10 +38,14 @@ The result is a single binary that runs a `.lua` file. There is no package manag ## The standard library -The standard library is the main purpose of `a`. Planned surface: +The standard library is the main purpose of `a`. Available today: -- **Networking** — HTTP client, WebSocket, and MQTT (3.1.1 and 5). +- **Networking** — HTTP client (WebSocket and MQTT are planned). - **SQLite** — access to embedded SQLite databases. +- **Core helpers** — `math`, `table`, and `utils` extensions, structured `log`, `os` time helpers, and `task.join` concurrency. + +Planned: + - **Filesystem** — read/write/glob/walk without stock Lua's `io` boilerplate. - **Terminal UI** — interactive line editing and full-screen TUI, comparable to `ncurses`. @@ -52,7 +56,7 @@ The intent is for the common case to be a single call rather than a multi-step s - Embeds Lua 5.5 via `mlua`, on an async core ([`tokio`](https://tokio.rs/)) so IO-heavy scripts don't block. - A single self-contained binary. No LuaRocks, no make, no virtualenv. - Scripts run in a **sandboxed** environment modeled on [Luau's safe environment](https://luau.org/sandbox): `io`, `package`, and `debug` are not loaded; `os` is trimmed to its time functions; the bytecode/chunk-loading escape hatches (`load`, `loadfile`, `dofile`, `string.dump`) are removed. The library `a` provides is the supported way to reach the outside world. -- A leading `#!` shebang line is skipped, so scripts can be made executable directly. +- A leading `#!` shebang line is skipped (after an optional UTF-8 BOM), so scripts can be made executable directly. Script files need not be valid UTF-8 — Lua source is byte-oriented. ## Building diff --git a/docs/utils.md b/docs/utils.md index 3179f1c..ee86c7f 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -10,7 +10,7 @@ local data = utils.fromJSON('{"name":"Alice","age":30}') print(data.name, data.age) --> Alice 30 print(utils.toJSON({1, 2, 3})) --> [1,2,3] -print(utils.dump({a = 1, b = {2, 3}})) --> { a = 1, b = { 2, 3 } } +print(utils.dump({a = 1, b = {2, 3}})) --> {a=1, b={2, 3}} ``` ## JSON diff --git a/lua/coroutines-demo.lua b/lua/coroutines-demo.lua index c578c23..6203303 100644 --- a/lua/coroutines-demo.lua +++ b/lua/coroutines-demo.lua @@ -3,7 +3,9 @@ -- -- Run with: cargo run -- lua/coroutines-demo.lua -local function banner(s) print("\n=== " .. s .. " ===") end +local function banner(s) + print("\n=== " .. s .. " ===") +end ---------------------------------------------------------------------- banner("1. Pure Lua coroutines (no async involved)") @@ -11,19 +13,19 @@ banner("1. Pure Lua coroutines (no async involved)") -- Plain cooperative coroutines work exactly as in stock Lua: this is pure VM -- machinery and never touches tokio. local function counter(n) - for i = 1, n do - coroutine.yield(i * i) - end - return "done" + for i = 1, n do + coroutine.yield(i * i) + end + return "done" end local co = coroutine.wrap(counter) print("squares:", co(3), co(3), co(3)) -- 1 4 9 local raw = coroutine.create(counter) -print("status fresh:", coroutine.status(raw)) -- suspended +print("status fresh:", coroutine.status(raw)) -- suspended coroutine.resume(raw, 1) -coroutine.resume(raw, 1) -- runs to `return` +coroutine.resume(raw, 1) -- runs to `return` print("status after return:", coroutine.status(raw)) -- dead ---------------------------------------------------------------------- @@ -43,14 +45,15 @@ banner("3. GOTCHA: async os.sleep inside a *manually resumed* coroutine") -- coroutine.resume / coroutine.wrap does NOT. So driving an async call yourself -- does not actually wait -- the sleep is not performed by your resume loop. local sleeper = coroutine.wrap(function() - os.sleep(0.50) - return "awoke" + os.sleep(0.50) + return "awoke" end) local t1 = os.microtime() -local first = sleeper() -- returns immediately with the sentinel -print(string.format("first resume returned %s after only %.3fs (did NOT wait 0.5s)", - tostring(first), os.microtime() - t1)) +local first = sleeper() -- returns immediately with the sentinel +print( + string.format("first resume returned %s after only %.3fs (did NOT wait 0.5s)", tostring(first), os.microtime() - t1) +) print("--> lesson: don't hand-drive coroutines that call async stdlib functions.") ---------------------------------------------------------------------- @@ -60,40 +63,43 @@ banner("4. Real concurrency: task.join drives coroutines on tokio") -- interleave them. Three tasks that each sleep concurrently finish in ~the -- longest single sleep, not the sum -- proof the sleeps overlap on one thread. local function worker(name, secs) - return function() - print(string.format(" [%s] start", name)) - os.sleep(secs) - print(string.format(" [%s] woke after %.2fs", name, secs)) - return name .. ":" .. secs - end + return function() + print(string.format(" [%s] start", name)) + os.sleep(secs) + print(string.format(" [%s] woke after %.2fs", name, secs)) + return name .. ":" .. secs + end end local t2 = os.microtime() -local a, b, c = task.join( - worker("slow", 0.30), - worker("med", 0.20), - worker("fast", 0.10) -) +local a, b, c = task.join(worker("slow", 0.30), worker("med", 0.20), worker("fast", 0.10)) local elapsed = os.microtime() - t2 print(string.format("results: %s, %s, %s", a, b, c)) print(string.format("wall time: %.3fs", elapsed)) -print(string.format("sequential would have been ~0.60s; concurrent ~0.30s => %s", - elapsed < 0.45 and "CONCURRENT (overlapped on tokio)" or "serialized?!")) +print( + string.format( + "sequential would have been ~0.60s; concurrent ~0.30s => %s", + elapsed < 0.45 and "CONCURRENT (overlapped on tokio)" or "serialized?!" + ) +) ---------------------------------------------------------------------- banner("5. Nested + return values") ---------------------------------------------------------------------- -- task.join itself yields, so it composes: a joined task can join again. -local outer = task.join( - function() - local x, y = task.join( - function() os.sleep(0.05); return 21 end, - function() os.sleep(0.05); return 21 end - ) +local outer = task.join(function() + local x, y = task.join(function() + os.sleep(0.05) + return 21 + end, function() + os.sleep(0.05) + return 21 + end) return x + y - end, - function() os.sleep(0.10); return "sibling" end -) +end, function() + os.sleep(0.10) + return "sibling" +end) print("nested join result:", outer) -- 42 print("\nAll demos finished.") diff --git a/lua/stdlib/http.lua b/lua/stdlib/http.lua index 5c4e4f8..e131744 100644 --- a/lua/stdlib/http.lua +++ b/lua/stdlib/http.lua @@ -5,7 +5,9 @@ -- Attach resp.json() to a response table: parses resp.body via utils.fromJSON. local function wrap_resp(resp) - resp.json = function() return utils.fromJSON(resp.body) end + resp.json = function() + return utils.fromJSON(resp.body) + end return resp end @@ -17,7 +19,9 @@ end -- Method shorthands for the stateless module. for _, m in ipairs({ "get", "post", "put", "patch", "delete", "head" }) do - http[m] = function(url, opts) return http.request(m:upper(), url, opts) end + http[m] = function(url, opts) + return http.request(m:upper(), url, opts) + end end function http.getJSON(url, opts) @@ -41,12 +45,24 @@ function session_mt:request(method, url, opts) return wrap_resp(self:_request(method, url, opts)) end -function session_mt:get(url, opts) return self:request("GET", url, opts) end -function session_mt:post(url, opts) return self:request("POST", url, opts) end -function session_mt:put(url, opts) return self:request("PUT", url, opts) end -function session_mt:patch(url, opts) return self:request("PATCH", url, opts) end -function session_mt:delete(url, opts) return self:request("DELETE", url, opts) end -function session_mt:head(url, opts) return self:request("HEAD", url, opts) end +function session_mt:get(url, opts) + return self:request("GET", url, opts) +end +function session_mt:post(url, opts) + return self:request("POST", url, opts) +end +function session_mt:put(url, opts) + return self:request("PUT", url, opts) +end +function session_mt:patch(url, opts) + return self:request("PATCH", url, opts) +end +function session_mt:delete(url, opts) + return self:request("DELETE", url, opts) +end +function session_mt:head(url, opts) + return self:request("HEAD", url, opts) +end function session_mt:getJSON(url, opts) local resp = self:get(url, opts) diff --git a/lua/stdlib/math.lua b/lua/stdlib/math.lua index 996097c..4e96241 100644 --- a/lua/stdlib/math.lua +++ b/lua/stdlib/math.lua @@ -11,8 +11,16 @@ local function roundHalfAwayFromZero(x) return i end ---- Round a number to the nearest integer, or to N decimal places. ---- Rounds half away from zero. round(value, 0) returns an integer; round(value, places > 0) returns a float. +--- @brief Round a number to the nearest integer, or to N decimal places. +--- +--- round(value) and round(value, 0) return an integer; round(value, places > 0) returns a float. +--- Rounds half away from zero. Errors on non-numbers, NaN and infinity, on negative or +--- non-integer places, and (in the integer form) on results outside the Lua integer range. +--- If value * 10^places overflows, rounding cannot change the value and it is returned unchanged. +--- +--- @param value number: Value to round +--- @param places number|nil: Optional number of decimal places, default 0 (must be a non-negative integer) +--- @return number: The rounded value function math.round(value, places) if type(value) ~= "number" then error("round() called with a non-number value") @@ -34,7 +42,7 @@ function math.round(value, places) if places == 0 then return value end - return value + 0.0 + return value + 0.0 -- an integer has no decimals to round, but the contract says float end if not math.isFinite(value) then @@ -52,64 +60,113 @@ function math.round(value, places) local mul = 10.0 ^ places local scaled = value * mul if not math.isFinite(scaled) then + -- rounding cannot change a value of this magnitude return value end return roundHalfAwayFromZero(scaled) / mul end ---- Clamp a number to an interval [min, max]. Either bound may be nil (half-open). +--- @brief Clamp number to an interval, optionally half-open. +--- If both bounds are nil, just returns the value. +--- Errors if any of the params is in wrong type (value must be number, min and max must be number or nil), +--- or if min is greater than max. +--- @param value number: Value to clamp +--- @param min number|nil: Lower bound, or nil for no lower bound +--- @param max number|nil: Upper bound, or nil for no upper bound +--- @return number: Value clamped to the interval function math.clamp(value, min, max) if type(value) ~= "number" then error("Non-number passed to clamp() as value") end + if min ~= nil and type(min) ~= "number" then error("Non-number passed to clamp() as minimum") end + if max ~= nil and type(max) ~= "number" then error("Non-number passed to clamp() as maximum") end + if min ~= nil and max ~= nil and min > max then error("clamp() minimum is greater than maximum") end - if min ~= nil and value < min then return min end - if max ~= nil and value > max then return max end + + if min ~= nil and value < min then + return min + end + + if max ~= nil and value > max then + return max + end + return value end ---- Return true if value is a finite number (not NaN or infinity). +--- @brief Check if a value is a finite number (not NaN or infinity) +--- @param value any: Value to check +--- @return boolean: True if the value is a finite number; false for NaN, infinity and non-number values function math.isFinite(value) - if type(value) ~= "number" then return false end + if type(value) ~= "number" then + return false + end + -- NaN is the only value not equal to itself + -- math.huge represents infinity return value == value and value ~= math.huge and value ~= -math.huge end ---- Return the sign of a number: -1, 0, or 1. NaN yields 0. +--- @brief Get the sign of a number: -1 for negative, 0 for zero, 1 for positive. +--- Note: NaN has no sign and yields 0. +--- @param value number: Value to take the sign of +--- @return number: -1, 0 or 1 function math.sign(value) if type(value) ~= "number" then error("Non-number passed to sign()") end - if value > 0 then return 1 - elseif value < 0 then return -1 - else return 0 + if value > 0 then + return 1 + elseif value < 0 then + return -1 + else + return 0 end end ---- Scale a value linearly from [inMin, inMax] to [outMin, outMax]. ---- If clamp is true, the result is clamped to the output range. +--- @brief Scale a value from one range to another (linear interpolation). +--- @param value number: The input value to scale +--- @param inMin number: The minimum of the input range +--- @param inMax number: The maximum of the input range +--- @param outMin number: The minimum of the output range +--- @param outMax number: The maximum of the output range +--- @param clamp boolean: If true, clamp the result to [outMin, outMax]. Default is false. +--- @return number: The scaled value function math.scale(value, inMin, inMax, outMin, outMax, clamp) - if type(value) ~= "number" then error("Non-number passed to scale() as value") end - if type(inMin) ~= "number" or type(inMax) ~= "number" then error("Non-number passed to scale() as input range") end - if type(outMin) ~= "number" or type(outMax) ~= "number" then error("Non-number passed to scale() as output range") end - if inMin == inMax then error("Input range cannot be zero (inMin == inMax)") end + if type(value) ~= "number" then + error("Non-number passed to scale() as value") + end + if type(inMin) ~= "number" or type(inMax) ~= "number" then + error("Non-number passed to scale() as input range") + end + if type(outMin) ~= "number" or type(outMax) ~= "number" then + error("Non-number passed to scale() as output range") + end + if inMin == inMax then + error("Input range cannot be zero (inMin == inMax)") + end local ratio = (value - inMin) / (inMax - inMin) local result = outMin + ratio * (outMax - outMin) if clamp then local lo, hi = outMin, outMax - if lo > hi then lo, hi = hi, lo end - if result < lo then return lo - elseif result > hi then return hi + if lo > hi then + lo, hi = hi, lo + end + if result < lo then + return lo + elseif result > hi then + return hi end end + return result end diff --git a/lua/stdlib/sqlite.lua b/lua/stdlib/sqlite.lua index dfc8f82..2949f5c 100644 --- a/lua/stdlib/sqlite.lua +++ b/lua/stdlib/sqlite.lua @@ -23,18 +23,21 @@ return function(Connection) end --- Run `fn` inside a transaction: commit on success, roll back and re-raise - --- on error. The original error is re-raised unchanged (level 0). + --- on error. The original error is re-raised unchanged (level 0), and fn's + --- return values are passed through on success. function Connection:transaction(fn) if type(fn) ~= "function" then error("sqlite: transaction requires a function argument (got " .. type(fn) .. ")", 2) end self:begin() - local ok, err = pcall(fn) - if ok then + local results = table.pack(pcall(fn)) + if results[1] then self:commit() - else - self:rollback() - error(err, 0) + return table.unpack(results, 2, results.n) end + -- On failure, roll back but never let a rollback error mask the original + -- one (the connection may already be closed, or no transaction active). + pcall(self.rollback, self) + error(results[2], 0) end end diff --git a/lua/stdlib/table.lua b/lua/stdlib/table.lua index 94b50e5..64d1a2a 100644 --- a/lua/stdlib/table.lua +++ b/lua/stdlib/table.lua @@ -1,5 +1,16 @@ ---- Make a read-only proxy of a table. Reads pass through; writes raise an error. ---- Caveat: pairs(), next() and # see no contents (proxy is empty). Intended for API namespaces. +--- @brief Make a read-only view of a table. +--- +--- Returns a proxy table: reads pass through to the original table, writes raise an error. +--- The original table is not modified and stays writable - changes to it show through the proxy. +--- The proxy's metatable is protected (getmetatable returns false). +--- +--- Caveat: the proxy itself is empty - pairs(), next() and the # operator see no contents, +--- and the table.* helpers built on them (keys, values, contains, isEmpty, ...) treat it +--- as empty. Intended for protecting API namespace tables, not for data tables. +--- +--- @param tbl table: Table to protect +--- @param name string: Optional name of the table, used in error messages +--- @return table: Read-only proxy of the table function table.readonly(tbl, name) if type(tbl) ~= "table" then error("table.readonly: argument is not a table (got " .. type(tbl) .. ")") @@ -7,73 +18,132 @@ function table.readonly(tbl, name) if name ~= nil and type(name) ~= "string" then error("table.readonly: name is not a string (got " .. type(name) .. ")") end + local message = "Attempt to update a read-only table" - if name ~= nil then message = message .. " " .. name end + if name ~= nil then + message = message .. " " .. name + end + return setmetatable({}, { __index = tbl, - __newindex = function() error(message, 2) end, + __newindex = function() + -- level 2 blames the assignment in the caller's code + error(message, 2) + end, __metatable = false, }) end ---- Filter a table by predicate, preserving keys. May leave gaps in a sequence table. +--- @brief: Filters a table based on a predicate, all values in the table are run through this predicate; the ones that apply stay. +--- Keys are preserved - filtering a numbered table may leave gaps, use table.ifilter for those. +--- @param tbl table: Table to filter +--- @param predicate function: The predicate/filtering function, e.g. function(v) return v <= 5 end - this keeps only values up to 5 +--- @return table: Product of the operation (new table) function table.filter(tbl, predicate) - if type(tbl) ~= "table" then error("table.filter: table argument is not a table (got " .. type(tbl) .. ")") end - if type(predicate) ~= "function" then error("table.filter: predicate is not a function (got " .. type(predicate) .. ")") end + if type(tbl) ~= "table" then + error("table.filter: table argument is not a table (got " .. type(tbl) .. ")") + end + if type(predicate) ~= "function" then + error("table.filter: predicate is not a function (got " .. type(predicate) .. ")") + end local result = {} for k, v in pairs(tbl) do - if predicate(v) then result[k] = v end + if predicate(v) then + result[k] = v + end end return result end ---- Filter a sequence table by predicate, producing a new gapless sequence. +--- @brief: Filters a numbered table based on a predicate, all values in the table are run through this predicate; the ones that apply stay. Produces a new numbered table without gaps. +--- @param tbl table: Numbered table to filter +--- @param predicate function: The predicate/filtering function, e.g. function(v) return v <= 5 end - this keeps only values up to 5 +--- @return table: Product of the operation (new table) function table.ifilter(tbl, predicate) - if type(tbl) ~= "table" then error("table.ifilter: argument is not a table (got " .. type(tbl) .. ")") end - if type(predicate) ~= "function" then error("table.ifilter: predicate is not a function (got " .. type(predicate) .. ")") end + if type(tbl) ~= "table" then + error("table.ifilter: table argument is not a table (got " .. type(tbl) .. ")") + end + if type(predicate) ~= "function" then + error("table.ifilter: predicate is not a function (got " .. type(predicate) .. ")") + end local result = {} for i = 1, #tbl do local v = tbl[i] - if predicate(v) then result[#result + 1] = v end + if predicate(v) then + result[#result + 1] = v + end end return result end ---- Map values through a function, preserving keys. +--- @brief: Maps the values in the table through a mapper function, preserving keys +--- @param tbl table: Table to map +--- @param mapper function: a mapping function, e.g. function(v) return v + 1 end +--- @return table: Product of the operation (new table) function table.map(tbl, mapper) - if type(tbl) ~= "table" then error("table.map: table argument is not a table (got " .. type(tbl) .. ")") end - if type(mapper) ~= "function" then error("table.map: mapper is not a function (got " .. type(mapper) .. ")") end + if type(tbl) ~= "table" then + error("table.map: table argument is not a table (got " .. type(tbl) .. ")") + end + if type(mapper) ~= "function" then + error("table.map: mapper is not a function (got " .. type(mapper) .. ")") + end local result = {} - for k, v in pairs(tbl) do result[k] = mapper(v) end + for k, v in pairs(tbl) do + result[k] = mapper(v) + end return result end ---- Map values through a function, dropping nils. May leave gaps in a sequence table. +--- @brief: Maps the values in the table through a mapper function, preserving keys. If the function returns nil, the value is dropped. May leave gaps in a numbered table! +--- @param tbl table: Table to map +--- @param mapper function: a mapping function, e.g. function(v) return v + 1 end +--- @return table: Product of the operation (new table) function table.filterMap(tbl, mapper) - if type(tbl) ~= "table" then error("table.filterMap: table argument is not a table (got " .. type(tbl) .. ")") end - if type(mapper) ~= "function" then error("table.filterMap: mapper is not a function (got " .. type(mapper) .. ")") end + if type(tbl) ~= "table" then + error("table.filterMap: table argument is not a table (got " .. type(tbl) .. ")") + end + if type(mapper) ~= "function" then + error("table.filterMap: mapper is not a function (got " .. type(mapper) .. ")") + end local result = {} for k, v in pairs(tbl) do local res = mapper(v) - if res ~= nil then result[k] = res end + if res ~= nil then + result[k] = res + end end return result end ---- Map a sequence through a function, dropping nils, producing a gapless sequence. +--- @brief: Maps the values in the numbered table through a mapper function. If the function returns nil, the value is dropped. Result is a numbered table without gaps. +--- @param tbl table: Numbered table to map +--- @param mapper function: a mapping function, e.g. function(v) return v + 1 end +--- @return table: Product of the operation (new table) function table.ifilterMap(tbl, mapper) - if type(tbl) ~= "table" then error("table.ifilterMap: argument is not a table (got " .. type(tbl) .. ")") end - if type(mapper) ~= "function" then error("table.ifilterMap: mapper is not a function (got " .. type(mapper) .. ")") end + if type(tbl) ~= "table" then + error("table.ifilterMap: table argument is not a table (got " .. type(tbl) .. ")") + end + if type(mapper) ~= "function" then + error("table.ifilterMap: mapper is not a function (got " .. type(mapper) .. ")") + end local result = {} for i = 1, #tbl do local res = mapper(tbl[i]) - if res ~= nil then result[#result + 1] = res end + if res ~= nil then + result[#result + 1] = res + end end return result end ---- Merge tables into a new table. Sequence parts are appended in order; other keys are ---- copied with later arguments overwriting earlier ones. Not recursive; not deep. +--- @brief Merge tables into a new table. The sequence part (consecutive positive integer keys +--- starting at 1) of each table is appended in argument order; all other keys (including +--- non-sequence numeric keys) are copied over, values from later arguments overwriting earlier +--- ones. Not recursive: nested tables are copied by reference. Errors if an argument is not a table. +--- +--- Metatable is not copied. +--- @param ... table: Tables to merge +--- @return table: Product of the operation (new table) function table.merge(...) local result = {} local n = 0 @@ -100,18 +170,27 @@ end local MERGE_DEPTH_LIMIT = 100 local function deepCopy(value, level) - if type(value) ~= "table" then return value end - if level > MERGE_DEPTH_LIMIT then error("table.deepMerge/deepCopy recursion too deep") end + if type(value) ~= "table" then + return value + end + if level > MERGE_DEPTH_LIMIT then + error("table.deepMerge/deepCopy recursion too deep") + end local result = {} - for k, v in pairs(value) do result[k] = deepCopy(v, level + 1) end + for k, v in pairs(value) do + result[k] = deepCopy(v, level + 1) + end return result end local function mergeRecurse(level, ...) - if level > MERGE_DEPTH_LIMIT then error("table.deepMerge recursion too deep") end + if level > MERGE_DEPTH_LIMIT then + error("table.deepMerge recursion too deep") + end + local result = {} local n = 0 - for argi = 1, select('#', ...) do + for argi = 1, select("#", ...) do local tbl = select(argi, ...) if type(tbl) ~= "table" then error("table.deepMerge: argument #" .. argi .. " is not a table (got " .. type(tbl) .. ")") @@ -135,126 +214,235 @@ local function mergeRecurse(level, ...) return result end ---- Recursively merge tables. Nested tables on both sides are merged; other values are overwritten. ---- The result shares no tables with the inputs (everything is deep-copied). +--- @brief Merge tables recursively into a new table. Sequence parts are appended in argument +--- order (like table.merge); for other keys, when both sides hold tables they are merged +--- recursively, otherwise the value from the later argument overwrites the earlier one. +--- The result shares no tables with the inputs (everything adopted is deep-copied). +--- Errors if an argument is not a table, or when nesting exceeds a depth limit. +--- +--- Metatables are not copied. +--- @param ... table: Tables to merge +--- @return table: Product of the operation (new table) function table.deepMerge(...) return mergeRecurse(1, ...) end ---- Deep-copy a table at all levels (excluding metatables). +--- @brief Make a deep copy of a table +--- +--- Raises an error if the value is not a table +--- +--- @param value table: Table to copy +--- @return table: Clone of the table at all levels, excluding metatables function table.deepCopy(value) if type(value) ~= "table" then error("table.deepCopy: argument is not a table (got " .. type(value) .. ")") end + return deepCopy(value, 1) end ---- Reduce all values in a table to one using a function. Iteration order is unspecified. +--- @brief: Reduces all values in a table to a single value using a reducer function & initial value. Iteration order is unspecified. +--- @param tbl table: Table to reduce +--- @param reducer function: The reducer function, e.g. function(acc, v) return acc + v end - this computes the sum of all values in the table +--- @param initialValue any: Starting value of the accumulator +--- @return any: Result of the reduction function table.reduce(tbl, reducer, initialValue) - if type(tbl) ~= "table" then error("table.reduce: table argument is not a table (got " .. type(tbl) .. ")") end - if type(reducer) ~= "function" then error("table.reduce: reducer is not a function (got " .. type(reducer) .. ")") end + if type(tbl) ~= "table" then + error("table.reduce: table argument is not a table (got " .. type(tbl) .. ")") + end + if type(reducer) ~= "function" then + error("table.reduce: reducer is not a function (got " .. type(reducer) .. ")") + end local accumulator = initialValue - for _, v in pairs(tbl) do accumulator = reducer(accumulator, v) end + for _, v in pairs(tbl) do + accumulator = reducer(accumulator, v) + end return accumulator end ---- Reduce a sequence table to one value, iterating in order. +--- @brief: Reduces a numbered table to a single value using a reducer function & initial value, iterating the sequence part in order. Other keys are ignored. +--- @param tbl table: Numbered table to reduce +--- @param reducer function: The reducer function, e.g. function(acc, v) return acc .. v end - this concatenates all values in order +--- @param initialValue any: Starting value of the accumulator +--- @return any: Result of the reduction function table.ireduce(tbl, reducer, initialValue) - if type(tbl) ~= "table" then error("table.ireduce: table argument is not a table (got " .. type(tbl) .. ")") end - if type(reducer) ~= "function" then error("table.ireduce: reducer is not a function (got " .. type(reducer) .. ")") end + if type(tbl) ~= "table" then + error("table.ireduce: table argument is not a table (got " .. type(tbl) .. ")") + end + if type(reducer) ~= "function" then + error("table.ireduce: reducer is not a function (got " .. type(reducer) .. ")") + end local accumulator = initialValue - for _, v in ipairs(tbl) do accumulator = reducer(accumulator, v) end + for _, v in ipairs(tbl) do + accumulator = reducer(accumulator, v) + end return accumulator end ---- Return the minimum value in a table. Returns nil for an empty table. NaN propagates. +--- @brief: Get the lowest of all values in a table (any table, all values are visited). +--- Returns nil if the table is empty. If any value is NaN, the result is NaN. +--- @param tbl table: Table to search +--- @return any: The lowest value, or nil if the table is empty function table.min(tbl) - if type(tbl) ~= "table" then error("table.min: argument is not a table (got " .. type(tbl) .. ")") end + if type(tbl) ~= "table" then + error("table.min: argument is not a table (got " .. type(tbl) .. ")") + end local min for _, val in pairs(tbl) do - if val ~= val then return val end - if min == nil or val < min then min = val end + if val ~= val then + return val -- NaN propagates + end + if min == nil or val < min then + min = val + end end return min end ---- Return the maximum value in a table. Returns nil for an empty table. NaN propagates. +--- @brief: Get the highest of all values in a table (any table, all values are visited). +--- Returns nil if the table is empty. If any value is NaN, the result is NaN. +--- @param tbl table: Table to search +--- @return any: The highest value, or nil if the table is empty function table.max(tbl) - if type(tbl) ~= "table" then error("table.max: argument is not a table (got " .. type(tbl) .. ")") end + if type(tbl) ~= "table" then + error("table.max: argument is not a table (got " .. type(tbl) .. ")") + end local max for _, val in pairs(tbl) do - if val ~= val then return val end - if max == nil or val > max then max = val end + if val ~= val then + return val -- NaN propagates + end + if max == nil or val > max then + max = val + end end return max end ---- Return the arithmetic mean of all values. Returns nil for an empty table. NaN propagates. +--- @brief: Get the mean of all values in a table (any table, all values are visited). +--- Returns nil if the table is empty. If any value is NaN, the result is NaN. +--- @param tbl table: Table to average +--- @return number|nil: Mean of the values, or nil if the table is empty function table.mean(tbl) - if type(tbl) ~= "table" then error("table.mean: argument is not a table (got " .. type(tbl) .. ")") end + if type(tbl) ~= "table" then + error("table.mean: argument is not a table (got " .. type(tbl) .. ")") + end local sum local count = 0 for _, val in pairs(tbl) do count = count + 1 - sum = sum and sum + val or val + if sum == nil then + sum = val + else + sum = sum + val + end + end + if count == 0 then + return nil end - return count > 0 and sum / count or nil + return sum / count end ---- Return the sum of all values. Returns 0 for an empty table. NaN propagates. +--- @brief: Get the sum of all values in a table (any table, all values are visited). +--- Returns 0 if the table is empty. If any value is NaN, the result is NaN. +--- @param tbl table: Table to sum +--- @return number: Sum of the values, 0 for an empty table function table.sum(tbl) - if type(tbl) ~= "table" then error("table.sum: argument is not a table (got " .. type(tbl) .. ")") end + if type(tbl) ~= "table" then + error("table.sum: argument is not a table (got " .. type(tbl) .. ")") + end local sum = 0 - for _, val in pairs(tbl) do sum = sum + val end + for _, val in pairs(tbl) do + sum = sum + val + end return sum end ---- Return an array of all keys. Order is unspecified. +--- @brief: Get an array of all keys in the table. Order is unspecified. +--- @param tbl table: Table to take the keys from +--- @return table: New numbered table with all keys function table.keys(tbl) - if type(tbl) ~= "table" then error("table.keys: argument is not a table (got " .. type(tbl) .. ")") end + if type(tbl) ~= "table" then + error("table.keys: argument is not a table (got " .. type(tbl) .. ")") + end local result = {} - for k in pairs(tbl) do result[#result + 1] = k end + for k, _ in pairs(tbl) do + result[#result + 1] = k + end return result end ---- Return an array of all values. Order is unspecified. +--- @brief: Get an array of all values in the table. Order is unspecified. +--- @param tbl table: Table to take the values from +--- @return table: New numbered table with all values function table.values(tbl) - if type(tbl) ~= "table" then error("table.values: argument is not a table (got " .. type(tbl) .. ")") end + if type(tbl) ~= "table" then + error("table.values: argument is not a table (got " .. type(tbl) .. ")") + end local result = {} - for _, v in pairs(tbl) do result[#result + 1] = v end + for _, v in pairs(tbl) do + result[#result + 1] = v + end return result end ---- Return true if the table contains the given value (compared with ==). +--- @brief: Check if a table contains a specific value. +--- @param tbl table: Table to check +--- @param value any: Value to search for (compared with ==) +--- @return boolean: True if the table contains the given element function table.contains(tbl, value) - if type(tbl) ~= "table" then error("table.contains: argument is not a table (got " .. type(tbl) .. ")") end + if type(tbl) ~= "table" then + error("table.contains: argument is not a table (got " .. type(tbl) .. ")") + end for _, v in pairs(tbl) do - if v == value then return true end + if v == value then + return true + end end return false end ---- Find a value matching a predicate. Returns (value, key) or (nil, nil) if not found. ---- Check the returned key for nil to distinguish a stored false from "not found". +--- @brief: Find a table element that matches a predicate. Search order is unspecified +--- (sequence part is in practice visited first, in order). +--- @param tbl table: Table to check +--- @param predicate function: Checker function +--- @return any, any: Matching value, and its index (value, key); If not found, returns nil, nil. +--- Check the key for nil to distinguish a stored false value from "not found". function table.find(tbl, predicate) - if type(tbl) ~= "table" then error("table.find: table argument is not a table (got " .. type(tbl) .. ")") end - if type(predicate) ~= "function" then error("table.find: predicate is not a function (got " .. type(predicate) .. ")") end + if type(tbl) ~= "table" then + error("table.find: table argument is not a table (got " .. type(tbl) .. ")") + end + if type(predicate) ~= "function" then + error("table.find: predicate is not a function (got " .. type(predicate) .. ")") + end for k, v in pairs(tbl) do - if predicate(v) then return v, k end + if predicate(v) then + return v, k + end end return nil, nil end ---- Return true if the table has no elements. +--- @brief: Check if a table is empty (has no elements). +--- @param tbl table: Table to check +--- @return boolean: True if empty table function table.isEmpty(tbl) - if type(tbl) ~= "table" then error("table.isEmpty: argument is not a table (got " .. type(tbl) .. ")") end + if type(tbl) ~= "table" then + error("table.isEmpty: argument is not a table (got " .. type(tbl) .. ")") + end return next(tbl) == nil end ---- Return a new array with elements in reverse order. +--- @brief: Reverse an array. Returns a new array with elements in reverse order. +--- @param tbl table: Table to reverse +--- @return table: New table with reversed order function table.reverse(tbl) - if type(tbl) ~= "table" then error("table.reverse: argument is not a table (got " .. type(tbl) .. ")") end + if type(tbl) ~= "table" then + error("table.reverse: argument is not a table (got " .. type(tbl) .. ")") + end local result = {} - for i = #tbl, 1, -1 do result[#result + 1] = tbl[i] end + for i = #tbl, 1, -1 do + result[#result + 1] = tbl[i] + end return result end diff --git a/lua/stdlib/utils.lua b/lua/stdlib/utils.lua index 6e8f540..3db9910 100644 --- a/lua/stdlib/utils.lua +++ b/lua/stdlib/utils.lua @@ -1,81 +1,103 @@ --- Lua-side of the utils module. --- utils.NULL, utils.isNull, utils.toJSON, utils.fromJSON are provided by Rust before this runs. +-- Lua-implemented part of the utils module, extending the global utils table. +-- The native parts of the module (dump, toJSON, fromJSON, NULL, isNull) +-- are declared in Rust before this file is loaded. +-- Substituted when a callback throws a nil error value (e.g. plain `error()`), +-- so that the returned err is always non-nil on failure. local NIL_ERROR_PLACEHOLDER = "unspecified error" --- Pretty-print any Lua value as a string. -local function dumpValue(val, depth, seen) - if depth > 20 then return "..." end - local t = type(val) - if t == "nil" then - return "nil" - elseif t == "boolean" or t == "number" then - return tostring(val) - elseif t == "string" then - return string.format("%q", val) - elseif t == "table" then - if utils.isNull(val) then return "null" end - if seen[val] then return "" end - seen[val] = true - local parts = {} - local len = #val - if len > 0 then - for i = 1, len do - parts[i] = dumpValue(val[i], depth + 1, seen) - end - else - for k, v in pairs(val) do - local key = type(k) == "string" and k or ("[" .. tostring(k) .. "]") - parts[#parts + 1] = key .. " = " .. dumpValue(v, depth + 1, seen) - end - end - seen[val] = nil - if #parts == 0 then return "{}" end - return "{ " .. table.concat(parts, ", ") .. " }" - else - return "<" .. t .. ">" - end -end - ---- Pretty-print any Lua value to a string. -function utils.dump(val) - return dumpValue(val, 0, {}) -end - ---- Call a function, capturing any error. Returns (result, nil) on success or (nil, err) on failure. ---- Extra arguments are forwarded to the callback, like pcall. +--- @brief Call a function, capturing any error - like pcall, but with Go-style returns. +--- +--- local value, err = utils.try(callback) +--- local value, err = utils.try(callback, arg1, arg2) -- pcall-style shorthand +--- if err then +--- print(err) +--- else +--- -- handle value +--- end +--- +--- - err is nil if the callback succeeded, otherwise the error value +--- - value is the callback's first return value (nil on error) +--- - extra arguments are passed to the callback, like with pcall +--- +--- This is less confusing than pcall when the return value is actually needed. +--- The thrown error value is passed through as-is, so a table thrown with +--- error({code = 42}) stays a table. The downside is that the result can't be +--- used directly as a condition in "if" or "while" - simply use pcall there. +--- +--- @param callback function: Function to call +--- @param ... any: Arguments passed to the callback +--- @return any, any: The callback's first return value and nil, or nil and the error function utils.try(callback, ...) if type(callback) ~= "function" then error("utils.try: callback is not a function (got " .. type(callback) .. ")") end + local ok, result = pcall(callback, ...) - if ok then return result, nil end - if result == nil then result = NIL_ERROR_PLACEHOLDER end + if ok then + return result, nil + end + if result == nil then + result = NIL_ERROR_PLACEHOLDER + end return nil, result end local TRYN_MAX_COUNT = 64 ---- Like utils.try but for functions that return N values. ---- Returns (v1, ..., vN, nil) on success or (nil, ..., nil, err) on failure. +--- @brief Call a function, capturing any error - like utils.try, but for N return values. +--- +--- local value1, value2, err = utils.tryn(2, callback) +--- local value1, value2, err = utils.tryn(2, callback, arg1, arg2) -- pcall-style shorthand +--- if err then +--- print(err) +--- else +--- -- handle (value1, value2) +--- end +--- +--- - err is nil if the callback succeeded, otherwise the error value +--- - values are the callback's return values, padded with nils or truncated to exactly N +--- - extra arguments are passed to the callback, like with pcall +--- +--- This is less confusing than pcall for multiple return values, where the error +--- and the first value share a slot: +--- +--- local status, errorOrValue1, value2 = pcall(callback) +--- +--- @param expectedCount number: How many return values to pass through (integer, 0 to 64) +--- @param callback function: Function to call +--- @param ... any: Arguments passed to the callback +--- @return any ...: expectedCount values followed by the error or nil function utils.tryn(expectedCount, callback, ...) local count = math.tointeger(expectedCount) - if count == nil then error("utils.tryn: expectedCount must be an integer number") end + if count == nil then + error("utils.tryn: expectedCount must be an integer number") + end expectedCount = count - if expectedCount < 0 then error("utils.tryn: expectedCount must not be negative") end + if expectedCount < 0 then + error("utils.tryn: expectedCount must not be negative") + end if expectedCount > TRYN_MAX_COUNT then error("utils.tryn: at most " .. TRYN_MAX_COUNT .. " return values are supported") end if type(callback) ~= "function" then error("utils.tryn: callback is not a function (got " .. type(callback) .. ")") end + local results = table.pack(pcall(callback, ...)) + if results[1] then + -- Success: pass through values 2..expectedCount+1 (truncating or nil-padding), + -- with nil in the error slot. The explicit assignment overwrites a possible + -- extra return value so it cannot leak into the error slot. results[expectedCount + 2] = nil return table.unpack(results, 2, expectedCount + 2) end + local err = results[2] - if err == nil then err = NIL_ERROR_PLACEHOLDER end + if err == nil then + err = NIL_ERROR_PLACEHOLDER + end local out = {} out[expectedCount + 1] = err return table.unpack(out, 1, expectedCount + 1) diff --git a/lua/test.lua b/lua/test.lua index d9b6711..1c23292 100644 --- a/lua/test.lua +++ b/lua/test.lua @@ -1,42 +1,50 @@ -- Smoke-test for the stdlib extensions. print("=== math ===") -print("round(2.5) =", math.round(2.5)) -- 3 +print("round(2.5) =", math.round(2.5)) -- 3 print("round(2.567,2) =", math.round(2.567, 2)) -- 2.57 print("clamp(10,0,5) =", math.clamp(10, 0, 5)) -- 5 -print("isFinite(1/0) =", math.isFinite(1/0)) -- false -print("isFinite(3.14) =", math.isFinite(3.14)) -- true -print("sign(-7) =", math.sign(-7)) -- -1 +print("isFinite(1/0) =", math.isFinite(1 / 0)) -- false +print("isFinite(3.14) =", math.isFinite(3.14)) -- true +print("sign(-7) =", math.sign(-7)) -- -1 print("scale(5,0,10,0,100) =", math.scale(5, 0, 10, 0, 100)) -- 50.0 print("\n=== table ===") -local t = {3, 1, 4, 1, 5} -print("sum =", table.sum(t)) -- 14 -print("min =", table.min(t)) -- 1 -print("max =", table.max(t)) -- 5 -print("mean =", table.mean(t)) -- 2.8 -local evens = table.ifilter(t, function(v) return v % 2 == 0 end) +local t = { 3, 1, 4, 1, 5 } +print("sum =", table.sum(t)) -- 14 +print("min =", table.min(t)) -- 1 +print("max =", table.max(t)) -- 5 +print("mean =", table.mean(t)) -- 2.8 +local evens = table.ifilter(t, function(v) + return v % 2 == 0 +end) print("evens =", table.concat(evens, ",")) -- 4 -local doubled = table.map({1,2,3}, function(v) return v * 2 end) +local doubled = table.map({ 1, 2, 3 }, function(v) + return v * 2 +end) print("doubled=", table.concat(doubled, ",")) -- 2,4,6 -print("deepCopy ok:", table.deepCopy({x={y=1}}).x.y == 1) -print("merged =", table.concat(table.merge({1,2},{3,4}), ",")) -- 1,2,3,4 +print("deepCopy ok:", table.deepCopy({ x = { y = 1 } }).x.y == 1) +print("merged =", table.concat(table.merge({ 1, 2 }, { 3, 4 }), ",")) -- 1,2,3,4 print("\n=== utils ===") print("dump(nil) =", utils.dump(nil)) -print("dump({1,2}) =", utils.dump({1, 2})) -print("dump({x=1}) =", utils.dump({x = 1})) +print("dump({1,2}) =", utils.dump({ 1, 2 })) +print("dump({x=1}) =", utils.dump({ x = 1 })) print("isNull(NULL) =", utils.isNull(utils.NULL)) print("isNull(nil) =", utils.isNull(nil)) -local json = utils.toJSON({name="Alice", age=30, tags={"a","b"}}) +local json = utils.toJSON({ name = "Alice", age = 30, tags = { "a", "b" } }) print("toJSON =", json) local parsed = utils.fromJSON(json) print("fromJSON name=", parsed.name, "age=", parsed.age) -local v, err = utils.try(function() return 42 end) +local v, err = utils.try(function() + return 42 +end) print("try ok: v=", v, "err=", err) -local v2, err2 = utils.try(function() error("oops") end) +local v2, err2 = utils.try(function() + error("oops") +end) print("try err: v=", v2, "err ~= nil:", err2 ~= nil) print("\n=== os ===") diff --git a/lua/test_sqlite.lua b/lua/test_sqlite.lua index 2de1581..b058c68 100644 --- a/lua/test_sqlite.lua +++ b/lua/test_sqlite.lua @@ -2,8 +2,10 @@ local function assert_eq(actual, expected, msg) if actual ~= expected then - error(string.format("%s: expected %s, got %s", - msg or "assertion failed", tostring(expected), tostring(actual)), 2) + error( + string.format("%s: expected %s, got %s", msg or "assertion failed", tostring(expected), tostring(actual)), + 2 + ) end end @@ -25,7 +27,7 @@ con:execute([[ -- Positional params, every supported type. local changed = con:execute( "INSERT INTO people (name, age, score, vip, notes) VALUES (?, ?, ?, ?, ?)", - {"Alice", 30, 9.5, true, "first"} + { "Alice", 30, 9.5, true, "first" } ) assert_eq(changed, 1, "execute returns rows changed") assert_eq(con:changes(), 1, "changes() after insert") @@ -35,7 +37,7 @@ assert_eq(first_id, 1, "lastInsertRowid") -- Named params (mix of :name in SQL). con:execute( "INSERT INTO people (name, age, score, vip, notes) VALUES (:name, :age, :score, :vip, :notes)", - {name = "Bob", age = 42, score = 7.25, vip = false, notes = utils.NULL} + { name = "Bob", age = 42, score = 7.25, vip = false, notes = utils.NULL } ) assert_eq(con:lastInsertRowid(), 2, "lastInsertRowid after second insert") @@ -51,33 +53,33 @@ assert_eq(rows[1].notes, "first", "row 1 notes (text round-trip)") assert_eq(rows[2].notes, nil, "row 2 notes is nil (NULL round-trip)") print("=== queryOne ===") -local bob = con:queryOne("SELECT * FROM people WHERE name = ?", {"Bob"}) +local bob = con:queryOne("SELECT * FROM people WHERE name = ?", { "Bob" }) assert(bob ~= nil, "queryOne found Bob") assert_eq(bob.age, 42, "queryOne Bob age") -local missing = con:queryOne("SELECT * FROM people WHERE name = ?", {"Nobody"}) +local missing = con:queryOne("SELECT * FROM people WHERE name = ?", { "Nobody" }) assert_eq(missing, nil, "queryOne returns nil for no match") print("=== UPDATE / DELETE ===") -local n = con:execute("UPDATE people SET age = age + 1 WHERE name = :who", {who = "Alice"}) +local n = con:execute("UPDATE people SET age = age + 1 WHERE name = :who", { who = "Alice" }) assert_eq(n, 1, "update affected 1 row") -assert_eq(con:queryOne("SELECT age FROM people WHERE name = ?", {"Alice"}).age, 31, "age incremented") +assert_eq(con:queryOne("SELECT age FROM people WHERE name = ?", { "Alice" }).age, 31, "age incremented") -n = con:execute("DELETE FROM people WHERE name = ?", {"Bob"}) +n = con:execute("DELETE FROM people WHERE name = ?", { "Bob" }) assert_eq(n, 1, "delete affected 1 row") assert_eq(#con:query("SELECT * FROM people"), 1, "one row left after delete") print("=== transaction(fn) — commit ===") con:transaction(function() - con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Carol", 25}) - con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Dave", 28}) + con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Carol", 25 }) + con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Dave", 28 }) end) assert_eq(#con:query("SELECT * FROM people"), 3, "two rows committed") print("=== transaction(fn) — rollback on error ===") local ok, err = pcall(function() con:transaction(function() - con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Eve", 99}) + con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Eve", 99 }) error("boom") end) end) @@ -87,25 +89,27 @@ assert_eq(#con:query("SELECT * FROM people"), 3, "failed transaction rolled back print("=== manual begin / commit ===") con:begin() -con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Frank", 50}) +con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Frank", 50 }) con:commit() assert_eq(#con:query("SELECT * FROM people"), 4, "manual commit persisted") print("=== manual begin / rollback ===") con:begin() -con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Grace", 60}) +con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Grace", 60 }) con:rollback() assert_eq(#con:query("SELECT * FROM people"), 4, "manual rollback discarded") print("=== mixed param table is rejected ===") local mixed_ok = pcall(function() - con:query("SELECT 1 WHERE 1 = ?", {1, key = "x"}) + con:query("SELECT 1 WHERE 1 = ?", { 1, key = "x" }) end) assert_eq(mixed_ok, false, "mixed positional/named params error") print("=== close ===") con:close() -local closed_ok = pcall(function() con:query("SELECT 1") end) +local closed_ok = pcall(function() + con:query("SELECT 1") +end) assert_eq(closed_ok, false, "use after close errors") print("\nAll sqlite checks passed.") diff --git a/src/lua_tests/async_tests.rs b/src/lua_tests/async_tests.rs new file mode 100644 index 0000000..fe30c40 --- /dev/null +++ b/src/lua_tests/async_tests.rs @@ -0,0 +1,313 @@ +//! Tests for the async parts of the stdlib: os.sleep, task.join and sqlite. +//! These have no flowbox-rt counterpart and need a tokio runtime, so they live +//! apart from the ported sync tests. + +use std::time::{Duration, Instant}; + +use super::lua; + +#[tokio::test] +async fn test_os_sleep_actually_sleeps() { + let lua = lua(); + + let start = Instant::now(); + lua.load(r#"os.sleep(0.1)"#).exec_async().await.unwrap(); + let elapsed = start.elapsed(); + + assert!(elapsed >= Duration::from_millis(95), "slept only {elapsed:?}"); + assert!(elapsed < Duration::from_millis(500), "slept too long: {elapsed:?}"); +} + +#[tokio::test] +async fn test_os_sleep_invalid_duration() { + let lua = lua(); + + // Negative, NaN and infinite durations must raise a Lua error, not panic the host + for src in ["os.sleep(-1)", "os.sleep(0/0)", "os.sleep(math.huge)"] { + let err = lua.load(src).exec_async().await.unwrap_err(); + assert!(err.to_string().contains("os.sleep"), "{src}: {err}"); + } +} + +#[tokio::test] +async fn test_os_sleep_rejects_huge_finite_duration() { + let lua = lua(); + + // A huge-but-finite duration must be rejected promptly, not parked forever + // (which would read as the runtime hanging). The test itself would hang if + // this regressed, so tokio's test timeout / the assertion guards it. + let err = lua.load(r#"os.sleep(1e18)"#).exec_async().await.unwrap_err(); + assert!(err.to_string().contains("os.sleep"), "{err}"); + + // A duration within the cap is accepted (0 sleeps instantly). + lua.load(r#"os.sleep(0)"#).exec_async().await.unwrap(); +} + +#[tokio::test] +async fn test_task_join_returns_results_positionally() { + let lua = lua(); + + let (a, b, c): (i64, String, bool) = lua + .load( + r#" + return task.join( + function() return 1 end, + function() os.sleep(0.01) return "two" end, + function() return true end + ) + "#, + ) + .eval_async() + .await + .unwrap(); + + assert_eq!(a, 1); + assert_eq!(b, "two"); + assert!(c); +} + +#[tokio::test] +async fn test_task_join_runs_concurrently() { + let lua = lua(); + + let start = Instant::now(); + lua.load( + r#" + task.join( + function() os.sleep(0.15) end, + function() os.sleep(0.1) end, + function() os.sleep(0.05) end + ) + "#, + ) + .exec_async() + .await + .unwrap(); + let elapsed = start.elapsed(); + + // Sequential execution would take ~0.3s; overlapped, the slowest task dominates. + assert!(elapsed >= Duration::from_millis(140), "finished too fast: {elapsed:?}"); + assert!(elapsed < Duration::from_millis(280), "tasks did not overlap: {elapsed:?}"); +} + +#[tokio::test] +async fn test_task_join_rejects_non_function_argument() { + let lua = lua(); + + // A non-function argument gives a prefixed, positioned error, not mlua's + // bare "error converting Lua integer to function". + let err = lua + .load(r#"task.join(function() end, 42)"#) + .exec_async() + .await + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("task.join: argument #2 must be a function"), "{msg}"); + assert!(msg.contains("integer"), "{msg}"); +} + +#[tokio::test] +async fn test_os_sleep_rejects_non_number() { + let lua = lua(); + + let err = lua.load(r#"os.sleep("soon")"#).exec_async().await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("os.sleep: duration must be a number"), "{msg}"); +} + +#[tokio::test] +async fn test_task_join_propagates_errors() { + let lua = lua(); + + let err = lua + .load(r#"task.join(function() error("boom") end, function() return 1 end)"#) + .exec_async() + .await + .unwrap_err(); + assert!(err.to_string().contains("boom")); +} + +#[tokio::test] +async fn test_sqlite_in_memory_roundtrip() { + let lua = lua(); + + let (name, id, count): (String, i64, i64) = lua + .load( + r#" + local db = sqlite.connect(":memory:") + db:execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)") + db:execute("INSERT INTO t (name) VALUES (?)", {"alice"}) + db:execute("INSERT INTO t (name) VALUES (?)", {"bob"}) + local id = db:lastInsertRowid() + local row = db:queryOne("SELECT name FROM t WHERE id = ?", {1}) + local rows = db:query("SELECT * FROM t") + db:close() + return row.name, id, #rows + "#, + ) + .eval_async() + .await + .unwrap(); + + assert_eq!(name, "alice"); + assert_eq!(id, 2); + assert_eq!(count, 2); +} + +#[tokio::test] +async fn test_sqlite_binds_non_utf8_as_blob() { + let lua = lua(); + + // A non-UTF-8 / NUL-containing Lua string must bind and round-trip intact + // (as a BLOB) rather than failing the UTF-8 conversion. + let ok: bool = lua + .load( + r#" + local db = sqlite.connect(":memory:") + db:execute("CREATE TABLE t (id INTEGER PRIMARY KEY, data)") + local blob = "\255\0\254bin" + db:execute("INSERT INTO t (data) VALUES (?)", {blob}) + local got = db:queryOne("SELECT data FROM t WHERE id = 1").data + return got == blob + "#, + ) + .eval_async() + .await + .unwrap(); + assert!(ok); +} + +#[tokio::test] +async fn test_sqlite_rejects_empty_and_multi_statements() { + let lua = lua(); + + let err = lua + .load(r#"local db = sqlite.connect(":memory:"); return db:execute(" ")"#) + .eval_async::() + .await + .unwrap_err(); + assert!(err.to_string().contains("no SQL statement"), "{err}"); + + // A comment-only statement has no runnable SQL either. + let err = lua + .load(r#"local db = sqlite.connect(":memory:"); return db:query("-- only a comment")"#) + .eval_async::() + .await + .unwrap_err(); + assert!(err.to_string().contains("no SQL statement"), "{err}"); + + let err = lua + .load( + r#" + local db = sqlite.connect(":memory:") + db:execute("CREATE TABLE t (x)") + return db:execute("INSERT INTO t VALUES (1); INSERT INTO t VALUES (2)") + "#, + ) + .eval_async::() + .await + .unwrap_err(); + assert!(err.to_string().contains("single SQL statement"), "{err}"); +} + +#[tokio::test] +async fn test_sqlite_execute_batch_runs_all_statements() { + let lua = lua(); + + let count: i64 = lua + .load( + r#" + local db = sqlite.connect(":memory:") + db:executeBatch([[ + CREATE TABLE t (x); + INSERT INTO t VALUES (1); + INSERT INTO t VALUES (2); + INSERT INTO t VALUES (3); + ]]) + return db:queryOne("SELECT COUNT(*) AS n FROM t").n + "#, + ) + .eval_async() + .await + .unwrap(); + assert_eq!(count, 3); +} + +#[tokio::test] +async fn test_sqlite_transaction_passes_through_return_values() { + let lua = lua(); + + let (a, b): (i64, String) = lua + .load( + r#" + local db = sqlite.connect(":memory:") + db:execute("CREATE TABLE t (x)") + return db:transaction(function() return 7, "eight" end) + "#, + ) + .eval_async() + .await + .unwrap(); + assert_eq!(a, 7); + assert_eq!(b, "eight"); +} + +#[tokio::test] +async fn test_sqlite_transaction_does_not_mask_original_error() { + let lua = lua(); + + // If the body fails AND rollback then also fails (here: the connection is + // closed inside the body), the caller must still see the body's error. + let err = lua + .load( + r#" + local db = sqlite.connect(":memory:") + db:execute("CREATE TABLE t (x)") + db:transaction(function() + db:close() + error("ORIGINAL ERROR") + end) + "#, + ) + .exec_async() + .await + .unwrap_err(); + assert!(err.to_string().contains("ORIGINAL ERROR"), "{err}"); +} + +#[tokio::test] +async fn test_sqlite_transaction_commit_and_rollback() { + let lua = lua(); + + let (after_commit, after_rollback): (i64, i64) = lua + .load( + r#" + local db = sqlite.connect(":memory:") + db:execute("CREATE TABLE t (x)") + + db:transaction(function() + db:execute("INSERT INTO t VALUES (1)") + end) + local committed = db:queryOne("SELECT COUNT(*) AS n FROM t").n + + local ok, err = pcall(function() + db:transaction(function() + db:execute("INSERT INTO t VALUES (2)") + error("nope") + end) + end) + assert(not ok) + assert(tostring(err):find("nope")) + local rolled_back = db:queryOne("SELECT COUNT(*) AS n FROM t").n + + return committed, rolled_back + "#, + ) + .eval_async() + .await + .unwrap(); + + assert_eq!(after_commit, 1); + // The failed transaction's insert must have been rolled back. + assert_eq!(after_rollback, 1); +} diff --git a/src/lua_tests/core_tests.rs b/src/lua_tests/core_tests.rs new file mode 100644 index 0000000..619b54f --- /dev/null +++ b/src/lua_tests/core_tests.rs @@ -0,0 +1,226 @@ +//! Core environment tests: basic Lua evaluation, sandbox verification, and +//! checks that the stdlib extends rather than replaces the standard modules. +//! +//! Ported from flowbox-rt's fb_lua core_tests, with sandbox-verification +//! tests added for this project's sandbox (see src/sandbox.rs). + +use super::assert_eq_f64; + +/// Smoke test for the environment setup +#[test] +fn test_call_lua_function() { + let lua = super::lua(); + + let chunk: mlua::Function = lua + .load( + r#" + function(a, b) + return a + b + end + "#, + ) + .eval() + .unwrap(); + + let res: i32 = chunk.call((10, 20)).unwrap(); + assert_eq!(res, 30); +} + +/// dofile, loadfile and load (raw bytecode loading) are removed from the sandbox. +#[test] +fn test_sandbox_chunk_loaders_removed() { + let lua = super::lua(); + + for name in ["dofile", "loadfile", "load"] { + let is_nil: bool = lua.load(format!("return {name} == nil")).eval().unwrap(); + assert!(is_nil, "global `{name}` should be nil in the sandbox"); + } +} + +/// string.dump (bytecode producer, the counterpart of load) is removed. +#[test] +fn test_sandbox_string_dump_removed() { + let lua = super::lua(); + + let is_nil: bool = lua.load(r#"return string.dump == nil"#).eval().unwrap(); + assert!(is_nil, "string.dump should be nil in the sandbox"); +} + +/// io, package and debug libraries are not loaded at all. +#[test] +fn test_sandbox_io_package_debug_not_loaded() { + let lua = super::lua(); + + for name in ["io", "package", "debug"] { + let is_nil: bool = lua.load(format!("return {name} == nil")).eval().unwrap(); + assert!(is_nil, "global `{name}` should be nil in the sandbox"); + } +} + +/// collectgarbage only allows the "count" option; everything else raises +/// an error mentioning the sandbox. +#[test] +fn test_sandbox_collectgarbage_count_only() { + let lua = super::lua(); + + let count: f64 = lua.load(r#"return collectgarbage("count")"#).eval().unwrap(); + assert!(count > 0.0, "collectgarbage(\"count\") should report heap KB, got {count}"); + + let err = lua.load(r#"return collectgarbage()"#).exec().unwrap_err(); + assert!(err.to_string().contains("sandboxed"), "unexpected error: {err}"); + + let err = lua.load(r#"return collectgarbage("collect")"#).exec().unwrap_err(); + assert!(err.to_string().contains("sandboxed"), "unexpected error: {err}"); +} + +/// The os table only keeps the safe time functions plus our extensions. +#[test] +fn test_sandbox_os_dangerous_functions_removed() { + let lua = super::lua(); + + let os_table = lua.globals().get::("os").unwrap(); + for name in ["execute", "exit", "getenv", "remove", "rename", "setlocale", "tmpname"] { + assert!( + !os_table.contains_key(name).unwrap(), + "os.{name} should be removed from the sandbox" + ); + } + for name in ["date", "difftime", "time", "clock", "microtime", "sleep"] { + assert!( + os_table.contains_key(name).unwrap(), + "os.{name} should be present in the sandbox" + ); + } +} + +/// Verify that the math table was extended with custom functions, +/// not replaced - standard Lua math functions must still exist. +#[test] +fn test_math_table_extended_not_replaced() { + let lua = super::lua(); + + // Test standard math functions still work + let result: f64 = lua.load(r#"return math.floor(3.7)"#).eval().unwrap(); + assert_eq_f64!(result, 3.0); + + let result: f64 = lua.load(r#"return math.ceil(3.2)"#).eval().unwrap(); + assert_eq_f64!(result, 4.0); + + let result: f64 = lua.load(r#"return math.abs(-5)"#).eval().unwrap(); + assert_eq_f64!(result, 5.0); + + let result: f64 = lua.load(r#"return math.sqrt(16)"#).eval().unwrap(); + assert_eq_f64!(result, 4.0); + + let result: f64 = lua.load(r#"return math.sin(0)"#).eval().unwrap(); + assert_eq_f64!(result, 0.0); + + let result: f64 = lua.load(r#"return math.cos(0)"#).eval().unwrap(); + assert_eq_f64!(result, 1.0); + + let result: f64 = lua.load(r#"return math.max(1, 5, 3)"#).eval().unwrap(); + assert_eq_f64!(result, 5.0); + + let result: f64 = lua.load(r#"return math.min(1, 5, 3)"#).eval().unwrap(); + assert_eq_f64!(result, 1.0); + + // Verify math.pi constant exists + let result: f64 = lua.load(r#"return math.pi"#).eval().unwrap(); + assert!(result > 3.14 && result < 3.15); + + // Verify our custom functions are also present + let result: i64 = lua.load(r#"return math.round(3.7)"#).eval().unwrap(); + assert_eq!(result, 4); + + let result: f64 = lua.load(r#"return math.round(3.1415, 2)"#).eval().unwrap(); + assert_eq_f64!(result, 3.14); + + let result: i64 = lua.load(r#"return math.clamp(50, 0, 100)"#).eval().unwrap(); + assert_eq!(result, 50); +} + +/// Verify that the table module was extended with custom functions, +/// not replaced - standard Lua table functions must still exist. +#[test] +fn test_table_module_extended_not_replaced() { + let lua = super::lua(); + + // Test standard table functions still work + // table.insert + let result: String = lua + .load( + r#" + local t = {1, 2, 3} + table.insert(t, 4) + return utils.toJSON(t) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[1,2,3,4]"); + + // table.remove + let result: String = lua + .load( + r#" + local t = {1, 2, 3, 4} + table.remove(t, 2) + return utils.toJSON(t) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[1,3,4]"); + + // table.concat + let result: String = lua.load(r#"return table.concat({"a", "b", "c"}, "-")"#).eval().unwrap(); + assert_eq!(result, "a-b-c"); + + // table.sort + let result: String = lua + .load( + r#" + local t = {3, 1, 4, 1, 5, 9} + table.sort(t) + return utils.toJSON(t) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[1,1,3,4,5,9]"); + + // table.unpack + let result: i64 = lua + .load( + r#" + local t = {10, 20, 30} + local a, b, c = table.unpack(t) + return a + b + c + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 60); + + // Verify our custom functions are also present + let _: () = lua + .load( + r#" + local ro = table.readonly({x = 1}) + assert(ro.x == 1) + "#, + ) + .exec() + .unwrap(); + + let result: String = lua + .load( + r#" + local merged = table.merge({a = 1}, {b = 2}) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + assert!(result.contains("\"a\":1") && result.contains("\"b\":2")); +} diff --git a/src/lua_tests/math_tests.rs b/src/lua_tests/math_tests.rs new file mode 100644 index 0000000..1f9ab62 --- /dev/null +++ b/src/lua_tests/math_tests.rs @@ -0,0 +1,398 @@ +//! Tests for the math stdlib extensions (lua/stdlib/math.lua). + +use super::assert_eq_f64; +use mlua::prelude::LuaValue; + +#[test] +fn test_clamp() { + let lua = super::lua(); + + // Simple, integers + let result: i64 = lua.load(r#"return math.clamp(3, 10, 20)"#).eval().unwrap(); + assert_eq!(result, 10); // low + let result: i64 = lua.load(r#"return math.clamp(3, nil, 20)"#).eval().unwrap(); + assert_eq!(result, 3); // low-unbounded + + let result: i64 = lua.load(r#"return math.clamp(15, 10, 20)"#).eval().unwrap(); + assert_eq!(result, 15); // inside + + let result: i64 = lua.load(r#"return math.clamp(25, 10, 20)"#).eval().unwrap(); + assert_eq!(result, 20); // high + let result: i64 = lua.load(r#"return math.clamp(25, 10, nil)"#).eval().unwrap(); + assert_eq!(result, 25); // high-unbounded + + let result: i64 = lua.load(r#"return math.clamp(999, nil, nil)"#).eval().unwrap(); + assert_eq!(result, 999); // unbounded + + // Floats + let result: f64 = lua.load(r#"return math.clamp(3.5, 10.5, 20.5)"#).eval().unwrap(); + assert_eq_f64!(result, 10.5); // low + let result: f64 = lua.load(r#"return math.clamp(3.5, nil, 20.5)"#).eval().unwrap(); + assert_eq_f64!(result, 3.5); // low-unbounded + + let result: f64 = lua.load(r#"return math.clamp(15.5, 10.5, 20.5)"#).eval().unwrap(); + assert_eq_f64!(result, 15.5); // inside + + let result: f64 = lua.load(r#"return math.clamp(25.5, 10.5, 20.5)"#).eval().unwrap(); + assert_eq_f64!(result, 20.5); // high + let result: f64 = lua.load(r#"return math.clamp(25.5, 10.5, nil)"#).eval().unwrap(); + assert_eq_f64!(result, 25.5); // high-unbounded + + let result: f64 = lua.load(r#"return math.clamp(999.9, nil, nil)"#).eval().unwrap(); + assert_eq_f64!(result, 999.9); // unbounded + + // Check some negative numbers + let result: i64 = lua.load(r#"return math.clamp(3, -10, 20)"#).eval().unwrap(); + assert_eq!(result, 3); // low + let result: i64 = lua.load(r#"return math.clamp(-15, -10, 20)"#).eval().unwrap(); + assert_eq!(result, -10); // inside + let result: i64 = lua.load(r#"return math.clamp(-25, -10, 20)"#).eval().unwrap(); + assert_eq!(result, -10); // high + let result: i64 = lua.load(r#"return math.clamp(25, -10, 20)"#).eval().unwrap(); + assert_eq!(result, 20); // high + + assert!(lua.load(r#"return math.clamp(nil, 1, 2)"#).eval::().is_err()); + assert!(lua.load(r#"return math.clamp(1, "1", 2)"#).eval::().is_err()); + assert!(lua.load(r#"return math.clamp(1, "foo", 2)"#).eval::().is_err()); + assert!(lua.load(r#"return math.clamp(1, 1, "bar")"#).eval::().is_err()); + assert!(lua.load(r#"return math.clamp(1, 1, "2")"#).eval::().is_err()); + + // Crossed bounds (min > max) is a programming error, not a valid interval + let err = lua.load(r#"return math.clamp(5, 10, 0)"#).exec().unwrap_err(); + assert!(err.to_string().contains("greater than")); +} + +#[test] +fn test_round() { + let lua = super::lua(); + + // Round positive numbers + let result: i64 = lua.load(r#"return math.round(3.4)"#).eval().unwrap(); + assert_eq!(result, 3); + + let result: i64 = lua.load(r#"return math.round(3.5)"#).eval().unwrap(); + assert_eq!(result, 4); + + let result: i64 = lua.load(r#"return math.round(3.9)"#).eval().unwrap(); + assert_eq!(result, 4); + + // Round negative numbers + let result: i64 = lua.load(r#"return math.round(-3.4)"#).eval().unwrap(); + assert_eq!(result, -3); + + let result: i64 = lua.load(r#"return math.round(-3.5)"#).eval().unwrap(); + assert_eq!(result, -4); + + // Round whole numbers (should stay the same) + let result: i64 = lua.load(r#"return math.round(5)"#).eval().unwrap(); + assert_eq!(result, 5); + + let result: i64 = lua.load(r#"return math.round(0)"#).eval().unwrap(); + assert_eq!(result, 0); + + // Round with a non-number should error + let err = lua.load(r#"return math.round("not a number")"#).exec().unwrap_err(); + assert!(err.to_string().contains("non-number")); + + // NaN must error, not silently become 0 + let err = lua.load(r#"return math.round(0/0)"#).exec().unwrap_err(); + assert!(err.to_string().contains("finite")); + + // Infinity must error (out of integer range) + assert!(lua.load(r#"return math.round(math.huge)"#).exec().is_err()); + assert!(lua.load(r#"return math.round(-math.huge)"#).exec().is_err()); + + // Out of i64 range must error + assert!(lua.load(r#"return math.round(1e19)"#).exec().is_err()); + assert!(lua.load(r#"return math.round(-1e19)"#).exec().is_err()); + + // Half away from zero at exactly +-0.5 + let result: i64 = lua.load(r#"return math.round(0.5)"#).eval().unwrap(); + assert_eq!(result, 1); + let result: i64 = lua.load(r#"return math.round(-0.5)"#).eval().unwrap(); + assert_eq!(result, -1); + + // The naive floor(value + 0.5) trap: 0.49999999999999994 + 0.5 == 1.0 in f64, + // but the nearest integer is 0 + let result: i64 = lua.load(r#"return math.round(0.49999999999999994)"#).eval().unwrap(); + assert_eq!(result, 0); + let result: i64 = lua.load(r#"return math.round(-0.49999999999999994)"#).eval().unwrap(); + assert_eq!(result, 0); + + // Integer-valued floats at the edge of f64 precision (2^53) + let result: i64 = lua.load(r#"return math.round(2.0^53 - 1.0)"#).eval().unwrap(); + assert_eq!(result, 9007199254740991); + let result: i64 = lua.load(r#"return math.round(2.0^53)"#).eval().unwrap(); + assert_eq!(result, 9007199254740992); + + // -2^63 is exactly representable as a float and is a valid Lua integer... + let result: bool = lua.load(r#"return math.round(-2.0^63) == math.mininteger"#).eval().unwrap(); + assert!(result); + + // ...but 2^63 is one above math.maxinteger and must error + assert!(lua.load(r#"return math.round(2.0^63)"#).exec().is_err()); +} + +//noinspection RsApproxConstant +#[test] +fn test_round_to_places() { + let lua = super::lua(); + + // roundn was merged into round - it must no longer exist + let result: bool = lua.load(r#"return math.roundn == nil"#).eval().unwrap(); + assert!(result); + + // places = 0 behaves like the one-argument form and returns an integer + let result: String = lua.load(r#"return math.type(math.round(3.1415, 0))"#).eval().unwrap(); + assert_eq!(result, "integer"); + let result: i64 = lua.load(r#"return math.round(3.7, 0)"#).eval().unwrap(); + assert_eq!(result, 4); + + // places > 0 rounds to N decimal places and returns a float + let result: f64 = lua.load(r#"return math.round(3.1415, 1)"#).eval().unwrap(); + assert_eq_f64!(result, 3.1); + + let result: f64 = lua.load(r#"return math.round(3.1415, 3)"#).eval().unwrap(); + assert_eq_f64!(result, 3.142); + + let result: f64 = lua.load(r#"return math.round(2.567, 2)"#).eval().unwrap(); + assert_eq_f64!(result, 2.57); + + let result: String = lua.load(r#"return math.type(math.round(3.1415, 2))"#).eval().unwrap(); + assert_eq!(result, "float"); + + // Integer input with places > 0 returns the same value as a float + let result: String = lua.load(r#"return math.type(math.round(5, 2))"#).eval().unwrap(); + assert_eq!(result, "float"); + let result: f64 = lua.load(r#"return math.round(5, 2)"#).eval().unwrap(); + assert_eq_f64!(result, 5.0); + + // Negative numbers round half away from zero + let result: f64 = lua.load(r#"return math.round(-2.345, 2)"#).eval().unwrap(); + assert_eq_f64!(result, -2.35); + + // Huge values must not overflow to infinity via the 10^places multiplier + let result: f64 = lua.load(r#"return math.round(1.5e308, 2)"#).eval().unwrap(); + assert_eq_f64!(result, 1.5e308); + + // Huge places are a no-op (cannot change any f64), not an overflow + let result: f64 = lua.load(r#"return math.round(12.5, 400)"#).eval().unwrap(); + assert_eq_f64!(result, 12.5); + + // Tiny values still round correctly with large places + let result: f64 = lua.load(r#"return math.round(1.23e-300, 300)"#).eval().unwrap(); + assert_eq_f64!(result, 1.0e-300); + + // Places given as an integral float is accepted + let result: f64 = lua.load(r#"return math.round(3.14159, 2.0)"#).eval().unwrap(); + assert_eq_f64!(result, 3.14); + + // Non-integral places must error + assert!(lua.load(r#"return math.round(3.14, 2.5)"#).exec().is_err()); + + // Negative places must error with a clear message + let err = lua.load(r#"return math.round(3.14, -2)"#).exec().unwrap_err(); + assert!(err.to_string().contains("negative")); + + // NaN must error + let err = lua.load(r#"return math.round(0/0, 2)"#).exec().unwrap_err(); + assert!(err.to_string().contains("finite")); + + // Infinity must error + assert!(lua.load(r#"return math.round(math.huge, 2)"#).exec().is_err()); + + // Non-number must error + let err = lua.load(r#"return math.round("foo", 2)"#).exec().unwrap_err(); + assert!(err.to_string().contains("non-number")); +} + +#[test] +fn test_is_finite() { + let lua = super::lua(); + + // Regular numbers are finite + let result: bool = lua.load(r#"return math.isFinite(42)"#).eval().unwrap(); + assert!(result); + + let result: bool = lua.load(r#"return math.isFinite(-3.14)"#).eval().unwrap(); + assert!(result); + + let result: bool = lua.load(r#"return math.isFinite(0)"#).eval().unwrap(); + assert!(result); + + // Infinity is not finite + let result: bool = lua.load(r#"return math.isFinite(math.huge)"#).eval().unwrap(); + assert!(!result); + + let result: bool = lua.load(r#"return math.isFinite(-math.huge)"#).eval().unwrap(); + assert!(!result); + + // NaN is not finite (0/0 produces NaN) + let result: bool = lua.load(r#"return math.isFinite(0/0)"#).eval().unwrap(); + assert!(!result); + + // Non-numbers return false + let result: bool = lua.load(r#"return math.isFinite("hello")"#).eval().unwrap(); + assert!(!result); + + let result: bool = lua.load(r#"return math.isFinite(nil)"#).eval().unwrap(); + assert!(!result); + + let result: bool = lua.load(r#"return math.isFinite({})"#).eval().unwrap(); + assert!(!result); + + // Very large numbers are still finite + let result: bool = lua.load(r#"return math.isFinite(1e308)"#).eval().unwrap(); + assert!(result); + + // But overflow to infinity is not + let result: bool = lua.load(r#"return math.isFinite(1e309)"#).eval().unwrap(); + assert!(!result); +} + +#[test] +fn test_sign() { + let lua = super::lua(); + + // Positive numbers return 1 + let result: i64 = lua.load(r#"return math.sign(42)"#).eval().unwrap(); + assert_eq!(result, 1); + + let result: i64 = lua.load(r#"return math.sign(0.001)"#).eval().unwrap(); + assert_eq!(result, 1); + + // Negative numbers return -1 + let result: i64 = lua.load(r#"return math.sign(-42)"#).eval().unwrap(); + assert_eq!(result, -1); + + let result: i64 = lua.load(r#"return math.sign(-0.001)"#).eval().unwrap(); + assert_eq!(result, -1); + + // Zero returns 0 + let result: i64 = lua.load(r#"return math.sign(0)"#).eval().unwrap(); + assert_eq!(result, 0); + + // Negative zero is still zero + let result: i64 = lua.load(r#"return math.sign(-0)"#).eval().unwrap(); + assert_eq!(result, 0); + + // Positive infinity returns 1 + let result: i64 = lua.load(r#"return math.sign(math.huge)"#).eval().unwrap(); + assert_eq!(result, 1); + + // Negative infinity returns -1 + let result: i64 = lua.load(r#"return math.sign(-math.huge)"#).eval().unwrap(); + assert_eq!(result, -1); + + // NaN returns 0 (NaN is not > 0 and not < 0) + let result: i64 = lua.load(r#"return math.sign(0/0)"#).eval().unwrap(); + assert_eq!(result, 0); + + // Error on non-number + let err = lua.load(r#"return math.sign("foo")"#).exec().unwrap_err(); + assert!(err.to_string().contains("Non-number")); +} + +#[test] +fn test_scale() { + let lua = super::lua(); + + // Scale 0-100 to 0-1 + let result: f64 = lua.load(r#"return math.scale(50, 0, 100, 0, 1)"#).eval().unwrap(); + assert_eq_f64!(result, 0.5); + + // Scale 0-100 to 0-10 + let result: f64 = lua.load(r#"return math.scale(25, 0, 100, 0, 10)"#).eval().unwrap(); + assert_eq_f64!(result, 2.5); + + // Scale with offset ranges + let result: f64 = lua.load(r#"return math.scale(15, 10, 20, 100, 200)"#).eval().unwrap(); + assert_eq_f64!(result, 150.0); + + // Scale to inverted range + let result: f64 = lua.load(r#"return math.scale(0, 0, 100, 100, 0)"#).eval().unwrap(); + assert_eq_f64!(result, 100.0); + + let result: f64 = lua.load(r#"return math.scale(100, 0, 100, 100, 0)"#).eval().unwrap(); + assert_eq_f64!(result, 0.0); + + // Value outside input range (no clamp) + let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10)"#).eval().unwrap(); + assert_eq_f64!(result, 15.0); + + let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 0, 10)"#).eval().unwrap(); + assert_eq_f64!(result, -5.0); + + // Value outside input range with clamp=true + let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10, true)"#).eval().unwrap(); + assert_eq_f64!(result, 10.0); + + let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 0, 10, true)"#).eval().unwrap(); + assert_eq_f64!(result, 0.0); + + // Clamp with inverted output range + let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 10, 0, true)"#).eval().unwrap(); + assert_eq_f64!(result, 0.0); + + let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 10, 0, true)"#).eval().unwrap(); + assert_eq_f64!(result, 10.0); + + // Clamp=false (explicit) behaves same as default + let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10, false)"#).eval().unwrap(); + assert_eq_f64!(result, 15.0); + + // Zero-width output range is allowed (always returns outMin) + let result: f64 = lua.load(r#"return math.scale(50, 0, 100, 5, 5)"#).eval().unwrap(); + assert_eq_f64!(result, 5.0); + + // Boundary values + let result: f64 = lua.load(r#"return math.scale(0, 0, 100, 0, 10)"#).eval().unwrap(); + assert_eq_f64!(result, 0.0); + + let result: f64 = lua.load(r#"return math.scale(100, 0, 100, 0, 10)"#).eval().unwrap(); + assert_eq_f64!(result, 10.0); + + // Negative input range + let result: f64 = lua.load(r#"return math.scale(-50, -100, 0, 0, 10)"#).eval().unwrap(); + assert_eq_f64!(result, 5.0); + + // Inverted input range (inMin > inMax) + let result: f64 = lua.load(r#"return math.scale(75, 100, 0, 0, 10)"#).eval().unwrap(); + assert_eq_f64!(result, 2.5); + + // Error on zero-width input range (division by zero) + let err = lua.load(r#"return math.scale(50, 100, 100, 0, 10)"#).exec().unwrap_err(); + assert!(err.to_string().contains("Input range cannot be zero")); + + // Error on non-number value + let err = lua.load(r#"return math.scale("foo", 0, 100, 0, 10)"#).exec().unwrap_err(); + assert!(err.to_string().contains("Non-number")); + + // Error on non-number range + let err = lua.load(r#"return math.scale(50, "a", 100, 0, 10)"#).exec().unwrap_err(); + assert!(err.to_string().contains("Non-number")); + + // Infinity input produces infinity output (no clamp) + let result: bool = lua + .load(r#"return math.scale(math.huge, 0, 100, 0, 10) == math.huge"#) + .eval() + .unwrap(); + assert!(result); + + // Infinity input with clamp gets clamped + let result: f64 = lua.load(r#"return math.scale(math.huge, 0, 100, 0, 10, true)"#).eval().unwrap(); + assert_eq_f64!(result, 10.0); + + let result: f64 = lua + .load(r#"return math.scale(-math.huge, 0, 100, 0, 10, true)"#) + .eval() + .unwrap(); + assert_eq_f64!(result, 0.0); + + // NaN input produces NaN output + let result: bool = lua + .load(r#"local r = math.scale(0/0, 0, 100, 0, 10); return r ~= r"#) + .eval() + .unwrap(); + assert!(result); // NaN ~= NaN is true +} diff --git a/src/lua_tests/mod.rs b/src/lua_tests/mod.rs new file mode 100644 index 0000000..7d501de --- /dev/null +++ b/src/lua_tests/mod.rs @@ -0,0 +1,41 @@ +//! Tests for the Lua stdlib, ported from flowbox-rt's fb_lua `lua_tests` and +//! adapted to this project's sandbox and stdlib. + +mod async_tests; +mod core_tests; +mod math_tests; +mod os_tests; +mod table_tests; +mod utils_tests; + +use mlua::Lua; + +/// A fresh sandboxed Lua with the full stdlib installed - the same state scripts get. +pub(crate) fn lua() -> Lua { + crate::sandbox::create_sandboxed_lua().unwrap() +} + +/// Compare two JSON strings as parsed values (key order independent). +#[allow(dead_code)] +pub(crate) fn json_eq(a: &str, b: &str) -> bool { + let a: serde_json::Value = serde_json::from_str(a).unwrap(); + let b: serde_json::Value = serde_json::from_str(b).unwrap(); + a == b +} + +/// Assert that two f64 values are equal within an epsilon (default `f64::EPSILON`). +macro_rules! assert_eq_f64 { + ($a:expr, $b:expr) => { + assert_eq_f64!($a, $b, f64::EPSILON) + }; + ($a:expr, $b:expr, $eps:expr) => {{ + let (a, b): (f64, f64) = ($a, $b); + let eps: f64 = $eps; + assert!( + (a - b).abs() <= eps, + "assertion failed: `(left !== right)` (left: `{a:?}`, right: `{b:?}`, epsilon: `{eps:?}`, diff: `{:?}`)", + (a - b).abs() + ); + }}; +} +pub(crate) use assert_eq_f64; diff --git a/src/lua_tests/os_tests.rs b/src/lua_tests/os_tests.rs new file mode 100644 index 0000000..9a45624 --- /dev/null +++ b/src/lua_tests/os_tests.rs @@ -0,0 +1,173 @@ +//! Tests for the sandboxed `os` table. +//! +//! Ported from flowbox-rt's fb_lua os_tests. Unlike flowbox, this project +//! keeps the real Lua 5.5 os.date/os.time/os.difftime (flowbox used a +//! chrono-based shim), so a few edge cases differ - see individual tests. +//! os.sleep is async here and is covered by async_tests, not this file. + +use std::time::{Duration, Instant}; + +#[test] +fn test_os_time_now() { + let lua = super::lua(); + + let result: i64 = lua.load(r#"return os.time()"#).eval().unwrap(); + let now = chrono::Utc::now().timestamp(); + assert!((result - now).abs() <= 5, "os.time() = {result}, expected ~{now}"); +} + +#[test] +fn test_os_time_from_table() { + let lua = super::lua(); + + // Round trip through local time - independent of the host timezone + let ok: bool = lua + .load( + r#" + local t = os.time({year = 2020, month = 1, day = 15, hour = 10, min = 30, sec = 20}) + local d = os.date("*t", t) + return d.year == 2020 and d.month == 1 and d.day == 15 + and d.hour == 10 and d.min == 30 and d.sec == 20 + "#, + ) + .eval() + .unwrap(); + assert!(ok); + + // Out-of-range fields are normalized like mktime; hour defaults to 12 + let ok: bool = lua + .load( + r#" + local a = os.time({year = 2020, month = 14, day = 1}) + local b = os.time({year = 2021, month = 2, day = 1}) + return a == b + "#, + ) + .eval() + .unwrap(); + assert!(ok); + + // The normalized fields are written back into the table + let ok: bool = lua + .load( + r#" + local tbl = {year = 2020, month = 14, day = 1} + os.time(tbl) + return tbl.year == 2021 and tbl.month == 2 and tbl.day == 1 + and tbl.hour == 12 and tbl.wday == 2 -- 2021-02-01 was a Monday + "#, + ) + .eval() + .unwrap(); + assert!(ok); + + // Missing mandatory field is an error + let ok: bool = lua.load(r#"return (pcall(os.time, {year = 2020}))"#).eval().unwrap(); + assert!(!ok); +} + +#[test] +fn test_os_date_format() { + let lua = super::lua(); + + let result: String = lua.load(r#"return os.date("!%Y-%m-%dT%H:%M:%S", 1000000000)"#).eval().unwrap(); + assert_eq!(result, "2001-09-09T01:46:40"); + + // Epoch + let result: String = lua.load(r#"return os.date("!%Y-%m-%d %H:%M:%S", 0)"#).eval().unwrap(); + assert_eq!(result, "1970-01-01 00:00:00"); + + // Stock Lua difference: flowbox's chrono shim truncated float time values; + // real Lua 5.5 requires an integer-representable time and raises an error. + let ok: bool = lua.load(r#"return (pcall(os.date, "!%Y-%m-%d", 0.9))"#).eval().unwrap(); + assert!(!ok, "os.date with a fractional time value should raise in stock Lua"); + + // Default format is %c, applied to the current time + let result: String = lua.load(r#"return os.date()"#).eval().unwrap(); + assert!(!result.is_empty()); + + // Invalid format specifier is an error, not garbage output + let ok: bool = lua.load(r#"return (pcall(os.date, "%Q"))"#).eval().unwrap(); + assert!(!ok); +} + +#[test] +fn test_os_date_table() { + let lua = super::lua(); + + // 2001-09-09T01:46:40Z was a Sunday, day 252 of the year + let ok: bool = lua + .load( + r#" + local d = os.date("!*t", 1000000000) + return d.year == 2001 and d.month == 9 and d.day == 9 + and d.hour == 1 and d.min == 46 and d.sec == 40 + and d.wday == 1 and d.yday == 252 + "#, + ) + .eval() + .unwrap(); + assert!(ok); +} + +/// os.clock is replaced with monotonic wall time since state creation. +#[test] +fn test_os_clock() { + let start = Instant::now(); + let lua = super::lua(); + + std::thread::sleep(Duration::from_millis(30)); + + let clock: f64 = lua.load(r#"return os.clock()"#).eval().unwrap(); + assert!( + (start.elapsed().as_secs_f64() - clock).abs() < 0.1, + "Clock function works" + ); + + // 200 ms elapses, so it should be reported accurately + std::thread::sleep(Duration::from_millis(200)); + let clock2: f64 = lua.load(r#"return os.clock()"#).eval().unwrap(); + + assert!((clock2 - clock - 0.2) < 0.03, "Clock tracks time"); +} + +#[test] +fn test_os_difftime() { + let lua = super::lua(); + + let result: f64 = lua.load(r#"return os.difftime(10, 4)"#).eval().unwrap(); + assert_eq!(result, 6.0); + + let ok: bool = lua.load(r#"return (pcall(os.difftime, 10))"#).eval().unwrap(); + assert!(!ok); +} + +#[test] +fn test_os_missing_dangerous_functions() { + let lua = super::lua(); + + let os_table = lua.globals().get::("os").unwrap(); + for name in ["execute", "exit", "getenv", "remove", "rename", "setlocale", "tmpname"] { + assert!( + !os_table.contains_key(name).unwrap(), + "os.{name} should be removed from the sandbox" + ); + } +} + +#[test] +fn test_os_microtime() { + let lua = super::lua(); + + let result1: f64 = lua.load(r#"return os.microtime()"#).eval().unwrap(); + std::thread::sleep(Duration::from_millis(100)); + let result2: f64 = lua.load(r#"return os.microtime()"#).eval().unwrap(); + assert!( + (result2 - result1 - 0.1).abs() < 0.03, + "os.microtime() should return fractional seconds" + ); + + // It is a Unix timestamp + let now = chrono::Utc::now().timestamp() as f64; + assert!((result2 - now).abs() <= 5.0, "os.microtime() = {result2}, expected ~{now}"); +} diff --git a/src/lua_tests/table_tests.rs b/src/lua_tests/table_tests.rs new file mode 100644 index 0000000..82d61c8 --- /dev/null +++ b/src/lua_tests/table_tests.rs @@ -0,0 +1,1497 @@ +//! Tests for the `table` stdlib extensions (lua/stdlib/table.lua). +//! Ported from flowbox-rt's fb_lua lua_tests/table_tests.rs. + +use super::assert_eq_f64; +use super::json_eq; + +#[test] +fn test_readonly_lua_function() { + let lua = super::lua(); + + // Test that reads work on readonly table + let result: i64 = lua + .load( + r#" + local t = {foo = 42, bar = "hello"} + local ro = table.readonly(t) + return ro.foo + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 42); + + // Test that writes fail on readonly table + let write_result = lua.load( + r#" + local t = {foo = 42} + local ro = table.readonly(t) + ro.foo = 100 + "#, + ); + let err = write_result.exec().unwrap_err(); + assert!(err.to_string().contains("read-only table")); + + // Test adding new keys also fails + let add_result = lua.load( + r#" + local t = {foo = 42} + local ro = table.readonly(t) + ro.new_key = "value" + "#, + ); + let err = add_result.exec().unwrap_err(); + assert!(err.to_string().contains("read-only table")); +} + +#[test] +fn test_readonly_with_name() { + let lua = super::lua(); + + // Reads still pass through when a name is given + let val: i64 = lua + .load( + r#" + local ro = table.readonly({x = 1}, "MyTable") + return ro.x + "#, + ) + .eval() + .unwrap(); + assert_eq!(val, 1); + + // The name is included in the write error message + let err = lua + .load( + r#" + local ro = table.readonly({x = 1}, "MyTable") + ro.x = 2 + "#, + ) + .exec() + .unwrap_err(); + assert!(err.to_string().contains("read-only table MyTable")); +} + +#[test] +fn test_readonly_metatable_protected() { + let lua = super::lua(); + + // The metatable must not be reachable - otherwise scripts could mutate + // the protected table through getmetatable(ro).__index + let result: bool = lua + .load( + r#" + local ro = table.readonly({x = 1}) + return getmetatable(ro) == false + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // Replacing the metatable must fail too + let err = lua + .load( + r#" + local ro = table.readonly({x = 1}) + setmetatable(ro, {}) + "#, + ) + .exec() + .unwrap_err(); + assert!(err.to_string().contains("protected metatable")); +} + +#[test] +fn test_merge_tables() { + let lua = super::lua(); + + // Merge two tables with string keys (second overwrites first) + let result: String = lua + .load( + r#" + local a = {x = 1, y = 2} + local b = {y = 10, z = 3} + local merged = table.merge(a, b) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"x":1,"y":10,"z":3}"#)); + + // Merge array-style tables (numeric keys append) + let result: String = lua + .load( + r#" + local a = {1, 2, 3} + local b = {4, 5} + local merged = table.merge(a, b) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[1,2,3,4,5]"); + + // Merge more than two tables + let result: String = lua + .load( + r#" + local a = {x = 1} + local b = {y = 2} + local c = {z = 3} + local merged = table.merge(a, b, c) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"x":1,"y":2,"z":3}"#)); + + // Empty table handling + let result: String = lua + .load( + r#" + local a = {x = 42} + local merged = table.merge({}, a, {}) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"x":42}"#)); + + // Original tables are not modified + let result: String = lua + .load( + r#" + local a = {x = 1} + local b = {x = 2} + local _ = table.merge(a, b) + return utils.toJSON(a) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"x":1}"#)); + + // Nested tables are NOT recursively merged (shallow merge) + let result: String = lua + .load( + r#" + local a = {config = {a = 1, b = 2}} + local b = {config = {c = 3}} + local merged = table.merge(a, b) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + // config is overwritten entirely, not merged + assert!(json_eq(&result, r#"{"config":{"c":3}}"#)); +} + +#[test] +fn test_deep_merge_tables() { + let lua = super::lua(); + + // Simple merge (same as table.merge for flat tables) + let result: String = lua + .load( + r#" + local a = {x = 1, y = 2} + local b = {y = 10, z = 3} + local merged = table.deepMerge(a, b) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"x":1,"y":10,"z":3}"#)); + + // Deep merge of nested tables + let result: String = lua + .load( + r#" + local a = {config = {a = 1, b = 2}} + local b = {config = {b = 20, c = 3}} + local merged = table.deepMerge(a, b) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + // config is merged recursively + assert!(json_eq(&result, r#"{"config":{"a":1,"b":20,"c":3}}"#)); + + // Multiple levels of nesting + let result: String = lua + .load( + r#" + local a = {a = {b = {c = {val = 1}}}} + local b = {a = {b = {c = {other = 2}, d = 3}}} + local merged = table.deepMerge(a, b) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"a":{"b":{"c":{"other":2,"val":1},"d":3}}}"#)); + + // Table overwrites non-table + let result: String = lua + .load( + r#" + local a = {x = 1} + local b = {x = {nested = 42}} + local merged = table.deepMerge(a, b) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"x":{"nested":42}}"#)); + + // Non-table overwrites table + let result: String = lua + .load( + r#" + local a = {x = {nested = 42}} + local b = {x = 100} + local merged = table.deepMerge(a, b) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"x":100}"#)); + + // Merge more than two tables with nesting + let result: String = lua + .load( + r#" + local a = {config = {a = 1}} + local b = {config = {b = 2}} + local c = {config = {c = 3}} + local merged = table.deepMerge(a, b, c) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"config":{"a":1,"b":2,"c":3}}"#)); + + // Array-style tables append (same as table.merge) + let result: String = lua + .load( + r#" + local a = {1, 2} + local b = {3, 4} + local merged = table.deepMerge(a, b) + return utils.toJSON(merged) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[1,2,3,4]"); + + // Original tables are not modified + let result: String = lua + .load( + r#" + local a = {config = {val = 1}} + local b = {config = {val = 2}} + local _ = table.deepMerge(a, b) + return utils.toJSON(a) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"config":{"val":1}}"#)); + + // Recursion limit protection (should error at depth > 100) + let err = lua + .load( + r#" + local function buildDeep(depth) + if depth == 0 then + return {val = 1} + end + return {nested = buildDeep(depth - 1)} + end + local a = buildDeep(101) + local b = buildDeep(101) + return table.deepMerge(a, b) + "#, + ) + .exec(); + assert!(err.is_err()); + assert!(err.unwrap_err().to_string().contains("recursion too deep")); +} + +#[test] +fn test_filter() { + let lua = super::lua(); + + // Filter array with predicate - preserves sequential numeric keys, serializes as array + let result: String = lua + .load( + r#" + local t = {1, 2, 3, 4, 5, 6} + local filtered = table.filter(t, function(v) return v <= 3 end) + return utils.toJSON(filtered) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[1,2,3]"); + + // Filter dictionary with predicate + let result: String = lua + .load( + r#" + local t = {a = 10, b = 20, c = 30, d = 5} + local filtered = table.filter(t, function(v) return v >= 15 end) + return utils.toJSON(filtered) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"b":20,"c":30}"#)); + + // Filter with empty result + let result: String = lua + .load( + r#" + local t = {1, 2, 3} + local filtered = table.filter(t, function(v) return v > 100 end) + return utils.toJSON(filtered) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "{}"); + + // Filter empty table + let result: String = lua + .load( + r#" + local t = {} + local filtered = table.filter(t, function(v) return true end) + return utils.toJSON(filtered) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "{}"); + + // Original table unmodified + let result: String = lua + .load( + r#" + local t = {a = 1, b = 2, c = 3} + local _ = table.filter(t, function(v) return v == 2 end) + return utils.toJSON(t) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"a":1,"b":2,"c":3}"#)); +} + +#[test] +fn test_ifilter() { + let lua = super::lua(); + + // ifilter produces numbered table without gaps + let result: String = lua + .load( + r#" + local t = {1, 2, 3, 4, 5, 6} + local filtered = table.ifilter(t, function(v) return v % 2 == 0 end) + return utils.toJSON(filtered) + "#, + ) + .eval() + .unwrap(); + // Should be a clean array [2, 4, 6] without gaps + assert_eq!(result, "[2,4,6]"); + + // ifilter with none matching + let result: String = lua + .load( + r#" + local t = {1, 2, 3} + local filtered = table.ifilter(t, function(v) return v > 100 end) + return utils.toJSON(filtered) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "{}"); + + // ifilter with all matching + let result: String = lua + .load( + r#" + local t = {10, 20, 30} + local filtered = table.ifilter(t, function(v) return v >= 5 end) + return utils.toJSON(filtered) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[10,20,30]"); + + // ifilter preserves order + let result: String = lua + .load( + r#" + local t = {"a", "bb", "ccc", "dddd", "eeeee"} + local filtered = table.ifilter(t, function(v) return #v >= 3 end) + return utils.toJSON(filtered) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, r#"["ccc","dddd","eeeee"]"#); + + // Original table unmodified + let result: String = lua + .load( + r#" + local t = {1, 2, 3, 4, 5} + local _ = table.ifilter(t, function(v) return v == 3 end) + return utils.toJSON(t) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[1,2,3,4,5]"); +} + +#[test] +fn test_map() { + let lua = super::lua(); + + // Map array values + let result: String = lua + .load( + r#" + local t = {1, 2, 3} + local mapped = table.map(t, function(v) return v * 2 end) + return utils.toJSON(mapped) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[2,4,6]"); + + // Map dictionary values + let result: String = lua + .load( + r#" + local t = {a = 1, b = 2, c = 3} + local mapped = table.map(t, function(v) return v + 10 end) + return utils.toJSON(mapped) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"a":11,"b":12,"c":13}"#)); + + // Map to different types + let result: String = lua + .load( + r#" + local t = {1, 2, 3} + local mapped = table.map(t, function(v) return "val" .. v end) + return utils.toJSON(mapped) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, r#"["val1","val2","val3"]"#); + + // Map empty table + let result: String = lua + .load( + r#" + local t = {} + local mapped = table.map(t, function(v) return v * 2 end) + return utils.toJSON(mapped) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "{}"); + + // Original table unmodified + let result: String = lua + .load( + r#" + local t = {x = 100} + local _ = table.map(t, function(v) return v * 999 end) + return utils.toJSON(t) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"x":100}"#)); +} + +#[test] +fn test_filter_map() { + let lua = super::lua(); + + // filterMap drops nil results + let result: String = lua + .load( + r#" + local t = {a = 1, b = 2, c = 3, d = 4} + local mapped = table.filterMap(t, function(v) + if v % 2 == 0 then + return v * 10 + else + return nil + end + end) + return utils.toJSON(mapped) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"b":20,"d":40}"#)); + + // filterMap all pass (none return nil) + let result: String = lua + .load( + r#" + local t = {x = 5, y = 10} + local mapped = table.filterMap(t, function(v) return v + 1 end) + return utils.toJSON(mapped) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"x":6,"y":11}"#)); + + // filterMap all filtered (all return nil) + let result: String = lua + .load( + r#" + local t = {a = 1, b = 2} + local mapped = table.filterMap(t, function(v) return nil end) + return utils.toJSON(mapped) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "{}"); + + // filterMap on numbered table may leave gaps (preserves keys) + let result: String = lua + .load( + r#" + local t = {10, 20, 30, 40, 50} + local mapped = table.filterMap(t, function(v) + if v > 20 then return v end + return nil + end) + -- Check that the result has correct values even if keys are sparse + local count = 0 + local sum = 0 + for _, v in pairs(mapped) do + count = count + 1 + sum = sum + v + end + return count .. ":" .. sum + "#, + ) + .eval() + .unwrap(); + // Should have 3 values: 30, 40, 50 summing to 120 + assert_eq!(result, "3:120"); +} + +#[test] +fn test_ifilter_map() { + let lua = super::lua(); + + // ifilterMap produces numbered table without gaps + let result: String = lua + .load( + r#" + local t = {1, 2, 3, 4, 5} + local mapped = table.ifilterMap(t, function(v) + if v % 2 == 1 then + return v * 100 + end + return nil + end) + return utils.toJSON(mapped) + "#, + ) + .eval() + .unwrap(); + // Should produce clean array [100, 300, 500] + assert_eq!(result, "[100,300,500]"); + + // ifilterMap preserves order + let result: String = lua + .load( + r#" + local t = {"apple", "banana", "cherry", "date"} + local mapped = table.ifilterMap(t, function(v) + if #v > 5 then + return string.upper(v) + end + return nil + end) + return utils.toJSON(mapped) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, r#"["BANANA","CHERRY"]"#); + + // ifilterMap empty result + let result: String = lua + .load( + r#" + local t = {1, 2, 3} + local mapped = table.ifilterMap(t, function(v) return nil end) + return utils.toJSON(mapped) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "{}"); + + // ifilterMap all values pass + let result: String = lua + .load( + r#" + local t = {10, 20, 30} + local mapped = table.ifilterMap(t, function(v) return v // 10 end) + return utils.toJSON(mapped) + "#, + ) + .eval() + .unwrap(); + // Using integer division (//) to get integers + assert_eq!(result, "[1,2,3]"); +} + +#[test] +fn test_reduce() { + let lua = super::lua(); + + // Sum of values + let result: i64 = lua + .load( + r#" + local t = {1, 2, 3, 4, 5} + return table.reduce(t, function(acc, v) return acc + v end, 0) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 15); + + // Product of values + let result: i64 = lua + .load( + r#" + local t = {2, 3, 4} + return table.reduce(t, function(acc, v) return acc * v end, 1) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 24); + + // String concatenation + let result: String = lua + .load( + r#" + local t = {"hello", " ", "world"} + return table.reduce(t, function(acc, v) return acc .. v end, "") + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "hello world"); + + // Reduce empty table returns initial value + let result: i64 = lua + .load( + r#" + local t = {} + return table.reduce(t, function(acc, v) return acc + v end, 42) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 42); + + // Reduce dictionary values (order unspecified but result should be deterministic) + let result: i64 = lua + .load( + r#" + local t = {a = 10, b = 20, c = 30} + return table.reduce(t, function(acc, v) return acc + v end, 0) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 60); + + // Reduce with complex accumulator (count and sum) + let result: String = lua + .load( + r#" + local t = {5, 10, 15, 20} + local r = table.reduce(t, function(acc, v) + acc.count = acc.count + 1 + acc.sum = acc.sum + v + return acc + end, {count = 0, sum = 0}) + return r.count .. ":" .. r.sum + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "4:50"); +} + +#[test] +fn test_min() { + let lua = super::lua(); + + // Min of positive numbers + let result: i64 = lua.load(r#"return table.min({5, 3, 8, 1, 9})"#).eval().unwrap(); + assert_eq!(result, 1); + + // Min with negative numbers + let result: i64 = lua.load(r#"return table.min({-5, 3, -8, 1, 9})"#).eval().unwrap(); + assert_eq!(result, -8); + + // Min of floats + let result: f64 = lua.load(r#"return table.min({3.14, 2.71, 1.41})"#).eval().unwrap(); + assert_eq_f64!(result, 1.41); + + // Min with single element + let result: i64 = lua.load(r#"return table.min({42})"#).eval().unwrap(); + assert_eq!(result, 42); + + // Min of empty table returns nil + let result: bool = lua.load(r#"return table.min({}) == nil"#).eval().unwrap(); + assert!(result); + + // Min works on dictionary values too (uses pairs) + let result: i64 = lua.load(r#"return table.min({a = 100, b = 50, c = 75})"#).eval().unwrap(); + assert_eq!(result, 50); +} + +#[test] +fn test_max() { + let lua = super::lua(); + + // Max of positive numbers + let result: i64 = lua.load(r#"return table.max({5, 3, 8, 1, 9})"#).eval().unwrap(); + assert_eq!(result, 9); + + // Max with negative numbers + let result: i64 = lua.load(r#"return table.max({-5, -3, -8, -1, -9})"#).eval().unwrap(); + assert_eq!(result, -1); + + // Max of floats + let result: f64 = lua.load(r#"return table.max({3.14, 2.71, 1.41})"#).eval().unwrap(); + assert_eq_f64!(result, 3.14); + + // Max with single element + let result: i64 = lua.load(r#"return table.max({42})"#).eval().unwrap(); + assert_eq!(result, 42); + + // Max of empty table returns nil + let result: bool = lua.load(r#"return table.max({}) == nil"#).eval().unwrap(); + assert!(result); + + // Max works on dictionary values too + let result: i64 = lua.load(r#"return table.max({a = 100, b = 50, c = 75})"#).eval().unwrap(); + assert_eq!(result, 100); +} + +#[test] +fn test_mean() { + let lua = super::lua(); + + // Mean of integers + let result: f64 = lua.load(r#"return table.mean({10, 20, 30})"#).eval().unwrap(); + assert_eq_f64!(result, 20.0); + + // Mean of floats: (1.5 + 2.5 + 3.0) / 3 = 7.0 / 3 + let result: f64 = lua.load(r#"return table.mean({1.5, 2.5, 3.0})"#).eval().unwrap(); + assert_eq_f64!(result, 7.0 / 3.0); + + // Mean with single element + let result: f64 = lua.load(r#"return table.mean({42})"#).eval().unwrap(); + assert_eq_f64!(result, 42.0); + + // Mean of empty table returns nil (consistent with min/max) + let result: bool = lua.load(r#"return table.mean({}) == nil"#).eval().unwrap(); + assert!(result); + + // Mean with negative numbers + let result: f64 = lua.load(r#"return table.mean({-10, 0, 10})"#).eval().unwrap(); + assert_eq_f64!(result, 0.0); + + // Mean works on dictionary values too + let result: f64 = lua.load(r#"return table.mean({a = 100, b = 200, c = 300})"#).eval().unwrap(); + assert_eq_f64!(result, 200.0); +} + +#[test] +fn test_sum() { + let lua = super::lua(); + + // Sum of integers + let result: i64 = lua.load(r#"return table.sum({1, 2, 3, 4, 5})"#).eval().unwrap(); + assert_eq!(result, 15); + + // Sum of floats + let result: f64 = lua.load(r#"return table.sum({1.5, 2.5, 3.0})"#).eval().unwrap(); + assert_eq_f64!(result, 7.0); + + // Sum with negative numbers + let result: i64 = lua.load(r#"return table.sum({-10, 5, -5, 10})"#).eval().unwrap(); + assert_eq!(result, 0); + + // Sum of empty table returns 0 (unlike min/max/mean which return nil) + let result: i64 = lua.load(r#"return table.sum({})"#).eval().unwrap(); + assert_eq!(result, 0); + + // The 0 result of an empty sum is an integer + let result: String = lua.load(r#"return math.type(table.sum({}))"#).eval().unwrap(); + assert_eq!(result, "integer"); + + // Sum works on dictionary values + let result: i64 = lua.load(r#"return table.sum({a = 10, b = 20, c = 30})"#).eval().unwrap(); + assert_eq!(result, 60); + + // Sum with single element + let result: i64 = lua.load(r#"return table.sum({42})"#).eval().unwrap(); + assert_eq!(result, 42); +} + +#[test] +fn test_keys() { + let lua = super::lua(); + + // Keys of a dictionary - order unspecified, so check via sorting + let result: String = lua + .load( + r#" + local k = table.keys({a = 1, b = 2, c = 3}) + table.sort(k) + return utils.toJSON(k) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, r#"["a","b","c"]"#); + + // Keys of an array are numeric indices + let result: String = lua + .load( + r#" + local k = table.keys({"x", "y", "z"}) + table.sort(k) + return utils.toJSON(k) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[1,2,3]"); + + // Keys of empty table + let result: String = lua.load(r#"return utils.toJSON(table.keys({}))"#).eval().unwrap(); + assert_eq!(result, "{}"); + + // Keys count matches table size + let result: i64 = lua.load(r#"return #table.keys({a = 1, b = 2, c = 3, d = 4})"#).eval().unwrap(); + assert_eq!(result, 4); +} + +#[test] +fn test_values() { + let lua = super::lua(); + + // Values of a dictionary - order unspecified, so check via sorting + let result: String = lua + .load( + r#" + local v = table.values({a = 10, b = 20, c = 30}) + table.sort(v) + return utils.toJSON(v) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[10,20,30]"); + + // Values of an array + let result: String = lua + .load( + r#" + local v = table.values({"x", "y", "z"}) + table.sort(v) + return utils.toJSON(v) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, r#"["x","y","z"]"#); + + // Values of empty table + let result: String = lua.load(r#"return utils.toJSON(table.values({}))"#).eval().unwrap(); + assert_eq!(result, "{}"); + + // Values count matches table size + let result: i64 = lua + .load(r#"return #table.values({a = 1, b = 2, c = 3, d = 4})"#) + .eval() + .unwrap(); + assert_eq!(result, 4); +} + +#[test] +fn test_contains() { + let lua = super::lua(); + + // Contains in array + let result: bool = lua.load(r#"return table.contains({1, 2, 3, 4, 5}, 3)"#).eval().unwrap(); + assert!(result); + + // Does not contain + let result: bool = lua.load(r#"return table.contains({1, 2, 3, 4, 5}, 99)"#).eval().unwrap(); + assert!(!result); + + // Contains in dictionary values + let result: bool = lua + .load(r#"return table.contains({a = "foo", b = "bar"}, "bar")"#) + .eval() + .unwrap(); + assert!(result); + + // Does not check keys, only values + let result: bool = lua.load(r#"return table.contains({a = 1, b = 2}, "a")"#).eval().unwrap(); + assert!(!result); + + // Contains nil check (nil is never contained) + let result: bool = lua.load(r#"return table.contains({1, nil, 3}, nil)"#).eval().unwrap(); + assert!(!result); + + // Empty table contains nothing + let result: bool = lua.load(r#"return table.contains({}, 1)"#).eval().unwrap(); + assert!(!result); + + // Contains with string values + let result: bool = lua + .load(r#"return table.contains({"apple", "banana", "cherry"}, "banana")"#) + .eval() + .unwrap(); + assert!(result); +} + +#[test] +fn test_is_empty() { + let lua = super::lua(); + + // Empty table is empty + let result: bool = lua.load(r#"return table.isEmpty({})"#).eval().unwrap(); + assert!(result); + + // Array is not empty + let result: bool = lua.load(r#"return table.isEmpty({1, 2, 3})"#).eval().unwrap(); + assert!(!result); + + // Dictionary is not empty + let result: bool = lua.load(r#"return table.isEmpty({a = 1})"#).eval().unwrap(); + assert!(!result); + + // Table with only nil values is empty (nil doesn't count as a value) + let result: bool = lua.load(r#"return table.isEmpty({nil, nil})"#).eval().unwrap(); + assert!(result); + + // Single element is not empty + let result: bool = lua.load(r#"return table.isEmpty({42})"#).eval().unwrap(); + assert!(!result); +} + +#[test] +fn test_min_max_nan_propagates() { + let lua = super::lua(); + + // A NaN anywhere in the input must yield NaN, not an arbitrary survivor value + let result: bool = lua.load(r#"local r = table.max({5, 0/0, 3}); return r ~= r"#).eval().unwrap(); + assert!(result, "max with NaN in the middle must be NaN"); + + let result: bool = lua.load(r#"local r = table.min({3, 0/0, 5}); return r ~= r"#).eval().unwrap(); + assert!(result, "min with NaN in the middle must be NaN"); + + let result: bool = lua.load(r#"local r = table.max({0/0, 5}); return r ~= r"#).eval().unwrap(); + assert!(result, "max with NaN first must be NaN"); + + let result: bool = lua.load(r#"local r = table.min({0/0}); return r ~= r"#).eval().unwrap(); + assert!(result, "min of only NaN must be NaN"); +} + +#[test] +fn test_merge_argument_validation() { + let lua = super::lua(); + + // A nil argument must error, not silently swallow the remaining arguments + let err = lua + .load( + r#" + local function maybeNil() return nil end + return table.merge({a = 1}, maybeNil(), {b = 2}) + "#, + ) + .exec() + .unwrap_err(); + assert!(err.to_string().contains("not a table")); + + // Non-table arguments must error with a clear message + let err = lua.load(r#"return table.merge({}, 5)"#).exec().unwrap_err(); + assert!(err.to_string().contains("not a table")); + + // Same for deepMerge + let err = lua.load(r#"return table.deepMerge({a = 1}, nil, {b = 2})"#).exec().unwrap_err(); + assert!(err.to_string().contains("not a table")); + + // No arguments is a valid (empty) merge + let result: bool = lua.load(r#"return table.isEmpty(table.merge())"#).eval().unwrap(); + assert!(result); +} + +#[test] +fn test_merge_key_handling() { + let lua = super::lua(); + + // Non-sequence numeric keys (float, zero, negative, sparse) are preserved, not renumbered + let result: bool = lua + .load( + r#" + local m = table.merge({[1.5] = "x", [0] = "y", [-3] = "z"}) + return m[1.5] == "x" and m[0] == "y" and m[-3] == "z" and m[1] == nil + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // Sparse numeric keys beyond the sequence stay where they are + let result: bool = lua + .load( + r#" + local m = table.merge({[1] = "a", [5] = "b"}) + return m[1] == "a" and m[5] == "b" and m[2] == nil + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // Non-sequence numeric keys collide map-style: later table overwrites + let result: String = lua + .load(r#"return table.merge({[10] = "a"}, {[10] = "b"})[10]"#) + .eval() + .unwrap(); + assert_eq!(result, "b"); + + // The sequence parts still concatenate in order + let result: String = lua + .load(r#"return utils.toJSON(table.merge({1, 2}, {3, 4}, {5}))"#) + .eval() + .unwrap(); + assert_eq!(result, "[1,2,3,4,5]"); +} + +#[test] +fn test_deep_merge_array_concat() { + let lua = super::lua(); + + // Arrays of tables concatenate just like arrays of scalars - + // element-wise merging only applies to non-sequence keys + let result: bool = lua + .load( + r#" + local m = table.deepMerge({{a = 1}}, {{b = 2}}) + return #m == 2 and m[1].a == 1 and m[1].b == nil and m[2].b == 2 and m[2].a == nil + "#, + ) + .eval() + .unwrap(); + assert!(result); +} + +#[test] +fn test_deep_merge_no_aliasing() { + let lua = super::lua(); + + // Mutating the merge result must never touch the input tables + let result: i64 = lua + .load( + r#" + local inner = {val = 1} + local m = table.deepMerge({}, {x = inner}) + m.x.val = 99 + return inner.val + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 1); + + // Also for appended sequence elements + let result: i64 = lua + .load( + r#" + local inner = {val = 1} + local m = table.deepMerge({inner}) + m[1].val = 99 + return inner.val + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 1); + + // And for nested tables adopted deep inside + let result: i64 = lua + .load( + r#" + local deep = {leaf = 1} + local m = table.deepMerge({cfg = {}}, {cfg = {sub = deep}}) + m.cfg.sub.leaf = 99 + return deep.leaf + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 1); +} + +#[test] +fn test_deep_merge_error_message() { + let lua = super::lua(); + + // The recursion limit error must name the actual function, not a stale one + let err = lua + .load( + r#" + local function buildDeep(depth) + if depth == 0 then + return {val = 1} + end + return {nested = buildDeep(depth - 1)} + end + return table.deepMerge(buildDeep(101), buildDeep(101)) + "#, + ) + .exec() + .unwrap_err(); + assert!(err.to_string().contains("table.deepMerge")); +} + +#[test] +fn test_ireduce() { + let lua = super::lua(); + + // Iterates in sequence order - order-sensitive reduction proves it + let result: String = lua + .load( + r#" + local t = {"a", "b", "c", "d"} + return table.ireduce(t, function(acc, v) return acc .. v end, "") + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "abcd"); + + // Empty table returns the initial value + let result: i64 = lua + .load(r#"return table.ireduce({}, function(acc, v) return acc + v end, 42)"#) + .eval() + .unwrap(); + assert_eq!(result, 42); + + // Only the sequence part is reduced, other keys are ignored + let result: i64 = lua + .load( + r#" + local t = {1, 2, 3} + t.x = 100 + return table.ireduce(t, function(acc, v) return acc + v end, 0) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 6); +} + +#[test] +fn test_reverse() { + let lua = super::lua(); + + // Reverse array of numbers + let result: String = lua + .load(r#"return utils.toJSON(table.reverse({1, 2, 3, 4, 5}))"#) + .eval() + .unwrap(); + assert_eq!(result, "[5,4,3,2,1]"); + + // Reverse array of strings + let result: String = lua + .load(r#"return utils.toJSON(table.reverse({"a", "b", "c"}))"#) + .eval() + .unwrap(); + assert_eq!(result, r#"["c","b","a"]"#); + + // Reverse empty array + let result: String = lua.load(r#"return utils.toJSON(table.reverse({}))"#).eval().unwrap(); + assert_eq!(result, "{}"); + + // Reverse single element + let result: String = lua.load(r#"return utils.toJSON(table.reverse({42}))"#).eval().unwrap(); + assert_eq!(result, "[42]"); + + // Original array unmodified + let result: String = lua + .load( + r#" + local t = {1, 2, 3} + local _ = table.reverse(t) + return utils.toJSON(t) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "[1,2,3]"); + + // Reverse of reverse is original + let result: String = lua + .load(r#"return utils.toJSON(table.reverse(table.reverse({1, 2, 3, 4})))"#) + .eval() + .unwrap(); + assert_eq!(result, "[1,2,3,4]"); +} + +#[test] +fn test_find() { + let lua = super::lua(); + + // Find in a sequence returns the value and its key + let result: bool = lua + .load( + r#" + local v, k = table.find({10, 20, 30}, function(x) return x == 20 end) + return v == 20 and k == 2 + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // Find in a dictionary + let result: bool = lua + .load( + r#" + local v, k = table.find({a = 1, b = 2}, function(x) return x == 2 end) + return v == 2 and k == "b" + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // Not found returns nil, nil + let result: bool = lua + .load( + r#" + local v, k = table.find({1, 2, 3}, function() return false end) + return v == nil and k == nil + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // Empty table finds nothing + let result: bool = lua + .load( + r#" + local v, k = table.find({}, function() return true end) + return v == nil and k == nil + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // A stored false value is findable - the key distinguishes it from "not found" + let result: bool = lua + .load( + r#" + local v, k = table.find({false}, function(x) return x == false end) + return v == false and k == 1 + "#, + ) + .eval() + .unwrap(); + assert!(result); +} + +#[test] +fn test_deep_copy() { + let lua = super::lua(); + + // The copy is structurally equal to the original + // (key order in the JSON dumps is unspecified, compare parsed) + let (copy, orig): (String, String) = lua + .load( + r#" + local orig = {a = 1, nested = {b = 2, deeper = {c = 3}}, arr = {1, 2, 3}} + local copy = table.deepCopy(orig) + return utils.toJSON(copy), utils.toJSON(orig) + "#, + ) + .eval() + .unwrap(); + assert!(json_eq(©, &orig)); + + // Mutating the copy at any level must not touch the original + let result: bool = lua + .load( + r#" + local orig = {a = 1, nested = {b = 2}, arr = {1, 2, 3}} + local copy = table.deepCopy(orig) + copy.a = 99 + copy.nested.b = 99 + copy.arr[1] = 99 + return orig.a == 1 and orig.nested.b == 2 and orig.arr[1] == 1 + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // Metatables are not copied + let result: bool = lua + .load( + r#" + local orig = setmetatable({x = 1}, {__index = function() return 42 end}) + local copy = table.deepCopy(orig) + return getmetatable(copy) == nil and copy.x == 1 and copy.missing == nil + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // Recursion limit protection (also catches self-referencing tables) + let err = lua + .load( + r#" + local function buildDeep(depth) + if depth == 0 then + return {val = 1} + end + return {nested = buildDeep(depth - 1)} + end + return table.deepCopy(buildDeep(101)) + "#, + ) + .exec() + .unwrap_err(); + assert!(err.to_string().contains("recursion too deep")); + + let err = lua + .load( + r#" + local t = {} + t.self = t + return table.deepCopy(t) + "#, + ) + .exec() + .unwrap_err(); + assert!(err.to_string().contains("recursion too deep")); +} + +#[test] +fn test_argument_type_validation() { + let lua = super::lua(); + + // Every table.* function must reject wrong argument types with a clear message + let cases: &[(&str, &str)] = &[ + ("table.filter(5, function() end)", "not a table"), + ("table.filter({}, 5)", "not a function"), + ("table.ifilter(nil, function() end)", "not a table"), + ("table.ifilter({}, nil)", "not a function"), + ("table.map(5, function() end)", "not a table"), + ("table.map({}, 'x')", "not a function"), + ("table.filterMap(5, function() end)", "not a table"), + ("table.filterMap({}, 5)", "not a function"), + ("table.ifilterMap(5, function() end)", "not a table"), + ("table.ifilterMap({}, 5)", "not a function"), + ("table.reduce(5, function() end, 0)", "not a table"), + ("table.reduce({}, 5, 0)", "not a function"), + ("table.ireduce(5, function() end, 0)", "not a table"), + ("table.ireduce({}, 5, 0)", "not a function"), + ("table.min(5)", "not a table"), + ("table.max('foo')", "not a table"), + ("table.mean(nil)", "not a table"), + ("table.sum(5)", "not a table"), + ("table.keys(5)", "not a table"), + ("table.values(5)", "not a table"), + ("table.contains(5, 1)", "not a table"), + ("table.find(5, function() end)", "not a table"), + ("table.find({}, 5)", "not a function"), + ("table.isEmpty(5)", "not a table"), + ("table.reverse(5)", "not a table"), + ("table.deepCopy(5)", "not a table"), + ("table.deepCopy(nil)", "not a table"), + ]; + + for (code, expected) in cases { + let err = lua.load(format!("return {code}")).exec().unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains(expected), + "case `{code}` should error with `{expected}`, got: {msg}" + ); + } +} + +#[test] +fn test_readonly_arg_validation() { + let lua = super::lua(); + + // Non-table argument must error with the same message style as the other table functions + let err = lua.load(r#"return table.readonly(5)"#).exec().unwrap_err(); + assert!(err.to_string().contains("not a table"), "got: {err}"); + + let err = lua.load(r#"return table.readonly(nil)"#).exec().unwrap_err(); + assert!(err.to_string().contains("not a table"), "got: {err}"); + + // A non-string name must error instead of being silently coerced + let err = lua.load(r#"return table.readonly({}, 5)"#).exec().unwrap_err(); + assert!(err.to_string().contains("not a string"), "got: {err}"); +} diff --git a/src/lua_tests/utils_tests.rs b/src/lua_tests/utils_tests.rs new file mode 100644 index 0000000..c4f0b09 --- /dev/null +++ b/src/lua_tests/utils_tests.rs @@ -0,0 +1,813 @@ +//! Tests for the `utils` global: NULL sentinel, isNull, toJSON/fromJSON, dump +//! (Rust side) and try, tryn (Lua side, lua/stdlib/utils.lua). +//! +//! Ported from flowbox-rt fb_lua utils_tests. `utils.dump` is the flowbox Rust +//! implementation (src/stdlib/lua_dump.rs), so the dump tests match flowbox's. + +use super::json_eq; + +#[test] +fn test_dump() { + let lua = super::lua(); + + let dump = |code: &str| -> String { + lua.load(format!("return utils.dump({})", code)) + .eval::() + .unwrap() + }; + + // nil + assert_eq!(dump("nil"), "nil"); + + // booleans + assert_eq!(dump("true"), "true"); + assert_eq!(dump("false"), "false"); + + // integers + assert_eq!(dump("1"), "1"); + assert_eq!(dump("0"), "0"); + assert_eq!(dump("-42"), "-42"); + + // floats - whole numbers show .0 (Lua 5.4 tostring) + assert_eq!(dump("1.0"), "1.0"); + assert_eq!(dump("0.0"), "0.0"); + assert_eq!(dump("-5.0"), "-5.0"); + + // floats - with fractional part + assert_eq!(dump("1.5"), "1.5"); + assert_eq!(dump("-3.14159"), "-3.14159"); + assert_eq!(dump("0.001"), "0.001"); + + // strings are quoted and escaped JSON-style + assert_eq!(dump("'hello'"), "\"hello\""); + assert_eq!(dump("''"), "\"\""); // empty + assert_eq!(dump("'with spaces'"), "\"with spaces\""); + assert_eq!(dump("'special\\nchars\\ttab'"), "\"special\\nchars\\ttab\""); + + // empty table + assert_eq!(dump("{}"), "{}"); + + // simple table with string key + assert_eq!(dump("{foo=1}"), "{foo=1}"); + assert_eq!(dump("{_foo=1}"), "{_foo=1}"); + assert_eq!(dump("{_1=1}"), "{_1=1}"); + // a string key that does not look like an identifier is bracketed and quoted + assert_eq!(dump("{[\"1\"]=1}"), "{[\"1\"]=1}"); + + // nested table + assert_eq!(dump("{foo={bar=1}}"), "{foo={bar=1}}"); + + // array-style table (numeric keys) + assert_eq!(dump("{10, 20, 30}"), "{10, 20, 30}"); + + // mixed keys - order may vary, so check by substrings + let dumped = dump("{foo=1, [999]='a'}"); + assert!(dumped.contains("foo=1"), "got: {dumped}"); + assert!(dumped.contains("[999]=\"a\""), "got: {dumped}"); + + // a table with both an array part and hash keys dumps both + assert_eq!(dump("{10, 20, foo='hash'}"), "{10, 20, foo=\"hash\"}"); + + // function + assert_eq!(dump("function() end"), ""); +} + +#[test] +fn test_dump_deep_nesting() { + let lua = super::lua(); + + // Recursion limit: tables at depth > 20 are rendered as + // Build a table nested 25 levels deep - truncation happens at depth 21 + let deep_result: String = lua + .load( + r#" + local t = {val="leaf"} + for i = 1, 25 do + t = {nested=t} + end + return utils.dump(t) + "#, + ) + .eval() + .unwrap(); + // 20 levels of {nested=, then the innermost table's value truncated at depth 21 + let expected = format!("{}{{nested=}}{}", "{nested=".repeat(20), "}".repeat(20)); + assert_eq!(deep_result, expected); + + // A table just within the limit still dumps fully + let shallow_result: String = lua + .load( + r#" + local t = {val="leaf"} + for i = 1, 5 do + t = {nested=t} + end + return utils.dump(t) + "#, + ) + .eval() + .unwrap(); + let expected = format!("{}{{val=\"leaf\"}}{}", "{nested=".repeat(5), "}".repeat(5)); + assert_eq!(shallow_result, expected); +} + +#[test] +fn test_dump_circular() { + let lua = super::lua(); + + // There is no cycle detection; the depth limit terminates the recursion, + // so a self-referencing table dumps 21 levels and then truncates + let result: String = lua + .load( + r#" + local t = {} + t.self = t + return utils.dump(t) + "#, + ) + .eval() + .unwrap(); + let expected = format!("{}{}", "{self=".repeat(21), "}".repeat(21)); + assert_eq!(result, expected); + + // The same table appearing twice as a sibling dumps twice + let result: String = lua + .load( + r#" + local shared = {x = 1} + return utils.dump({shared, shared}) + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, "{{x=1}, {x=1}}"); +} + +#[test] +fn test_to_json() { + let lua = super::lua(); + + // Primitives + let result: String = lua.load(r#"return utils.toJSON(nil)"#).eval().unwrap(); + assert_eq!(result, "null"); + + let result: String = lua.load(r#"return utils.toJSON(true)"#).eval().unwrap(); + assert_eq!(result, "true"); + + let result: String = lua.load(r#"return utils.toJSON(false)"#).eval().unwrap(); + assert_eq!(result, "false"); + + let result: String = lua.load(r#"return utils.toJSON(42)"#).eval().unwrap(); + assert_eq!(result, "42"); + + let result: String = lua.load(r#"return utils.toJSON(3.14)"#).eval().unwrap(); + assert_eq!(result, "3.14"); + + let result: String = lua.load(r#"return utils.toJSON("hello")"#).eval().unwrap(); + assert_eq!(result, "\"hello\""); + + // Empty table serializes as an empty object + let result: String = lua.load(r#"return utils.toJSON({})"#).eval().unwrap(); + assert_eq!(result, "{}"); + + // Array-style table + let result: String = lua.load(r#"return utils.toJSON({1, 2, 3})"#).eval().unwrap(); + assert_eq!(result, "[1,2,3]"); + + // Object-style table + let result: String = lua.load(r#"return utils.toJSON({a=1})"#).eval().unwrap(); + assert_eq!(result, "{\"a\":1}"); + + // Nested structure + let result: String = lua + .load(r#"return utils.toJSON({nested={value=42}})"#) + .eval() + .unwrap(); + assert_eq!(result, "{\"nested\":{\"value\":42}}"); +} + +#[test] +fn test_to_json_mixed_and_integer_keys() { + let lua = super::lua(); + + // A table with both a sequence part and hash keys is serialized as an + // object; integer keys become string keys + let result: String = lua + .load(r#"return utils.toJSON({1, 2, x = 3})"#) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"1":1,"2":2,"x":3}"#), "got: {result}"); + + // Non-sequential integer keys become string object keys + let result: String = lua + .load(r#"return utils.toJSON({a = 1, [10] = "x"})"#) + .eval() + .unwrap(); + assert!(json_eq(&result, r#"{"a":1,"10":"x"}"#), "got: {result}"); +} + +#[test] +fn test_to_json_pretty() { + let lua = super::lua(); + + // Second argument enables pretty-printing + let result: String = lua.load(r#"return utils.toJSON({a=1}, true)"#).eval().unwrap(); + assert_eq!(result, "{\n \"a\": 1\n}"); + + // Explicit false behaves like the default compact output + let result: String = lua.load(r#"return utils.toJSON({a=1}, false)"#).eval().unwrap(); + assert_eq!(result, "{\"a\":1}"); +} + +#[test] +fn test_to_json_unserializable_errors() { + let lua = super::lua(); + + // Unserializable values must raise an error instead of silently returning "null" + let err = lua.load(r#"return utils.toJSON(function() end)"#).exec().unwrap_err(); + assert!(err.to_string().contains("toJSON"), "got: {err}"); + assert!(err.to_string().contains("function"), "got: {err}"); + + // A single bad value must not silently discard the whole structure + let err = lua + .load(r#"return utils.toJSON({x = 1, f = function() end})"#) + .exec() + .unwrap_err(); + assert!(err.to_string().contains("toJSON"), "got: {err}"); + + // Self-referencing tables cannot be serialized (caught by the depth limit) + let err = lua + .load( + r#" + local t = {} + t.self = t + return utils.toJSON(t) + "#, + ) + .exec() + .unwrap_err(); + assert!(err.to_string().contains("toJSON"), "got: {err}"); + assert!(err.to_string().contains("nesting too deep"), "got: {err}"); + + // NaN and Infinity have no JSON representation + let err = lua.load(r#"return utils.toJSON(0/0)"#).exec().unwrap_err(); + assert!(err.to_string().contains("NaN"), "got: {err}"); + + let err = lua.load(r#"return utils.toJSON(math.huge)"#).exec().unwrap_err(); + assert!(err.to_string().contains("Infinity"), "got: {err}"); + + // Only string and integer keys can become JSON object keys + let err = lua.load(r#"return utils.toJSON({[true] = 1})"#).exec().unwrap_err(); + assert!(err.to_string().contains("object key"), "got: {err}"); + + let err = lua.load(r#"return utils.toJSON({[1.5] = 1})"#).exec().unwrap_err(); + assert!(err.to_string().contains("object key"), "got: {err}"); + + // An integer key that stringifies onto an existing string key must error + // rather than silently dropping one of the two values. + let err = lua + .load(r#"return utils.toJSON({[1] = "int", ["1"] = "str"})"#) + .exec() + .unwrap_err(); + assert!(err.to_string().contains("duplicate object key"), "got: {err}"); + + // A non-UTF-8 string value gets a clear prefixed error, not mlua's bare + // "error converting Lua string to &str". + let err = lua.load(r#"return utils.toJSON("\255bad")"#).exec().unwrap_err(); + assert!(err.to_string().contains("utils.toJSON"), "got: {err}"); + assert!(err.to_string().contains("UTF-8"), "got: {err}"); +} + +#[test] +fn test_null_sentinel() { + let lua = super::lua(); + + // utils.NULL exists and is distinct from nil + let result: bool = lua.load(r#"return utils.NULL ~= nil"#).eval().unwrap(); + assert!(result); + + // isNull identifies the sentinel and nothing else + let result: bool = lua.load(r#"return utils.isNull(utils.NULL)"#).eval().unwrap(); + assert!(result); + + let result: bool = lua + .load( + r#" + return utils.isNull(nil) or utils.isNull(0) or utils.isNull("") + or utils.isNull({}) or utils.isNull(false) + "#, + ) + .eval() + .unwrap(); + assert!(!result); + + // JSON null deserializes to the sentinel - consistently at any nesting level + let result: bool = lua.load(r#"return utils.isNull(utils.fromJSON("null"))"#).eval().unwrap(); + assert!(result); + + let result: bool = lua + .load( + r#" + local obj = utils.fromJSON('{"a": null, "b": 1}') + return utils.isNull(obj.a) and obj.a ~= nil and obj.b == 1 + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // Nulls in arrays preserve the array length + let result: bool = lua + .load( + r#" + local arr = utils.fromJSON('[1, null, 3]') + return #arr == 3 and utils.isNull(arr[2]) and arr[1] == 1 and arr[3] == 3 + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // The sentinel serializes back to JSON null + let result: String = lua.load(r#"return utils.toJSON(utils.NULL)"#).eval().unwrap(); + assert_eq!(result, "null"); + + let result: String = lua.load(r#"return utils.toJSON({a = utils.NULL})"#).eval().unwrap(); + assert_eq!(result, "{\"a\":null}"); + + // dump renders the sentinel as NULL, bare and inside tables + // (other light userdata dumps as ) + let result: String = lua.load(r#"return utils.dump(utils.NULL)"#).eval().unwrap(); + assert_eq!(result, "NULL"); + + let result: String = lua.load(r#"return utils.dump({a = utils.NULL})"#).eval().unwrap(); + assert_eq!(result, "{a=NULL}"); + + let result: String = lua.load(r#"return utils.dump({1, utils.NULL, 3})"#).eval().unwrap(); + assert_eq!(result, "{1, NULL, 3}"); +} + +/// utils.NULL is mlua's own null sentinel (Value::NULL, a null-pointer light +/// userdata), so nulls interoperate with mlua's serde layer in both directions. +#[test] +fn test_null_sentinel_matches_mlua() { + use mlua::LuaSerdeExt; + + let lua = super::lua(); + + let null_val: mlua::Value = lua.load(r#"return utils.NULL"#).eval().unwrap(); + assert_eq!(null_val, mlua::Value::NULL); + + // a null produced by mlua (lua.null()) is recognized by utils.isNull + let is_null: bool = lua + .globals() + .get::("utils") + .unwrap() + .get::("isNull") + .unwrap() + .call(lua.null()) + .unwrap(); + assert!(is_null); +} + +#[test] +fn test_utils_table_extensible() { + let lua = super::lua(); + + // utils is a plain global table just like math and table - scripts can extend it + let result: i64 = lua + .load( + r#" + utils.myHelper = function() return 41 + 1 end + return utils.myHelper() + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 42); + + // and the built-in functions are of course still there + let result: bool = lua.load(r#"return type(utils.try) == "function""#).eval().unwrap(); + assert!(result); +} + +#[test] +fn test_try_tryn_arg_validation() { + let lua = super::lua(); + + // The callback must be a function, rejected with a clear message + let err = lua.load(r#"return utils.try(5)"#).exec().unwrap_err(); + assert!(err.to_string().contains("not a function"), "got: {err}"); + + let err = lua.load(r#"return utils.try(nil)"#).exec().unwrap_err(); + assert!(err.to_string().contains("not a function"), "got: {err}"); + + let err = lua.load(r#"return utils.tryn(2, 5)"#).exec().unwrap_err(); + assert!(err.to_string().contains("not a function"), "got: {err}"); + + // expectedCount must be a non-negative integer + let err = lua.load(r#"return utils.tryn(2.5, function() end)"#).exec().unwrap_err(); + assert!(err.to_string().contains("integer"), "got: {err}"); + + assert!(lua.load(r#"return utils.tryn(-1, function() end)"#).exec().is_err()); +} + +#[test] +fn test_try_error_values() { + let lua = super::lua(); + + // A thrown table is passed through as-is (pcall semantics), so scripts can + // attach structured data to errors + let result: bool = lua + .load( + r#" + local v, err = utils.try(function() error({code = 42}) end) + return v == nil and type(err) == "table" and err.code == 42 + "#, + ) + .eval() + .unwrap(); + assert!(result); + + // A nil error value still yields a non-nil err, so `if err` always detects failure + let result: bool = lua + .load( + r#" + local v, err = utils.try(function() error() end) + return err ~= nil + "#, + ) + .eval() + .unwrap(); + assert!(result); + + let result: bool = lua + .load( + r#" + local a, err = utils.tryn(1, function() error() end) + return err ~= nil + "#, + ) + .eval() + .unwrap(); + assert!(result); +} + +#[test] +fn test_tryn_return_counts() { + let lua = super::lua(); + + // tryn returns exactly expectedCount + 1 values (the last one is the error slot), + // both on success and on failure + let result: i64 = lua + .load(r#"return select('#', utils.tryn(2, function() return 1 end))"#) + .eval() + .unwrap(); + assert_eq!(result, 3); + + let result: i64 = lua + .load(r#"return select('#', utils.tryn(2, function() error("x") end))"#) + .eval() + .unwrap(); + assert_eq!(result, 3); + + // expectedCount = 0 gives just the error slot + let result: bool = lua + .load( + r#" + local err = utils.tryn(0, function() return 1, 2 end) + return err == nil and select('#', utils.tryn(0, function() end)) == 1 + "#, + ) + .eval() + .unwrap(); + assert!(result); +} + +#[test] +fn test_tryn_count_cap() { + let lua = super::lua(); + + // Excessive expected_count must error early instead of allocating huge buffers + let err = lua + .load(r#"return utils.tryn(1000000, function() return 1 end)"#) + .exec() + .unwrap_err(); + assert!(err.to_string().contains("at most"), "got: {err}"); + + let err = lua + .load(r#"return utils.tryn(65, function() return 1 end)"#) + .exec() + .unwrap_err(); + assert!(err.to_string().contains("at most"), "got: {err}"); + + // The maximum allowed count still works + let result: i64 = lua + .load( + r#" + local r = {utils.tryn(64, function() return 42 end)} + return r[1] + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 42); +} + +#[test] +fn test_from_json() { + let lua = super::lua(); + + // Primitives - JSON null becomes the utils.NULL sentinel, not nil + lua.load(r#"assert(utils.isNull(utils.fromJSON("null")))"#).exec().unwrap(); + + let result: bool = lua.load(r#"return utils.fromJSON("true")"#).eval().unwrap(); + assert!(result); + + let result: bool = lua.load(r#"return utils.fromJSON("false")"#).eval().unwrap(); + assert!(!result); + + let result: i64 = lua.load(r#"return utils.fromJSON("42")"#).eval().unwrap(); + assert_eq!(result, 42); + + // Whole JSON numbers arrive as Lua integers, fractional ones as floats + let result: bool = lua + .load(r#"return math.type(utils.fromJSON("42")) == "integer""#) + .eval() + .unwrap(); + assert!(result); + + let result: f64 = lua.load(r#"return utils.fromJSON("3.14")"#).eval().unwrap(); + assert!((result - 3.14).abs() < 0.001); + + let result: bool = lua + .load(r#"return math.type(utils.fromJSON("3.14")) == "float""#) + .eval() + .unwrap(); + assert!(result); + + let result: String = lua.load(r#"return utils.fromJSON('"hello"')"#).eval().unwrap(); + assert_eq!(result, "hello"); + + // Array + let result: i64 = lua + .load( + r#" + local arr = utils.fromJSON("[1, 2, 3]") + return arr[1] + arr[2] + arr[3] + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 6); + + // Object + let result: i64 = lua + .load( + r#" + local obj = utils.fromJSON('{"a": 10, "b": 20}') + return obj.a + obj.b + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 30); + + // Nested structure + let result: i64 = lua + .load( + r#" + local data = utils.fromJSON('{"nested": {"value": 42}}') + return data.nested.value + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 42); + + // Invalid JSON should error, and the message names the function + let err = lua.load(r#"return utils.fromJSON("not valid json")"#).exec().unwrap_err(); + assert!(err.to_string().contains("fromJSON"), "got: {err}"); +} + +#[test] +fn test_json_roundtrip() { + let lua = super::lua(); + + // Round-trip test: Lua -> JSON -> Lua + let result: i64 = lua + .load( + r#" + local original = {x = 10, y = 20, items = {1, 2, 3}} + local json = utils.toJSON(original) + local restored = utils.fromJSON(json) + return restored.x + restored.y + restored.items[1] + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 31); // 10 + 20 + 1 + + // NULL survives a Lua -> JSON -> Lua round trip + let result: bool = lua + .load( + r#" + local restored = utils.fromJSON(utils.toJSON({a = utils.NULL})) + return utils.isNull(restored.a) + "#, + ) + .eval() + .unwrap(); + assert!(result); +} + +#[test] +fn test_try() { + let lua = super::lua(); + + // Success case: returns value, nil error + let (value, has_error): (i64, bool) = lua + .load( + r#" + local val, err = utils.try(function() return 42 end) + return val, err ~= nil + "#, + ) + .eval() + .unwrap(); + assert_eq!(value, 42); + assert!(!has_error); + + // Error case: returns nil value, error + let (is_nil, has_error): (bool, bool) = lua + .load( + r#" + local val, err = utils.try(function() error("boom") end) + return val == nil, err ~= nil + "#, + ) + .eval() + .unwrap(); + assert!(is_nil); + assert!(has_error); + + // Error message is preserved + let contains_boom: bool = lua + .load( + r#" + local val, err = utils.try(function() error("boom") end) + return tostring(err):find("boom") ~= nil + "#, + ) + .eval() + .unwrap(); + assert!(contains_boom); +} + +#[test] +fn test_try_shorthand() { + let lua = super::lua(); + + // pcall-style shorthand: the function and its arguments, no closure needed + let (value, has_error): (String, bool) = lua + .load( + r#" + local val, err = utils.try(string.rep, "ab", 3) + return val, err ~= nil + "#, + ) + .eval() + .unwrap(); + assert_eq!(value, "ababab"); + assert!(!has_error); + + // Errors thrown by the called function are captured the same way as with a closure + let captured: bool = lua + .load( + r#" + local val, err = utils.try(error, "boom") + return val == nil and tostring(err):find("boom") ~= nil + "#, + ) + .eval() + .unwrap(); + assert!(captured); + + // nil arguments are forwarded with the argument count preserved (pcall semantics) + let result: i64 = lua + .load( + r#" + local function countArgs(...) return select('#', ...) end + local n = utils.try(countArgs, nil, nil, nil) + return n + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 3); +} + +#[test] +fn test_tryn() { + let lua = super::lua(); + + // Success case with multiple return values + let (a, b, c, has_error): (i64, i64, i64, bool) = lua + .load( + r#" + local a, b, c, err = utils.tryn(3, function() return 1, 2, 3 end) + return a, b, c, err ~= nil + "#, + ) + .eval() + .unwrap(); + assert_eq!((a, b, c), (1, 2, 3)); + assert!(!has_error); + + // Error case: all values are nil, error is present + let (all_nil, has_error): (bool, bool) = lua + .load( + r#" + local a, b, c, err = utils.tryn(3, function() error("fail") end) + return a == nil and b == nil and c == nil, err ~= nil + "#, + ) + .eval() + .unwrap(); + assert!(all_nil); + assert!(has_error); + + // Fewer return values than expected: pads with nil + let (a, b_nil, c_nil, has_error): (i64, bool, bool, bool) = lua + .load( + r#" + local a, b, c, err = utils.tryn(3, function() return 100 end) + return a, b == nil, c == nil, err ~= nil + "#, + ) + .eval() + .unwrap(); + assert_eq!(a, 100); + assert!(b_nil); + assert!(c_nil); + assert!(!has_error); + + // More return values than expected: truncates + let (a, b, has_error): (i64, i64, bool) = lua + .load( + r#" + local a, b, err = utils.tryn(2, function() return 1, 2, 3, 4, 5 end) + return a, b, err ~= nil + "#, + ) + .eval() + .unwrap(); + assert_eq!((a, b), (1, 2)); + assert!(!has_error); +} + +#[test] +fn test_tryn_shorthand() { + let lua = super::lua(); + + // pcall-style shorthand: arguments after the callback are passed to it + let (q, r, has_error): (i64, i64, bool) = lua + .load( + r#" + local function divmod(a, b) return a // b, a % b end + local q, r, err = utils.tryn(2, divmod, 17, 5) + return q, r, err ~= nil + "#, + ) + .eval() + .unwrap(); + assert_eq!((q, r), (3, 2)); + assert!(!has_error); + + // Error case: arguments are passed, the error lands in the last slot + let (all_nil, captured): (bool, bool) = lua + .load( + r#" + local function fail(msg) error("failed: " .. msg) end + local a, b, err = utils.tryn(2, fail, "badly") + return a == nil and b == nil, tostring(err):find("failed: badly") ~= nil + "#, + ) + .eval() + .unwrap(); + assert!(all_nil); + assert!(captured); + + // nil arguments are forwarded with the argument count preserved (pcall semantics) + let result: i64 = lua + .load( + r#" + local function countArgs(...) return select('#', ...) end + local n, err = utils.tryn(1, countArgs, nil, 5, nil) + return n + "#, + ) + .eval() + .unwrap(); + assert_eq!(result, 3); +} diff --git a/src/main.rs b/src/main.rs index 3810a2c..bf85457 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,11 @@ use std::path::PathBuf; use clap::Parser; +// Lua source embedded in test strings (e.g. `math.round(3.1415, 2)`) trips +// clippy's approx_constant lint, which mistakes the literals for `PI`. +#[cfg(test)] +#[allow(clippy::approx_constant)] +mod lua_tests; mod repl; mod sandbox; mod stdlib; @@ -16,9 +21,18 @@ struct Args { } #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + if let Err(err) = run().await { + // Display (not Debug) so a Lua error's traceback and multi-line messages + // read as intended instead of as one escaped line. + eprintln!("{err}"); + std::process::exit(1); + } +} + +async fn run() -> Result<(), Box> { let args = Args::parse(); let lua = sandbox::create_sandboxed_lua()?; @@ -27,15 +41,23 @@ async fn main() -> Result<(), Box> { return repl::run(&lua).await; }; - let source = tokio::fs::read_to_string(&filepath).await?; + // Read as bytes, not a UTF-8 String: Lua source (and its string literals) are + // byte strings, and stock Lua runs files that aren't valid UTF-8. + let source = tokio::fs::read(&filepath) + .await + .map_err(|e| format!("a: cannot read {}: {e}", filepath.display()))?; + + // Strip a leading UTF-8 BOM, as stock Lua does, so a BOM before a shebang + // (common from Windows editors) doesn't reach the lexer. + let source = source.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&source); - // Skip a leading shebang line like stock Lua's luaL_loadfilex does (it - // skips any first line starting with '#'). The newline is kept so error + // Skip a leading shebang line, if present. The newline is kept so error // messages still report correct line numbers. - let chunk = if source.starts_with('#') { - &source[source.find('\n').unwrap_or(source.len())..] + let chunk: &[u8] = if source.first() == Some(&b'#') { + let nl = source.iter().position(|&b| b == b'\n').unwrap_or(source.len()); + &source[nl..] } else { - source.as_str() + source }; lua.load(chunk) diff --git a/src/repl.rs b/src/repl.rs index 757e1de..2842b35 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -1,16 +1,14 @@ //! Interactive read-eval-print loop. //! -//! Started when `a` is run with no script path. Each line is compiled and run in -//! the same sandboxed Lua state used for scripts, so the full stdlib (including -//! the async http/sqlite/os.sleep calls) is available. Evaluation uses the async -//! executor, matching how files are run. +//! Each line is compiled and run in exactly the same way scripts are run, +//! keeping the Lua state between lines. use mlua::prelude::{LuaFunction, LuaMultiValue, LuaTable}; use mlua::Lua; use rustyline::error::ReadlineError; use rustyline::DefaultEditor; -const PROMPT: &str = "a> "; +const PROMPT: &str = ">>> "; const CONTINUATION_PROMPT: &str = "..> "; /// Outcome of trying to compile a piece of REPL input. @@ -86,6 +84,9 @@ pub(crate) async fn run(lua: &Lua) -> Result<(), Box> { // Accumulated lines of an as-yet-incomplete statement. let mut buffer = String::new(); + // Guards against an unbroken stream of read errors spinning forever (e.g. a + // reader that keeps failing without ever reaching EOF). + let mut consecutive_errors = 0u32; loop { let prompt = if buffer.is_empty() { @@ -96,6 +97,7 @@ pub(crate) async fn run(lua: &Lua) -> Result<(), Box> { match editor.readline(prompt) { Ok(line) => { + consecutive_errors = 0; if buffer.is_empty() { buffer = line; } else { @@ -127,12 +129,21 @@ pub(crate) async fn run(lua: &Lua) -> Result<(), Box> { // Ctrl-C: abandon the current input and start fresh. Err(ReadlineError::Interrupted) => { buffer.clear(); + consecutive_errors = 0; } // Ctrl-D (on an empty line) or end of piped input: quit. Err(ReadlineError::Eof) => break, + // Any other read error (e.g. a non-UTF-8 input line) is reported but + // does not end the session — a stray pasted byte shouldn't kill the + // REPL. Bail only if errors keep coming with no successful read in + // between, so a permanently broken stream can't spin forever. Err(err) => { eprintln!("{err}"); - break; + buffer.clear(); + consecutive_errors += 1; + if consecutive_errors >= 100 { + break; + } } } } diff --git a/src/stdlib/http.rs b/src/stdlib/http.rs index 6aba9ba..eb68065 100644 --- a/src/stdlib/http.rs +++ b/src/stdlib/http.rs @@ -54,13 +54,18 @@ impl CookieJar { return; } - let mut domain = host.to_ascii_lowercase(); + let request_host = host.to_ascii_lowercase(); + let mut domain = request_host.clone(); for seg in segments { if let Some((k, v)) = seg.split_once('=') && k.trim().eq_ignore_ascii_case("domain") { let d = v.trim().trim_start_matches('.').to_ascii_lowercase(); - if !d.is_empty() { + // Only accept a Domain the request host actually belongs to + // (exact match or a subdomain). Otherwise a response could plant + // a cookie scoped to an unrelated domain ("cookie tossing"); such + // an attribute is ignored and the cookie stays host-scoped. + if !d.is_empty() && (request_host == d || request_host.ends_with(&format!(".{d}"))) { domain = d; } } @@ -141,6 +146,17 @@ fn form_urlencode(pairs: &[(String, String)]) -> String { const MAX_REDIRECTS: u32 = 10; +/// The origin of a URL: (scheme, host, effective port). Two URLs share an origin +/// when all three match — the boundary across which credentials and secret +/// headers must not be replayed on a redirect. +fn origin_of(u: &reqwest::Url) -> (String, Option, Option) { + ( + u.scheme().to_ascii_lowercase(), + u.host_str().map(|h| h.to_ascii_lowercase()), + u.port_or_known_default(), + ) +} + /// Shared by the stateless `http.request` and a session's `:request`. When `jar` /// is `Some`, the matching jar cookies are sent and any `Set-Cookie` responses /// are stored back. @@ -228,39 +244,63 @@ async fn execute_request( } } + // Did the caller supply their own Content-Type? If so it wins over the one a + // json/form body would otherwise imply (no silent override, no duplicate). + let has_user_content_type = custom_headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("content-type")); + let is_digest = matches!(auth.as_ref(), Some((true, _, _))); let mut digest_header: Option = None; let mut digest_tried = false; let mut url = url; let mut redirects_left = MAX_REDIRECTS; + // Origin (scheme, host, port) of the ORIGINAL request. Once a redirect + // leaves this origin we stop attaching credentials, custom headers, and + // per-request cookies, mirroring what reqwest's own redirect policy does: + // a redirect to an attacker-controlled host must not receive the caller's + // Authorization, Cookie, or secret headers. + let origin = reqwest::Url::parse(&url).ok().map(|u| origin_of(&u)); let resp = loop { // Host used for cookie matching and as the Set-Cookie domain fallback; // recomputed each hop since a redirect may cross hosts. - let host = reqwest::Url::parse(&url) - .ok() + let parsed = reqwest::Url::parse(&url).ok(); + let host = parsed + .as_ref() .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase())); + // Are we still on the origin the request was addressed to? + let same_origin = match (origin.as_ref(), parsed.as_ref()) { + (Some(o), Some(u)) => *o == origin_of(u), + _ => false, + }; let mut req = client.request(method.clone(), &url); if let Some(t) = timeout { req = req.timeout(t); } - for (k, v) in &custom_headers { - req = req.header(k, v); + // Custom headers carry only while on the original origin. + if same_origin { + for (k, v) in &custom_headers { + req = req.header(k, v); + } } - // Cookie header: jar cookies for this host, then per-request cookies - // which override on name collision. + // Cookie header: jar cookies for this host (the jar is domain-scoped, so + // this is always safe), then per-request cookies which override on name + // collision — but the explicit per-request cookies stay on-origin only. let mut cookie_map: HashMap = HashMap::new(); if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) { - for (n, v) in jar.lock().unwrap().cookies_for(host) { + for (n, v) in jar.lock().unwrap_or_else(|e| e.into_inner()).cookies_for(host) { cookie_map.insert(n, v); } } - for (k, v) in &opts_cookies { - cookie_map.insert(k.clone(), v.clone()); + if same_origin { + for (k, v) in &opts_cookies { + cookie_map.insert(k.clone(), v.clone()); + } } if !cookie_map.is_empty() { let header = cookie_map @@ -272,15 +312,23 @@ async fn execute_request( } if let Some((bytes, ct)) = body.as_ref() { - if let Some(ct) = ct { + // Apply the body's implied Content-Type only when the caller didn't + // set their own (and theirs is still in effect, i.e. same origin). + if let Some(ct) = ct + && !(same_origin && has_user_content_type) + { req = req.header(reqwest::header::CONTENT_TYPE, *ct); } req = req.body(bytes.clone()); } - // Auth. Basic goes out on every hop; digest's Authorization is set only - // after the 401 challenge below has been answered (digest_header). - if let Some((digest, username, password)) = auth.as_ref() { + // Auth. Basic goes out on each hop; digest's Authorization is set only + // after the 401 challenge below has been answered (digest_header). Both + // are withheld once a redirect leaves the original origin, so credentials + // never reach a host the caller did not address. + if same_origin + && let Some((digest, username, password)) = auth.as_ref() + { if *digest { if let Some(h) = digest_header.as_ref() { req = req.header(reqwest::header::AUTHORIZATION, h); @@ -298,7 +346,7 @@ async fn execute_request( // Store Set-Cookie from this hop into the jar. if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) { - let mut j = jar.lock().unwrap(); + let mut j = jar.lock().unwrap_or_else(|e| e.into_inner()); for value in resp.headers().get_all(reqwest::header::SET_COOKIE).iter() { if let Ok(s) = value.to_str() { j.set_from_header(host, s); @@ -339,15 +387,21 @@ async fn execute_request( } } - // Follow a redirect if there is one to follow. + // Follow a redirect if there is one to follow. A redirect with a usable + // Location that we can't follow because the budget is spent is a "too + // many redirects" error, not a silent success returning the 3xx. if status.is_redirection() - && redirects_left > 0 && let Some(next) = resp .headers() .get(reqwest::header::LOCATION) .and_then(|l| l.to_str().ok()) .and_then(|loc| reqwest::Url::parse(&url).and_then(|base| base.join(loc)).ok()) { + if redirects_left == 0 { + return Err(mlua::Error::external(format!( + "http: too many redirects (max {MAX_REDIRECTS})" + ))); + } redirects_left -= 1; // 303, and 301/302 on a POST, degrade to a bodyless GET — the // behaviour browsers and reqwest's own redirect policy apply. @@ -420,7 +474,7 @@ fn make_session( tbl.raw_set("save", { let jar = jar.clone(); lua.create_function(move |_, (_this, path): (LuaTable, String)| { - let jsonl = jar.lock().unwrap().to_jsonl(); + let jsonl = jar.lock().unwrap_or_else(|e| e.into_inner()).to_jsonl(); std::fs::write(&path, jsonl) .map_err(|e| mlua::Error::external(format!("session:save: {e}"))) })? @@ -431,7 +485,7 @@ fn make_session( lua.create_function(move |_, (_this, path): (LuaTable, String)| { let src = std::fs::read_to_string(&path) .map_err(|e| mlua::Error::external(format!("session:load: {e}")))?; - jar.lock().unwrap().merge_jsonl(&src); + jar.lock().unwrap_or_else(|e| e.into_inner()).merge_jsonl(&src); Ok(()) })? })?; @@ -439,7 +493,7 @@ fn make_session( tbl.raw_set("clearCookies", { let jar = jar.clone(); lua.create_function(move |_, _this: LuaTable| { - jar.lock().unwrap().cookies.clear(); + jar.lock().unwrap_or_else(|e| e.into_inner()).cookies.clear(); Ok(()) })? })?; @@ -448,7 +502,7 @@ fn make_session( let jar = jar.clone(); lua.create_function(move |lua, _this: LuaTable| { let outer = lua.create_table()?; - let j = jar.lock().unwrap(); + let j = jar.lock().unwrap_or_else(|e| e.into_inner()); for (domain, names) in j.cookies.iter() { let inner = lua.create_table()?; for (name, value) in names.iter() { @@ -527,3 +581,71 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { // session metatable wrapping http.session(). lua.load(HTTP_LUA).set_name("@[stdlib/http]").exec() } + +#[cfg(test)] +mod tests { + use super::*; + + fn cookie(jar: &CookieJar, domain: &str, name: &str) -> Option { + jar.cookies.get(domain).and_then(|m| m.get(name).cloned()) + } + + #[test] + fn set_cookie_defaults_to_request_host() { + let mut jar = CookieJar::default(); + jar.set_from_header("example.com", "sid=abc"); + assert_eq!(cookie(&jar, "example.com", "sid").as_deref(), Some("abc")); + } + + #[test] + fn set_cookie_honors_domain_the_host_belongs_to() { + let mut jar = CookieJar::default(); + // A subdomain may scope a cookie up to its parent domain. + jar.set_from_header("app.example.com", "sid=abc; Domain=example.com"); + assert_eq!(cookie(&jar, "example.com", "sid").as_deref(), Some("abc")); + } + + #[test] + fn set_cookie_rejects_foreign_domain() { + let mut jar = CookieJar::default(); + // Cookie tossing: a response from one host must not plant a cookie scoped + // to an unrelated domain. The bad Domain= is ignored; it stays host-scoped. + jar.set_from_header("127.0.0.1", "evil=1; Domain=other.example"); + assert!(cookie(&jar, "other.example", "evil").is_none()); + assert_eq!(cookie(&jar, "127.0.0.1", "evil").as_deref(), Some("1")); + } + + #[test] + fn set_cookie_rejects_partial_suffix_match() { + let mut jar = CookieJar::default(); + // "notexample.com" ends with "example.com" as a raw substring but is not + // a subdomain, so the Domain must be rejected. + jar.set_from_header("notexample.com", "x=1; Domain=example.com"); + assert!(cookie(&jar, "example.com", "x").is_none()); + assert_eq!(cookie(&jar, "notexample.com", "x").as_deref(), Some("1")); + } + + #[test] + fn origins_compare_by_scheme_host_port() { + let u = |s: &str| origin_of(&reqwest::Url::parse(s).unwrap()); + assert_eq!(u("https://a.com/x"), u("https://a.com/y")); // path-independent + assert_eq!(u("https://a.com"), u("https://a.com:443")); // default port + assert_ne!(u("https://a.com"), u("http://a.com")); // scheme differs + assert_ne!(u("https://a.com"), u("https://b.com")); // host differs + assert_ne!(u("https://a.com"), u("https://a.com:8443")); // port differs + } + + #[test] + fn cookies_for_matches_domain_and_subdomains() { + let mut jar = CookieJar::default(); + jar.set_from_header("example.com", "a=1"); + assert_eq!(jar.cookies_for("example.com"), vec![("a".into(), "1".into())]); + // a cookie stored on example.com is sent to its subdomains + jar.set_from_header("sub.example.com", "b=2; Domain=example.com"); + let mut got = jar.cookies_for("sub.example.com"); + got.sort(); + assert_eq!(got, vec![("a".into(), "1".into()), ("b".into(), "2".into())]); + // but not to an unrelated host + assert!(jar.cookies_for("other.com").is_empty()); + } +} diff --git a/src/stdlib/logging.rs b/src/stdlib/logging.rs index 7d4f8ee..e451119 100644 --- a/src/stdlib/logging.rs +++ b/src/stdlib/logging.rs @@ -5,7 +5,17 @@ fn args_to_string(lua: &Lua, args: Variadic) -> LuaResult { let tostring: mlua::Function = lua.globals().get("tostring")?; let parts: Vec = args .into_iter() - .map(|v| tostring.call::(v).unwrap_or_else(|_| "?".to_string())) + .map(|v| { + // tostring can yield a non-UTF-8 byte string (Lua strings are byte + // strings); render it lossily rather than collapsing the whole + // argument to "?" and losing its readable parts. "?" is reserved for + // the rare case where tostring itself errors (e.g. a broken + // __tostring metamethod). + match tostring.call::(v) { + Ok(s) => s.to_string_lossy(), + Err(_) => "?".to_string(), + } + }) .collect(); Ok(parts.join("\t")) } @@ -55,3 +65,28 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { lua.globals().raw_set("log", log)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_utf8_arg_is_rendered_lossily_not_dropped() { + let lua = Lua::new(); + // A byte string that is not valid UTF-8 must keep its readable parts + // (via lossy conversion) rather than collapsing the whole argument to "?". + let args: Variadic = + Variadic::from_iter([LuaValue::String(lua.create_string(b"\xff readable").unwrap())]); + let rendered = args_to_string(&lua, args).unwrap(); + assert!(rendered.contains("readable"), "got: {rendered:?}"); + assert_ne!(rendered, "?"); + } + + #[test] + fn multiple_args_are_tab_joined() { + let lua = Lua::new(); + let args: Variadic = + Variadic::from_iter([LuaValue::Integer(1), LuaValue::Boolean(true)]); + assert_eq!(args_to_string(&lua, args).unwrap(), "1\ttrue"); + } +} diff --git a/src/stdlib/lua_dump.rs b/src/stdlib/lua_dump.rs new file mode 100644 index 0000000..e1e8456 --- /dev/null +++ b/src/stdlib/lua_dump.rs @@ -0,0 +1,116 @@ +use std::borrow::Cow; + +use mlua::Value as LuaValue; +use serde_json::Value as JsonValue; + +/// Pretty-print a Lua value for humans (the backend of `utils.dump`). +/// Ported from flowbox-rt's fb_lua lua_dump.rs, adapted to mlua 0.11. +pub(super) fn dump(value: LuaValue) -> Cow<'static, str> { + dump_inner(value, DumpPosition::Value, 0) +} + +enum DumpPosition { + Key, + Value, +} + +fn keywrap(value: Cow<'static, str>, position: DumpPosition) -> Cow<'static, str> { + match position { + DumpPosition::Key => format!("[{value}]").into(), + DumpPosition::Value => value, + } +} + +fn dump_inner(value: LuaValue, position: DumpPosition, recursion_depth: usize) -> Cow<'static, str> { + let res: Cow<'static, str> = match value { + LuaValue::Nil => "nil".into(), + LuaValue::Boolean(v) => if v { "true" } else { "false" }.into(), + LuaValue::Integer(v) => v.to_string().into(), + LuaValue::Number(v) => { + if v.fract() == 0.0 { + // ensure decimal places are shown to indicate it is a float + format!("{:.1}", v.trunc()).into() + } else { + v.to_string().into() + } + } + LuaValue::String(v) => { + let stringified = v.to_string_lossy().to_string(); + match position { + DumpPosition::Key => { + // return to bypass the keywrap + return if stringified.char_indices().any(|(index, c)| { + if index == 0 { + !c.is_ascii_alphabetic() && c != '_' + } else { + !c.is_ascii_alphanumeric() && c != '_' + } + }) { + // json-serialize the string to add quotes and escapes + format!("[{}]", serde_json::to_string(&JsonValue::String(stringified)).unwrap()).into() + } else { + // string lua key can be used as is {foo="bar"} + stringified.into() + }; + } + DumpPosition::Value => serde_json::to_string(&JsonValue::String(stringified)).unwrap().into(), + } + } + LuaValue::Table(t) => { + if recursion_depth > 20 { + // Flow through keywrap so a table used as a key still renders as + // `[]`, consistent with every other key. + return keywrap("".into(), position); + } + let recursion_depth = recursion_depth + 1; + + let mut buf = "{".to_string(); + + let mut first = true; + let mut list_next = Some(1); + for pair in t.pairs::() { + let Ok((k, v)) = pair else { + list_next = None; + continue; + }; + + if !first { + buf.push_str(", "); + } + first = false; + + // special case for sequential numeric keys, starting at 1 + if let Some(list_index) = list_next { + if let LuaValue::Integer(index) = k + && index == list_index + { + buf.push_str(&dump_inner(v, DumpPosition::Value, recursion_depth)); + list_next = Some(list_index + 1); + continue; + } + list_next = None; + } + + buf.push_str(&format!( + "{}={}", + dump_inner(k, DumpPosition::Key, recursion_depth), + dump_inner(v, DumpPosition::Value, recursion_depth) + )); + } + + buf.push('}'); + buf.into() + } + // The null pointer lightuserdata is the JSON null sentinel (utils.NULL) + LuaValue::LightUserData(ud) if ud.0.is_null() => "NULL".into(), + // Complex objects can't be displayed + LuaValue::LightUserData(_) => "".into(), + LuaValue::Function(_) => "".into(), + LuaValue::Thread(_) => "".into(), + LuaValue::UserData(_) => "".into(), + LuaValue::Error(e) => format!("").into(), + other => format!("<{}>", other.type_name()).into(), + }; + + keywrap(res, position) +} diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 98f6e64..4c988a0 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -1,5 +1,6 @@ mod http; mod logging; +mod lua_dump; mod math; mod os_ext; mod sqlite; diff --git a/src/stdlib/os_ext.rs b/src/stdlib/os_ext.rs index f7901f8..9d4f769 100644 --- a/src/stdlib/os_ext.rs +++ b/src/stdlib/os_ext.rs @@ -1,7 +1,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use mlua::prelude::LuaResult; -use mlua::{Lua, Table as LuaTable}; +use mlua::{Lua, Table as LuaTable, Value as LuaValue}; /// Extend the already-sandboxed `os` table with non-standard additions. pub(super) fn install(lua: &Lua) -> LuaResult<()> { @@ -21,7 +21,33 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { // os.sleep(seconds) — yields the Lua coroutine via tokio; does not block the thread. os.raw_set( "sleep", - lua.create_async_function(|_, seconds: f64| async move { + lua.create_async_function(|_, seconds: LuaValue| async move { + // Accept only a number, with a prefixed message (mlua's own coercion + // would report a bare "error converting ..." for e.g. a string). + let seconds = match seconds { + LuaValue::Integer(i) => i as f64, + LuaValue::Number(n) => n, + other => { + return Err(mlua::Error::external(format!( + "os.sleep: duration must be a number (got {})", + other.type_name() + ))); + } + }; + // Cap the delay: a huge-but-finite value (e.g. 1e18) would otherwise + // park the task effectively forever with no way to interrupt it from + // Lua, which reads as the runtime hanging rather than the script. + const MAX_SLEEP_SECS: f64 = 24.0 * 60.0 * 60.0; // one day + if !(seconds.is_finite() && seconds >= 0.0) { + return Err(mlua::Error::external( + "os.sleep: duration must be a non-negative finite number of seconds", + )); + } + if seconds > MAX_SLEEP_SECS { + return Err(mlua::Error::external(format!( + "os.sleep: refusing to sleep for more than {MAX_SLEEP_SECS} seconds (got {seconds})" + ))); + } let duration = Duration::try_from_secs_f64(seconds).map_err(|_| { mlua::Error::external( "os.sleep: duration must be a non-negative finite number of seconds", diff --git a/src/stdlib/sqlite.rs b/src/stdlib/sqlite.rs index 064ff22..050a4d1 100644 --- a/src/stdlib/sqlite.rs +++ b/src/stdlib/sqlite.rs @@ -130,7 +130,16 @@ fn lua_value_to_sqlite(value: LuaValue) -> LuaResult { LuaValue::Boolean(b) => Ok(Value::Integer(b as i64)), LuaValue::Integer(i) => Ok(Value::Integer(i)), LuaValue::Number(f) => Ok(Value::Real(f)), - LuaValue::String(s) => Ok(Value::Text(s.to_str()?.to_owned())), + // Lua strings are byte strings. Valid UTF-8 binds as TEXT; anything else + // (raw bytes, embedded NULs) binds as a BLOB so binary data round-trips + // instead of failing the UTF-8 conversion. + LuaValue::String(s) => { + let bytes = s.as_bytes(); + match std::str::from_utf8(&bytes) { + Ok(text) => Ok(Value::Text(text.to_owned())), + Err(_) => Ok(Value::Blob(bytes.to_vec())), + } + } other => Err(mlua::Error::external(format!( "sqlite: cannot bind value of type '{}'", other.type_name() @@ -224,11 +233,37 @@ fn rows_to_lua(lua: &Lua, rows: Vec) -> LuaResult { // Blocking workers (run on the tokio blocking pool) // --------------------------------------------------------------------------- +/// Reject SQL that is empty/comment-only or contains more than one statement. +/// +/// `execute`/`query`/`queryOne` compile a single statement via `prepare_cached`, +/// which silently ignores anything after the first — so `execute("A; B")` would +/// run only `A` and drop `B` with no error, and `execute("")` surfaces SQLite's +/// bare "not an error". `Batch` uses SQLite's own tokenizer to count statements +/// (skipping comments/whitespace), giving a clear message instead. Use +/// `executeBatch` to run multiple statements on purpose. +fn ensure_single_statement(conn: &rusqlite::Connection, sql: &str) -> Result<(), DbError> { + let mut batch = rusqlite::Batch::new(conn, sql); + let mut count = 0usize; + while batch.next()?.is_some() { + count += 1; + if count > 1 { + return Err(DbError::Other( + "sqlite: expected a single SQL statement (use executeBatch for multiple)".to_string(), + )); + } + } + if count == 0 { + return Err(DbError::Other("sqlite: no SQL statement to run".to_string())); + } + Ok(()) +} + /// Run an INSERT/UPDATE/DELETE-style statement, returning rows changed. async fn run_execute(handle: ConnHandle, sql: String, params: BoundParams) -> Result { tokio::task::spawn_blocking(move || -> Result { let guard = handle.lock().unwrap_or_else(|e| e.into_inner()); let conn = guard.as_ref().ok_or(DbError::Closed)?; + ensure_single_statement(conn, &sql)?; let mut stmt = conn.prepare_cached(&sql)?; bind_params(&mut stmt, ¶ms)?; Ok(stmt.raw_execute()?) @@ -237,11 +272,24 @@ async fn run_execute(handle: ConnHandle, sql: String, params: BoundParams) -> Re .unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}")))) } +/// Run every statement in `sql` in order, with no parameter binding. +async fn run_execute_batch(handle: ConnHandle, sql: String) -> Result<(), DbError> { + tokio::task::spawn_blocking(move || -> Result<(), DbError> { + let guard = handle.lock().unwrap_or_else(|e| e.into_inner()); + let conn = guard.as_ref().ok_or(DbError::Closed)?; + conn.execute_batch(&sql)?; + Ok(()) + }) + .await + .unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}")))) +} + /// Run a SELECT-style statement, materializing every row. async fn run_query(handle: ConnHandle, sql: String, params: BoundParams) -> Result, DbError> { tokio::task::spawn_blocking(move || -> Result, DbError> { let guard = handle.lock().unwrap_or_else(|e| e.into_inner()); let conn = guard.as_ref().ok_or(DbError::Closed)?; + ensure_single_statement(conn, &sql)?; let mut stmt = conn.prepare_cached(&sql)?; // Column names must be captured before the mutable borrows below. let columns: Vec = stmt.column_names().into_iter().map(String::from).collect(); @@ -279,6 +327,14 @@ impl UserData for SqliteConn { } }); + methods.add_async_method("executeBatch", |_, this, sql: String| { + let handle = this.0.clone(); + async move { + run_execute_batch(handle, sql).await.map_err(mlua::Error::external)?; + Ok(()) + } + }); + methods.add_async_method("query", |lua, this, (sql, params): (String, Option)| { let handle = this.0.clone(); let bound = convert_params(params); @@ -301,23 +357,49 @@ impl UserData for SqliteConn { } }); - // These only read in-memory connection state, so they stay synchronous. - methods.add_method("lastInsertRowid", |_, this, ()| { - let guard = this.0.lock().unwrap_or_else(|e| e.into_inner()); - let conn = guard.as_ref().ok_or_else(|| mlua::Error::external(DbError::Closed))?; - Ok(conn.last_insert_rowid()) + // These only read in-memory connection state, but they still contend on + // the same mutex that a running query holds for its whole duration. Doing + // the lock on the blocking pool (not inline) keeps the tokio event loop + // responsive: a `close()` next to a long query no longer freezes every + // other coroutine and timer. + methods.add_async_method("lastInsertRowid", |_, this, ()| { + let handle = this.0.clone(); + async move { + tokio::task::spawn_blocking(move || -> Result { + let guard = handle.lock().unwrap_or_else(|e| e.into_inner()); + Ok(guard.as_ref().ok_or(DbError::Closed)?.last_insert_rowid()) + }) + .await + .unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}")))) + .map_err(mlua::Error::external) + } }); - methods.add_method("changes", |_, this, ()| { - let guard = this.0.lock().unwrap_or_else(|e| e.into_inner()); - let conn = guard.as_ref().ok_or_else(|| mlua::Error::external(DbError::Closed))?; - Ok(conn.changes() as i64) + methods.add_async_method("changes", |_, this, ()| { + let handle = this.0.clone(); + async move { + tokio::task::spawn_blocking(move || -> Result { + let guard = handle.lock().unwrap_or_else(|e| e.into_inner()); + Ok(guard.as_ref().ok_or(DbError::Closed)?.changes() as i64) + }) + .await + .unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}")))) + .map_err(mlua::Error::external) + } }); - methods.add_method("close", |_, this, ()| { - // Dropping the connection releases the SQLite handle; later use errors. - *this.0.lock().unwrap_or_else(|e| e.into_inner()) = None; - Ok(()) + methods.add_async_method("close", |_, this, ()| { + let handle = this.0.clone(); + async move { + // Dropping the connection releases the SQLite handle; later use errors. + // Done on the blocking pool so it waits behind an in-flight query + // there rather than stalling the event loop. + tokio::task::spawn_blocking(move || { + *handle.lock().unwrap_or_else(|e| e.into_inner()) = None; + }) + .await + .map_err(|e| mlua::Error::external(format!("sqlite: background task failed: {e}"))) + } }); } } @@ -331,8 +413,13 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { sqlite.set( "connect", - lua.create_function(|_, path: String| { - let conn = rusqlite::Connection::open(&path).map_err(mlua::Error::external)?; + lua.create_async_function(|_, path: String| async move { + // Opening a file-backed database touches disk, so do it on the + // blocking pool rather than the tokio event loop. + let conn = tokio::task::spawn_blocking(move || rusqlite::Connection::open(&path)) + .await + .map_err(|e| mlua::Error::external(format!("sqlite.connect: background task failed: {e}")))? + .map_err(|e| mlua::Error::external(format!("sqlite.connect: {e}")))?; Ok(SqliteConn(Arc::new(Mutex::new(Some(conn))))) })?, )?; diff --git a/src/stdlib/task.rs b/src/stdlib/task.rs index 548c0fa..07647e0 100644 --- a/src/stdlib/task.rs +++ b/src/stdlib/task.rs @@ -2,6 +2,26 @@ use futures::future::join_all; use mlua::prelude::{LuaResult, LuaValue}; use mlua::{Function, Lua, MultiValue, Variadic}; +/// Validate that every variadic argument is a function, returning them as a +/// `Vec`. Produces a `task.join:`-prefixed error naming the offending +/// argument position and type, instead of mlua's bare conversion message. +fn functions_or_error(args: Variadic) -> LuaResult> { + let mut out = Vec::with_capacity(args.len()); + for (i, v) in args.into_iter().enumerate() { + match v { + LuaValue::Function(f) => out.push(f), + other => { + return Err(mlua::Error::external(format!( + "task.join: argument #{} must be a function (got {})", + i + 1, + other.type_name() + ))) + } + } + } + Ok(out) +} + /// Proof-of-concept concurrency primitive built on Lua coroutines + tokio. /// /// `task.join(f1, f2, ...)` runs each function as its own Lua coroutine and @@ -19,7 +39,8 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { task.raw_set( "join", - lua.create_async_function(|lua, funcs: Variadic| async move { + lua.create_async_function(|lua, args: Variadic| async move { + let funcs = functions_or_error(args)?; // Wrap each function in its own coroutine and turn it into a future. let mut threads = Vec::with_capacity(funcs.len()); for f in funcs.iter() { diff --git a/src/stdlib/utils.rs b/src/stdlib/utils.rs index 046d460..1734ca0 100644 --- a/src/stdlib/utils.rs +++ b/src/stdlib/utils.rs @@ -1,20 +1,12 @@ -use std::ffi::c_void; - use mlua::prelude::LuaResult; -use mlua::{LightUserData, Lua, Value as LuaValue}; -use serde_json; +use mlua::{Lua, Value as LuaValue}; const UTILS_LUA: &str = include_str!("../../lua/stdlib/utils.lua"); -// A stable address used as the identity of utils.NULL. -static NULL_MARKER: u8 = 0; - -fn null_lud() -> LightUserData { - LightUserData(std::ptr::addr_of!(NULL_MARKER) as *mut c_void) -} - +// utils.NULL is a null-pointer light userdata - the same sentinel mlua itself +// uses for JSON null (mlua::Value::NULL), and the same convention flowbox uses. pub(crate) fn is_null(val: &LuaValue) -> bool { - matches!(val, LuaValue::LightUserData(p) if *p == null_lud()) + matches!(val, LuaValue::LightUserData(p) if p.0.is_null()) } // --------------------------------------------------------------------------- @@ -38,7 +30,17 @@ pub(crate) fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult Ok(serde_json::Value::String(s.to_str()?.to_owned())), + LuaValue::String(s) => { + // JSON strings must be UTF-8. Lua strings are byte strings, so give a + // clear prefixed error instead of mlua's bare conversion message. + let bytes = s.as_bytes(); + match std::str::from_utf8(&bytes) { + Ok(text) => Ok(serde_json::Value::String(text.to_owned())), + Err(_) => Err(mlua::Error::external( + "utils.toJSON: string is not valid UTF-8 and cannot be encoded as JSON", + )), + } + } LuaValue::Table(t) => table_to_json(t, depth), other => Err(mlua::Error::external(format!( "utils.toJSON: cannot serialize value of type '{}'", @@ -48,7 +50,7 @@ pub(crate) fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult LuaResult { - let seq_len = t.raw_len() as usize; + let seq_len = t.raw_len(); // Try array serialization: every key must be a sequential integer in 1..=seq_len. if seq_len > 0 { @@ -89,6 +91,14 @@ pub(crate) fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult LuaResult LuaResult { match val { - serde_json::Value::Null => Ok(LuaValue::LightUserData(null_lud())), + serde_json::Value::Null => Ok(LuaValue::NULL), serde_json::Value::Bool(b) => Ok(LuaValue::Boolean(b)), serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { @@ -134,13 +144,18 @@ fn json_to_lua(lua: &Lua, val: serde_json::Value) -> LuaResult { pub(super) fn install(lua: &Lua) -> LuaResult<()> { let utils = lua.create_table()?; - utils.raw_set("NULL", LuaValue::LightUserData(null_lud()))?; + utils.raw_set("NULL", LuaValue::NULL)?; utils.raw_set( "isNull", lua.create_function(|_, val: LuaValue| Ok(is_null(&val)))?, )?; + utils.raw_set( + "dump", + lua.create_function(|_, val: LuaValue| Ok(super::lua_dump::dump(val).into_owned()))?, + )?; + utils.raw_set( "toJSON", lua.create_function(|_, (val, pretty): (LuaValue, Option)| { @@ -166,6 +181,6 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> { lua.globals().raw_set("utils", utils)?; - // Lua side adds: utils.dump, utils.try, utils.tryn + // Lua side adds: utils.try, utils.tryn lua.load(UTILS_LUA).set_name("@[stdlib/utils]").exec() }