From 77df2a30a769762bad127b5e0c601c95054475e6 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 17 May 2026 17:44:00 -0700 Subject: [PATCH] add cli command framework --- src/commands/framework.zig | 373 +++++++++++++++++++++++++++++++++++++ src/comptime_validator.zig | 201 ++++++++++++++++++++ src/main.zig | 7 + src/tui/tab_framework.zig | 194 +++++-------------- 4 files changed, 624 insertions(+), 151 deletions(-) create mode 100644 src/commands/framework.zig create mode 100644 src/comptime_validator.zig diff --git a/src/commands/framework.zig b/src/commands/framework.zig new file mode 100644 index 0000000..03153c5 --- /dev/null +++ b/src/commands/framework.zig @@ -0,0 +1,373 @@ +//! CLI command framework — comptime-validated registry for `zfin` subcommands. +//! +//! The CLI dispatch in `src/main.zig` walks a `command_modules` registry — +//! an anonymous struct literal — at comptime to derive the dispatch chain, +//! the grouped `zfin help` output, and the per-command `zfin --help` +//! handler. This file defines the contract every command module must +//! satisfy. Mirrors the TUI tab framework in `src/tui/tab_framework.zig`. +//! +//! ## Contract (per command module) +//! +//! ```zig +//! pub const ParsedArgs = ...; // struct or tagged union +//! +//! pub const meta = struct { +//! pub const name: []const u8 = "perf"; +//! pub const group: framework.Group = .symbol_lookup; +//! pub const synopsis: []const u8 = "Show 1y/3y/5y/10y trailing returns"; +//! pub const help: []const u8 = ...; // multi-line; rendered by `--help` +//! }; +//! +//! pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs; +//! pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void; +//! ``` +//! +//! The `validateCommandModule(Module)` walker enforces the contract at +//! comptime with copy-paste-ready error messages. +//! +//! ## Global-option promotion criterion +//! +//! A flag belongs as a global iff it is *orthogonal* to every command's +//! semantics (purely an I/O / output / cache-policy concern) AND every +//! command would honor it identically. Today's globals are +//! `--no-color`, `-p`, `-w` (and `--refresh` / `--no-refresh` once +//! commit 18c lands). `--as-of` does NOT pass this test because its +//! meaning varies between view-as-of (`projections`) and write-as-of +//! (`snapshot`), and the two-sided commands need two endpoints, not +//! one — so it stays per-command. + +const std = @import("std"); +const zfin = @import("../root.zig"); +const validator = @import("../comptime_validator.zig"); + +// ── Group taxonomy ──────────────────────────────────────────── + +/// Workflow-grouping tag used for `zfin help`'s sectioned output. +/// Every command's `meta.group` field references one of these. +/// Order in this enum is the order sections render in `zfin help`. +pub const Group = enum { + /// Per-symbol lookups: `perf`, `quote`, `history` (symbol mode), + /// `divs`, `splits`, `options`, `earnings`, `etf`. + symbol_lookup, + /// Portfolio analysis: `portfolio`, `analysis`, `history` + /// (portfolio mode), `projections`, `milestones`. + portfolio, + /// Time-series & journaling: `snapshot`, `compare`, + /// `contributions`. + timeseries, + /// Data hygiene: `audit`, `enrich`, `lookup`. + hygiene, + /// Infrastructure: `cache`, `version`, `interactive`. + infra, + + /// Display label rendered above each section in `zfin help`. + pub fn label(self: Group) []const u8 { + return switch (self) { + .symbol_lookup => "Per-symbol lookups", + .portfolio => "Portfolio analysis", + .timeseries => "Time-series & journaling", + .hygiene => "Data hygiene", + .infra => "Infrastructure", + }; + } +}; + +// ── Refresh policy ──────────────────────────────────────────── + +/// Cache-freshness policy for the invocation. Defined here ahead of +/// commit 18c so command modules can take a dependency on the type +/// without churn later. Threading into `loadPortfolioPrices` and the +/// per-symbol getters happens in 18c. +/// +/// - `default`: respect cache TTLs. Fresh entries served from cache; +/// stale entries trigger a provider refetch (or server sync if +/// `ZFIN_SERVER` is configured). The right behavior for almost +/// every invocation. +/// - `force`: invalidate cache before reading. Equivalent to today's +/// `portfolio --refresh` flag, generalized to all commands. +/// - `never`: serve whatever's in cache regardless of TTL. No +/// provider calls. Useful for offline operation, debugging, and +/// reproducible historical analysis. +pub const RefreshPolicy = enum { default, force, never }; + +// ── Globals ─────────────────────────────────────────────────── + +/// Parsed global options. Shared between `parseGlobals` (in main.zig) +/// and `RunCtx`. Lives here so `command_modules` entries can reach +/// fields like `globals.portfolio_path` without depending on main.zig. +pub const Globals = struct { + no_color: bool = false, + /// Explicit portfolio path from -p/--portfolio (raw, null if not set). + portfolio_path: ?[]const u8 = null, + /// Explicit watchlist path from -w/--watchlist (raw, null if not set). + watchlist_path: ?[]const u8 = null, + /// Cache-freshness policy. Wired in commit 18c; default for now. + refresh_policy: RefreshPolicy = .default, +}; + +// ── RunCtx ──────────────────────────────────────────────────── + +/// Per-invocation context bundle. Every command's `parseArgs` and +/// `run` receive this. Commands ignore fields they don't need. +/// +/// Lifetime: created in `runCli` after globals + arena setup, freed +/// implicitly when the invocation ends. `allocator` IS the arena; +/// commands should allocate against it freely without tracking +/// per-allocation deinits. +/// +/// Fields here are *invocation context*, not per-command state. +/// Per-command state belongs in the command's own module. If you're +/// tempted to add a field used by only one command, push back — +/// it probably belongs in the command's `ParsedArgs` instead. +pub const RunCtx = struct { + io: std.Io, + /// Per-invocation arena. Use this for almost all allocations. + allocator: std.mem.Allocator, + /// General-purpose allocator. Reserved for handoff to the TUI + /// (which has different lifetime requirements than CLI batch + /// commands). Most commands should ignore this. + gpa: std.mem.Allocator, + /// Process environment. Used by `interactive` to thread into + /// the TUI's Config refresh, and by anything that needs to + /// inspect env vars at command time. + environ_map: *const std.process.EnvMap, + config: zfin.Config, + /// Null for commands that don't need provider access (`version`, + /// `cache`). All other commands must error gracefully if they + /// receive a null here. + svc: ?*zfin.DataService, + globals: Globals, + /// Today's date, captured once at invocation entry. Stable + /// across all uses within this invocation per the AGENTS.md + /// `today` vs `as_of` rule. + today: zfin.Date, + /// Wall-clock seconds since epoch, captured once at invocation + /// entry. For metadata fields like snapshot `captured_at` and + /// rollup `#!created=`. + now_s: i64, + color: bool, + out: *std.Io.Writer, + + /// Resolve the portfolio path argument (from `-p`/`--portfolio` + /// or the default `portfolio.srf` filename) through cwd → ZFIN_HOME. + /// Returns the path the caller should open. Caller must free + /// `resolved.deinit(allocator)` if non-null. Allocations come + /// from the arena. + pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath { + return resolveUserPath( + self.io, + self.allocator, + self.config, + self.globals.portfolio_path, + zfin.Config.default_portfolio_filename, + ); + } + + /// Same as `resolvePortfolioPath` but for the watchlist file. + pub fn resolveWatchlistPath(self: *RunCtx) ResolvedPath { + return resolveUserPath( + self.io, + self.allocator, + self.config, + self.globals.watchlist_path, + zfin.Config.default_watchlist_filename, + ); + } +}; + +pub const ResolvedPath = struct { + path: []const u8, + resolved: ?zfin.Config.ResolvedPath, + + pub fn deinit(self: ResolvedPath, allocator: std.mem.Allocator) void { + if (self.resolved) |r| r.deinit(allocator); + } +}; + +fn resolveUserPath( + io: std.Io, + allocator: std.mem.Allocator, + config: zfin.Config, + explicit: ?[]const u8, + default_name: []const u8, +) ResolvedPath { + if (explicit) |p| { + if (config.resolveUserFile(io, allocator, p)) |r| { + return .{ .path = r.path, .resolved = r }; + } + return .{ .path = p, .resolved = null }; + } + if (config.resolveUserFile(io, allocator, default_name)) |r| { + return .{ .path = r.path, .resolved = r }; + } + return .{ .path = default_name, .resolved = null }; +} + +// ── Comptime contract validation ────────────────────────────── + +/// Validate a command module against the framework contract. Emits +/// a `@compileError` with the full expected signature for any +/// missing or wrong-shape decl. Call from the `command_modules` +/// registry walker in `src/main.zig`. +pub fn validateCommandModule(comptime Module: type) void { + comptime { + const mod_name = @typeName(Module); + + // ── Top-level decls ──────────────────────────────────── + if (!@hasDecl(Module, "ParsedArgs")) { + @compileError("Command module `" ++ mod_name ++ "` is missing `pub const ParsedArgs = ...;` " ++ + "(use `pub const ParsedArgs = struct {};` if the command takes no arguments)"); + } + if (!@hasDecl(Module, "meta")) { + @compileError("Command module `" ++ mod_name ++ "` is missing `pub const meta = struct { ... };` " ++ + "(see src/commands/framework.zig for the full contract)"); + } + + const meta_decl = Module.meta; + const ParsedArgs = Module.ParsedArgs; + + // ── meta fields ──────────────────────────────────────── + validator.expectDeclWithType( + "Command module", + mod_name, + meta_decl, + "name", + []const u8, + "pub const name: []const u8 = \"...\";", + ); + validator.expectDeclWithType( + "Command module", + mod_name, + meta_decl, + "group", + Group, + "pub const group: framework.Group = .symbol_lookup;", + ); + validator.expectDeclWithType( + "Command module", + mod_name, + meta_decl, + "synopsis", + []const u8, + "pub const synopsis: []const u8 = \"one-line description for `zfin help`\";", + ); + validator.expectDeclWithType( + "Command module", + mod_name, + meta_decl, + "help", + []const u8, + "pub const help: []const u8 = \"multi-line text for `zfin --help`\";", + ); + + // ── parseArgs ────────────────────────────────────────── + validator.expectFnInferredError( + "Command module", + mod_name, + Module, + "parseArgs", + &.{ *RunCtx, []const []const u8 }, + ParsedArgs, + "pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs", + ); + + // ── run ──────────────────────────────────────────────── + validator.expectFnInferredError( + "Command module", + mod_name, + Module, + "run", + &.{ *RunCtx, ParsedArgs }, + void, + "pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void", + ); + } +} + +// ── Help rendering ──────────────────────────────────────────── + +/// Print a single command's help text. Called by main.zig when the +/// user invokes `zfin --help` or `zfin -h`. The help +/// text comes from the module's `meta.help` field verbatim — no +/// post-processing, so multi-paragraph caveats render exactly as +/// authored. +pub fn printCommandHelp(out: *std.Io.Writer, comptime Module: type) !void { + try out.writeAll(Module.meta.help); + if (Module.meta.help.len == 0 or Module.meta.help[Module.meta.help.len - 1] != '\n') { + try out.writeAll("\n"); + } +} + +// ── Tests ───────────────────────────────────────────────────── + +const testing = std.testing; + +// Probe module: a minimal stub satisfying the command contract. +// Used by the validator's happy-path test below. +const ProbeModule = struct { + pub const ParsedArgs = struct {}; + + pub const meta = struct { + pub const name: []const u8 = "probe"; + pub const group: Group = .infra; + pub const synopsis: []const u8 = "test probe"; + pub const help: []const u8 = "Usage: zfin probe\n"; + }; + + pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { + return .{}; + } + + pub fn run(_: *RunCtx, _: ParsedArgs) !void {} +}; + +test "validateCommandModule: probe module passes" { + comptime validateCommandModule(ProbeModule); +} + +test "Group.label: every variant has a non-empty label" { + inline for (std.meta.fields(Group)) |f| { + const g: Group = @enumFromInt(f.value); + try testing.expect(g.label().len > 0); + } +} + +test "RefreshPolicy: default variant exists" { + const policy: RefreshPolicy = .default; + try testing.expectEqual(RefreshPolicy.default, policy); +} + +test "Globals: zero-init defaults" { + const g: Globals = .{}; + try testing.expect(!g.no_color); + try testing.expect(g.portfolio_path == null); + try testing.expect(g.watchlist_path == null); + try testing.expectEqual(RefreshPolicy.default, g.refresh_policy); +} + +test "printCommandHelp: writes meta.help and ensures trailing newline" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try printCommandHelp(&w, ProbeModule); + try testing.expectEqualStrings("Usage: zfin probe\n", w.buffered()); +} + +test "printCommandHelp: appends newline when meta.help lacks one" { + const NoNewline = struct { + pub const ParsedArgs = struct {}; + pub const meta = struct { + pub const name: []const u8 = "x"; + pub const group: Group = .infra; + pub const synopsis: []const u8 = "x"; + pub const help: []const u8 = "no trailing newline"; + }; + pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { + return .{}; + } + pub fn run(_: *RunCtx, _: ParsedArgs) !void {} + }; + var buf: [64]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try printCommandHelp(&w, NoNewline); + try testing.expectEqualStrings("no trailing newline\n", w.buffered()); +} diff --git a/src/comptime_validator.zig b/src/comptime_validator.zig new file mode 100644 index 0000000..eec2ea9 --- /dev/null +++ b/src/comptime_validator.zig @@ -0,0 +1,201 @@ +//! Shared comptime contract-validation helpers. +//! +//! Both the TUI tab framework (`src/tui/tab_framework.zig`) and the CLI +//! command framework (`src/commands/framework.zig`) walk a candidate +//! module at comptime to assert it conforms to the framework contract. +//! The check shapes are identical: +//! +//! - `expectDeclWithType` — a non-fn decl exists with the exact type. +//! - `expectFn` — a fn decl exists with the exact fn-pointer type. +//! - `expectFnInferredError` — a fn decl exists, params match, and +//! the return is an error union ending in the expected payload +//! (the error set itself is not checked because Zig infers an +//! empty error set per fn for `pub fn foo() !void`). +//! +//! Each helper takes a caller-supplied `kind` prefix ("Tab module" / +//! "Command module") so the resulting compile errors point at the right +//! framework. Error messages include the offending decl name and a +//! verbatim `expected_signature` so the developer can copy-paste the fix. + +const std = @import("std"); + +/// Assert a decl exists on `Container` with the exact expected type. +/// `kind` is a short prefix shown in error messages — typically +/// `"Tab module"` or `"Command module"`. +pub fn expectDeclWithType( + comptime kind: []const u8, + comptime mod_name: []const u8, + comptime Container: type, + comptime decl_name: []const u8, + comptime ExpectedT: type, + comptime expected_signature: []const u8, +) void { + comptime { + if (!@hasDecl(Container, decl_name)) { + @compileError(kind ++ " `" ++ mod_name ++ "` is missing `" ++ decl_name ++ + "`. Expected:\n " ++ expected_signature); + } + const ActualT = @TypeOf(@field(Container, decl_name)); + if (ActualT != ExpectedT) { + @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ + "` has wrong type.\n Expected: " ++ @typeName(ExpectedT) ++ + "\n Got: " ++ @typeName(ActualT) ++ + "\n Expected signature:\n " ++ expected_signature); + } + } +} + +/// Assert a function decl exists on `Container` with the exact fn-pointer +/// type. Use this for non-fallible functions (`fn (...) T`) or for fns +/// that explicitly use `anyerror`. For fns whose error set is inferred +/// (the common `pub fn foo() !void` shape), use `expectFnInferredError`. +pub fn expectFn( + comptime kind: []const u8, + comptime mod_name: []const u8, + comptime Container: type, + comptime decl_name: []const u8, + comptime ExpectedT: type, + comptime expected_signature: []const u8, +) void { + comptime { + if (!@hasDecl(Container, decl_name)) { + @compileError(kind ++ " `" ++ mod_name ++ "` is missing `" ++ decl_name ++ + "`. Expected:\n " ++ expected_signature); + } + const ActualT = @TypeOf(@field(Container, decl_name)); + if (ActualT != ExpectedT) { + @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ + "` has wrong signature.\n Expected: " ++ @typeName(ExpectedT) ++ + "\n Got: " ++ @typeName(ActualT) ++ + "\n Full expected declaration:\n " ++ expected_signature); + } + } +} + +/// Assert a fallible function decl exists on `Container` whose +/// parameter types match `expected_params` and whose return is an +/// error union ending in `ExpectedReturn`. The error set itself is +/// NOT checked — Zig infers a per-fn empty error set for `pub fn foo() +/// !void` whose name varies per call site, so exact-equality fails. +/// This loosens equality to "params match, return is `!Return`". +pub fn expectFnInferredError( + comptime kind: []const u8, + comptime mod_name: []const u8, + comptime Container: type, + comptime decl_name: []const u8, + comptime expected_params: []const type, + comptime ExpectedReturn: type, + comptime expected_signature: []const u8, +) void { + comptime { + if (!@hasDecl(Container, decl_name)) { + @compileError(kind ++ " `" ++ mod_name ++ "` is missing `" ++ decl_name ++ + "`. Expected:\n " ++ expected_signature); + } + const ActualT = @TypeOf(@field(Container, decl_name)); + const info = @typeInfo(ActualT); + if (info != .@"fn") { + @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ + "` must be a function, got " ++ @typeName(ActualT) ++ + "\n Full expected declaration:\n " ++ expected_signature); + } + const fn_info = info.@"fn"; + + // Verify params count + types. + if (fn_info.params.len != expected_params.len) { + @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ + "` has wrong arity.\n Got: " ++ @typeName(ActualT) ++ + "\n Full expected declaration:\n " ++ expected_signature); + } + for (expected_params, 0..) |Expected, i| { + const ActualParam = fn_info.params[i].type orelse { + @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ + "` parameter " ++ std.fmt.comptimePrint("{d}", .{i}) ++ + " is `anytype`, but the contract requires `" ++ @typeName(Expected) ++ "`.\n" ++ + " Full expected declaration:\n " ++ expected_signature); + }; + if (ActualParam != Expected) { + @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ + "` parameter " ++ std.fmt.comptimePrint("{d}", .{i}) ++ + " has wrong type.\n Expected: " ++ @typeName(Expected) ++ + "\n Got: " ++ @typeName(ActualParam) ++ + "\n Full expected declaration:\n " ++ expected_signature); + } + } + + // Verify return is an error union ending in ExpectedReturn. + const ActualReturn = fn_info.return_type orelse { + @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ + "` has no return type.\n Full expected declaration:\n " ++ expected_signature); + }; + const ret_info = @typeInfo(ActualReturn); + if (ret_info != .error_union) { + @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ + "` must return an error union (`!" ++ @typeName(ExpectedReturn) ++ + "`), got " ++ @typeName(ActualReturn) ++ + "\n Full expected declaration:\n " ++ expected_signature); + } + if (ret_info.error_union.payload != ExpectedReturn) { + @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ + "` returns wrong payload type.\n Expected: !" ++ @typeName(ExpectedReturn) ++ + "\n Got: " ++ @typeName(ActualReturn) ++ + "\n Full expected declaration:\n " ++ expected_signature); + } + } +} + +// ── Tests ───────────────────────────────────────────────────── +// +// The validators themselves are comptime-only — runtime tests can +// only exercise the happy path (a decl with the right shape passes +// silently). The error paths are covered by the fact that every +// existing tab module compiles, so any breakage to these helpers +// surfaces immediately at `zig build`. + +const testing = std.testing; + +test "expectDeclWithType: matching type passes" { + const M = struct { + pub const label: []const u8 = "ok"; + }; + comptime expectDeclWithType("Test", "M", M, "label", []const u8, "pub const label: []const u8 = \"...\";"); +} + +test "expectFn: matching fn signature passes" { + const M = struct { + pub fn handler(_: u32) void {} + }; + comptime expectFn("Test", "M", M, "handler", fn (u32) void, "pub fn handler(x: u32) void"); +} + +test "expectFnInferredError: !void function with correct params passes" { + const M = struct { + pub fn doit(_: u32, _: []const u8) !void {} + }; + comptime expectFnInferredError( + "Test", + "M", + M, + "doit", + &.{ u32, []const u8 }, + void, + "pub fn doit(x: u32, s: []const u8) !void", + ); +} + +test "expectFnInferredError: !T payload check works for non-void" { + const M = struct { + pub fn doit(_: u32) !u64 { + return 0; + } + }; + comptime expectFnInferredError( + "Test", + "M", + M, + "doit", + &.{u32}, + u64, + "pub fn doit(x: u32) !u64", + ); +} diff --git a/src/main.zig b/src/main.zig index a69cba6..f63667d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -909,4 +909,11 @@ test "parseGlobals: subcommand-local flag NOT consumed as global" { test { std.testing.refAllDecls(@This()); + // TEMPORARY: force test discovery for the command framework + // module. Nothing in main.zig sema-touches framework.zig yet, so + // its `test` blocks would otherwise be skipped. Remove this line + // once `runCli` references the framework directly (via the + // comptime command-registry walk that replaces the legacy + // if-else dispatch chain). + _ = @import("commands/framework.zig"); } diff --git a/src/tui/tab_framework.zig b/src/tui/tab_framework.zig index 1750c38..4e72221 100644 --- a/src/tui/tab_framework.zig +++ b/src/tui/tab_framework.zig @@ -114,6 +114,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const StyledLine = @import("../tui.zig").StyledLine; +const validator = @import("../comptime_validator.zig"); /// Re-exported KeyCombo so tab modules don't need to import /// keybinds.zig directly for binding declarations. This is the @@ -268,28 +269,32 @@ pub fn validateTabModule(comptime Module: type) void { } // ── Binding / label / hint constants ─────────────────── - expectDeclWithType( + validator.expectDeclWithType( + "Tab module", mod_name, tab_decl, "label", []const u8, "pub const label: []const u8 = \"...\";", ); - expectDeclWithType( + validator.expectDeclWithType( + "Tab module", mod_name, tab_decl, "default_bindings", []const TabBinding(Action), "pub const default_bindings: []const TabBinding(Action) = &.{ ... };", ); - expectDeclWithType( + validator.expectDeclWithType( + "Tab module", mod_name, tab_decl, "action_labels", std.enums.EnumArray(Action, []const u8), "pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ ... });", ); - expectDeclWithType( + validator.expectDeclWithType( + "Tab module", mod_name, tab_decl, "status_hints", @@ -298,7 +303,8 @@ pub fn validateTabModule(comptime Module: type) void { ); // ── Lifecycle hooks (required) ───────────────────────── - expectFnInferredError( + validator.expectFnInferredError( + "Tab module", mod_name, tab_decl, "init", @@ -306,14 +312,16 @@ pub fn validateTabModule(comptime Module: type) void { void, "pub fn init(state: *State, app: *App) !void { ... }", ); - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "deinit", fn (*State, *App) void, "pub fn deinit(state: *State, app: *App) void { ... }", ); - expectFnInferredError( + validator.expectFnInferredError( + "Tab module", mod_name, tab_decl, "activate", @@ -321,14 +329,16 @@ pub fn validateTabModule(comptime Module: type) void { void, "pub fn activate(state: *State, app: *App) !void { ... }", ); - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "deactivate", fn (*State, *App) void, "pub fn deactivate(state: *State, app: *App) void { ... }", ); - expectFnInferredError( + validator.expectFnInferredError( + "Tab module", mod_name, tab_decl, "reload", @@ -336,7 +346,8 @@ pub fn validateTabModule(comptime Module: type) void { void, "pub fn reload(state: *State, app: *App) !void { ... }", ); - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "tick", @@ -345,7 +356,8 @@ pub fn validateTabModule(comptime Module: type) void { ); // ── Action dispatch (required) ───────────────────────── - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "handleAction", @@ -354,7 +366,8 @@ pub fn validateTabModule(comptime Module: type) void { ); // ── Misc (required) ──────────────────────────────────── - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "isDisabled", @@ -367,7 +380,8 @@ pub fn validateTabModule(comptime Module: type) void { // surface that as a hard error so a typo'd signature // doesn't silently get skipped at dispatch time. if (@hasDecl(tab_decl, "handleKey")) { - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "handleKey", @@ -376,7 +390,8 @@ pub fn validateTabModule(comptime Module: type) void { ); } if (@hasDecl(tab_decl, "handleMouse")) { - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "handleMouse", @@ -385,7 +400,8 @@ pub fn validateTabModule(comptime Module: type) void { ); } if (@hasDecl(tab_decl, "handlePaste")) { - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "handlePaste", @@ -394,7 +410,8 @@ pub fn validateTabModule(comptime Module: type) void { ); } if (@hasDecl(tab_decl, "statusOverride")) { - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "statusOverride", @@ -430,7 +447,8 @@ pub fn validateTabModule(comptime Module: type) void { "`drawContent`. Only one is allowed — pick the right one for your tab's render shape."); } if (has_build) { - expectFnInferredError( + validator.expectFnInferredError( + "Tab module", mod_name, Module, "buildStyledLines", @@ -440,7 +458,8 @@ pub fn validateTabModule(comptime Module: type) void { ); } if (has_draw) { - expectFnInferredError( + validator.expectFnInferredError( + "Tab module", mod_name, Module, "drawContent", @@ -452,7 +471,8 @@ pub fn validateTabModule(comptime Module: type) void { // ── Context-change hooks (optional, typed when present) ── if (@hasDecl(tab_decl, "onSymbolChange")) { - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "onSymbolChange", @@ -461,7 +481,8 @@ pub fn validateTabModule(comptime Module: type) void { ); } if (@hasDecl(tab_decl, "onScroll")) { - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "onScroll", @@ -470,7 +491,8 @@ pub fn validateTabModule(comptime Module: type) void { ); } if (@hasDecl(tab_decl, "onCursorMove")) { - expectFn( + validator.expectFn( + "Tab module", mod_name, tab_decl, "onCursorMove", @@ -481,136 +503,6 @@ pub fn validateTabModule(comptime Module: type) void { } } -/// Internal helper: assert a `tab` decl exists and has the -/// expected type. The `expected_signature` string is shown -/// verbatim in the error message so the developer can copy-paste -/// the fix. -fn expectDeclWithType( - comptime mod_name: []const u8, - comptime tab_decl: type, - comptime decl_name: []const u8, - comptime ExpectedT: type, - comptime expected_signature: []const u8, -) void { - comptime { - if (!@hasDecl(tab_decl, decl_name)) { - @compileError("Tab module `" ++ mod_name ++ "`: `tab` is missing `" ++ decl_name ++ - "`. Expected:\n " ++ expected_signature); - } - const ActualT = @TypeOf(@field(tab_decl, decl_name)); - if (ActualT != ExpectedT) { - @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ - "` has wrong type.\n Expected: " ++ @typeName(ExpectedT) ++ - "\n Got: " ++ @typeName(ActualT) ++ - "\n Expected signature:\n " ++ expected_signature); - } - } -} - -/// Internal helper: assert a `tab` function decl exists and has -/// the expected fn-pointer type. The `expected_signature` string -/// is shown verbatim in the error message. -/// -/// For functions returning `!void` (or any error union with an -/// inferred error set), pass `expectFnInferredError` instead — -/// this helper requires exact type equality, which means the -/// expected type must use `anyerror` and the actual function must -/// also use `anyerror`. In practice that's not what `pub fn foo() -/// !void` produces (Zig infers an empty error set), so for -/// fallible-but-error-set-inferred hooks we need the looser check. -fn expectFn( - comptime mod_name: []const u8, - comptime tab_decl: type, - comptime decl_name: []const u8, - comptime ExpectedT: type, - comptime expected_signature: []const u8, -) void { - comptime { - if (!@hasDecl(tab_decl, decl_name)) { - @compileError("Tab module `" ++ mod_name ++ "`: `tab` is missing `" ++ decl_name ++ - "`. Expected:\n " ++ expected_signature); - } - const ActualT = @TypeOf(@field(tab_decl, decl_name)); - if (ActualT != ExpectedT) { - @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ - "` has wrong signature.\n Expected: " ++ @typeName(ExpectedT) ++ - "\n Got: " ++ @typeName(ActualT) ++ - "\n Full expected declaration:\n " ++ expected_signature); - } - } -} - -/// Internal helper: assert a `tab` fallible function decl exists -/// and matches the expected parameter types + returns an error -/// union ending in `Return`. The error set itself is not checked, -/// because `pub fn foo() !void` produces an inferred (often empty) -/// error set whose name varies per function. This loosens equality -/// to "params match, return is `!Return`." -fn expectFnInferredError( - comptime mod_name: []const u8, - comptime tab_decl: type, - comptime decl_name: []const u8, - comptime expected_params: []const type, - comptime ExpectedReturn: type, - comptime expected_signature: []const u8, -) void { - comptime { - if (!@hasDecl(tab_decl, decl_name)) { - @compileError("Tab module `" ++ mod_name ++ "`: `tab` is missing `" ++ decl_name ++ - "`. Expected:\n " ++ expected_signature); - } - const ActualT = @TypeOf(@field(tab_decl, decl_name)); - const info = @typeInfo(ActualT); - if (info != .@"fn") { - @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ - "` must be a function, got " ++ @typeName(ActualT) ++ - "\n Full expected declaration:\n " ++ expected_signature); - } - const fn_info = info.@"fn"; - - // Verify params count + types. - if (fn_info.params.len != expected_params.len) { - @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ - "` has wrong arity.\n Got: " ++ @typeName(ActualT) ++ - "\n Full expected declaration:\n " ++ expected_signature); - } - for (expected_params, 0..) |Expected, i| { - const ActualParam = fn_info.params[i].type orelse { - @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ - "` parameter " ++ std.fmt.comptimePrint("{d}", .{i}) ++ - " is `anytype`, but the contract requires `" ++ @typeName(Expected) ++ "`.\n" ++ - " Full expected declaration:\n " ++ expected_signature); - }; - if (ActualParam != Expected) { - @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ - "` parameter " ++ std.fmt.comptimePrint("{d}", .{i}) ++ - " has wrong type.\n Expected: " ++ @typeName(Expected) ++ - "\n Got: " ++ @typeName(ActualParam) ++ - "\n Full expected declaration:\n " ++ expected_signature); - } - } - - // Verify return is an error union ending in ExpectedReturn. - const ActualReturn = fn_info.return_type orelse { - @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ - "` has no return type.\n Full expected declaration:\n " ++ expected_signature); - }; - const ret_info = @typeInfo(ActualReturn); - if (ret_info != .error_union) { - @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ - "` must return an error union (`!" ++ @typeName(ExpectedReturn) ++ - "`), got " ++ @typeName(ActualReturn) ++ - "\n Full expected declaration:\n " ++ expected_signature); - } - if (ret_info.error_union.payload != ExpectedReturn) { - @compileError("Tab module `" ++ mod_name ++ "`: `tab." ++ decl_name ++ - "` returns wrong payload type.\n Expected: !" ++ @typeName(ExpectedReturn) ++ - "\n Got: " ++ @typeName(ActualReturn) ++ - "\n Full expected declaration:\n " ++ expected_signature); - } - } -} - // ── Tests ───────────────────────────────────────────────────── const testing = std.testing;