manen

Fancy Lua REPL
Log | Files | Refs | README | LICENSE

commit fec7ee91b26a0dc6baae7f835bc2fc23454b2597
parent 190519a21c84c0753cc1ad7913f70751d59ac4d1
Author: Sylvia Ivory <git@sivory.net>
Date:   Sun, 29 Jun 2025 17:17:12 -0700

Outline system Lua executor

Diffstat:
MCargo.lock | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MCargo.toml | 1+
Alua/rpc.lua | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/inspect.rs | 10+++++++---
Msrc/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!() + } +}