//! Lua C-API bridge for the panto CLI. //! //! Exposes a `panto` table inside any `lua_State` we construct, reachable //! from Lua via `require('panto')` (registered into `package.preload` — //! there is no `panto` global). Internal Zig code reaches the same table //! through the registry via `pushPantoTable`. //! 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.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, //! version, etc.) can be added without breaking existing extensions. //! //! Each call records a registration in a Lua-side table at a fixed //! registry slot (`registrations_key`). The Zig side reads that table //! to decide what to do with the entries: //! //! - The long-lived `LuaRuntime` (`src/lua_runtime.zig`) loads each //! extension into a single `lua_State`, then walks the registrations //! table once per loaded script (between loads it calls //! `resetRegistrations`). For each entry it copies name / //! description / schema, `luaL_ref`s the handler function into the //! Lua registry, and records the ref under the tool name. //! //! - On dispatch, the runtime spawns a coroutine, pushes the handler //! onto it via `lua_rawgeti(LUA_REGISTRYINDEX, ref)`, pushes the //! parsed JSON input, and `lua_resume`s. Top-level extension code //! never runs again — only handler bodies. //! //! This bridge module itself is stateless beyond the public //! `registrations_key` address; it cooperates with whatever runtime //! owns the `lua_State`. const std = @import("std"); const Allocator = std.mem.Allocator; const panto = @import("panto"); pub const c = @cImport({ @cInclude("lua.h"); @cInclude("lauxlib.h"); @cInclude("lualib.h"); }); // Lua type constants are #defines that translate_c surfaces as inline // functions returning the literal; using them in switch prongs needs // explicit `c_int` constants. Define our own clean aliases. pub const T_NIL: c_int = 0; pub const T_BOOLEAN: c_int = 1; 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 /// Errors the bridge can produce when talking to a Lua state. pub const BridgeError = error{ LuaInitFailed, LuaLoadFailed, LuaRunFailed, LuaHandlerCrashed, LuaHandlerNotFound, BadRegistration, BadHandlerReturn, InputNotJsonObject, OutOfMemory, }; /// The key under which we stash the registrations table in /// `LUA_REGISTRYINDEX`. Any unique pointer works — we use the address of a /// module-level `u8` so multiple states all use the same key value. /// /// Public so the long-lived runtime can poke at it directly when it /// needs to harvest entries. pub var registrations_key: u8 = 0; /// The key under which we stash the *command* registrations table in /// `LUA_REGISTRYINDEX`. Distinct from `registrations_key` so tool and /// slash-command registrations never collide. Holds an array of records /// 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; /// The key under which we stash the canonical `panto` module table in /// `LUA_REGISTRYINDEX`. Internal Zig setup (`installEmit`, the runtime's /// `_record_result` / wrapper / register_tool paths) fetches it via /// `pushPantoTable` instead of a global lookup — `panto` is not a global. /// /// This slot is populated by the runtime's `installPantoModule`, which /// obtains the **native** `panto` table via `require('panto')` (resolving /// the staged `panto.so` on `cpath`) and attaches `ext` to it. Until then /// the slot is empty and `pushPantoTable` pushes `nil`; nothing fetches it /// before module load. pub var panto_table_key: u8 = 0; /// The key under which we stash the `panto.ext` extension subtable in /// `LUA_REGISTRYINDEX`. Built by `install` (before any native module /// exists) so the host can attach it to the native `panto` table once /// `require('panto')` resolves. Distinct from `panto_table_key` because /// `ext` is constructed first and outlives the module-load step. pub var ext_table_key: u8 = 0; /// A single declared tool, as harvested from a script's top-level call to /// `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, description: []const u8, /// Serialized JSON Schema for the tool's input. schema_json: []const u8, }; /// A single declared slash command, harvested from a script's top-level /// 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 // --------------------------------------------------------------------------- /// Push the canonical `panto` module table onto the stack from its /// registry slot. The slot is populated by the runtime's /// `installPantoModule` (which `require`s the native module and attaches /// `ext`); before that runs the slot is empty and this pushes `nil`. /// Internal Zig setup uses this instead of a global lookup, since `panto` /// is not a global — it is delivered to Lua only through `require('panto')`. pub fn pushPantoTable(L: *c.lua_State) void { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &panto_table_key); } /// Push the `panto.ext` extension subtable from its registry slot. Built /// by `install`, so it is available before the native module loads (the /// host attaches it to the native table in `installPantoModule`). pub fn pushExtTable(L: *c.lua_State) void { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &ext_table_key); } /// Stash a `panto` module table (on the stack top) into `panto_table_key`. /// Called by `installPantoModule` after it builds the native+`ext` table. /// Does NOT pop — leaves the table on the stack for the caller. pub fn setPantoTable(L: *c.lua_State) void { c.lua_pushvalue(L, -1); // dup for the registry slot c.lua_rawsetp(L, LUA_REGISTRYINDEX, &panto_table_key); } /// Register a `package.preload['panto']` loader returning the `panto` /// module table currently on the stack top. Does NOT pop the table — /// leaves it in place for the caller. The loader returns the table stashed /// under `panto_table_key`, so it must be called together with /// `setPantoTable` (the runtime does both in `installPantoModule`). pub fn installPantoPreload(L: *c.lua_State) void { _ = c.lua_getglobal(L, "package"); _ = c.lua_getfield(L, -1, "preload"); c.lua_pushcclosure(L, pantoPreloadThunk, 0); c.lua_setfield(L, -2, "panto"); // preload.panto = thunk c.lua_settop(L, c.lua_gettop(L) - 2); // pop preload + package } /// The `package.preload['panto']` loader: returns the augmented `panto` /// table from `panto_table_key`. Standard preload-loader contract: one /// return value. Any `require('panto')` after `installPantoModule` — from /// an extension or otherwise — routes here (preload is searcher slot 1). fn pantoPreloadThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; pushPantoTable(L); return 1; } /// Build the `panto.ext` extension subtable and the registry tables that /// hold harvested tool/command/event-handler registrations. /// /// This does **not** build the `panto` module table and does **not** touch /// `package.preload` — the `panto` table comes from the native /// `libpanto-lua` module via `require('panto')`, wired by the runtime's /// `installPantoModule` once the staged `panto.so` is on `cpath`. Here we /// only construct `ext` (stashed under `ext_table_key`) so the host can /// attach it to that native table later. /// /// All extension-authoring APIs (`register_tool`, `register_command`, /// `on`, `emit`) live under `panto.ext`. `emit` is installed here as a /// safe no-op default; the long-lived runtime overrides it with a closure /// carrying its context via `installEmit` once the module table exists. pub fn install(L: *c.lua_State) void { // Create the registrations table: an array of records, each shaped // { name=, description=, schema_json=, handler= }. c.lua_createtable(L, 0, 0); // Stash under our registry key. c.lua_rawsetp(L, LUA_REGISTRYINDEX, ®istrations_key); // Create the command registrations table (parallel to the tool one). c.lua_createtable(L, 0, 0); c.lua_rawsetp(L, LUA_REGISTRYINDEX, &command_registrations_key); // 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.ext` subtable and stash it for later attachment. 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"); // Stash `ext` under its registry key, consuming it. c.lua_rawsetp(L, LUA_REGISTRYINDEX, &ext_table_key); } /// 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 { pushExtTable(L); 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) - 1); // pop ext } /// 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 /// (not accumulated from prior loads). pub fn resetRegistrations(L: *c.lua_State) void { c.lua_createtable(L, 0, 0); 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.ext.register_tool(...)` one or more /// times, populating the registrations table. /// /// On Lua error, the error message is left on the stack — callers that /// want to surface it can read the top with `lua_tolstring`. pub fn loadFile(L: *c.lua_State, path: [:0]const u8) BridgeError!void { if (c.luaL_loadfilex(L, path.ptr, null) != 0) return BridgeError.LuaLoadFailed; if (c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) return BridgeError.LuaRunFailed; } /// Walk the registrations table and copy each entry's name, description, /// and schema_json into freshly-allocated bytes owned by `arena`. The /// returned slice (and every byte slice inside each `Registration`) lives /// as long as the arena does. /// /// The handler field is ignored — discovery mode doesn't care about it. pub fn harvestRegistrations( L: *c.lua_State, arena: Allocator, ) BridgeError![]Registration { // Push the registrations table. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, ®istrations_key); defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop the table when done const n: usize = @intCast(c.lua_rawlen(L, -1)); if (n == 0) return arena.alloc(Registration, 0) catch BridgeError.OutOfMemory; var out = arena.alloc(Registration, n) catch return BridgeError.OutOfMemory; var i: usize = 1; while (i <= n) : (i += 1) { _ = c.lua_rawgeti(L, -1, @intCast(i)); // record table on top defer c.lua_settop(L, c.lua_gettop(L) - 1); const name = try readStringField(L, -1, "name", arena); const desc = try readStringField(L, -1, "description", arena); const schema = try readStringField(L, -1, "schema_json", arena); out[i - 1] = .{ .name = name, .description = desc, .schema_json = schema, }; } return out; } /// Walk the *command* registrations table and copy each entry's name and /// description into arena-owned bytes. The handler field is ignored; /// callers that need to invoke handlers `luaL_ref` them separately. pub fn harvestCommandRegistrations( L: *c.lua_State, arena: Allocator, ) BridgeError![]CommandRegistration { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &command_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(CommandRegistration, 0) catch BridgeError.OutOfMemory; var out = arena.alloc(CommandRegistration, 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] = .{ .name = try readStringField(L, -1, "name", arena), .description = try readStringField(L, -1, "description", arena), }; } 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. /// /// Returns LuaHandlerNotFound if no registration with that name exists. pub fn pushHandler(L: *c.lua_State, tool_name: []const u8) BridgeError!void { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, ®istrations_key); 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)); _ = c.lua_getfield(L, -1, "name"); var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); const matched = ptr != null and std.mem.eql(u8, ptr[0..len], tool_name); c.lua_settop(L, c.lua_gettop(L) - 1); // pop name if (matched) { // Replace the record with its handler field. _ = c.lua_getfield(L, -1, "handler"); // Stack: ..., regs_table, record, handler. Remove record, regs_table. c.lua_copy(L, -1, -3); c.lua_settop(L, c.lua_gettop(L) - 2); return; } c.lua_settop(L, c.lua_gettop(L) - 1); // pop record } c.lua_settop(L, c.lua_gettop(L) - 1); // pop regs table return BridgeError.LuaHandlerNotFound; } /// Convert raw JSON bytes into a Lua value and push it onto the stack. /// Top-level value must be a JSON object (matches our schema convention /// that tool input is always an object). pub fn pushJsonAsLua( L: *c.lua_State, arena: Allocator, input: []const u8, ) BridgeError!void { var parsed = std.json.parseFromSlice(std.json.Value, arena, input, .{}) catch { return BridgeError.InputNotJsonObject; }; defer parsed.deinit(); if (parsed.value != .object) return BridgeError.InputNotJsonObject; pushJsonValue(L, parsed.value) catch return BridgeError.OutOfMemory; } /// Read a tool handler's return value at `idx` and convert it into an /// owned `panto.ResultParts`. Two accepted shapes: /// /// * a plain string -> one `.text` part; /// * a table `{ text = "...", attachments = { { media_type = "...", /// data = "..." }, ... } }` -> an optional `.text` part followed by /// one `.media` part per attachment (`data` is base64-encoded bytes). /// /// Every returned slice/part owns its bytes (allocated with `allocator`); /// the caller frees via `panto.ResultParts.deinit`. pub fn readHandlerResult( L: *c.lua_State, idx: c_int, allocator: Allocator, ) BridgeError!panto.ResultParts { const ty = c.lua_type(L, idx); if (ty == T_STRING) { var len: usize = 0; const ptr = c.lua_tolstring(L, idx, &len); if (ptr == null) return BridgeError.BadHandlerReturn; return panto.ResultParts.fromText(allocator, ptr[0..len]) catch return BridgeError.OutOfMemory; } if (ty != T_TABLE) return BridgeError.BadHandlerReturn; 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. fn optStringField(L: *c.lua_State, tbl_idx: c_int, name: [*:0]const u8) BridgeError!?[]const u8 { const t = c.lua_getfield(L, tbl_idx, name); if (t == T_NIL) { c.lua_settop(L, c.lua_gettop(L) - 1); return null; } if (t != T_STRING) { c.lua_settop(L, c.lua_gettop(L) - 1); return BridgeError.BadHandlerReturn; } var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); if (ptr == null) { c.lua_settop(L, c.lua_gettop(L) - 1); return BridgeError.BadHandlerReturn; } // Note: value still on stack; caller pops after copying. return ptr[0..len]; } fn readHandlerResultTable( L: *c.lua_State, idx: c_int, allocator: Allocator, ) BridgeError!panto.ResultParts { var parts: std.ArrayList(panto.ResultPart) = .empty; errdefer { for (parts.items) |p| p.deinit(allocator); parts.deinit(allocator); } // Optional `text` field. { const maybe = try optStringField(L, idx, "text"); if (maybe) |s| { const owned = allocator.dupe(u8, s) catch return BridgeError.OutOfMemory; c.lua_settop(L, c.lua_gettop(L) - 1); // pop field value parts.append(allocator, .{ .text = owned }) catch { allocator.free(owned); return BridgeError.OutOfMemory; }; } } // Optional `attachments` array. { const t = c.lua_getfield(L, idx, "attachments"); defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop attachments if (t == T_TABLE) { 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 attachment table defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop attachment if (c.lua_type(L, -1) != T_TABLE) return BridgeError.BadHandlerReturn; // `media_type` is an optional hint; libpanto detects the // type from the raw bytes when it's absent. const media_type: ?[]const u8 = if (try optStringField(L, -1, "media_type")) |mt_slice| blk: { const owned = allocator.dupe(u8, mt_slice) catch return BridgeError.OutOfMemory; c.lua_settop(L, c.lua_gettop(L) - 1); // pop media_type value break :blk owned; } else null; errdefer if (media_type) |mt| allocator.free(mt); const data_slice = (try optStringField(L, -1, "data")) orelse return BridgeError.BadHandlerReturn; const data = allocator.dupe(u8, data_slice) catch return BridgeError.OutOfMemory; c.lua_settop(L, c.lua_gettop(L) - 1); // pop data value errdefer allocator.free(data); parts.append(allocator, .{ .media = .{ .media_type = media_type, .data = data, } }) catch return BridgeError.OutOfMemory; } } else if (t != T_NIL) { return BridgeError.BadHandlerReturn; } } const items = parts.toOwnedSlice(allocator) catch return BridgeError.OutOfMemory; return .{ .items = items }; } /// Test-only: fabricate a minimal `panto` module table `{ ext = }` /// and wire `require('panto')` to it, mirroring what the runtime's /// `installPantoModule` does in production (minus the native agent/stream /// surface, which needs a staged `panto.so` unavailable in unit tests). /// /// Production code never calls this — it `require`s the real native /// module. Unit tests that load extension scripts (which `require('panto')` /// for `panto.ext.*`) call `install(L)` then this, so those scripts /// resolve without a `.so` on disk. pub fn installTestPantoTable(L: *c.lua_State) void { c.lua_createtable(L, 0, 1); // panto pushExtTable(L); c.lua_setfield(L, -2, "ext"); // panto.ext = ext installPantoPreload(L); // preload.panto -> panto_table_key setPantoTable(L); // stash; leaves panto on stack c.lua_settop(L, c.lua_gettop(L) - 1); // pop panto } // --------------------------------------------------------------------------- // Lua-callable C functions // --------------------------------------------------------------------------- /// 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 /// the registrations table at `registry[®istrations_key]`. Throws a Lua /// error via `luaL_error` if anything is malformed — that propagates out /// of the running script as a Lua exception, which our `loadFile` surfaces /// as `LuaRunFailed`. fn registerToolThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; c.luaL_checktype(L, 1, T_TABLE); // Pull each named field onto the stack and type-check it. After this // block, the stack layout is: // 1: args table (input) // 2: name (string) // 3: description (string) // 4: schema (table) // 5: handler (function) expectField(L, 1, "name", T_STRING); expectField(L, 1, "description", T_STRING); expectField(L, 1, "schema", T_TABLE); expectField(L, 1, "handler", T_FUNCTION); // Serialize the schema table to JSON, leaving the string on top. pushSchemaAsJson(L, 4) catch |err| { const msg = switch (err) { BridgeError.OutOfMemory => "register_tool: out of memory serializing schema", else => "register_tool: schema is not JSON-serializable", }; _ = c.luaL_error(L, msg); unreachable; }; // Stack now: args(1), name(2), desc(3), schema(4), handler(5), schema_json(6) // Build the record table. c.lua_createtable(L, 0, 4); c.lua_pushvalue(L, 2); c.lua_setfield(L, -2, "name"); c.lua_pushvalue(L, 3); c.lua_setfield(L, -2, "description"); c.lua_pushvalue(L, 6); // schema_json c.lua_setfield(L, -2, "schema_json"); c.lua_pushvalue(L, 5); // handler c.lua_setfield(L, -2, "handler"); // Append the record to the registrations table. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, ®istrations_key); // Stack: ..., record, regs_table 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; } /// 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 /// remainder of the slash-command line (a string, possibly empty). Its /// return value is ignored — commands act by side effect, like the /// builtin Zig commands. Appends a `{ name, description, handler }` /// record to the command registrations table. fn registerCommandThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; c.luaL_checktype(L, 1, T_TABLE); // Stack after these pulls: // 1: args table (input) // 2: name (string) // 3: description (string) // 4: handler (function) expectFieldNamed(L, 1, "name", T_STRING, "register_command"); expectFieldNamed(L, 1, "description", T_STRING, "register_command"); expectFieldNamed(L, 1, "handler", T_FUNCTION, "register_command"); // Build the record table { name, description, handler }. c.lua_createtable(L, 0, 3); c.lua_pushvalue(L, 2); c.lua_setfield(L, -2, "name"); c.lua_pushvalue(L, 3); c.lua_setfield(L, -2, "description"); c.lua_pushvalue(L, 4); // handler c.lua_setfield(L, -2, "handler"); // Append to the command registrations table. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &command_registrations_key); // Stack: ..., record, regs_table 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; } /// 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 = , handler = }. 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( L: *c.lua_State, args_idx: c_int, comptime field_name: [:0]const u8, expected_type: c_int, ) void { expectFieldNamed(L, args_idx, field_name, expected_type, "register_tool"); } /// Like `expectField`, but lets the caller name the bridge function in /// the error message (`register_tool` vs `register_command`). fn expectFieldNamed( L: *c.lua_State, args_idx: c_int, comptime field_name: [:0]const u8, expected_type: c_int, comptime fn_name: [:0]const u8, ) void { _ = c.lua_getfield(L, args_idx, field_name.ptr); const got = c.lua_type(L, -1); if (got != expected_type) { const fmt: [:0]const u8 = fn_name ++ ": field '%s' must be %s (got %s)"; _ = c.luaL_error( L, fmt.ptr, field_name.ptr, c.lua_typename(L, expected_type), c.lua_typename(L, got), ); unreachable; } } // --------------------------------------------------------------------------- // JSON <-> Lua conversion // --------------------------------------------------------------------------- /// Serialize the Lua table at stack index `idx` to a JSON string and push /// that string onto the stack. fn pushSchemaAsJson(L: *c.lua_State, idx: c_int) BridgeError!void { var aw: std.Io.Writer.Allocating = .init(std.heap.c_allocator); defer aw.deinit(); var s: std.json.Stringify = .{ .writer = &aw.writer }; writeLuaValueAsJson(L, idx, &s) catch return BridgeError.OutOfMemory; const bytes = aw.written(); _ = c.lua_pushlstring(L, bytes.ptr, bytes.len); } /// Serialize the value at stack index `idx` to JSON via `stringifier`. We /// allow strings, numbers, booleans, nil (→ JSON null), arrays (Lua tables /// with sequential integer keys 1..n), and objects (any other table). fn writeLuaValueAsJson(L: *c.lua_State, idx: c_int, w: *std.json.Stringify) anyerror!void { switch (c.lua_type(L, idx)) { T_NIL => try w.write(null), T_BOOLEAN => try w.write(c.lua_toboolean(L, idx) != 0), T_NUMBER => { // Distinguish integer vs float so we emit clean integers. var isnum: c_int = 0; const as_int = c.lua_tointegerx(L, idx, &isnum); if (isnum != 0) { try w.write(as_int); } else { const as_float = c.lua_tonumberx(L, idx, null); try w.write(as_float); } }, T_STRING => { var len: usize = 0; const ptr = c.lua_tolstring(L, idx, &len); try w.write(ptr[0..len]); }, T_TABLE => try writeLuaTableAsJson(L, idx, w), else => return error.UnsupportedLuaType, } } /// Decide whether the table at `idx` is JSON-array-shaped or object-shaped /// and serialize accordingly. /// /// Heuristic: if `lua_rawlen > 0`, treat it as an array (Lua's standard /// length operator returns the array-part border, so this catches the /// common case of `{ "a", "b", "c" }`). Otherwise iterate via `lua_next` /// and emit a JSON object keyed by stringified keys. /// /// An empty Lua table is ambiguous (could be either) and we serialize it /// as `{}`, since JSON Schema usage almost always wants empty-object /// shape (e.g. `properties = {}`). fn writeLuaTableAsJson(L: *c.lua_State, idx_in: c_int, w: *std.json.Stringify) !void { // Normalize negative indices since we'll be pushing more on the stack. const abs_idx = c.lua_absindex(L, idx_in); const len = c.lua_rawlen(L, abs_idx); if (len > 0) { try w.beginArray(); var i: c.lua_Integer = 1; while (i <= @as(c.lua_Integer, @intCast(len))) : (i += 1) { _ = c.lua_rawgeti(L, abs_idx, i); try writeLuaValueAsJson(L, -1, w); c.lua_settop(L, c.lua_gettop(L) - 1); } try w.endArray(); return; } try w.beginObject(); c.lua_pushnil(L); // first key while (c.lua_next(L, abs_idx) != 0) { // Stack: ..., key, value. Key must be a string for JSON objects. if (c.lua_type(L, -2) != T_STRING) { // Pop value, leave key for next lua_next iteration. c.lua_settop(L, c.lua_gettop(L) - 1); return error.UnsupportedLuaKey; } var klen: usize = 0; // CAREFUL: lua_tolstring on a non-string-key would mutate the key // and break lua_next. We already verified it's T_STRING above. const kptr = c.lua_tolstring(L, -2, &klen); try w.objectField(kptr[0..klen]); try writeLuaValueAsJson(L, -1, w); c.lua_settop(L, c.lua_gettop(L) - 1); // pop value, keep key } try w.endObject(); } /// Push a parsed `std.json.Value` onto the stack. fn pushJsonValue(L: *c.lua_State, v: std.json.Value) !void { switch (v) { .null => c.lua_pushnil(L), .bool => |b| c.lua_pushboolean(L, if (b) 1 else 0), .integer => |i| c.lua_pushinteger(L, @intCast(i)), .float => |f| c.lua_pushnumber(L, f), .number_string => |s| { // Best effort: try integer, else float. if (std.fmt.parseInt(c.lua_Integer, s, 10)) |i| { c.lua_pushinteger(L, i); } else |_| { const f = try std.fmt.parseFloat(c.lua_Number, s); c.lua_pushnumber(L, f); } }, .string => |s| _ = c.lua_pushlstring(L, s.ptr, s.len), .array => |arr| { c.lua_createtable(L, @intCast(arr.items.len), 0); for (arr.items, 0..) |item, i| { try pushJsonValue(L, item); c.lua_rawseti(L, -2, @intCast(i + 1)); } }, .object => |obj| { c.lua_createtable(L, 0, @intCast(obj.count())); var it = obj.iterator(); while (it.next()) |kv| { // Push key as Lua string (length-prefixed; doesn't need NUL). const key = kv.key_ptr.*; _ = c.lua_pushlstring(L, key.ptr, key.len); try pushJsonValue(L, kv.value_ptr.*); // Stack: ..., table, key, value -> table[key]=value, leaving table. c.lua_rawset(L, -3); } }, } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Read a string-typed field `field_name` from the record at stack index /// `record_idx`, duplicate its bytes into `arena`, and return the slice. fn readStringField( L: *c.lua_State, record_idx: c_int, field_name: [:0]const u8, arena: Allocator, ) BridgeError![]const u8 { _ = c.lua_getfield(L, record_idx, field_name.ptr); defer c.lua_settop(L, c.lua_gettop(L) - 1); if (c.lua_type(L, -1) != T_STRING) return BridgeError.BadRegistration; var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); if (ptr == null) return BridgeError.BadRegistration; return arena.dupe(u8, ptr[0..len]) catch BridgeError.OutOfMemory; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- test "install creates the 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); install(L); // No `panto` global, and `install` alone does NOT build the module // table — the native module supplies it via `require('panto')`, and // the host attaches `ext` in `installPantoModule`. So before that // wiring the module slot is empty. try std.testing.expectEqual(@as(c_int, T_NIL), c.lua_getglobal(L, "panto")); c.lua_settop(L, c.lua_gettop(L) - 1); pushPantoTable(L); try std.testing.expectEqual(@as(c_int, T_NIL), c.lua_type(L, -1)); c.lua_settop(L, c.lua_gettop(L) - 1); // The `ext` table exists and carries the four authoring functions. pushExtTable(L); 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); } // After the test wiring, `require('panto')` resolves to a `{ ext }` // module table. installTestPantoTable(L); pushPantoTable(L); try std.testing.expectEqual(@as(c_int, T_TABLE), 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)); } 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); installTestPantoTable(L); const script = \\local panto = require("panto") \\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" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\local panto = require("panto") \\panto.ext.register_command { \\ name = "hello", \\ description = "Greets the user.", \\ handler = function(args) return "hi " .. args 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 cmds = try harvestCommandRegistrations(L, arena_state.allocator()); try std.testing.expectEqual(@as(usize, 1), cmds.len); try std.testing.expectEqualStrings("hello", cmds[0].name); try std.testing.expectEqualStrings("Greets the user.", cmds[0].description); } test "register_command rejects a non-function handler" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\local panto = require("panto") \\panto.ext.register_command { \\ name = "bad", description = "d", handler = 42, \\} ; const loaded = c.luaL_loadstring(L, script) == 0; try std.testing.expect(loaded); // Should raise a Lua error (non-zero pcall result). try std.testing.expect(c.lua_pcallk(L, 0, 0, 0, 0, null) != 0); var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); try std.testing.expect(std.mem.indexOf(u8, msg[0..len], "register_command") != null); } test "register_tool records name, description, schema_json" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\local panto = require("panto") \\panto.ext.register_tool { \\ name = "echo", \\ description = "Echoes its input back.", \\ schema = { type = "object", properties = { msg = { type = "string" } } }, \\ handler = function(input) return input.msg 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 regs = try harvestRegistrations(L, arena_state.allocator()); try std.testing.expectEqual(@as(usize, 1), regs.len); try std.testing.expectEqualStrings("echo", regs[0].name); try std.testing.expectEqualStrings("Echoes its input back.", regs[0].description); // schema_json should be valid JSON containing "type": "object" try std.testing.expect(std.mem.indexOf(u8, regs[0].schema_json, "\"type\"") != null); try std.testing.expect(std.mem.indexOf(u8, regs[0].schema_json, "\"object\"") != null); } test "handler invocation: input parsed, result captured" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\local panto = require("panto") \\panto.ext.register_tool { \\ name = "echo", description = "echoes", \\ schema = { type = "object" }, \\ handler = function(input) return "got: " .. input.msg end, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { return error.LuaScriptFailed; } try pushHandler(L, "echo"); try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1)); var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); try pushJsonAsLua(L, arena_state.allocator(), "{\"msg\":\"hello\"}"); // Call: 1 arg, 1 return. if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) { return error.LuaCallFailed; } const result = try readHandlerResult(L, -1, std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 1), result.items.len); try std.testing.expectEqualStrings("got: hello", result.items[0].text); } test "readHandlerResult: table with text and attachments" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\return { \\ text = "see image", \\ attachments = { \\ { media_type = "image/png", data = "AAA=" }, \\ { media_type = "application/pdf", data = "BBB=" }, \\ }, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { return error.LuaScriptFailed; } const result = try readHandlerResult(L, -1, std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 3), result.items.len); try std.testing.expectEqualStrings("see image", result.items[0].text); try std.testing.expectEqualStrings("image/png", result.items[1].media.media_type.?); try std.testing.expectEqualStrings("AAA=", result.items[1].media.data); try std.testing.expectEqualStrings("application/pdf", result.items[2].media.media_type.?); try std.testing.expectEqualStrings("BBB=", result.items[2].media.data); } test "handler crash: error message surfaces via xpcall traceback hook" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\local panto = require("panto") \\panto.ext.register_tool { \\ name = "boom", description = "crashes", \\ schema = { type = "object" }, \\ handler = function(input) error("explosion") end, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { return error.LuaScriptFailed; } // Push a traceback error handler at the bottom of 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); const errfunc_idx = c.lua_gettop(L); try pushHandler(L, "boom"); c.lua_pushnil(L); // input arg // pcallk with errfunc index = where we put debug.traceback. const rc = c.lua_pcallk(L, 1, 1, errfunc_idx, 0, null); try std.testing.expect(rc != 0); var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); try std.testing.expect(msg != null); const slice = msg[0..len]; try std.testing.expect(std.mem.indexOf(u8, slice, "explosion") != null); // Should also contain a traceback marker since we used debug.traceback. try std.testing.expect(std.mem.indexOf(u8, slice, "stack traceback") != null); }