add cli command framework
This commit is contained in:
parent
5667492461
commit
5d63e89607
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 {
|
test {
|
||||||
std.testing.refAllDecls(@This());
|
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 std = @import("std");
|
||||||
const vaxis = @import("vaxis");
|
const vaxis = @import("vaxis");
|
||||||
const StyledLine = @import("../tui.zig").StyledLine;
|
const StyledLine = @import("../tui.zig").StyledLine;
|
||||||
|
const validator = @import("../comptime_validator.zig");
|
||||||
|
|
||||||
/// Re-exported KeyCombo so tab modules don't need to import
|
/// Re-exported KeyCombo so tab modules don't need to import
|
||||||
/// keybinds.zig directly for binding declarations. This is the
|
/// keybinds.zig directly for binding declarations. This is the
|
||||||
|
|
@ -268,28 +269,32 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Binding / label / hint constants ───────────────────
|
// ── Binding / label / hint constants ───────────────────
|
||||||
expectDeclWithType(
|
validator.expectDeclWithType(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"label",
|
"label",
|
||||||
[]const u8,
|
[]const u8,
|
||||||
"pub const label: []const u8 = \"...\";",
|
"pub const label: []const u8 = \"...\";",
|
||||||
);
|
);
|
||||||
expectDeclWithType(
|
validator.expectDeclWithType(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"default_bindings",
|
"default_bindings",
|
||||||
[]const TabBinding(Action),
|
[]const TabBinding(Action),
|
||||||
"pub const default_bindings: []const TabBinding(Action) = &.{ ... };",
|
"pub const default_bindings: []const TabBinding(Action) = &.{ ... };",
|
||||||
);
|
);
|
||||||
expectDeclWithType(
|
validator.expectDeclWithType(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"action_labels",
|
"action_labels",
|
||||||
std.enums.EnumArray(Action, []const u8),
|
std.enums.EnumArray(Action, []const u8),
|
||||||
"pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ ... });",
|
"pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ ... });",
|
||||||
);
|
);
|
||||||
expectDeclWithType(
|
validator.expectDeclWithType(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"status_hints",
|
"status_hints",
|
||||||
|
|
@ -298,7 +303,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Lifecycle hooks (required) ─────────────────────────
|
// ── Lifecycle hooks (required) ─────────────────────────
|
||||||
expectFnInferredError(
|
validator.expectFnInferredError(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"init",
|
"init",
|
||||||
|
|
@ -306,14 +312,16 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
void,
|
void,
|
||||||
"pub fn init(state: *State, app: *App) !void { ... }",
|
"pub fn init(state: *State, app: *App) !void { ... }",
|
||||||
);
|
);
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"deinit",
|
"deinit",
|
||||||
fn (*State, *App) void,
|
fn (*State, *App) void,
|
||||||
"pub fn deinit(state: *State, app: *App) void { ... }",
|
"pub fn deinit(state: *State, app: *App) void { ... }",
|
||||||
);
|
);
|
||||||
expectFnInferredError(
|
validator.expectFnInferredError(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"activate",
|
"activate",
|
||||||
|
|
@ -321,14 +329,16 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
void,
|
void,
|
||||||
"pub fn activate(state: *State, app: *App) !void { ... }",
|
"pub fn activate(state: *State, app: *App) !void { ... }",
|
||||||
);
|
);
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"deactivate",
|
"deactivate",
|
||||||
fn (*State, *App) void,
|
fn (*State, *App) void,
|
||||||
"pub fn deactivate(state: *State, app: *App) void { ... }",
|
"pub fn deactivate(state: *State, app: *App) void { ... }",
|
||||||
);
|
);
|
||||||
expectFnInferredError(
|
validator.expectFnInferredError(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"reload",
|
"reload",
|
||||||
|
|
@ -336,7 +346,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
void,
|
void,
|
||||||
"pub fn reload(state: *State, app: *App) !void { ... }",
|
"pub fn reload(state: *State, app: *App) !void { ... }",
|
||||||
);
|
);
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"tick",
|
"tick",
|
||||||
|
|
@ -345,7 +356,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Action dispatch (required) ─────────────────────────
|
// ── Action dispatch (required) ─────────────────────────
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"handleAction",
|
"handleAction",
|
||||||
|
|
@ -354,7 +366,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Misc (required) ────────────────────────────────────
|
// ── Misc (required) ────────────────────────────────────
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"isDisabled",
|
"isDisabled",
|
||||||
|
|
@ -367,7 +380,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
// surface that as a hard error so a typo'd signature
|
// surface that as a hard error so a typo'd signature
|
||||||
// doesn't silently get skipped at dispatch time.
|
// doesn't silently get skipped at dispatch time.
|
||||||
if (@hasDecl(tab_decl, "handleKey")) {
|
if (@hasDecl(tab_decl, "handleKey")) {
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"handleKey",
|
"handleKey",
|
||||||
|
|
@ -376,7 +390,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (@hasDecl(tab_decl, "handleMouse")) {
|
if (@hasDecl(tab_decl, "handleMouse")) {
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"handleMouse",
|
"handleMouse",
|
||||||
|
|
@ -385,7 +400,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (@hasDecl(tab_decl, "handlePaste")) {
|
if (@hasDecl(tab_decl, "handlePaste")) {
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"handlePaste",
|
"handlePaste",
|
||||||
|
|
@ -394,7 +410,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (@hasDecl(tab_decl, "statusOverride")) {
|
if (@hasDecl(tab_decl, "statusOverride")) {
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"statusOverride",
|
"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.");
|
"`drawContent`. Only one is allowed — pick the right one for your tab's render shape.");
|
||||||
}
|
}
|
||||||
if (has_build) {
|
if (has_build) {
|
||||||
expectFnInferredError(
|
validator.expectFnInferredError(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
Module,
|
Module,
|
||||||
"buildStyledLines",
|
"buildStyledLines",
|
||||||
|
|
@ -440,7 +458,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (has_draw) {
|
if (has_draw) {
|
||||||
expectFnInferredError(
|
validator.expectFnInferredError(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
Module,
|
Module,
|
||||||
"drawContent",
|
"drawContent",
|
||||||
|
|
@ -452,7 +471,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
|
|
||||||
// ── Context-change hooks (optional, typed when present) ──
|
// ── Context-change hooks (optional, typed when present) ──
|
||||||
if (@hasDecl(tab_decl, "onSymbolChange")) {
|
if (@hasDecl(tab_decl, "onSymbolChange")) {
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"onSymbolChange",
|
"onSymbolChange",
|
||||||
|
|
@ -461,7 +481,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (@hasDecl(tab_decl, "onScroll")) {
|
if (@hasDecl(tab_decl, "onScroll")) {
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"onScroll",
|
"onScroll",
|
||||||
|
|
@ -470,7 +491,8 @@ pub fn validateTabModule(comptime Module: type) void {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (@hasDecl(tab_decl, "onCursorMove")) {
|
if (@hasDecl(tab_decl, "onCursorMove")) {
|
||||||
expectFn(
|
validator.expectFn(
|
||||||
|
"Tab module",
|
||||||
mod_name,
|
mod_name,
|
||||||
tab_decl,
|
tab_decl,
|
||||||
"onCursorMove",
|
"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 ─────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue