add cli command framework
This commit is contained in:
parent
39ebf23775
commit
77df2a30a7
4 changed files with 624 additions and 151 deletions
373
src/commands/framework.zig
Normal file
373
src/commands/framework.zig
Normal 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
201
src/comptime_validator.zig
Normal 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",
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue