add cli command framework

This commit is contained in:
Emil Lerch 2026-05-17 17:44:00 -07:00
parent 39ebf23775
commit 77df2a30a7
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 624 additions and 151 deletions

373
src/commands/framework.zig Normal file
View file

@ -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 <cmd> --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 <cmd> --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 <cmd> --help` or `zfin <cmd> -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());
}

201
src/comptime_validator.zig Normal file
View file

@ -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 `<some_err>!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",
);
}

View file

@ -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");
}

View file

@ -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 `<some_error_set>!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;