summaryrefslogtreecommitdiff
path: root/src/lua_runtime.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-02 19:41:56 -0600
committert <t@tjp.lol>2026-06-02 22:09:31 -0600
commit442888c6c306cfff0708c3d4bbbeda685cec2e75 (patch)
tree858ad09d4da908cc7e61ca5e570e2f46e74bdb68 /src/lua_runtime.zig
parent8b88b886346460b1ab29edb03ad85bc3bb565a45 (diff)
lua /commands
Diffstat (limited to 'src/lua_runtime.zig')
-rw-r--r--src/lua_runtime.zig204
1 files changed, 204 insertions, 0 deletions
diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig
index 2cf3cf9..7d18fe9 100644
--- a/src/lua_runtime.zig
+++ b/src/lua_runtime.zig
@@ -46,6 +46,15 @@ pub const RuntimeError = error{
OutOfMemory,
};
+/// A slash command declared by a Lua extension. Name and description
+/// point into the runtime's `strings` pool; the handler lives in the Lua
+/// registry, addressed by `handler_ref`.
+pub const LuaCommand = struct {
+ name: []const u8,
+ description: []const u8,
+ handler_ref: c_int,
+};
+
/// Owned state for the runtime.
pub const LuaRuntime = struct {
allocator: Allocator,
@@ -58,6 +67,14 @@ pub const LuaRuntime = struct {
/// the Lua registry (`luaL_ref` index).
handlers: std.StringHashMap(c_int),
+ /// Slash commands declared by extensions via `panto.register_command`.
+ /// Owned by this runtime; the CLI reads these to build its command
+ /// registry. Each entry's name/description point into `strings`.
+ commands: std.array_list.Managed(LuaCommand),
+ /// Map from command name (borrowed from `commands`) to its handler
+ /// ref in the Lua registry.
+ command_handlers: std.StringHashMap(c_int),
+
/// Registry ref to the wrapper closure that runs a user handler
/// inside a `pcall` and reports the result back to Zig via
/// `panto._record_result`. Allocated once at `create`; reused for
@@ -92,6 +109,8 @@ pub const LuaRuntime = struct {
.decls = std.array_list.Managed(panto.ToolDecl).init(allocator),
.strings = std.array_list.Managed([]u8).init(allocator),
.handlers = std.StringHashMap(c_int).init(allocator),
+ .commands = std.array_list.Managed(LuaCommand).init(allocator),
+ .command_handlers = std.StringHashMap(c_int).init(allocator),
};
return self;
}
@@ -123,6 +142,11 @@ pub const LuaRuntime = struct {
c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, entry.value_ptr.*);
}
self.handlers.deinit();
+ var cit = self.command_handlers.iterator();
+ while (cit.next()) |entry| {
+ c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, entry.value_ptr.*);
+ }
+ self.command_handlers.deinit();
if (self.wrapper_ref != 0) {
c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.wrapper_ref);
}
@@ -133,6 +157,7 @@ pub const LuaRuntime = struct {
c.lua_close(self.L);
self.decls.deinit();
+ self.commands.deinit();
for (self.strings.items) |s| self.allocator.free(s);
self.strings.deinit();
self.allocator.destroy(self);
@@ -309,6 +334,105 @@ pub const LuaRuntime = struct {
c.lua_settop(L, c.lua_gettop(L) - 1); // pop record
}
+
+ try self.harvestAndStoreCommands();
+ }
+
+ /// Walk the command registrations table populated by the script just
+ /// loaded. For each `{ name, description, handler }` record: copy the
+ /// strings, `luaL_ref` the handler, and append a `LuaCommand`.
+ /// Duplicate command names within the runtime are rejected.
+ fn harvestAndStoreCommands(self: *LuaRuntime) !void {
+ const L = self.L;
+
+ _ = c.lua_rawgetp(L, lua_bridge.LUA_REGISTRYINDEX, &lua_bridge.command_registrations_key);
+ defer c.lua_settop(L, c.lua_gettop(L) - 1);
+
+ const n: usize = @intCast(c.lua_rawlen(L, -1));
+ var i: usize = 1;
+ while (i <= n) : (i += 1) {
+ _ = c.lua_rawgeti(L, -1, @intCast(i)); // push record
+
+ const name = try self.readStringFieldOwned("name");
+ const desc = try self.readStringFieldOwned("description");
+
+ _ = c.lua_getfield(L, -1, "handler");
+ if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) {
+ c.lua_settop(L, c.lua_gettop(L) - 2); // pop handler + record
+ return RuntimeError.LuaHandlerNotFound;
+ }
+ const ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX);
+ // Stack: ..., regs_table, record
+
+ const gop = try self.command_handlers.getOrPut(name);
+ if (gop.found_existing) {
+ c.luaL_unref(L, lua_bridge.LUA_REGISTRYINDEX, ref);
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop record
+ return error.DuplicateCommand;
+ }
+ gop.value_ptr.* = ref;
+
+ try self.commands.append(.{
+ .name = name,
+ .description = desc,
+ .handler_ref = ref,
+ });
+
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop record
+ }
+ }
+
+ /// Run a Lua slash-command handler synchronously. `handler_ref` is a
+ /// registry ref to a `function(args)` closure; `args` is the trimmed
+ /// remainder of the command line. The handler runs under `pcall` with
+ /// a `debug.traceback` error handler so failures surface a readable
+ /// message instead of crashing the REPL.
+ ///
+ /// Slash-command handlers act by side effect (printing, mutating
+ /// state) and their return value is ignored. On Lua error, returns
+ /// `RuntimeError.LuaHandlerCrashed` after copying the error message
+ /// into `err_buf` (truncated to fit); `*err_len` is set to the number
+ /// of bytes written.
+ pub fn runCommand(
+ self: *LuaRuntime,
+ handler_ref: c_int,
+ args: []const u8,
+ err_buf: []u8,
+ err_len: *usize,
+ ) RuntimeError!void {
+ const L = self.L;
+ err_len.* = 0;
+
+ // Install a traceback error handler beneath the call frame.
+ _ = c.lua_getglobal(L, "debug");
+ _ = c.lua_getfield(L, -1, "traceback");
+ c.lua_copy(L, -1, -2);
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop the `debug` table
+ const errfunc_idx = c.lua_gettop(L);
+
+ // Push handler + args string.
+ _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref));
+ _ = c.lua_pushlstring(L, args.ptr, args.len);
+
+ const rc = c.lua_pcallk(L, 1, 0, errfunc_idx, 0, null);
+ if (rc != 0) {
+ var len: usize = 0;
+ const ptr = c.lua_tolstring(L, -1, &len);
+ if (ptr != null) {
+ const n = @min(len, err_buf.len);
+ @memcpy(err_buf[0..n], ptr[0..n]);
+ err_len.* = n;
+ }
+ c.lua_settop(L, c.lua_gettop(L) - 2); // pop error + errfunc
+ return RuntimeError.LuaHandlerCrashed;
+ }
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop errfunc
+ }
+
+ /// Slash commands declared by loaded extensions, in registration
+ /// order. The CLI walks these to populate its command registry.
+ pub fn commandList(self: *const LuaRuntime) []const LuaCommand {
+ return self.commands.items;
}
fn readStringFieldOwned(self: *LuaRuntime, field_name: [:0]const u8) ![]const u8 {
@@ -963,6 +1087,86 @@ test "loadExtension records tool decls" {
try testing.expectEqualStrings("greet", rt.decls.items[0].name);
}
+test "loadExtension records command decls" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const source =
+ \\_G.last_args = nil
+ \\panto.register_command {
+ \\ name = "shout", description = "Uppercases its args.",
+ \\ handler = function(args) _G.last_args = args:upper() end,
+ \\}
+ ;
+ const path = try writeTempScript(tmp.dir, "shout.lua", source);
+ defer testing.allocator.free(path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+
+ try rt.loadExtension(path, null);
+ const cmds = rt.commandList();
+ try testing.expectEqual(@as(usize, 1), cmds.len);
+ try testing.expectEqualStrings("shout", cmds[0].name);
+ try testing.expectEqualStrings("Uppercases its args.", cmds[0].description);
+ // No tool was registered.
+ try testing.expectEqual(@as(usize, 0), rt.toolCount());
+
+ // Invoke the handler and confirm the side effect ran with our args.
+ var err_buf: [256]u8 = undefined;
+ var err_len: usize = 0;
+ try rt.runCommand(cmds[0].handler_ref, "hi there", &err_buf, &err_len);
+
+ _ = c.lua_getglobal(rt.L, "last_args");
+ var len: usize = 0;
+ const got = c.lua_tolstring(rt.L, -1, &len);
+ try testing.expectEqualStrings("HI THERE", got[0..len]);
+ c.lua_settop(rt.L, c.lua_gettop(rt.L) - 1);
+}
+
+test "runCommand surfaces a Lua error" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const source =
+ \\panto.register_command {
+ \\ name = "boom", description = "crashes",
+ \\ handler = function(args) error("kaboom") end,
+ \\}
+ ;
+ const path = try writeTempScript(tmp.dir, "boom.lua", source);
+ defer testing.allocator.free(path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+ try rt.loadExtension(path, null);
+
+ const cmds = rt.commandList();
+ var err_buf: [4096]u8 = undefined;
+ var err_len: usize = 0;
+ try testing.expectError(
+ RuntimeError.LuaHandlerCrashed,
+ rt.runCommand(cmds[0].handler_ref, "", &err_buf, &err_len),
+ );
+ try testing.expect(std.mem.indexOf(u8, err_buf[0..err_len], "kaboom") != null);
+}
+
+test "duplicate command name within runtime is rejected" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const source =
+ \\panto.register_command { name = "dup", description = "a", handler = function() end }
+ \\panto.register_command { name = "dup", description = "b", handler = function() end }
+ ;
+ const path = try writeTempScript(tmp.dir, "dup.lua", source);
+ defer testing.allocator.free(path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+ try testing.expectError(error.DuplicateCommand, rt.loadExtension(path, null));
+}
+
test "invokeBatch runs each call through a coroutine and returns the result" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();