Ports battle-tested stdlib extensions from an internal Lua runtime into `a`, giving scripts a rich set of built-ins with no setup: - math: round, clamp, isFinite, sign, scale - table: filter/map/reduce variants, merge/deepMerge/deepCopy, min/max/mean/sum, keys/values, contains/find, isEmpty, reverse, readonly - utils: NULL sentinel, toJSON/fromJSON, dump, try/tryn - os: microtime() (sub-second timestamp), async sleep() - log: trace/debug/info/warn/error (routed to RUST_LOG) Lua stdlib files are embedded via include_str! for a single-binary distribution. os.sleep uses create_async_function so it yields to tokio instead of blocking the thread.repl
commit
1821a0f116
@ -0,0 +1 @@ |
||||
/target |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,17 @@ |
||||
[package] |
||||
name = "a" |
||||
version = "0.1.0" |
||||
edition = "2024" |
||||
publish = false |
||||
|
||||
[dependencies] |
||||
chrono = "0.4.45" |
||||
env_logger = "0.11" |
||||
log = "0.4" |
||||
clap = { version = "4.6.1", features = ["derive"] } |
||||
mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", "anyhow"]} |
||||
reqwest = "0.13.4" |
||||
serde = { version = "1.0.228", features = ["derive"] } |
||||
serde_json = "1.0.150" |
||||
thiserror = "2.0.18" |
||||
tokio = { version = "1.52.3", features = ["full"] } |
||||
@ -0,0 +1,44 @@ |
||||
# `a` |
||||
|
||||
A Swiss army knife for everyday hacks. `a` embeds a Lua interpreter and wraps it in a rich standard library and real IO access, so a small script can reach the network, a database, the filesystem, and the terminal without any setup. |
||||
|
||||
```sh |
||||
a file.lua |
||||
``` |
||||
|
||||
That's the whole interface. The script is the program. |
||||
|
||||
## What it is |
||||
|
||||
`a` is not a Lua implementation; it embeds [Lua 5.5](https://www.lua.org/) (via [`mlua`](https://github.com/mlua-rs/mlua)) and adds the parts stock Lua leaves out. Plain Lua is small and pleasant but ships almost no standard library and no way to talk to the outside world without C modules and a build toolchain. `a` fills that in with a standard library built partly in Rust and partly in Lua, plus the IO access that makes scripts actually useful. |
||||
|
||||
The result is a single binary you point at a `.lua` file. No package manager, no build step, no project scaffolding, just a script and the tools it needs to do real work. |
||||
|
||||
## The standard library |
||||
|
||||
This is the point of `a`. Planned surface: |
||||
|
||||
- **Networking** — HTTP client, WebSocket, and MQTT (3.1.1 and 5), with high-level APIs. |
||||
- **SQLite** — a real embedded database one call away. |
||||
- **Filesystem** — ergonomic read/write/glob/walk, without the ceremony of stock Lua's `io`. |
||||
- **Terminal UI** — interactive line and full-screen TUI in the spirit of `ncurses`, but without the awkwardness. |
||||
|
||||
Each should feel native: the common case is one obvious call, not a setup ritual. |
||||
|
||||
## How it works |
||||
|
||||
- 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 sanctioned way to reach the outside world. |
||||
- A leading `#!` shebang line is skipped, so scripts can be made executable directly. |
||||
|
||||
## Building |
||||
|
||||
```sh |
||||
cargo build --release |
||||
# binary at target/release/a |
||||
|
||||
cargo run -- lua/test.lua # run a script during development |
||||
``` |
||||
|
||||
Requires a Rust toolchain (2024 edition). Lua is vendored and built from source, so no system Lua is needed. |
||||
@ -0,0 +1,115 @@ |
||||
-- Round a finite float half away from zero, exactly. |
||||
-- math.modf is exact, unlike the naive floor(x + 0.5) which goes wrong |
||||
-- for values like 0.49999999999999994 (x + 0.5 rounds up to 1.0 in the float domain). |
||||
local function roundHalfAwayFromZero(x) |
||||
local i, f = math.modf(x) |
||||
if f >= 0.5 then |
||||
return i + 1 |
||||
elseif f <= -0.5 then |
||||
return i - 1 |
||||
end |
||||
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. |
||||
function math.round(value, places) |
||||
if type(value) ~= "number" then |
||||
error("round() called with a non-number value") |
||||
end |
||||
|
||||
if places == nil then |
||||
places = 0 |
||||
else |
||||
places = math.tointeger(places) |
||||
if places == nil then |
||||
error("round() places must be an integer number") |
||||
end |
||||
if places < 0 then |
||||
error("round() called with negative places") |
||||
end |
||||
end |
||||
|
||||
if math.type(value) == "integer" then |
||||
if places == 0 then |
||||
return value |
||||
end |
||||
return value + 0.0 |
||||
end |
||||
|
||||
if not math.isFinite(value) then |
||||
error("round() called with a non-finite value (NaN or infinity)") |
||||
end |
||||
|
||||
if places == 0 then |
||||
local int = math.tointeger(roundHalfAwayFromZero(value)) |
||||
if int == nil then |
||||
error("round() called with value out of range of Lua integer") |
||||
end |
||||
return int |
||||
end |
||||
|
||||
local mul = 10.0 ^ places |
||||
local scaled = value * mul |
||||
if not math.isFinite(scaled) then |
||||
return value |
||||
end |
||||
return roundHalfAwayFromZero(scaled) / mul |
||||
end |
||||
|
||||
--- Clamp a number to an interval [min, max]. Either bound may be nil (half-open). |
||||
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 |
||||
return value |
||||
end |
||||
|
||||
--- Return true if value is a finite number (not NaN or infinity). |
||||
function math.isFinite(value) |
||||
if type(value) ~= "number" then return false end |
||||
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. |
||||
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 |
||||
end |
||||
end |
||||
|
||||
--- Scale a value linearly from [inMin, inMax] to [outMin, outMax]. |
||||
--- If clamp is true, the result is clamped to the output range. |
||||
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 |
||||
|
||||
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 |
||||
end |
||||
end |
||||
return result |
||||
end |
||||
@ -0,0 +1,260 @@ |
||||
--- 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. |
||||
function table.readonly(tbl, name) |
||||
if type(tbl) ~= "table" then |
||||
error("table.readonly: argument is not a table (got " .. type(tbl) .. ")") |
||||
end |
||||
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 |
||||
return setmetatable({}, { |
||||
__index = tbl, |
||||
__newindex = function() error(message, 2) end, |
||||
__metatable = false, |
||||
}) |
||||
end |
||||
|
||||
--- Filter a table by predicate, preserving keys. May leave gaps in a sequence 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 |
||||
local result = {} |
||||
for k, v in pairs(tbl) do |
||||
if predicate(v) then result[k] = v end |
||||
end |
||||
return result |
||||
end |
||||
|
||||
--- Filter a sequence table by predicate, producing a new gapless sequence. |
||||
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 |
||||
local result = {} |
||||
for i = 1, #tbl do |
||||
local v = tbl[i] |
||||
if predicate(v) then result[#result + 1] = v end |
||||
end |
||||
return result |
||||
end |
||||
|
||||
--- Map values through a function, preserving keys. |
||||
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 |
||||
local result = {} |
||||
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. |
||||
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 |
||||
local result = {} |
||||
for k, v in pairs(tbl) do |
||||
local res = mapper(v) |
||||
if res ~= nil then result[k] = res end |
||||
end |
||||
return result |
||||
end |
||||
|
||||
--- Map a sequence through a function, dropping nils, producing a gapless sequence. |
||||
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 |
||||
local result = {} |
||||
for i = 1, #tbl do |
||||
local res = mapper(tbl[i]) |
||||
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. |
||||
function table.merge(...) |
||||
local result = {} |
||||
local n = 0 |
||||
for argi = 1, select("#", ...) do |
||||
local tbl = select(argi, ...) |
||||
if type(tbl) ~= "table" then |
||||
error("table.merge: argument #" .. argi .. " is not a table (got " .. type(tbl) .. ")") |
||||
end |
||||
local len = 0 |
||||
for i, v in ipairs(tbl) do |
||||
n = n + 1 |
||||
result[n] = v |
||||
len = i |
||||
end |
||||
for k, v in pairs(tbl) do |
||||
if not (math.type(k) == "integer" and k >= 1 and k <= len) then |
||||
result[k] = v |
||||
end |
||||
end |
||||
end |
||||
return result |
||||
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 |
||||
local result = {} |
||||
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 |
||||
local result = {} |
||||
local n = 0 |
||||
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) .. ")") |
||||
end |
||||
local len = 0 |
||||
for i, v in ipairs(tbl) do |
||||
n = n + 1 |
||||
result[n] = deepCopy(v, level) |
||||
len = i |
||||
end |
||||
for k, v in pairs(tbl) do |
||||
if not (math.type(k) == "integer" and k >= 1 and k <= len) then |
||||
if type(v) == "table" and type(result[k]) == "table" then |
||||
result[k] = mergeRecurse(level + 1, result[k], v) |
||||
else |
||||
result[k] = deepCopy(v, level) |
||||
end |
||||
end |
||||
end |
||||
end |
||||
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). |
||||
function table.deepMerge(...) |
||||
return mergeRecurse(1, ...) |
||||
end |
||||
|
||||
--- Deep-copy a 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. |
||||
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 |
||||
local accumulator = initialValue |
||||
for _, v in pairs(tbl) do accumulator = reducer(accumulator, v) end |
||||
return accumulator |
||||
end |
||||
|
||||
--- Reduce a sequence table to one value, iterating in order. |
||||
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 |
||||
local accumulator = initialValue |
||||
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. |
||||
function table.min(tbl) |
||||
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 |
||||
end |
||||
return min |
||||
end |
||||
|
||||
--- Return the maximum value in a table. Returns nil for an empty table. NaN propagates. |
||||
function table.max(tbl) |
||||
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 |
||||
end |
||||
return max |
||||
end |
||||
|
||||
--- Return the arithmetic mean of all values. Returns nil for an empty table. NaN propagates. |
||||
function table.mean(tbl) |
||||
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 |
||||
end |
||||
return count > 0 and sum / count or nil |
||||
end |
||||
|
||||
--- Return the sum of all values. Returns 0 for an empty table. NaN propagates. |
||||
function table.sum(tbl) |
||||
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 |
||||
return sum |
||||
end |
||||
|
||||
--- Return an array of all keys. Order is unspecified. |
||||
function table.keys(tbl) |
||||
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 |
||||
return result |
||||
end |
||||
|
||||
--- Return an array of all values. Order is unspecified. |
||||
function table.values(tbl) |
||||
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 |
||||
return result |
||||
end |
||||
|
||||
--- Return true if the table contains the given value (compared with ==). |
||||
function table.contains(tbl, value) |
||||
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 |
||||
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". |
||||
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 |
||||
for k, v in pairs(tbl) do |
||||
if predicate(v) then return v, k end |
||||
end |
||||
return nil, nil |
||||
end |
||||
|
||||
--- Return true if the table has no elements. |
||||
function table.isEmpty(tbl) |
||||
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. |
||||
function table.reverse(tbl) |
||||
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 |
||||
return result |
||||
end |
||||
@ -0,0 +1,82 @@ |
||||
-- Lua-side of the utils module. |
||||
-- utils.NULL, utils.isNull, utils.toJSON, utils.fromJSON are provided by Rust before this runs. |
||||
|
||||
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 "<circular>" 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. |
||||
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 |
||||
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. |
||||
function utils.tryn(expectedCount, callback, ...) |
||||
local count = math.tointeger(expectedCount) |
||||
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 > 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 |
||||
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 |
||||
local out = {} |
||||
out[expectedCount + 1] = err |
||||
return table.unpack(out, 1, expectedCount + 1) |
||||
end |
||||
@ -0,0 +1,53 @@ |
||||
-- Smoke-test for the stdlib extensions. |
||||
|
||||
print("=== math ===") |
||||
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("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) |
||||
print("evens =", table.concat(evens, ",")) -- 4 |
||||
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("\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("isNull(NULL) =", utils.isNull(utils.NULL)) |
||||
print("isNull(nil) =", utils.isNull(nil)) |
||||
|
||||
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) |
||||
print("try ok: v=", v, "err=", err) |
||||
local v2, err2 = utils.try(function() error("oops") end) |
||||
print("try err: v=", v2, "err ~= nil:", err2 ~= nil) |
||||
|
||||
print("\n=== os ===") |
||||
local t1 = os.microtime() |
||||
os.sleep(0.01) |
||||
local t2 = os.microtime() |
||||
print("microtime ok:", t2 > t1) |
||||
print("clock >= 0:", os.clock() >= 0) |
||||
|
||||
print("\n=== log (set RUST_LOG=info to see output on stderr) ===") |
||||
log.info("hello from log.info") |
||||
log.warn("hello from log.warn") |
||||
|
||||
print("\nAll checks passed.") |
||||
@ -0,0 +1,42 @@ |
||||
use std::path::PathBuf; |
||||
|
||||
use clap::Parser; |
||||
|
||||
mod sandbox; |
||||
mod stdlib; |
||||
|
||||
/// Runs a Lua script in a customized environment
|
||||
#[derive(Parser, Debug)] |
||||
#[command(version, about)] |
||||
struct Args { |
||||
/// Path to the Lua script to execute
|
||||
filepath: PathBuf, |
||||
} |
||||
|
||||
#[tokio::main] |
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> { |
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); |
||||
|
||||
let args = Args::parse(); |
||||
|
||||
let source = tokio::fs::read_to_string(&args.filepath).await?; |
||||
|
||||
// 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
|
||||
// messages still report correct line numbers.
|
||||
let chunk = if source.starts_with('#') { |
||||
&source[source.find('\n').unwrap_or(source.len())..] |
||||
} else { |
||||
source.as_str() |
||||
}; |
||||
|
||||
let lua = sandbox::create_sandboxed_lua()?; |
||||
|
||||
lua.load(chunk) |
||||
// "@" marks the chunk name as a file path in error messages / tracebacks
|
||||
.set_name(format!("@{}", args.filepath.display())) |
||||
.exec_async() |
||||
.await?; |
||||
|
||||
Ok(()) |
||||
} |
||||
@ -0,0 +1,117 @@ |
||||
use std::cell::RefCell; |
||||
use std::time::Instant; |
||||
|
||||
use mlua::prelude::{LuaResult, LuaTable, LuaValue}; |
||||
use mlua::{Lua, LuaOptions, StdLib}; |
||||
|
||||
use crate::stdlib; |
||||
|
||||
/// Creates a Lua state sandboxed after the model of Luau's safe environment:
|
||||
/// io, package and debug are not loaded, os is reduced to its time functions,
|
||||
/// and bytecode/chunk-loading escape hatches are removed from base and string.
|
||||
pub(crate) fn create_sandboxed_lua() -> LuaResult<Lua> { |
||||
let libs = StdLib::COROUTINE |
||||
| StdLib::TABLE |
||||
| StdLib::STRING |
||||
| StdLib::UTF8 |
||||
| StdLib::MATH |
||||
| StdLib::OS; |
||||
let lua = Lua::new_with(libs, LuaOptions::default())?; |
||||
|
||||
let globals = lua.globals(); |
||||
|
||||
// Remove filesystem-access functions and `load` which works with raw bytecode.
|
||||
for name in ["dofile", "loadfile", "load"] { |
||||
globals.set(name, mlua::Nil)?; |
||||
} |
||||
|
||||
// Patch std libs
|
||||
patch_string(&lua, &globals)?; |
||||
patch_os(&lua, &globals)?; |
||||
patch_collectgarbage(&lua, &globals)?; |
||||
install_warning_handler(&lua); |
||||
|
||||
stdlib::install(&lua)?; |
||||
|
||||
Ok(lua) |
||||
} |
||||
|
||||
/// Installs a warning handler matching the one stock Lua sets in `luaL_newstate`
|
||||
/// (mlua builds the state with `lua_newstate`, which leaves it unset, making
|
||||
/// `warn` a silent no-op): prints "Lua warning: <msg>" to stderr, toggled by
|
||||
/// the `warn("@off")` / `warn("@on")` control messages. Warnings start ON,
|
||||
/// the Lua 5.5 default (5.4 started them off).
|
||||
fn install_warning_handler(lua: &Lua) { |
||||
struct WarnState { |
||||
enabled: bool, |
||||
// multi-arg warn() arrives as one piece per argument; buffer until the last
|
||||
buffer: String, |
||||
} |
||||
let state = RefCell::new(WarnState { |
||||
enabled: true, |
||||
buffer: String::new(), |
||||
}); |
||||
|
||||
lua.set_warning_function(move |_, msg, tocont| { |
||||
let mut st = state.borrow_mut(); |
||||
// Control messages: a complete, single-piece message starting with '@'
|
||||
if !tocont && st.buffer.is_empty() && msg.starts_with('@') { |
||||
match msg { |
||||
"@on" => st.enabled = true, |
||||
"@off" => st.enabled = false, |
||||
_ => {} // unknown control messages are ignored, as in stock Lua
|
||||
} |
||||
return Ok(()); |
||||
} |
||||
st.buffer.push_str(msg); |
||||
if !tocont { |
||||
if st.enabled { |
||||
eprintln!("Lua warning: {}", st.buffer); |
||||
} |
||||
st.buffer.clear(); |
||||
} |
||||
Ok(()) |
||||
}); |
||||
} |
||||
|
||||
/// Patch the string table to remove "dump"
|
||||
fn patch_string<'lua>(_lua: &'lua Lua, globals: &'lua LuaTable) -> LuaResult<()> { |
||||
// string.dump produces bytecode (the counterpart of load).
|
||||
globals.get::<LuaTable>("string")?.set("dump", mlua::Nil)?; |
||||
Ok(()) |
||||
} |
||||
|
||||
/// Patch the "os" table by removing non-time utils, and changing os.clock() to return monotonic clock (secs float)
|
||||
fn patch_os<'lua>(lua: &'lua Lua, globals: &'lua LuaTable) -> LuaResult<()> { |
||||
// os: keep only the time functions Luau provides; drops execute, exit,
|
||||
// getenv, remove, rename, setlocale and tmpname.
|
||||
let os_table: LuaTable = globals.get("os")?; |
||||
let safe_os = lua.create_table()?; |
||||
for name in ["date", "difftime", "time"] { |
||||
safe_os.set(name, os_table.get::<LuaValue>(name)?)?; |
||||
} |
||||
|
||||
// os.clock measures wall time since script start instead of process CPU time.
|
||||
let start = Instant::now(); |
||||
let clock = lua.create_function(move |_, ()| Ok(start.elapsed().as_secs_f64()))?; |
||||
safe_os.set("clock", clock)?; |
||||
|
||||
globals.set("os", safe_os)?; |
||||
Ok(()) |
||||
} |
||||
|
||||
/// Patch `collectgarbage` to only allow "count" - returning heap size
|
||||
fn patch_collectgarbage<'lua>(lua: &'lua Lua, globals: &'lua LuaTable) -> LuaResult<()> { |
||||
// collectgarbage: like Luau, only the "count" option is allowed.
|
||||
let collectgarbage = lua.create_function(|lua, opt: Option<String>| { |
||||
if opt.as_deref() == Some("count") { |
||||
Ok(lua.used_memory() as f64 / 1024.0) |
||||
} else { |
||||
Err(mlua::Error::runtime( |
||||
"collectgarbage is sandboxed; only collectgarbage(\"count\") is allowed", |
||||
)) |
||||
} |
||||
})?; |
||||
globals.set("collectgarbage", collectgarbage)?; |
||||
Ok(()) |
||||
} |
||||
@ -0,0 +1,57 @@ |
||||
use mlua::prelude::LuaResult; |
||||
use mlua::{Lua, Table as LuaTable, Value as LuaValue, Variadic}; |
||||
|
||||
fn args_to_string(lua: &Lua, args: Variadic<LuaValue>) -> LuaResult<String> { |
||||
let tostring: mlua::Function = lua.globals().get("tostring")?; |
||||
let parts: Vec<String> = args |
||||
.into_iter() |
||||
.map(|v| tostring.call::<String>(v).unwrap_or_else(|_| "?".to_string())) |
||||
.collect(); |
||||
Ok(parts.join("\t")) |
||||
} |
||||
|
||||
pub(super) fn install(lua: &Lua) -> LuaResult<()> { |
||||
let log: LuaTable = lua.create_table()?; |
||||
|
||||
log.raw_set( |
||||
"trace", |
||||
lua.create_function(|lua, args: Variadic<LuaValue>| { |
||||
::log::trace!("{}", args_to_string(lua, args)?); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
log.raw_set( |
||||
"debug", |
||||
lua.create_function(|lua, args: Variadic<LuaValue>| { |
||||
::log::debug!("{}", args_to_string(lua, args)?); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
log.raw_set( |
||||
"info", |
||||
lua.create_function(|lua, args: Variadic<LuaValue>| { |
||||
::log::info!("{}", args_to_string(lua, args)?); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
let warn_fn = lua.create_function(|lua, args: Variadic<LuaValue>| { |
||||
::log::warn!("{}", args_to_string(lua, args)?); |
||||
Ok(()) |
||||
})?; |
||||
log.raw_set("warn", warn_fn.clone())?; |
||||
log.raw_set("warning", warn_fn)?; |
||||
|
||||
log.raw_set( |
||||
"error", |
||||
lua.create_function(|lua, args: Variadic<LuaValue>| { |
||||
::log::error!("{}", args_to_string(lua, args)?); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
lua.globals().raw_set("log", log)?; |
||||
Ok(()) |
||||
} |
||||
@ -0,0 +1,5 @@ |
||||
const MATH_LUA: &str = include_str!("../../lua/stdlib/math.lua"); |
||||
|
||||
pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> { |
||||
lua.load(MATH_LUA).set_name("@[stdlib/math]").exec() |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
mod logging; |
||||
mod math; |
||||
mod os_ext; |
||||
mod table; |
||||
pub(crate) mod utils; |
||||
|
||||
pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> { |
||||
math::install(lua)?; |
||||
table::install(lua)?; |
||||
utils::install(lua)?; |
||||
os_ext::install(lua)?; |
||||
logging::install(lua)?; |
||||
Ok(()) |
||||
} |
||||
@ -0,0 +1,36 @@ |
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH}; |
||||
|
||||
use mlua::prelude::LuaResult; |
||||
use mlua::{Lua, Table as LuaTable}; |
||||
|
||||
/// Extend the already-sandboxed `os` table with non-standard additions.
|
||||
pub(super) fn install(lua: &Lua) -> LuaResult<()> { |
||||
let os: LuaTable = lua.globals().get("os")?; |
||||
|
||||
// os.microtime() — Unix timestamp as f64 with sub-second precision.
|
||||
os.raw_set( |
||||
"microtime", |
||||
lua.create_function(|_, ()| { |
||||
SystemTime::now() |
||||
.duration_since(UNIX_EPOCH) |
||||
.map(|d| d.as_secs_f64()) |
||||
.map_err(|e| mlua::Error::external(format!("os.microtime: {e}"))) |
||||
})?, |
||||
)?; |
||||
|
||||
// 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 { |
||||
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", |
||||
) |
||||
})?; |
||||
tokio::time::sleep(duration).await; |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
Ok(()) |
||||
} |
||||
@ -0,0 +1,5 @@ |
||||
const TABLE_LUA: &str = include_str!("../../lua/stdlib/table.lua"); |
||||
|
||||
pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> { |
||||
lua.load(TABLE_LUA).set_name("@[stdlib/table]").exec() |
||||
} |
||||
@ -0,0 +1,171 @@ |
||||
use std::ffi::c_void; |
||||
|
||||
use mlua::prelude::LuaResult; |
||||
use mlua::{LightUserData, Lua, Value as LuaValue}; |
||||
use serde_json; |
||||
|
||||
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) |
||||
} |
||||
|
||||
pub(crate) fn is_null(val: &LuaValue) -> bool { |
||||
matches!(val, LuaValue::LightUserData(p) if *p == null_lud()) |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lua → JSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult<serde_json::Value> { |
||||
const MAX_DEPTH: u32 = 64; |
||||
if depth > MAX_DEPTH { |
||||
return Err(mlua::Error::external("utils.toJSON: nesting too deep (max 64 levels)")); |
||||
} |
||||
if is_null(&val) { |
||||
return Ok(serde_json::Value::Null); |
||||
} |
||||
match val { |
||||
LuaValue::Nil => Ok(serde_json::Value::Null), |
||||
LuaValue::Boolean(b) => Ok(serde_json::Value::Bool(b)), |
||||
LuaValue::Integer(i) => Ok(serde_json::Value::Number(i.into())), |
||||
LuaValue::Number(f) => serde_json::Number::from_f64(f) |
||||
.map(serde_json::Value::Number) |
||||
.ok_or_else(|| { |
||||
mlua::Error::external("utils.toJSON: NaN and Infinity cannot be serialized as JSON") |
||||
}), |
||||
LuaValue::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_owned())), |
||||
LuaValue::Table(t) => table_to_json(t, depth), |
||||
other => Err(mlua::Error::external(format!( |
||||
"utils.toJSON: cannot serialize value of type '{}'", |
||||
other.type_name() |
||||
))), |
||||
} |
||||
} |
||||
|
||||
fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult<serde_json::Value> { |
||||
let seq_len = t.raw_len() as usize; |
||||
|
||||
// Try array serialization: every key must be a sequential integer in 1..=seq_len.
|
||||
if seq_len > 0 { |
||||
let mut is_pure_seq = true; |
||||
let mut total = 0usize; |
||||
for pair in t.clone().pairs::<LuaValue, LuaValue>() { |
||||
let (k, _) = pair?; |
||||
total += 1; |
||||
match k { |
||||
LuaValue::Integer(i) if i >= 1 && (i as usize) <= seq_len => {} |
||||
_ => { |
||||
is_pure_seq = false; |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
if is_pure_seq && total == seq_len { |
||||
let mut arr = Vec::with_capacity(seq_len); |
||||
for i in 1i64..=(seq_len as i64) { |
||||
let v: LuaValue = t.raw_get(i)?; |
||||
arr.push(lua_to_json(v, depth + 1)?); |
||||
} |
||||
return Ok(serde_json::Value::Array(arr)); |
||||
} |
||||
} |
||||
|
||||
// Object serialization — only string and integer keys are supported.
|
||||
let mut map = serde_json::Map::new(); |
||||
for pair in t.pairs::<LuaValue, LuaValue>() { |
||||
let (k, v) = pair?; |
||||
let key = match k { |
||||
LuaValue::String(s) => s.to_str()?.to_owned(), |
||||
LuaValue::Integer(i) => i.to_string(), |
||||
other => { |
||||
return Err(mlua::Error::external(format!( |
||||
"utils.toJSON: table key of type '{}' cannot be used as a JSON object key", |
||||
other.type_name() |
||||
))) |
||||
} |
||||
}; |
||||
map.insert(key, lua_to_json(v, depth + 1)?); |
||||
} |
||||
Ok(serde_json::Value::Object(map)) |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON → Lua
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn json_to_lua(lua: &Lua, val: serde_json::Value) -> LuaResult<LuaValue> { |
||||
match val { |
||||
serde_json::Value::Null => Ok(LuaValue::LightUserData(null_lud())), |
||||
serde_json::Value::Bool(b) => Ok(LuaValue::Boolean(b)), |
||||
serde_json::Value::Number(n) => { |
||||
if let Some(i) = n.as_i64() { |
||||
Ok(LuaValue::Integer(i)) |
||||
} else { |
||||
Ok(LuaValue::Number(n.as_f64().unwrap_or(f64::NAN))) |
||||
} |
||||
} |
||||
serde_json::Value::String(s) => Ok(LuaValue::String(lua.create_string(s.as_bytes())?)), |
||||
serde_json::Value::Array(arr) => { |
||||
let t = lua.create_table()?; |
||||
for (i, v) in arr.into_iter().enumerate() { |
||||
t.raw_set(i + 1, json_to_lua(lua, v)?)?; |
||||
} |
||||
Ok(LuaValue::Table(t)) |
||||
} |
||||
serde_json::Value::Object(obj) => { |
||||
let t = lua.create_table()?; |
||||
for (k, v) in obj { |
||||
t.raw_set(k, json_to_lua(lua, v)?)?; |
||||
} |
||||
Ok(LuaValue::Table(t)) |
||||
} |
||||
} |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> LuaResult<()> { |
||||
let utils = lua.create_table()?; |
||||
|
||||
utils.raw_set("NULL", LuaValue::LightUserData(null_lud()))?; |
||||
|
||||
utils.raw_set( |
||||
"isNull", |
||||
lua.create_function(|_, val: LuaValue| Ok(is_null(&val)))?, |
||||
)?; |
||||
|
||||
utils.raw_set( |
||||
"toJSON", |
||||
lua.create_function(|_, (val, pretty): (LuaValue, Option<bool>)| { |
||||
let json = lua_to_json(val, 0)?; |
||||
let s = if pretty.unwrap_or(false) { |
||||
serde_json::to_string_pretty(&json) |
||||
} else { |
||||
serde_json::to_string(&json) |
||||
} |
||||
.map_err(mlua::Error::external)?; |
||||
Ok(s) |
||||
})?, |
||||
)?; |
||||
|
||||
utils.raw_set( |
||||
"fromJSON", |
||||
lua.create_function(|lua, s: String| { |
||||
let json: serde_json::Value = serde_json::from_str(&s) |
||||
.map_err(|e| mlua::Error::external(format!("utils.fromJSON: {e}")))?; |
||||
json_to_lua(lua, json) |
||||
})?, |
||||
)?; |
||||
|
||||
lua.globals().raw_set("utils", utils)?; |
||||
|
||||
// Lua side adds: utils.dump, utils.try, utils.tryn
|
||||
lua.load(UTILS_LUA).set_name("@[stdlib/utils]").exec() |
||||
} |
||||
Loading…
Reference in new issue