summaryrefslogtreecommitdiff
path: root/src/extension_loader.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/extension_loader.zig')
-rw-r--r--src/extension_loader.zig1181
1 files changed, 415 insertions, 766 deletions
diff --git a/src/extension_loader.zig b/src/extension_loader.zig
index 87eb609..4620a2e 100644
--- a/src/extension_loader.zig
+++ b/src/extension_loader.zig
@@ -1,45 +1,37 @@
-//! Extension and tool discovery: walk well-known directories, locate Lua
-//! files, and load each into a long-lived `LuaRuntime`.
+//! Extension discovery: walk well-known directories (plus any extra
+//! `paths` and installed `rocks`), evaluate each Lua source to learn what
+//! extension *entries* it declares, then activate the surviving ones into a
+//! long-lived `LuaRuntime`.
//!
-//! Two parallel namespaces are scanned, each at three scopes — base,
-//! user, and project. Project shadows user shadows base.
+//! Sources, in precedence order. Cross-layer: project > user > base.
+//! Within a layer: `rocks < paths < dir` (the canonical dir is most
+//! authoritative locally, a rock least). The scanned dirs are:
//!
-//! Extensions (full-featured; call `panto.ext.register_tool` from a script
-//! that may register many tools):
-//! 1. `<data home>/agent/extensions/` ("base")
-//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user")
-//! 3. `./.panto/extensions/` ("project")
+//! <data home>/agent/{extensions,tools}/ ("base")
+//! ${XDG_CONFIG_HOME:-$HOME/.config}/panto/{extensions,tools}/ ("user")
+//! ./.panto/{extensions,tools}/ ("project")
//!
-//! Tools (ergonomic single-tool form; the script returns one table
-//! shaped like the argument to `panto.ext.register_tool`):
-//! 1. `<data home>/agent/tools/` ("base")
-//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/tools/` ("user")
-//! 3. `./.panto/tools/` ("project")
+//! The `base` layer is staged at bootstrap from files embedded into the
+//! binary (see `build/gen_agent_embed.zig`). `extensions/` and `tools/` are
+//! no longer distinct namespaces — both just contribute entries.
//!
-//! The `base` layer is populated at bootstrap from files embedded
-//! into the panto binary (see `build/gen_agent_embed.zig` and
-//! `luarocks_runtime.stageAgentTree`). It is panto's "batteries" tier:
-//! ships in the binary, but every entry is individually shadowable
-//! from the user or project layer.
+//! Layout per directory:
+//! - `<file>.lua` -- single-file source.
+//! - `<dir>/init.lua` -- directory source; the directory is added to
+//! `package.path` so it can `require` siblings.
//!
-//! Layout per directory (identical for extensions and tools):
-//! - `<name>.lua` -- single-file entry; the logical name is the
-//! basename without the `.lua` suffix.
-//! - `<name>/init.lua` -- directory entry; the logical name is the
-//! directory name. The directory is added to
-//! the script's `package.path` so it can
-//! `require` sibling Lua files.
+//! Each source is eval'd (side-effect-free) and must return an *entry*
+//! `{ name, activate }`, the sugar tool form `{ name, handler, schema, ... }`,
+//! or a list of those. Identity is the declared `name`, not the filename.
//!
-//! Conflict rules:
-//! - Within one directory, two entries with the same logical name
-//! are an error.
-//! - Project shadows user shadows base *within the same kind*
-//! (extension or tool). Extensions and tools live in distinct
-//! namespaces for shadowing; the registered *tool names* still
-//! share one global namespace across both, and collisions there
-//! are still an error.
+//! Resolution (two passes):
+//! 1. Availability — eval every source, collect `name → entry`. Later
+//! (higher-precedence) source wins for the same name; same-precedence
+//! duplicate is an error.
+//! 2. Activation — for every permitted name (per the `[extensions]`
+//! policy), call `entry.activate()`.
//!
-//! Symlinks: followed normally. Dotfiles: skipped.
+//! Symlinks: followed normally. Dotfiles and `_`-prefixed files: skipped.
const std = @import("std");
const panto = @import("panto");
@@ -49,224 +41,233 @@ const config_file = @import("config_file.zig");
const Allocator = std.mem.Allocator;
const Io = std.Io;
const LuaRuntime = lua_runtime.LuaRuntime;
+const Entry = LuaRuntime.Entry;
-/// Availability policies for the two namespaces. Both are optional; a
-/// null policy permits everything. `extensions` gates extension/tool
-/// *entries* by their logical (file/dir) name before loading; `tools`
-/// gates the *registered tool names* (e.g. `std.read`) after loading.
-pub const Policies = struct {
- extensions: ?*const config_file.Policy = null,
- tools: ?*const config_file.Policy = null,
+pub const Layer = enum(u8) { base = 0, user = 1, project = 2 };
+
+/// Within-layer origin, ordered least → most authoritative.
+pub const Origin = enum(u8) { rocks = 0, paths = 1, dir = 2 };
+
+/// Where a source came from, and thus its precedence. Higher `rank()` wins
+/// when two sources declare the same entry name.
+pub const Source = struct {
+ layer: Layer,
+ origin: Origin,
+
+ pub fn rank(self: Source) u8 {
+ return (@as(u8, @intFromEnum(self.layer)) << 2) | @intFromEnum(self.origin);
+ }
+
+ pub fn label(self: Source) []const u8 {
+ return switch (self.layer) {
+ .base => "base",
+ .user => "user",
+ .project => "project",
+ };
+ }
};
-fn policyPermits(p: ?*const config_file.Policy, name: []const u8) bool {
- const pol = p orelse return true;
- return pol.permits(name);
-}
+/// One directory to scan, tagged with its source precedence.
+pub const ScanDir = struct {
+ path: []const u8,
+ source: Source,
+};
-/// Free-function form of `Policy.permits` taking the policy by pointer,
-/// matching the `fn(ctx, name) bool` shape `LuaRuntime.filterTools` wants.
-fn permitsByPtr(pol: *const config_file.Policy, name: []const u8) bool {
- return pol.permits(name);
-}
+/// An installed rock to load as an extension source: the Lua module name to
+/// `require`, tagged with its source precedence.
+pub const RockSource = struct {
+ module: []const u8,
+ source: Source,
+};
-/// A discovered extension or tool before loading. Owns its strings.
+/// A discovered Lua source file before evaluation. Owns its strings.
const Found = struct {
- /// Logical name (basename without `.lua`, or directory name).
- name: []u8,
- /// Absolute path to the Lua script to execute.
script_path: []u8,
- /// For directory-style entries, the directory containing the script.
+ /// For directory-style entries, the directory added to `package.path`.
package_root: ?[]u8,
- /// Which search-path source this came from.
source: Source,
- /// Whether this is a full extension or a single-tool script.
- kind: Kind,
pub fn deinit(self: *Found, allocator: Allocator) void {
- allocator.free(self.name);
allocator.free(self.script_path);
if (self.package_root) |p| allocator.free(p);
}
};
-pub const Source = enum {
- /// Staged at bootstrap from files embedded in the panto binary.
- /// Lowest priority — shadowed by user and project.
- base,
- user,
- project,
-
- pub fn label(self: Source) []const u8 {
- return switch (self) {
- .base => "base",
- .user => "user",
- .project => "project",
- };
- }
+/// A runtime `Entry` paired with the source it came from, for shadowing and
+/// policy resolution.
+const Candidate = struct {
+ entry: Entry,
+ source: Source,
+ script_path: []const u8, // borrowed from a `Found`, for diagnostics
};
-pub const Kind = enum {
- extension,
- tool,
+fn policyPermits(p: ?*const config_file.Policy, name: []const u8) bool {
+ const pol = p orelse return true;
+ return pol.permits(name);
+}
- pub fn label(self: Kind) []const u8 {
- return switch (self) {
- .extension => "extension",
- .tool => "tool",
- };
- }
-};
+/// Log at `err` in production but `warn` under test — the Zig test runner
+/// fails any test that logs an error, and several tests deliberately
+/// exercise error paths.
+fn logConflict(comptime fmt: []const u8, args: anytype) void {
+ if (@import("builtin").is_test) std.log.warn(fmt, args) else std.log.err(fmt, args);
+}
-/// Discover and load every extension and tool found in the standard
-/// paths into `runtime`. Returns the number of registered tools added
-/// to the runtime by this call.
-///
-/// `base_agent_dir`, when non-null, is the path under which embedded
-/// base tools/extensions have been staged — typically
-/// `<data home>/agent/`. Pass `null` to skip the base layer entirely
-/// (mostly useful for tests).
+/// Discover and load every extension into `runtime`, returning the number of
+/// registered tools this call added.
///
-/// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The
-/// project directories are always `cwd()/.panto/{extensions,tools}`.
+/// `base_agent_dir`, when non-null, is where embedded base sources have been
+/// staged (typically `<data home>/agent/`); pass `null` to skip base.
+/// `environ_map` is consulted for `HOME`/`XDG_CONFIG_HOME`; project dirs are
+/// `cwd()/.panto/{extensions,tools}`.
pub fn discoverAndLoad(
allocator: Allocator,
io: Io,
environ_map: *const std.process.Environ.Map,
base_agent_dir: ?[]const u8,
runtime: *LuaRuntime,
- policies: Policies,
+ policy: ?*const config_file.Policy,
+ /// Extra dirs from `extensions.paths`, each tagged with its config layer.
+ extra_paths: []const config_file.LayeredStr,
+ /// Installed rocks from `extensions.rocks` (raw dependency specs), each
+ /// tagged with its config layer. The Lua module `require`d is the spec's
+ /// first whitespace-delimited token (the rock name). Installation happens
+ /// upstream (see `main`); this only loads already-installed modules.
+ rocks: []const config_file.LayeredStr,
) !usize {
- const sys_ext = if (base_agent_dir) |d|
- try std.fs.path.join(allocator, &.{ d, kindSubdir(.extension) })
- else
- null;
- defer if (sys_ext) |d| allocator.free(d);
- const sys_tool = if (base_agent_dir) |d|
- try std.fs.path.join(allocator, &.{ d, kindSubdir(.tool) })
- else
- null;
- defer if (sys_tool) |d| allocator.free(d);
+ var dirs: std.array_list.Managed(ScanDir) = .init(allocator);
+ defer {
+ for (dirs.items) |d| allocator.free(d.path);
+ dirs.deinit();
+ }
- const user_ext = try userKindDir(allocator, environ_map, .extension);
- defer if (user_ext) |d| allocator.free(d);
- const user_tool = try userKindDir(allocator, environ_map, .tool);
- defer if (user_tool) |d| allocator.free(d);
+ const cwd = try std.process.currentPathAlloc(io, allocator);
+ defer allocator.free(cwd);
- const project_ext = try projectKindDir(allocator, io, .extension);
- defer allocator.free(project_ext);
- const project_tool = try projectKindDir(allocator, io, .tool);
- defer allocator.free(project_tool);
+ // base: <data home>/agent/{extensions,tools}
+ if (base_agent_dir) |d| {
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ d, "extensions" }), .source = .{ .layer = .base, .origin = .dir } });
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ d, "tools" }), .source = .{ .layer = .base, .origin = .dir } });
+ }
+ // user: $XDG_CONFIG_HOME/panto/{extensions,tools}
+ if (try userConfigDir(allocator, environ_map)) |base| {
+ defer allocator.free(base);
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ base, "extensions" }), .source = .{ .layer = .user, .origin = .dir } });
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ base, "tools" }), .source = .{ .layer = .user, .origin = .dir } });
+ }
+ // project: cwd/.panto/{extensions,tools}
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ cwd, ".panto", "extensions" }), .source = .{ .layer = .project, .origin = .dir } });
+ try dirs.append(.{ .path = try std.fs.path.join(allocator, &.{ cwd, ".panto", "tools" }), .source = .{ .layer = .project, .origin = .dir } });
- return loadFromDirs(
- allocator,
- io,
- runtime,
- .{
- .base_extensions = sys_ext,
- .user_extensions = user_ext,
- .project_extensions = project_ext,
- .base_tools = sys_tool,
- .user_tools = user_tool,
- .project_tools = project_tool,
- },
- policies,
- );
+ // extensions.paths: extra dirs at their config layer's precedence,
+ // origin=paths (below the canonical dir, above rocks). Relative paths
+ // resolve against cwd.
+ for (extra_paths) |p| {
+ const path = if (std.fs.path.isAbsolute(p.value))
+ try allocator.dupe(u8, p.value)
+ else
+ try std.fs.path.join(allocator, &.{ cwd, p.value });
+ try dirs.append(.{ .path = path, .source = .{ .layer = configLayer(p.layer), .origin = .paths } });
+ }
+
+ // extensions.rocks: require each rock's module (its name = first token).
+ var rock_srcs: std.array_list.Managed(RockSource) = .init(allocator);
+ defer rock_srcs.deinit();
+ for (rocks) |r| {
+ var it = std.mem.tokenizeAny(u8, r.value, " \t");
+ const module = it.next() orelse continue;
+ try rock_srcs.append(.{ .module = module, .source = .{ .layer = configLayer(r.layer), .origin = .rocks } });
+ }
+
+ return loadFromDirs(allocator, io, runtime, dirs.items, rock_srcs.items, policy);
}
-/// Set of search paths consumed by `loadFromDirs`. Any field may be
-/// null; missing directories on disk are silently skipped.
-///
-/// Scan order is base → user → project. `applyShadowing` keeps the
-/// *last* occurrence of each (kind, name), so project entries win,
-/// then user, then base.
-pub const DirSet = struct {
- base_extensions: ?[]const u8 = null,
- user_extensions: ?[]const u8 = null,
- project_extensions: ?[]const u8 = null,
- base_tools: ?[]const u8 = null,
- user_tools: ?[]const u8 = null,
- project_tools: ?[]const u8 = null,
-};
+/// Map a config layer index (base=0, user=1, project=2, local=3) to a loader
+/// precedence layer. The `local` layer shares `project` precedence.
+fn configLayer(layer: u16) Layer {
+ return switch (layer) {
+ 0 => .base,
+ 1 => .user,
+ else => .project,
+ };
+}
-/// Lower-level entry point: load from explicit user/project paths.
-/// Either path may be null; missing directories are silently skipped.
+/// Lower-level entry point: scan an explicit list of dirs (each tagged with
+/// its source precedence), then eval → shadow → filter → activate.
pub fn loadFromDirs(
allocator: Allocator,
io: Io,
runtime: *LuaRuntime,
- dirs: DirSet,
- policies: Policies,
+ dirs: []const ScanDir,
+ rocks: []const RockSource,
+ policy: ?*const config_file.Policy,
) !usize {
var found: std.array_list.Managed(Found) = .init(allocator);
defer {
for (found.items) |*f| f.deinit(allocator);
found.deinit();
}
+ for (dirs) |d| try scanDir(allocator, io, d.path, d.source, &found);
- if (dirs.base_extensions) |d| try scanDir(allocator, io, d, .base, .extension, &found);
- if (dirs.user_extensions) |d| try scanDir(allocator, io, d, .user, .extension, &found);
- if (dirs.project_extensions) |d| try scanDir(allocator, io, d, .project, .extension, &found);
- if (dirs.base_tools) |d| try scanDir(allocator, io, d, .base, .tool, &found);
- if (dirs.user_tools) |d| try scanDir(allocator, io, d, .user, .tool, &found);
- if (dirs.project_tools) |d| try scanDir(allocator, io, d, .project, .tool, &found);
-
- try applyShadowing(allocator, &found);
-
- // Gate *entries* by the extensions policy (matched on logical name).
- // This drops whole scripts before they run — a denied extension
- // never executes, never registers tools. The tools policy is
- // applied post-load against registered tool names.
- if (policies.extensions) |_| {
- var keep: std.array_list.Managed(Found) = .init(allocator);
- errdefer {
- for (keep.items) |*f| f.deinit(allocator);
- keep.deinit();
+ // ---- Pass 1: eval every source, collect candidate entries. ----
+ var cands: std.array_list.Managed(Candidate) = .init(allocator);
+ defer {
+ // Any candidate still here at scope exit was neither activated nor
+ // dropped (an error path); reclaim its Lua ref + name.
+ for (cands.items) |cnd| runtime.dropEntry(cnd.entry);
+ cands.deinit();
+ }
+ for (found.items) |f| {
+ var entries: std.array_list.Managed(Entry) = .init(allocator);
+ defer entries.deinit();
+ try runtime.evalEntries(f.script_path, f.package_root, &entries);
+ for (entries.items) |e| {
+ try cands.append(.{ .entry = e, .source = f.source, .script_path = f.script_path });
}
- for (found.items) |*f| {
- if (policyPermits(policies.extensions, f.name)) {
- try keep.append(f.*);
- } else {
- std.log.debug("{s}: '{s}' denied by extensions policy", .{ f.kind.label(), f.name });
- f.deinit(allocator);
- }
+ }
+ // Rocks: `require` each installed module. A rock that fails to load
+ // (not installed, bad shape) is logged and skipped rather than aborting
+ // startup — one bad optional rock should not kill the REPL.
+ for (rocks) |r| {
+ var entries: std.array_list.Managed(Entry) = .init(allocator);
+ defer entries.deinit();
+ runtime.evalEntriesFromModule(r.module, &entries) catch |err| {
+ logConflict("rock '{s}' failed to load: {t}", .{ r.module, err });
+ for (entries.items) |e| runtime.dropEntry(e);
+ continue;
+ };
+ for (entries.items) |e| {
+ try cands.append(.{ .entry = e, .source = r.source, .script_path = r.module });
}
- found.clearRetainingCapacity();
- try found.appendSlice(keep.items);
- keep.deinit();
}
+ // ---- Shadowing: keep the highest-precedence entry per name. ----
+ try applyShadowing(allocator, runtime, &cands);
+
+ // ---- Pass 2: activate permitted survivors. ----
const before = runtime.toolCount();
- for (found.items) |f| {
- const load_result = switch (f.kind) {
- .extension => runtime.loadExtension(f.script_path, f.package_root),
- .tool => runtime.loadTool(f.script_path, f.package_root),
- };
- load_result catch |err| {
- if (@import("builtin").is_test) {
- std.log.warn(
- "{s} '{s}' ({s}: {s}) failed to load: {t}",
- .{ f.kind.label(), f.name, f.source.label(), f.script_path, err },
- );
- } else {
- std.log.err(
- "{s} '{s}' ({s}: {s}) failed to load: {t}",
- .{ f.kind.label(), f.name, f.source.label(), f.script_path, err },
- );
- }
+ var i: usize = 0;
+ while (i < cands.items.len) : (i += 1) {
+ const cnd = cands.items[i];
+ if (!policyPermits(policy, cnd.entry.name)) {
+ std.log.debug("extension: '{s}' denied by policy", .{cnd.entry.name});
+ runtime.dropEntry(cnd.entry);
+ continue;
+ }
+ runtime.activateEntry(cnd.entry) catch |err| {
+ logConflict(
+ "extension '{s}' ({s}: {s}) failed to activate: {t}",
+ .{ cnd.entry.name, cnd.source.label(), cnd.script_path, err },
+ );
+ // activateEntry consumed the entry already. Drop the rest.
+ var j = i + 1;
+ while (j < cands.items.len) : (j += 1) runtime.dropEntry(cands.items[j].entry);
+ cands.clearRetainingCapacity();
return err;
};
- std.log.debug(
- "{s}: loaded '{s}' ({s})",
- .{ f.kind.label(), f.name, f.source.label() },
- );
- }
-
- // Apply the tools policy to *registered tool names* (e.g. `std.read`).
- if (policies.tools) |pol| {
- const dropped = runtime.filterTools(pol, permitsByPtr);
- if (dropped > 0) std.log.debug("tools: {d} tool(s) removed by tools policy", .{dropped});
+ std.log.debug("extension: activated '{s}' ({s})", .{ cnd.entry.name, cnd.source.label() });
}
+ cands.clearRetainingCapacity();
return runtime.toolCount() - before;
}
@@ -275,34 +276,17 @@ pub fn loadFromDirs(
// Path resolution
// ---------------------------------------------------------------------------
-fn kindSubdir(kind: Kind) []const u8 {
- return switch (kind) {
- .extension => "extensions",
- .tool => "tools",
- };
-}
-
-fn userKindDir(
- allocator: Allocator,
- environ_map: *const std.process.Environ.Map,
- kind: Kind,
-) !?[]u8 {
- const sub = kindSubdir(kind);
+/// `${XDG_CONFIG_HOME:-$HOME/.config}/panto`, or null if neither is set.
+fn userConfigDir(allocator: Allocator, environ_map: *const std.process.Environ.Map) !?[]u8 {
if (environ_map.get("XDG_CONFIG_HOME")) |xdg| {
- return try std.fs.path.join(allocator, &.{ xdg, "panto", sub });
+ return try std.fs.path.join(allocator, &.{ xdg, "panto" });
}
if (environ_map.get("HOME")) |home| {
- return try std.fs.path.join(allocator, &.{ home, ".config", "panto", sub });
+ return try std.fs.path.join(allocator, &.{ home, ".config", "panto" });
}
return null;
}
-fn projectKindDir(allocator: Allocator, io: Io, kind: Kind) ![]u8 {
- const cwd = try std.process.currentPathAlloc(io, allocator);
- defer allocator.free(cwd);
- return try std.fs.path.join(allocator, &.{ cwd, ".panto", kindSubdir(kind) });
-}
-
// ---------------------------------------------------------------------------
// Directory scanning
// ---------------------------------------------------------------------------
@@ -312,7 +296,6 @@ fn scanDir(
io: Io,
dir_path: []const u8,
source: Source,
- kind: Kind,
out: *std.array_list.Managed(Found),
) !void {
var dir = Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch |err| switch (err) {
@@ -321,44 +304,16 @@ fn scanDir(
};
defer dir.close(io);
- var local_names: std.StringHashMap(void) = .init(allocator);
- defer {
- var it = local_names.keyIterator();
- while (it.next()) |k| allocator.free(k.*);
- local_names.deinit();
- }
-
var iter = dir.iterate();
while (try iter.next(io)) |entry| {
if (entry.name.len == 0 or entry.name[0] == '.') continue;
const maybe_found: ?Found = switch (entry.kind) {
- .file, .sym_link => try classifyFile(allocator, dir_path, entry.name, source, kind),
- .directory => try classifyDirectory(allocator, io, dir, dir_path, entry.name, source, kind),
+ .file, .sym_link => try classifyFile(allocator, dir_path, entry.name, source),
+ .directory => try classifyDirectory(allocator, io, dir, dir_path, entry.name, source),
else => null,
};
- const f = maybe_found orelse continue;
-
- const gop = try local_names.getOrPut(f.name);
- if (gop.found_existing) {
- if (@import("builtin").is_test) {
- std.log.warn(
- "{s} name '{s}' is provided by multiple entries in {s}",
- .{ kind.label(), f.name, dir_path },
- );
- } else {
- std.log.err(
- "{s} name '{s}' is provided by multiple entries in {s}",
- .{ kind.label(), f.name, dir_path },
- );
- }
- var dup = f;
- dup.deinit(allocator);
- return error.DuplicateExtensionInDirectory;
- }
- gop.key_ptr.* = try allocator.dupe(u8, f.name);
-
- try out.append(f);
+ if (maybe_found) |f| try out.append(f);
}
}
@@ -367,7 +322,6 @@ fn classifyFile(
dir_path: []const u8,
entry_name: []const u8,
source: Source,
- kind: Kind,
) !?Found {
if (!std.mem.endsWith(u8, entry_name, ".lua")) return null;
const base = entry_name[0 .. entry_name.len - ".lua".len];
@@ -376,18 +330,10 @@ fn classifyFile(
const script_path = try std.fs.path.join(allocator, &.{ dir_path, entry_name });
errdefer allocator.free(script_path);
- const name = try allocator.dupe(u8, base);
- errdefer allocator.free(name);
const package_root = try allocator.dupe(u8, dir_path);
errdefer allocator.free(package_root);
- return Found{
- .name = name,
- .script_path = script_path,
- .package_root = package_root,
- .source = source,
- .kind = kind,
- };
+ return Found{ .script_path = script_path, .package_root = package_root, .source = source };
}
fn classifyDirectory(
@@ -397,93 +343,75 @@ fn classifyDirectory(
dir_path: []const u8,
entry_name: []const u8,
source: Source,
- kind: Kind,
) !?Found {
var sub = parent.openDir(io, entry_name, .{}) catch return null;
defer sub.close(io);
- sub.access(io, "init.lua", .{}) catch |err| switch (err) {
- error.FileNotFound => return null,
- else => return null,
- };
+ sub.access(io, "init.lua", .{}) catch return null;
const package_root = try std.fs.path.join(allocator, &.{ dir_path, entry_name });
errdefer allocator.free(package_root);
const script_path = try std.fs.path.join(allocator, &.{ package_root, "init.lua" });
errdefer allocator.free(script_path);
- const name = try allocator.dupe(u8, entry_name);
- errdefer allocator.free(name);
- return Found{
- .name = name,
- .script_path = script_path,
- .package_root = package_root,
- .source = source,
- .kind = kind,
- };
+ return Found{ .script_path = script_path, .package_root = package_root, .source = source };
}
// ---------------------------------------------------------------------------
// Shadowing
// ---------------------------------------------------------------------------
-/// Shadowing key combines (kind, name) so a tool named `foo` does not
-/// shadow an extension named `foo` (or vice versa). Tool-name collisions
-/// across these are still caught later by the runtime/registry.
-const ShadowKey = struct {
- kind: Kind,
- name: []const u8,
-};
-
-const ShadowKeyCtx = struct {
- pub fn hash(_: ShadowKeyCtx, k: ShadowKey) u64 {
- var hasher = std.hash.Wyhash.init(0);
- hasher.update(&[_]u8{@intFromEnum(k.kind)});
- hasher.update(k.name);
- return hasher.final();
- }
- pub fn eql(_: ShadowKeyCtx, a: ShadowKey, b: ShadowKey) bool {
- return a.kind == b.kind and std.mem.eql(u8, a.name, b.name);
- }
-};
+/// Keep the highest-precedence candidate per declared name; drop the rest
+/// (their Lua refs are reclaimed). Two candidates with the same name AND the
+/// same source precedence are an ambiguous duplicate — an error.
+fn applyShadowing(
+ allocator: Allocator,
+ runtime: *LuaRuntime,
+ cands: *std.array_list.Managed(Candidate),
+) !void {
+ // name -> index of current winner in `cands`.
+ var winners: std.StringHashMap(usize) = .init(allocator);
+ defer winners.deinit();
-fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !void {
- const keep = try allocator.alloc(bool, list.items.len);
+ const keep = try allocator.alloc(bool, cands.items.len);
defer allocator.free(keep);
- @memset(keep, false);
+ @memset(keep, true);
- {
- var latest: std.HashMap(ShadowKey, usize, ShadowKeyCtx, std.hash_map.default_max_load_percentage) = .init(allocator);
- defer latest.deinit();
-
- for (list.items, 0..) |f, i| {
- try latest.put(.{ .kind = f.kind, .name = f.name }, i);
+ for (cands.items, 0..) |cnd, i| {
+ const gop = try winners.getOrPut(cnd.entry.name);
+ if (!gop.found_existing) {
+ gop.value_ptr.* = i;
+ continue;
}
-
- for (list.items, 0..) |f, i| {
- const winner = latest.get(.{ .kind = f.kind, .name = f.name }).?;
- if (winner == i) {
- keep[i] = true;
- } else {
- std.log.debug(
- "{s}: '{s}' from {s} shadowed by {s}",
- .{ f.kind.label(), f.name, f.source.label(), list.items[winner].source.label() },
- );
- }
+ const prev = gop.value_ptr.*;
+ const prev_rank = cands.items[prev].source.rank();
+ const cur_rank = cnd.source.rank();
+ if (cur_rank == prev_rank) {
+ logConflict(
+ "extension name '{s}' declared by two same-precedence sources: {s} and {s}",
+ .{ cnd.entry.name, cands.items[prev].script_path, cnd.script_path },
+ );
+ return error.DuplicateExtensionName;
+ } else if (cur_rank > prev_rank) {
+ keep[prev] = false;
+ std.log.debug("extension: '{s}' from {s} shadowed by {s}", .{ cnd.entry.name, cands.items[prev].source.label(), cnd.source.label() });
+ gop.value_ptr.* = i;
+ } else {
+ keep[i] = false;
+ std.log.debug("extension: '{s}' from {s} shadowed by {s}", .{ cnd.entry.name, cnd.source.label(), cands.items[prev].source.label() });
}
}
var write: usize = 0;
- for (list.items, keep) |f, k| {
+ for (cands.items, keep) |cnd, k| {
if (k) {
- list.items[write] = f;
+ cands.items[write] = cnd;
write += 1;
} else {
- var dropped = f;
- dropped.deinit(allocator);
+ runtime.dropEntry(cnd.entry);
}
}
- list.shrinkRetainingCapacity(write);
+ cands.shrinkRetainingCapacity(write);
}
// ---------------------------------------------------------------------------
@@ -521,543 +449,264 @@ fn makeDir(dir: Io.Dir, sub_path: []const u8) !void {
try dir.createDirPath(testing.io, sub_path);
}
-test "scanDir picks up single-file and directory-style extensions" {
- var tmp = testing.tmpDir(.{ .iterate = true });
- defer tmp.cleanup();
-
- try makeDir(tmp.dir, "ext_root");
- try makeDir(tmp.dir, "ext_root/beta");
- try writeFile(tmp.dir, "ext_root/alpha.lua", "-- alpha\n");
- try writeFile(tmp.dir, "ext_root/_helper.lua", "-- helper module\n");
- try writeFile(tmp.dir, "ext_root/beta/init.lua", "-- beta init\n");
- try writeFile(tmp.dir, "ext_root/beta/helper.lua", "-- helper\n");
- try writeFile(tmp.dir, "ext_root/.ignored.lua", "-- hidden\n");
- try writeFile(tmp.dir, "ext_root/readme.txt", "noise\n");
-
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const ext_root_len = try tmp.dir.realPathFile(testing.io, "ext_root", &path_buf);
- const ext_root = path_buf[0..ext_root_len];
-
- var list: std.array_list.Managed(Found) = .init(testing.allocator);
- defer {
- for (list.items) |*f| f.deinit(testing.allocator);
- list.deinit();
- }
- try scanDir(testing.allocator, testing.io, ext_root, .user, .extension, &list);
-
- try testing.expectEqual(@as(usize, 2), list.items.len);
-
- std.mem.sort(Found, list.items, {}, struct {
- fn lt(_: void, a: Found, b: Found) bool {
- return std.mem.lessThan(u8, a.name, b.name);
- }
- }.lt);
-
- try testing.expectEqualStrings("alpha", list.items[0].name);
- try testing.expect(list.items[0].package_root != null);
- try testing.expect(std.mem.endsWith(u8, list.items[0].script_path, "alpha.lua"));
-
- try testing.expectEqualStrings("beta", list.items[1].name);
- try testing.expect(list.items[1].package_root != null);
- try testing.expect(std.mem.endsWith(u8, list.items[1].script_path, "init.lua"));
-}
-
-test "duplicate name in same directory is an error" {
- var tmp = testing.tmpDir(.{ .iterate = true });
- defer tmp.cleanup();
-
- try makeDir(tmp.dir, "ext_root/foo");
- try writeFile(tmp.dir, "ext_root/foo.lua", "-- single\n");
- try writeFile(tmp.dir, "ext_root/foo/init.lua", "-- dir\n");
-
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const n = try tmp.dir.realPathFile(testing.io, "ext_root", &path_buf);
- const ext_root = path_buf[0..n];
-
- var list: std.array_list.Managed(Found) = .init(testing.allocator);
- defer {
- for (list.items) |*f| f.deinit(testing.allocator);
- list.deinit();
- }
-
- const result = scanDir(testing.allocator, testing.io, ext_root, .project, .extension, &list);
- try testing.expectError(error.DuplicateExtensionInDirectory, result);
-}
-
-test "applyShadowing keeps the latest occurrence" {
- var list: std.array_list.Managed(Found) = .init(testing.allocator);
- defer {
- for (list.items) |*f| f.deinit(testing.allocator);
- list.deinit();
- }
-
- inline for (.{
- .{ "shared", "/u/shared.lua", Source.user },
- .{ "only_user", "/u/only_user.lua", Source.user },
- .{ "shared", "/p/shared.lua", Source.project },
- .{ "only_project", "/p/only_project.lua", Source.project },
- }) |row| {
- try list.append(.{
- .name = try testing.allocator.dupe(u8, row[0]),
- .script_path = try testing.allocator.dupe(u8, row[1]),
- .package_root = null,
- .source = row[2],
- .kind = .extension,
- });
- }
-
- try applyShadowing(testing.allocator, &list);
- try testing.expectEqual(@as(usize, 3), list.items.len);
-
- var shared_count: usize = 0;
- var shared_source: ?Source = null;
- for (list.items) |f| {
- if (std.mem.eql(u8, f.name, "shared")) {
- shared_count += 1;
- shared_source = f.source;
- }
- }
- try testing.expectEqual(@as(usize, 1), shared_count);
- try testing.expectEqual(@as(?Source, .project), shared_source);
+/// Resolve an absolute path for a subdirectory of the tmp dir.
+fn realDir(tmp: *testing.TmpDir, sub: []const u8, buf: []u8) ![]const u8 {
+ const n = try tmp.dir.realPathFile(testing.io, sub, buf);
+ return buf[0..n];
}
-test "loadFromDirs: project shadows user end-to-end (via long-lived runtime)" {
- var tmp = testing.tmpDir(.{ .iterate = true });
- defer tmp.cleanup();
-
- try makeDir(tmp.dir, "user_ext");
- try makeDir(tmp.dir, "project_ext");
-
- try writeFile(tmp.dir, "user_ext/greet.lua",
- \\local panto = require("panto")
- \\panto.ext.register_tool {
- \\ name = "greet", description = "user version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "USER" end,
- \\}
- );
- try writeFile(tmp.dir, "project_ext/greet.lua",
- \\local panto = require("panto")
- \\panto.ext.register_tool {
- \\ name = "greet", description = "project version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "PROJECT" end,
- \\}
- );
-
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const user_len = try tmp.dir.realPathFile(testing.io, "user_ext", &path_buf);
- const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]);
- defer testing.allocator.free(user_path);
-
- const proj_len = try tmp.dir.realPathFile(testing.io, "project_ext", &path_buf);
- const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]);
- defer testing.allocator.free(proj_path);
-
- var rt = try LuaRuntime.create(testing.allocator);
- defer rt.deinit();
-
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .user_extensions = user_path,
- .project_extensions = proj_path,
- }, .{});
- try testing.expectEqual(@as(usize, 1), n_tools);
-
- // Invoke the tool through the source and verify the project handler ran.
+fn invokeOne(rt: *LuaRuntime, name: []const u8, input: []const u8) !panto.ToolCallResult {
var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }};
+ const calls = [_]panto.ToolCall{.{ .tool_name = name, .input = input }};
var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("PROJECT", xokText(results[0]));
+ return results[0];
}
-test "loadFromDirs: tool-name collision between extensions errors" {
+test "loadFromDirs: activates a sugar tool and an entry extension" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "ext");
- try writeFile(tmp.dir, "ext/alpha.lua",
- \\local panto = require("panto")
- \\panto.ext.register_tool {
- \\ name = "clash", description = "a",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "a" end,
- \\}
- );
- try writeFile(tmp.dir, "ext/beta.lua",
- \\local panto = require("panto")
- \\panto.ext.register_tool {
- \\ name = "clash", description = "b",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "b" end,
- \\}
- );
-
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const n = try tmp.dir.realPathFile(testing.io, "ext", &path_buf);
- const ext_path = try testing.allocator.dupe(u8, path_buf[0..n]);
- defer testing.allocator.free(ext_path);
-
- var rt = try LuaRuntime.create(testing.allocator);
- defer rt.deinit();
-
- const result = loadFromDirs(testing.allocator, testing.io, rt, .{
- .project_extensions = ext_path,
- }, .{});
- try testing.expectError(error.DuplicateTool, result);
-}
-
-test "loadFromDirs: tools/ directory — single-file tool form" {
- var tmp = testing.tmpDir(.{ .iterate = true });
- defer tmp.cleanup();
-
- try makeDir(tmp.dir, "tools");
- try writeFile(tmp.dir, "tools/echo.lua",
+ try makeDir(tmp.dir, "d");
+ // Sugar tool form (return a table with handler).
+ try writeFile(tmp.dir, "d/echo.lua",
\\return {
- \\ name = "echo", description = "Echo back input.",
- \\ schema = { type = "object", properties = { msg = { type = "string" } } },
- \\ handler = function(input) return "echo: " .. input.msg end,
+ \\ name = "echo", description = "e",
+ \\ schema = { type = "object", properties = { m = { type = "string" } } },
+ \\ handler = function(input) return "echo: " .. input.m end,
\\}
);
-
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const n = try tmp.dir.realPathFile(testing.io, "tools", &path_buf);
- const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]);
- defer testing.allocator.free(tools_path);
-
- var rt = try LuaRuntime.create(testing.allocator);
- defer rt.deinit();
-
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .user_tools = tools_path,
- }, .{});
- try testing.expectEqual(@as(usize, 1), n_tools);
-
- var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "echo", .input = "{\"msg\":\"hi\"}" }};
- var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
- try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("echo: hi", xokText(results[0]));
-}
-
-test "loadFromDirs: tools/ directory — directory-style tool with sibling require" {
- var tmp = testing.tmpDir(.{ .iterate = true });
- defer tmp.cleanup();
-
- try makeDir(tmp.dir, "tools/shout");
- try writeFile(tmp.dir, "tools/shout/util.lua",
- \\local M = {}
- \\function M.shout(s) return s:upper() .. "!" end
- \\return M
- );
- try writeFile(tmp.dir, "tools/shout/init.lua",
- \\local util = require("util")
- \\return {
- \\ name = "shout", description = "uppercase + bang",
- \\ schema = { type = "object", properties = { text = { type = "string" } } },
- \\ handler = function(input) return util.shout(input.text) end,
- \\}
+ // Entry form with deferred activate().
+ try writeFile(tmp.dir, "d/greet.lua",
+ \\local panto = require("panto")
+ \\return { name = "greet", activate = function()
+ \\ panto.ext.register_tool {
+ \\ name = "greet", description = "g",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "hi" end,
+ \\ }
+ \\end }
);
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const n = try tmp.dir.realPathFile(testing.io, "tools", &path_buf);
- const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]);
- defer testing.allocator.free(tools_path);
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const d = try realDir(&tmp, "d", &buf);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .project_tools = tools_path,
- }, .{});
- try testing.expectEqual(@as(usize, 1), n_tools);
+ const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = d, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, null);
+ try testing.expectEqual(@as(usize, 2), n);
- var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "shout", .input = "{\"text\":\"hi\"}" }};
- var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
- try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("HI!", xokText(results[0]));
+ const r = try invokeOne(rt, "echo", "{\"m\":\"hi\"}");
+ defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r}));
+ try testing.expectEqualStrings("echo: hi", xokText(r));
}
-test "loadFromDirs: project tool shadows user tool of the same name" {
+test "loadFromDirs: higher precedence shadows same name" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "user_tools");
- try makeDir(tmp.dir, "project_tools");
-
- try writeFile(tmp.dir, "user_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "user version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "USER" end,
- \\}
+ try makeDir(tmp.dir, "u");
+ try makeDir(tmp.dir, "p");
+ try writeFile(tmp.dir, "u/greet.lua",
+ \\return { name = "greet", description = "u", schema = { type = "object" },
+ \\ handler = function(input) return "USER" end }
);
- try writeFile(tmp.dir, "project_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "project version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "PROJECT" end,
- \\}
+ try writeFile(tmp.dir, "p/greet.lua",
+ \\return { name = "greet", description = "p", schema = { type = "object" },
+ \\ handler = function(input) return "PROJECT" end }
);
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf);
- const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]);
- defer testing.allocator.free(user_path);
-
- const proj_len = try tmp.dir.realPathFile(testing.io, "project_tools", &path_buf);
- const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]);
- defer testing.allocator.free(proj_path);
+ var ubuf: [std.fs.max_path_bytes]u8 = undefined;
+ var pbuf: [std.fs.max_path_bytes]u8 = undefined;
+ const u = try realDir(&tmp, "u", &ubuf);
+ const p = try realDir(&tmp, "p", &pbuf);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .user_tools = user_path,
- .project_tools = proj_path,
- }, .{});
- try testing.expectEqual(@as(usize, 1), n_tools);
+ const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = u, .source = .{ .layer = .user, .origin = .dir } },
+ .{ .path = p, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, null);
+ try testing.expectEqual(@as(usize, 1), n);
- var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }};
- var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
- try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("PROJECT", xokText(results[0]));
+ const r = try invokeOne(rt, "greet", "{}");
+ defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r}));
+ try testing.expectEqualStrings("PROJECT", xokText(r));
}
-test "loadFromDirs: project tool shadows user shadows base" {
+test "loadFromDirs: within a layer, dir shadows paths shadows rocks" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "base_tools");
- try makeDir(tmp.dir, "user_tools");
- try makeDir(tmp.dir, "project_tools");
-
- try writeFile(tmp.dir, "base_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "base version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "BASE" end,
- \\}
- );
- try writeFile(tmp.dir, "user_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "user version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "USER" end,
- \\}
- );
- try writeFile(tmp.dir, "project_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "project version",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "PROJECT" end,
- \\}
- );
+ try makeDir(tmp.dir, "rock");
+ try makeDir(tmp.dir, "path");
+ try makeDir(tmp.dir, "dir");
+ inline for (.{ .{ "rock", "ROCK" }, .{ "path", "PATH" }, .{ "dir", "DIR" } }) |row| {
+ try writeFile(tmp.dir, row[0] ++ "/w.lua", "return { name = \"w\", description = \"d\", schema = { type = \"object\" }," ++
+ " handler = function(input) return \"" ++ row[1] ++ "\" end }");
+ }
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const sys_len = try tmp.dir.realPathFile(testing.io, "base_tools", &path_buf);
- const sys_path = try testing.allocator.dupe(u8, path_buf[0..sys_len]);
- defer testing.allocator.free(sys_path);
- const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf);
- const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]);
- defer testing.allocator.free(user_path);
- const proj_len = try tmp.dir.realPathFile(testing.io, "project_tools", &path_buf);
- const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]);
- defer testing.allocator.free(proj_path);
+ var b1: [std.fs.max_path_bytes]u8 = undefined;
+ var b2: [std.fs.max_path_bytes]u8 = undefined;
+ var b3: [std.fs.max_path_bytes]u8 = undefined;
+ const rock = try realDir(&tmp, "rock", &b1);
+ const path = try realDir(&tmp, "path", &b2);
+ const dir = try realDir(&tmp, "dir", &b3);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .base_tools = sys_path,
- .user_tools = user_path,
- .project_tools = proj_path,
- }, .{});
- try testing.expectEqual(@as(usize, 1), n_tools);
+ // All at the same layer; only within-layer origin differs.
+ const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = rock, .source = .{ .layer = .user, .origin = .rocks } },
+ .{ .path = path, .source = .{ .layer = .user, .origin = .paths } },
+ .{ .path = dir, .source = .{ .layer = .user, .origin = .dir } },
+ }, &.{}, null);
+ try testing.expectEqual(@as(usize, 1), n);
- var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }};
- var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
- try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("PROJECT", xokText(results[0]));
+ const r = try invokeOne(rt, "w", "{}");
+ defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r}));
+ try testing.expectEqualStrings("DIR", xokText(r));
}
-test "loadFromDirs: user tool shadows base tool when no project entry" {
+test "loadFromDirs: same-precedence duplicate name is an error" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "base_tools");
- try makeDir(tmp.dir, "user_tools");
-
- try writeFile(tmp.dir, "base_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "base",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "BASE" end,
- \\}
+ try makeDir(tmp.dir, "a");
+ try makeDir(tmp.dir, "b");
+ try writeFile(tmp.dir, "a/x.lua",
+ \\return { name = "dup", description = "a", schema = { type = "object" },
+ \\ handler = function(input) return "a" end }
);
- try writeFile(tmp.dir, "user_tools/greet.lua",
- \\return {
- \\ name = "greet", description = "user",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "USER" end,
- \\}
+ try writeFile(tmp.dir, "b/y.lua",
+ \\return { name = "dup", description = "b", schema = { type = "object" },
+ \\ handler = function(input) return "b" end }
);
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const sys_len = try tmp.dir.realPathFile(testing.io, "base_tools", &path_buf);
- const sys_path = try testing.allocator.dupe(u8, path_buf[0..sys_len]);
- defer testing.allocator.free(sys_path);
- const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf);
- const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]);
- defer testing.allocator.free(user_path);
+ var ab: [std.fs.max_path_bytes]u8 = undefined;
+ var bb: [std.fs.max_path_bytes]u8 = undefined;
+ const a = try realDir(&tmp, "a", &ab);
+ const b = try realDir(&tmp, "b", &bb);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .base_tools = sys_path,
- .user_tools = user_path,
- }, .{});
- try testing.expectEqual(@as(usize, 1), n_tools);
-
- var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }};
- var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
- try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("USER", xokText(results[0]));
+ // Same layer AND same origin → same rank → ambiguous.
+ const result = loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = a, .source = .{ .layer = .project, .origin = .dir } },
+ .{ .path = b, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, null);
+ try testing.expectError(error.DuplicateExtensionName, result);
}
-test "loadFromDirs: extension and tool share a *file* name independently" {
- // The shadow-key is (kind, name) so an extension named `foo` and a
- // tool named `foo` coexist — as long as their *registered* tool names
- // don't collide.
+test "loadFromDirs: tool-name collision across entries errors" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "ext");
- try makeDir(tmp.dir, "tools");
-
- try writeFile(tmp.dir, "ext/foo.lua",
+ try makeDir(tmp.dir, "d");
+ // Two distinct entry names, but both register the same tool name.
+ try writeFile(tmp.dir, "d/a.lua",
\\local panto = require("panto")
- \\panto.ext.register_tool {
- \\ name = "ext_foo", description = "e",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "ext" end,
- \\}
+ \\return { name = "a", activate = function()
+ \\ panto.ext.register_tool { name = "clash", description = "a",
+ \\ schema = { type = "object" }, handler = function() return "a" end }
+ \\end }
);
- try writeFile(tmp.dir, "tools/foo.lua",
- \\return {
- \\ name = "tool_foo", description = "t",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "tool" end,
- \\}
+ try writeFile(tmp.dir, "d/b.lua",
+ \\local panto = require("panto")
+ \\return { name = "b", activate = function()
+ \\ panto.ext.register_tool { name = "clash", description = "b",
+ \\ schema = { type = "object" }, handler = function() return "b" end }
+ \\end }
);
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const e_len = try tmp.dir.realPathFile(testing.io, "ext", &path_buf);
- const ext_path = try testing.allocator.dupe(u8, path_buf[0..e_len]);
- defer testing.allocator.free(ext_path);
-
- const t_len = try tmp.dir.realPathFile(testing.io, "tools", &path_buf);
- const tools_path = try testing.allocator.dupe(u8, path_buf[0..t_len]);
- defer testing.allocator.free(tools_path);
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const d = try realDir(&tmp, "d", &buf);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .user_extensions = ext_path,
- .user_tools = tools_path,
- }, .{});
- try testing.expectEqual(@as(usize, 2), n_tools);
+ const result = loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = d, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, null);
+ try testing.expectError(error.DuplicateTool, result);
}
-test "loadFromDirs: tools policy removes a denied registered tool name" {
+test "loadFromDirs: policy denies a name — its activate() never runs" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "tools");
- try writeFile(tmp.dir, "tools/reader.lua",
- \\return {
- \\ name = "std.read", description = "r",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "READ" end,
- \\}
+ try makeDir(tmp.dir, "d");
+ // activate() would crash if it ran; the deny must prevent activation.
+ // eval (the top-level return) is always safe.
+ try writeFile(tmp.dir, "d/danger.lua",
+ \\return { name = "danger", activate = function()
+ \\ error("activate must not run")
+ \\end }
);
- try writeFile(tmp.dir, "tools/sheller.lua",
- \\return {
- \\ name = "std.shell", description = "s",
- \\ schema = { type = "object" },
- \\ handler = function(input) return "SHELL" end,
- \\}
+ try writeFile(tmp.dir, "d/ok.lua",
+ \\return { name = "ok", description = "o", schema = { type = "object" },
+ \\ handler = function(input) return "ok" end }
);
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const n = try tmp.dir.realPathFile(testing.io, "tools", &path_buf);
- const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]);
- defer testing.allocator.free(tools_path);
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const d = try realDir(&tmp, "d", &buf);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- var deny = try testing.allocator.alloc([]const u8, 1);
- deny[0] = try testing.allocator.dupe(u8, "std.shell");
- const tools_policy: config_file.Policy = .{ .allow = &.{}, .deny = deny };
- defer tools_policy.deinit(testing.allocator);
-
- // Both tools register, but std.shell is filtered out post-load.
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .user_tools = tools_path,
- }, .{ .tools = &tools_policy });
- try testing.expectEqual(@as(usize, 1), n_tools);
+ var rules = try testing.allocator.alloc(config_file.Rule, 1);
+ rules[0] = .{ .pattern = try testing.allocator.dupe(u8, "danger"), .verdict = .deny, .layer = 0, .spec = @import("glob.zig").specificity("danger") };
+ const policy: config_file.Policy = .{ .rules = rules };
+ defer policy.deinit(testing.allocator);
- var src = rt.toolSource();
- const calls = [_]panto.ToolCall{.{ .tool_name = "std.read", .input = "{}" }};
- var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
- try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
- defer xfreeResults(&results);
- try testing.expectEqualStrings("READ", xokText(results[0]));
+ const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = d, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, &policy);
+ // Only "ok" activates; "danger" is denied (its activate never runs).
+ try testing.expectEqual(@as(usize, 1), n);
}
-test "loadFromDirs: extensions policy denies a whole entry before it loads" {
+test "loadFromDirs: a source returning a list registers multiple entries" {
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
- try makeDir(tmp.dir, "tools");
- // This tool would crash if its script ran (calls a nil global). The
- // extensions policy must stop it from loading at all.
- try writeFile(tmp.dir, "danger.lua",
- \\error("this script must never run")
- );
- try makeDir(tmp.dir, "td");
- try writeFile(tmp.dir, "td/danger.lua",
- \\error("this script must never run")
+ try makeDir(tmp.dir, "d");
+ try writeFile(tmp.dir, "d/multi.lua",
+ \\local panto = require("panto")
+ \\local function tool(nm)
+ \\ return { name = nm, activate = function()
+ \\ panto.ext.register_tool { name = nm, description = nm,
+ \\ schema = { type = "object" }, handler = function() return nm end }
+ \\ end }
+ \\end
+ \\return { tool("agent.skills"), tool("agent.rules") }
);
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const n = try tmp.dir.realPathFile(testing.io, "td", &path_buf);
- const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]);
- defer testing.allocator.free(tools_path);
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const d = try realDir(&tmp, "d", &buf);
var rt = try LuaRuntime.create(testing.allocator);
defer rt.deinit();
- var deny = try testing.allocator.alloc([]const u8, 1);
- deny[0] = try testing.allocator.dupe(u8, "danger");
- const ext_policy: config_file.Policy = .{ .allow = &.{}, .deny = deny };
- defer ext_policy.deinit(testing.allocator);
+ // Deny one of the two namespaces; the other still activates.
+ var rules = try testing.allocator.alloc(config_file.Rule, 1);
+ rules[0] = .{ .pattern = try testing.allocator.dupe(u8, "agent.rules"), .verdict = .deny, .layer = 0, .spec = @import("glob.zig").specificity("agent.rules") };
+ const policy: config_file.Policy = .{ .rules = rules };
+ defer policy.deinit(testing.allocator);
+
+ const n = try loadFromDirs(testing.allocator, testing.io, rt, &.{
+ .{ .path = d, .source = .{ .layer = .project, .origin = .dir } },
+ }, &.{}, &policy);
+ try testing.expectEqual(@as(usize, 1), n);
- const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
- .user_tools = tools_path,
- }, .{ .extensions = &ext_policy });
- try testing.expectEqual(@as(usize, 0), n_tools);
+ const r = try invokeOne(rt, "agent.skills", "{}");
+ defer xfreeResults(@constCast(&[_]panto.ToolCallResult{r}));
+ try testing.expectEqualStrings("agent.skills", xokText(r));
}
+