diff options
Diffstat (limited to 'src/lua_bridge.zig')
| -rw-r--r-- | src/lua_bridge.zig | 264 |
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, ®istrations_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, |
