zfin/src/comptime_validator.zig

201 lines
8.4 KiB
Zig

//! 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",
);
}