summaryrefslogtreecommitdiff
path: root/src/lua_bridge.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-09 00:00:56 -0600
committert <t@tjp.lol>2026-06-09 08:52:47 -0600
commit97b10466d83d488ad2cb9e084159a445af74c845 (patch)
tree6bea7c23d00ff1178a43e631fc700f210cb7d85b /src/lua_bridge.zig
parent104001d25c9c8cb5ec45ced1678f7c7b70888808 (diff)
event lifecycle
Diffstat (limited to 'src/lua_bridge.zig')
-rw-r--r--src/lua_bridge.zig264
1 files changed, 239 insertions, 25 deletions
diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig
index 9276e1d..4446ca7 100644
--- a/src/lua_bridge.zig
+++ b/src/lua_bridge.zig
@@ -1,14 +1,20 @@
//! Lua C-API bridge for the panto CLI.
//!
-//! Exposes a `panto` global table inside any `lua_State` we construct,
-//! with a single function:
+//! Exposes a `panto` global table inside any `lua_State` we construct.
+//! All extension-authoring APIs live under the `panto.ext` subtable; the
+//! bare `panto` namespace is reserved for the future full libpanto API
+//! surface, so extensions never collide with it. The current `panto.ext`
+//! members are:
//!
-//! panto.register_tool {
+//! panto.ext.register_tool {
//! name = "...",
//! description = "...",
//! schema = { ... }, -- JSON Schema as a Lua table
//! handler = function(input) ... end,
//! }
+//! panto.ext.register_command { name=, description=, handler= }
+//! panto.ext.on(event_name, function(event) ... end) -- §7 UI events
+//! panto.ext.emit(event_name, data_table) -- fire a custom event
//!
//! The single-table-argument form is idiomatic Lua "named arguments".
//! It's also forward-compatible: future optional fields (examples,
@@ -53,6 +59,7 @@ pub const T_NUMBER: c_int = 3;
pub const T_STRING: c_int = 4;
pub const T_TABLE: c_int = 5;
pub const T_FUNCTION: c_int = 6;
+pub const T_USERDATA: c_int = 7;
pub const LUA_MULTRET: c_int = -1;
pub const LUA_REGISTRYINDEX: c_int = -1001000; // matches lua.h with LUAI_MAXSTACK=1000000
@@ -84,8 +91,15 @@ pub var registrations_key: u8 = 0;
/// shaped `{ name=, description=, handler= }`.
pub var command_registrations_key: u8 = 0;
+/// The key under which we stash the *event-handler* (`panto.ext.on`)
+/// registrations table in `LUA_REGISTRYINDEX`. Holds an array of records
+/// shaped `{ event=, handler= }`, in registration order (§7.3). The
+/// runtime harvests these and registers each as a native `EventBus`
+/// handler that bridges back into Lua.
+pub var on_registrations_key: u8 = 0;
+
/// A single declared tool, as harvested from a script's top-level call to
-/// `panto.register_tool`. All slices reference Lua-owned strings on the
+/// `panto.ext.register_tool`. All slices reference Lua-owned strings on the
/// state's stack/registry; copy them before closing the state.
pub const Registration = struct {
name: []const u8,
@@ -95,21 +109,36 @@ pub const Registration = struct {
};
/// A single declared slash command, harvested from a script's top-level
-/// call to `panto.register_command`. Slices reference Lua-owned strings;
+/// call to `panto.ext.register_command`. Slices reference Lua-owned strings;
/// copy them before closing the state.
pub const CommandRegistration = struct {
name: []const u8,
description: []const u8,
};
+/// A single `panto.ext.on(event, handler)` registration, harvested from a
+/// script. `event` references a Lua-owned string (copy before closing the
+/// state); the handler function is `luaL_ref`'d separately by the runtime.
+pub const OnRegistration = struct {
+ event: []const u8,
+};
+
// ---------------------------------------------------------------------------
// Public bridge API
// ---------------------------------------------------------------------------
-/// Install the `panto.register_tool` global into the given state.
+/// Install the `panto` global (with its `panto.ext` extension subtable)
+/// into the given state.
///
-/// Also creates the registry table that holds harvested registrations and
-/// the per-name handler references.
+/// All extension-authoring APIs (`register_tool`, `register_command`,
+/// `on`, `emit`) live under `panto.ext`; the bare `panto` table is
+/// reserved for the future libpanto API. `emit` is installed here as a
+/// safe no-op default; the long-lived runtime overrides it with a closure
+/// carrying its context via `installEmit` so a Lua `emit` can drive the
+/// native `EventBus`.
+///
+/// Also creates the registry tables that hold harvested tool, command, and
+/// event-handler registrations.
pub fn install(L: *c.lua_State) void {
// Create the registrations table: an array of records, each shaped
// { name=, description=, schema_json=, handler= }.
@@ -121,16 +150,55 @@ pub fn install(L: *c.lua_State) void {
c.lua_createtable(L, 0, 0);
c.lua_rawsetp(L, LUA_REGISTRYINDEX, &command_registrations_key);
- // Build the `panto` global table with `register_tool` and
- // `register_command`.
- c.lua_createtable(L, 0, 2);
+ // Create the `panto.ext.on` registrations table.
+ c.lua_createtable(L, 0, 0);
+ c.lua_rawsetp(L, LUA_REGISTRYINDEX, &on_registrations_key);
+
+ // Build the `panto` global as { ext = { ... } }.
+ c.lua_createtable(L, 0, 1); // panto
+ c.lua_createtable(L, 0, 4); // panto.ext
c.lua_pushcclosure(L, registerToolThunk, 0);
c.lua_setfield(L, -2, "register_tool");
c.lua_pushcclosure(L, registerCommandThunk, 0);
c.lua_setfield(L, -2, "register_command");
+ c.lua_pushcclosure(L, registerOnThunk, 0);
+ c.lua_setfield(L, -2, "on");
+ // Default `emit`: a no-op until the runtime installs the real one.
+ c.lua_pushcclosure(L, emitNoopThunk, 0);
+ c.lua_setfield(L, -2, "emit");
+ // panto.ext = <ext table>
+ c.lua_setfield(L, -2, "ext");
c.lua_setglobal(L, "panto");
}
+/// Override `panto.ext.emit` with a closure that carries `ctx` as a
+/// light-userdata upvalue and dispatches into `emit_fn`. The runtime calls
+/// this after `install` so a Lua `emit(name, data)` reaches the native
+/// `EventBus`. Until then, `emit` is a no-op (see `install`).
+///
+/// `emit_fn` runs with the Lua stack holding `(name_string, data_table?)`
+/// as args 1 and 2; it is a plain C function whose first upvalue is the
+/// light-userdata `ctx`.
+pub fn installEmit(
+ L: *c.lua_State,
+ ctx: *anyopaque,
+ emit_fn: *const fn (L_opt: ?*c.lua_State) callconv(.c) c_int,
+) void {
+ _ = c.lua_getglobal(L, "panto");
+ _ = c.lua_getfield(L, -1, "ext");
+ c.lua_pushlightuserdata(L, ctx);
+ c.lua_pushcclosure(L, emit_fn, 1);
+ c.lua_setfield(L, -2, "emit");
+ c.lua_settop(L, c.lua_gettop(L) - 2); // pop ext + panto
+}
+
+/// The default `panto.ext.emit`: a no-op. Replaced by `installEmit` once
+/// the runtime is ready.
+fn emitNoopThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ _ = L_opt;
+ return 0;
+}
+
/// Replace the registrations table with a fresh empty one. Used by the
/// long-lived runtime between loading distinct extension scripts so it
/// can harvest only the registrations made by the script just loaded
@@ -140,10 +208,12 @@ pub fn resetRegistrations(L: *c.lua_State) void {
c.lua_rawsetp(L, LUA_REGISTRYINDEX, &registrations_key);
c.lua_createtable(L, 0, 0);
c.lua_rawsetp(L, LUA_REGISTRYINDEX, &command_registrations_key);
+ c.lua_createtable(L, 0, 0);
+ c.lua_rawsetp(L, LUA_REGISTRYINDEX, &on_registrations_key);
}
/// Load and execute a Lua source file in the given state. The file's
-/// top-level code typically calls `panto.register_tool(...)` one or more
+/// top-level code typically calls `panto.ext.register_tool(...)` one or more
/// times, populating the registrations table.
///
/// On Lua error, the error message is left on the stack — callers that
@@ -215,6 +285,31 @@ pub fn harvestCommandRegistrations(
return out;
}
+/// Walk the `panto.ext.on` registrations table and copy each entry's event
+/// name into arena-owned bytes, in registration order. The handler
+/// function is ignored here; the runtime `luaL_ref`s handlers separately
+/// (it needs to keep the ref alive in the live state, which the arena
+/// cannot do).
+pub fn harvestOnRegistrations(
+ L: *c.lua_State,
+ arena: Allocator,
+) BridgeError![]OnRegistration {
+ _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &on_registrations_key);
+ defer c.lua_settop(L, c.lua_gettop(L) - 1);
+
+ const n: usize = @intCast(c.lua_rawlen(L, -1));
+ if (n == 0) return arena.alloc(OnRegistration, 0) catch BridgeError.OutOfMemory;
+
+ var out = arena.alloc(OnRegistration, n) catch return BridgeError.OutOfMemory;
+ var i: usize = 1;
+ while (i <= n) : (i += 1) {
+ _ = c.lua_rawgeti(L, -1, @intCast(i));
+ defer c.lua_settop(L, c.lua_gettop(L) - 1);
+ out[i - 1] = .{ .event = try readStringField(L, -1, "event", arena) };
+ }
+ return out;
+}
+
/// In an *invocation-mode* state (registrations table populated by re-
/// running the script), push the handler function for `tool_name` onto the
/// stack. Caller is responsible for popping it after use.
@@ -288,6 +383,46 @@ pub fn readHandlerResult(
return readHandlerResultTable(L, idx, allocator);
}
+/// Read a Lua array-of-strings at stack index `idx` into a freshly
+/// allocated `[][]u8` (each line owned, copied with `alloc`). Used by the
+/// bridged component vtable to marshal a Lua component's `render` return
+/// value (`{ "line1", "line2", ... }`) into engine line slices.
+///
+/// Non-string array entries are coerced via `lua_tolstring` (so numbers
+/// render as their text); a nil/absent entry ends the array (Lua's `#`
+/// border). Returns an empty slice for an empty/zero-length table. The
+/// caller owns the result and every inner slice.
+pub fn readLinesArray(
+ L: *c.lua_State,
+ idx: c_int,
+ alloc: Allocator,
+) BridgeError![][]u8 {
+ if (c.lua_type(L, idx) != T_TABLE) return BridgeError.BadHandlerReturn;
+ const abs = c.lua_absindex(L, idx);
+ const n: usize = @intCast(c.lua_rawlen(L, abs));
+ if (n == 0) return alloc.alloc([]u8, 0) catch BridgeError.OutOfMemory;
+
+ var out = alloc.alloc([]u8, n) catch return BridgeError.OutOfMemory;
+ var made: usize = 0;
+ errdefer {
+ for (out[0..made]) |s| alloc.free(s);
+ alloc.free(out);
+ }
+ var i: usize = 1;
+ while (i <= n) : (i += 1) {
+ _ = c.lua_rawgeti(L, abs, @intCast(i));
+ defer c.lua_settop(L, c.lua_gettop(L) - 1);
+ var len: usize = 0;
+ // lua_tolstring coerces numbers to strings in place; strings pass
+ // through. A non-coercible value (table/function/nil) yields null.
+ const ptr = c.lua_tolstring(L, -1, &len);
+ if (ptr == null) return BridgeError.BadHandlerReturn;
+ out[i - 1] = alloc.dupe(u8, ptr[0..len]) catch return BridgeError.OutOfMemory;
+ made = i;
+ }
+ return out;
+}
+
/// Read a string field `name` from the table at `tbl_idx`. Returns null
/// if absent/nil, an error if present-but-not-a-string. The returned
/// slice borrows from Lua's internal buffer — copy before popping.
@@ -380,7 +515,7 @@ fn readHandlerResultTable(
// Lua-callable C functions
// ---------------------------------------------------------------------------
-/// Implementation of `panto.register_tool { name=, description=, schema=, handler= }`.
+/// Implementation of `panto.ext.register_tool { name=, description=, schema=, handler= }`.
///
/// Expects a single table argument with the four named fields. Validates
/// each field type, serializes `schema` to JSON, and appends a record to
@@ -437,7 +572,7 @@ fn registerToolThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
return 0;
}
-/// Implementation of `panto.register_command { name=, description=, handler= }`.
+/// Implementation of `panto.ext.register_command { name=, description=, handler= }`.
///
/// Expects a single table argument with three named fields. The handler
/// is a function `function(args) ... end` where `args` is the trimmed
@@ -478,6 +613,35 @@ fn registerCommandThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
return 0;
}
+/// Implementation of `panto.ext.on(event_name, handler)`.
+///
+/// Expects a string event name and a function handler (the two-positional-
+/// argument form, NOT a named-args table — `on` is the subscribe verb).
+/// Appends a `{ event, handler }` record to the on-registrations table, in
+/// call order, so the runtime can register them into the native `EventBus`
+/// in registration order (§7.3).
+fn registerOnThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ c.luaL_checktype(L, 1, T_STRING);
+ c.luaL_checktype(L, 2, T_FUNCTION);
+
+ // Build the record { event = <name>, handler = <fn> }.
+ c.lua_createtable(L, 0, 2);
+ c.lua_pushvalue(L, 1);
+ c.lua_setfield(L, -2, "event");
+ c.lua_pushvalue(L, 2);
+ c.lua_setfield(L, -2, "handler");
+
+ // Append to the on-registrations table.
+ _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &on_registrations_key);
+ const n: c_int = @intCast(c.lua_rawlen(L, -1));
+ c.lua_pushvalue(L, -2); // copy record above regs_table
+ c.lua_rawseti(L, -2, n + 1);
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop regs_table
+
+ return 0;
+}
+
/// Push `args_table[field_name]` onto the stack and assert it has the
/// expected type. Raises a Lua error if missing or wrong type.
fn expectField(
@@ -670,7 +834,7 @@ fn readStringField(
// Tests
// ---------------------------------------------------------------------------
-test "install creates panto.register_tool global" {
+test "install creates panto.ext table with register_tool/register_command/on/emit" {
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
@@ -678,11 +842,61 @@ test "install creates panto.register_tool global" {
_ = c.lua_getglobal(L, "panto");
try std.testing.expectEqual(@as(c_int, T_TABLE), c.lua_type(L, -1));
- _ = c.lua_getfield(L, -1, "register_tool");
- try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1));
- _ = c.lua_getglobal(L, "panto");
- _ = c.lua_getfield(L, -1, "register_command");
- try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1));
+ _ = c.lua_getfield(L, -1, "ext");
+ try std.testing.expectEqual(@as(c_int, T_TABLE), c.lua_type(L, -1));
+ inline for (.{ "register_tool", "register_command", "on", "emit" }) |field| {
+ _ = c.lua_getfield(L, -1, field);
+ try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1));
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ }
+}
+
+test "panto.ext.on records event registrations in order" {
+ const L = c.luaL_newstate() orelse return error.LuaInitFailed;
+ defer c.lua_close(L);
+ c.luaL_openlibs(L);
+ install(L);
+
+ const script =
+ \\panto.ext.on("tool", function(e) end)
+ \\panto.ext.on("assistant_text", function(e) end)
+ \\panto.ext.on("tool", function(e) end)
+ ;
+ if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) {
+ var len: usize = 0;
+ const msg = c.lua_tolstring(L, -1, &len);
+ std.debug.print("lua error: {s}\n", .{msg[0..len]});
+ return error.LuaScriptFailed;
+ }
+
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ const ons = try harvestOnRegistrations(L, arena_state.allocator());
+ try std.testing.expectEqual(@as(usize, 3), ons.len);
+ try std.testing.expectEqualStrings("tool", ons[0].event);
+ try std.testing.expectEqualStrings("assistant_text", ons[1].event);
+ try std.testing.expectEqualStrings("tool", ons[2].event);
+}
+
+test "readLinesArray marshals a Lua array of strings" {
+ const L = c.luaL_newstate() orelse return error.LuaInitFailed;
+ defer c.lua_close(L);
+ c.luaL_openlibs(L);
+
+ if (c.luaL_loadstring(L, "return { \"a\", \"bb\", \"ccc\" }") != 0 or
+ c.lua_pcallk(L, 0, 1, 0, 0, null) != 0)
+ {
+ return error.LuaScriptFailed;
+ }
+ const lines = try readLinesArray(L, -1, std.testing.allocator);
+ defer {
+ for (lines) |ln| std.testing.allocator.free(ln);
+ std.testing.allocator.free(lines);
+ }
+ try std.testing.expectEqual(@as(usize, 3), lines.len);
+ try std.testing.expectEqualStrings("a", lines[0]);
+ try std.testing.expectEqualStrings("bb", lines[1]);
+ try std.testing.expectEqualStrings("ccc", lines[2]);
}
test "register_command records name and description" {
@@ -692,7 +906,7 @@ test "register_command records name and description" {
install(L);
const script =
- \\panto.register_command {
+ \\panto.ext.register_command {
\\ name = "hello",
\\ description = "Greets the user.",
\\ handler = function(args) return "hi " .. args end,
@@ -721,7 +935,7 @@ test "register_command rejects a non-function handler" {
install(L);
const script =
- \\panto.register_command {
+ \\panto.ext.register_command {
\\ name = "bad", description = "d", handler = 42,
\\}
;
@@ -741,7 +955,7 @@ test "register_tool records name, description, schema_json" {
install(L);
const script =
- \\panto.register_tool {
+ \\panto.ext.register_tool {
\\ name = "echo",
\\ description = "Echoes its input back.",
\\ schema = { type = "object", properties = { msg = { type = "string" } } },
@@ -774,7 +988,7 @@ test "handler invocation: input parsed, result captured" {
install(L);
const script =
- \\panto.register_tool {
+ \\panto.ext.register_tool {
\\ name = "echo", description = "echoes",
\\ schema = { type = "object" },
\\ handler = function(input) return "got: " .. input.msg end,
@@ -838,7 +1052,7 @@ test "handler crash: error message surfaces via xpcall traceback hook" {
install(L);
const script =
- \\panto.register_tool {
+ \\panto.ext.register_tool {
\\ name = "boom", description = "crashes",
\\ schema = { type = "object" },
\\ handler = function(input) error("explosion") end,