feat: add standard library — math, table, utils, os, log

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
Ondřej Hruška 3 weeks ago
commit 1821a0f116
  1. 1
      .gitignore
  2. 2341
      Cargo.lock
  3. 17
      Cargo.toml
  4. 44
      README.md
  5. 115
      lua/stdlib/math.lua
  6. 260
      lua/stdlib/table.lua
  7. 82
      lua/stdlib/utils.lua
  8. 53
      lua/test.lua
  9. 42
      src/main.rs
  10. 117
      src/sandbox.rs
  11. 57
      src/stdlib/logging.rs
  12. 5
      src/stdlib/math.rs
  13. 14
      src/stdlib/mod.rs
  14. 36
      src/stdlib/os_ext.rs
  15. 5
      src/stdlib/table.rs
  16. 171
      src/stdlib/utils.rs

1
.gitignore vendored

@ -0,0 +1 @@
/target

2341
Cargo.lock generated

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…
Cancel
Save