commit fec7ee91b26a0dc6baae7f835bc2fc23454b2597
parent 190519a21c84c0753cc1ad7913f70751d59ac4d1
Author: Sylvia Ivory <git@sivory.net>
Date: Sun, 29 Jun 2025 17:17:12 -0700
Outline system Lua executor
Diffstat:
| M | Cargo.lock | | | 81 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- |
| M | Cargo.toml | | | 1 | + |
| A | lua/rpc.lua | | | 236 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/inspect.rs | | | 10 | +++++++--- |
| M | src/lua.rs | | | 87 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
5 files changed, 405 insertions(+), 10 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -283,6 +283,12 @@ dependencies = [
]
[[package]]
+name = "comma"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335"
+
+[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -426,6 +432,12 @@ dependencies = [
]
[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
name = "fd-lock"
version = "4.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -469,7 +481,19 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
- "wasi",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasi 0.14.2+wasi-0.2.4",
]
[[package]]
@@ -725,6 +749,7 @@ dependencies = [
"mlua",
"nu-ansi-term",
"reedline",
+ "rexpect",
"rowan",
]
@@ -751,7 +776,7 @@ checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
"log",
- "wasi",
+ "wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0",
]
@@ -919,6 +944,12 @@ dependencies = [
]
[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
name = "redox_syscall"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -933,7 +964,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
- "getrandom",
+ "getrandom 0.2.16",
"libredox",
"thiserror",
]
@@ -988,6 +1019,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
+name = "rexpect"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c1bcd4ac488e9d2d726d147031cceff5cff6425011ff1914049739770fa4726"
+dependencies = [
+ "comma",
+ "nix",
+ "regex",
+ "tempfile",
+ "thiserror",
+]
+
+[[package]]
name = "rowan"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1293,6 +1337,19 @@ dependencies = [
]
[[package]]
+name = "tempfile"
+version = "3.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
+dependencies = [
+ "fastrand",
+ "getrandom 0.3.3",
+ "once_cell",
+ "rustix 1.0.7",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
name = "text-size"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1482,6 +1539,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
+name = "wasi"
+version = "0.14.2+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
+dependencies = [
+ "wit-bindgen-rt",
+]
+
+[[package]]
name = "wasm-bindgen"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1810,3 +1876,12 @@ name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
+
+[[package]]
+name = "wit-bindgen-rt"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
+dependencies = [
+ "bitflags 2.9.1",
+]
diff --git a/Cargo.toml b/Cargo.toml
@@ -28,4 +28,5 @@ lazy_static = "1.5.0"
mlua = { version = "0.10.5", features = ["anyhow", "send", "async"] }
nu-ansi-term = "0.50.1"
reedline = "0.40.0"
+rexpect = "0.6.2"
rowan = "0.16.1"
diff --git a/lua/rpc.lua b/lua/rpc.lua
@@ -0,0 +1,236 @@
+---@diagnostic disable: lowercase-global
+do
+ --[[
+ Serpent source is released under the MIT License
+
+ Copyright (c) 2012-2018 Paul Kulchenko (paul@kulchenko.com)
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
+ ]]
+ local n, v = "serpent", "0.303" -- (C) 2012-18 Paul Kulchenko; MIT License
+ local c, d = "Paul Kulchenko", "Lua serializer and pretty printer"
+ local snum = { [tostring(1 / 0)] = '1/0 --[[math.huge]]', [tostring(-1 / 0)] = '-1/0 --[[-math.huge]]',
+ [tostring(0 / 0)] = '0/0' }
+ local badtype = { thread = true, userdata = true, cdata = true }
+ local getmetatable = debug and debug.getmetatable or getmetatable
+ local pairs = function(t) return next, t end -- avoid using __pairs in Lua 5.2+
+ local keyword, globals, G = {}, {}, (_G or _ENV)
+ for _, k in ipairs({ 'and', 'break', 'do', 'else', 'elseif', 'end', 'false',
+ 'for', 'function', 'goto', 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat',
+ 'return', 'then', 'true', 'until', 'while' }) do keyword[k] = true end
+ for k, v in pairs(G) do globals[v] = k end -- build func to name mapping
+ for _, g in ipairs({ 'coroutine', 'debug', 'io', 'math', 'string', 'table', 'os' }) do
+ for k, v in pairs(type(G[g]) == 'table' and G[g] or {}) do globals[v] = g .. '.' .. k end
+ end
+
+ local function s(t, opts)
+ local name, indent, fatal, maxnum = opts.name, opts.indent, opts.fatal, opts.maxnum
+ local sparse, custom, huge = opts.sparse, opts.custom, not opts.nohuge
+ local space, maxl = (opts.compact and '' or ' '), (opts.maxlevel or math.huge)
+ local maxlen, metatostring = tonumber(opts.maxlength), opts.metatostring
+ local iname, comm = '_' .. (name or ''), opts.comment and (tonumber(opts.comment) or math.huge)
+ local numformat = opts.numformat or "%.17g"
+ local seen, sref, syms, symn = {}, { 'local ' .. iname .. '={}' }, {}, 0
+ local function gensym(val)
+ return '_' .. (tostring(tostring(val)):gsub("[^%w]", ""):gsub("(%d%w+)",
+ -- tostring(val) is needed because __tostring may return a non-string value
+ function(s)
+ if not syms[s] then
+ symn = symn + 1; syms[s] = symn
+ end
+ return tostring(syms[s])
+ end))
+ end
+ local function safestr(s)
+ return type(s) == "number" and (huge and snum[tostring(s)] or numformat:format(s))
+ or type(s) ~= "string" and tostring(s) -- escape NEWLINE/010 and EOF/026
+ or ("%q"):format(s):gsub("\010", "n"):gsub("\026", "\\026")
+ end
+ -- handle radix changes in some locales
+ if opts.fixradix and (".1f"):format(1.2) ~= "1.2" then
+ local origsafestr = safestr
+ safestr = function(s)
+ return type(s) == "number"
+ and (nohuge and snum[tostring(s)] or numformat:format(s):gsub(",", ".")) or origsafestr(s)
+ end
+ end
+ local function comment(s, l) return comm and (l or 0) < comm and ' --[[' .. select(2, pcall(tostring, s)) .. ']]' or
+ '' end
+ local function globerr(s, l)
+ return globals[s] and globals[s] .. comment(s, l) or not fatal
+ and safestr(select(2, pcall(tostring, s))) or error("Can't serialize " .. tostring(s))
+ end
+ local function safename(path, name) -- generates foo.bar, foo[3], or foo['b a r']
+ local n = name == nil and '' or name
+ local plain = type(n) == "string" and n:match("^[%l%u_][%w_]*$") and not keyword[n]
+ local safe = plain and n or '[' .. safestr(n) .. ']'
+ return (path or '') .. (plain and path and '.' or '') .. safe, safe
+ end
+ local alphanumsort = type(opts.sortkeys) == 'function' and opts.sortkeys or
+ function(k, o, n) -- k=keys, o=originaltable, n=padding
+ local maxn, to = tonumber(n) or 12, { number = 'a', string = 'b' }
+ local function padnum(d) return ("%0" .. tostring(maxn) .. "d"):format(tonumber(d)) end
+ table.sort(k, function(a, b)
+ -- sort numeric keys first: k[key] is not nil for numerical keys
+ return (k[a] ~= nil and 0 or to[type(a)] or 'z') .. (tostring(a):gsub("%d+", padnum))
+ < (k[b] ~= nil and 0 or to[type(b)] or 'z') .. (tostring(b):gsub("%d+", padnum))
+ end)
+ end
+ local function val2str(t, name, indent, insref, path, plainindex, level)
+ local ttype, level, mt = type(t), (level or 0), getmetatable(t)
+ local spath, sname = safename(path, name)
+ local tag = plainindex and
+ ((type(name) == "number") and '' or name .. space .. '=' .. space) or
+ (name ~= nil and sname .. space .. '=' .. space or '')
+ if seen[t] then -- already seen this element
+ sref[#sref + 1] = spath .. space .. '=' .. space .. seen[t]
+ return tag .. 'nil' .. comment('ref', level)
+ end
+ -- protect from those cases where __tostring may fail
+ if type(mt) == 'table' and metatostring ~= false then
+ local to, tr = pcall(function() return mt.__tostring(t) end)
+ local so, sr = pcall(function() return mt.__serialize(t) end)
+ if (to or so) then -- knows how to serialize itself
+ seen[t] = insref or spath
+ t = so and sr or tr
+ ttype = type(t)
+ end -- new value falls through to be serialized
+ end
+ if ttype == "table" then
+ if level >= maxl then return tag .. '{}' .. comment('maxlvl', level) end
+ seen[t] = insref or spath
+ if next(t) == nil then return tag .. '{}' .. comment(t, level) end -- table empty
+ if maxlen and maxlen < 0 then return tag .. '{}' .. comment('maxlen', level) end
+ local maxn, o, out = math.min(#t, maxnum or #t), {}, {}
+ for key = 1, maxn do o[key] = key end
+ if not maxnum or #o < maxnum then
+ local n = #o -- n = n + 1; o[n] is much faster than o[#o+1] on large tables
+ for key in pairs(t) do
+ if o[key] ~= key then
+ n = n + 1; o[n] = key
+ end
+ end
+ end
+ if maxnum and #o > maxnum then o[maxnum + 1] = nil end
+ if opts.sortkeys and #o > maxn then alphanumsort(o, t, opts.sortkeys) end
+ local sparse = sparse and #o > maxn -- disable sparsness if only numeric keys (shorter output)
+ for n, key in ipairs(o) do
+ local value, ktype, plainindex = t[key], type(key), n <= maxn and not sparse
+ if opts.valignore and opts.valignore[value] -- skip ignored values; do nothing
+ or opts.keyallow and not opts.keyallow[key]
+ or opts.keyignore and opts.keyignore[key]
+ or opts.valtypeignore and opts.valtypeignore[type(value)] -- skipping ignored value types
+ or sparse and value == nil then -- skipping nils; do nothing
+ elseif ktype == 'table' or ktype == 'function' or badtype[ktype] then
+ if not seen[key] and not globals[key] then
+ sref[#sref + 1] = 'placeholder'
+ local sname = safename(iname, gensym(key)) -- iname is table for local variables
+ sref[#sref] = val2str(key, sname, indent, sname, iname, true)
+ end
+ sref[#sref + 1] = 'placeholder'
+ local path = seen[t] .. '[' .. tostring(seen[key] or globals[key] or gensym(key)) .. ']'
+ sref[#sref] = path .. space .. '=' .. space .. tostring(seen[value] or val2str(value, nil, indent, path))
+ else
+ out[#out + 1] = val2str(value, key, indent, nil, seen[t], plainindex, level + 1)
+ if maxlen then
+ maxlen = maxlen - #out[#out]
+ if maxlen < 0 then break end
+ end
+ end
+ end
+ local prefix = string.rep(indent or '', level)
+ local head = indent and '{\n' .. prefix .. indent or '{'
+ local body = table.concat(out, ',' .. (indent and '\n' .. prefix .. indent or space))
+ local tail = indent and "\n" .. prefix .. '}' or '}'
+ return (custom and custom(tag, head, body, tail, level) or tag .. head .. body .. tail) .. comment(t, level)
+ elseif badtype[ttype] then
+ seen[t] = insref or spath
+ return tag .. globerr(t, level)
+ elseif ttype == 'function' then
+ seen[t] = insref or spath
+ if opts.nocode then return tag .. "function() --[[..skipped..]] end" .. comment(t, level) end
+ local ok, res = pcall(string.dump, t)
+ local func = ok and "((loadstring or load)(" .. safestr(res) .. ",'@serialized'))" .. comment(t, level)
+ return tag .. (func or globerr(t, level))
+ else
+ return tag .. safestr(t)
+ end -- handle all other types
+ end
+ local sepr = indent and "\n" or ";" .. space
+ local body = val2str(t, name, indent) -- this call also populates sref
+ local tail = #sref > 1 and table.concat(sref, sepr) .. sepr or ''
+ local warn = opts.comment and #sref > 1 and space .. "--[[incomplete output with shared/self-references skipped]]" or
+ ''
+ return not name and body .. warn or "do local " .. body .. sepr .. tail .. "return " .. name .. sepr .. "end"
+ end
+
+ local function deserialize(data, opts)
+ local env = (opts and opts.safe == false) and G
+ or setmetatable({}, {
+ __index = function(t, k) return t end,
+ __call = function(t, ...) error("cannot call functions") end
+ })
+ local f, res = (loadstring or load)('return ' .. data, nil, nil, env)
+ if not f then f, res = (loadstring or load)(data, nil, nil, env) end
+ if not f then return f, res end
+ if setfenv then setfenv(f, env) end
+ return pcall(f)
+ end
+
+ local function merge(a, b)
+ if b then for k, v in pairs(b) do a[k] = v end end; return a;
+ end
+
+ serpent = {
+ _NAME = n,
+ _COPYRIGHT = c,
+ _DESCRIPTION = d,
+ _VERSION = v,
+ serialize = s,
+ load = deserialize,
+ dump = function(a, opts) return s(a, merge({ name = '_', compact = true, sparse = true }, opts)) end,
+ line = function(a, opts) return s(a, merge({ sortkeys = true, comment = true }, opts)) end,
+ block = function(a, opts) return s(a, merge({ indent = ' ', sortkeys = true, comment = true }, opts)) end
+ }
+end
+
+rpc = {}
+
+function rpc.respond(command, data)
+ io.write(serpent.dump({
+ ty = type(data),
+ data = data,
+ command = command
+ }, { metatostring = false }))
+ io.write('\n--[[ done ]]--\n')
+end
+
+function rpc.globals()
+ rpc.respond('globals', _G)
+end
+
+function rpc.exec(code)
+ local res = assert(load(code, 'repl', 't'))()
+
+ rpc.respond('exec', res)
+end
+
+function rpc.ping()
+ rpc.respond('ping', 'pong')
+end
diff --git a/src/inspect.rs b/src/inspect.rs
@@ -128,8 +128,8 @@ pub fn cleanup_string(lua_str: &LuaString) -> String {
escape_control(&remove_invalid(&lua_str.as_bytes()))
}
-fn format_string(lua_str: &LuaString, colorize: bool) -> String {
- let mut s = remove_invalid(&lua_str.as_bytes());
+pub fn format_string_bytes(bytes: &[u8], colorize: bool) -> String {
+ let mut s = remove_invalid(bytes);
if colorize {
s = escape_control_color(&s);
@@ -146,6 +146,10 @@ fn format_string(lua_str: &LuaString, colorize: bool) -> String {
}
}
+fn format_string_lua_string(lua_str: &LuaString, colorize: bool) -> String {
+ format_string_bytes(&lua_str.as_bytes(), colorize)
+}
+
fn addr_color(value: &LuaValue) -> Option<(String, Color)> {
match value {
LuaValue::LightUserData(l) => Some((format!("{:?}", l.0), Color::Cyan)),
@@ -182,7 +186,7 @@ pub fn display_basic(value: &LuaValue, colorize: bool) -> String {
LuaValue::Boolean(b) => Color::LightYellow.paint(b.to_string()),
LuaValue::Integer(i) => Color::LightYellow.paint(i.to_string()),
LuaValue::Number(n) => Color::LightYellow.paint(n.to_string()),
- LuaValue::String(s) => Color::Green.paint(format_string(s, colorize)),
+ LuaValue::String(s) => Color::Green.paint(format_string_lua_string(s, colorize)),
#[cfg(feature = "luau")]
LuaValue::Vector(v) => {
let strings: &[AnsiString<'static>] = &[
diff --git a/src/lua.rs b/src/lua.rs
@@ -1,10 +1,11 @@
-use std::sync::{
- Arc,
- atomic::{AtomicBool, Ordering},
-};
+use std::{io::{self, Write}, process::{Child, ChildStdin, ChildStdout, Command, Stdio}, sync::{
+ atomic::{AtomicBool, Ordering}, Arc, RwLock
+}};
use mlua::prelude::*;
+use crate::inspect::format_string_bytes;
+
pub trait LuaExecutor: Send + Sync {
fn exec(&self, code: &str) -> LuaResult<LuaValue>;
fn globals(&self) -> LuaTable;
@@ -49,3 +50,81 @@ impl LuaExecutor for MluaExecutor {
self.cancelled.store(true, Ordering::Relaxed);
}
}
+
+const LUA_RPC: &str = include_str!("../lua/rpc.lua");
+
+pub struct SystemLuaExecutor {
+ child: RwLock<Child>,
+ stdin: RwLock<ChildStdin>,
+ stdout: RwLock<ChildStdout>,
+
+ program: String,
+}
+
+pub enum RpcCommand {
+ Globals,
+ Ping,
+ Exec(String),
+}
+
+impl RpcCommand {
+ pub fn to_lua(&self) -> String {
+ let func = match self {
+ Self::Globals => "globals",
+ Self::Ping => "ping",
+ Self::Exec(_) => "exec"
+ };
+
+ let param = if let Self::Exec(code) = self {
+ format_string_bytes(code.as_bytes(), false)
+ } else {
+ String::new()
+ };
+
+ format!("rpc.{func}({param})")
+ }
+}
+
+impl SystemLuaExecutor {
+ pub fn new(program: &str) -> io::Result<Self> {
+ let (child, stdin, stdout) = Self::obtain_process(program)?;
+
+ Ok(Self {
+ child: RwLock::new(child),
+ stdin: RwLock::new(stdin),
+ stdout: RwLock::new(stdout),
+
+ program: program.to_string(),
+ })
+ }
+
+ fn obtain_process(program: &str) -> io::Result<(Child, ChildStdin, ChildStdout)> {
+ let mut child = Command::new(program)
+ .stderr(Stdio::null())
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .spawn()?;
+
+ let mut stdin = child.stdin.take().unwrap();
+
+ stdin.write_all(LUA_RPC.as_bytes())?;
+
+ let stdout = child.stdout.take().unwrap();
+
+ Ok((child, stdin, stdout))
+ }
+}
+
+impl LuaExecutor for SystemLuaExecutor {
+ fn exec(&self, code: &str) -> LuaResult<LuaValue> {
+ todo!()
+ }
+
+ fn globals(&self) -> LuaTable {
+ todo!()
+ }
+
+ fn cancel(&self) {
+ todo!()
+ }
+}