//! Shared comptime contract-validation helpers. //! //! Both the TUI tab framework (`src/tui/tab_framework.zig`) and the CLI //! command framework (`src/commands/framework.zig`) walk a candidate //! module at comptime to assert it conforms to the framework contract. //! The check shapes are identical: //! //! - `expectDeclWithType` — a non-fn decl exists with the exact type. //! - `expectFn` — a fn decl exists with the exact fn-pointer type. //! - `expectFnInferredError` — a fn decl exists, params match, and //! the return is an error union ending in the expected payload //! (the error set itself is not checked because Zig infers an //! empty error set per fn for `pub fn foo() !void`). //! //! Each helper takes a caller-supplied `kind` prefix ("Tab module" / //! "Command module") so the resulting compile errors point at the right //! framework. Error messages include the offending decl name and a //! verbatim `expected_signature` so the developer can copy-paste the fix. const std = @import("std"); /// Assert a decl exists on `Container` with the exact expected type. /// `kind` is a short prefix shown in error messages — typically /// `"Tab module"` or `"Command module"`. pub fn expectDeclWithType( comptime kind: []const u8, comptime mod_name: []const u8, comptime Container: type, comptime decl_name: []const u8, comptime ExpectedT: type, comptime expected_signature: []const u8, ) void { comptime { if (!@hasDecl(Container, decl_name)) { @compileError(kind ++ " `" ++ mod_name ++ "` is missing `" ++ decl_name ++ "`. Expected:\n " ++ expected_signature); } const ActualT = @TypeOf(@field(Container, decl_name)); if (ActualT != ExpectedT) { @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ "` has wrong type.\n Expected: " ++ @typeName(ExpectedT) ++ "\n Got: " ++ @typeName(ActualT) ++ "\n Expected signature:\n " ++ expected_signature); } } } /// Assert a function decl exists on `Container` with the exact fn-pointer /// type. Use this for non-fallible functions (`fn (...) T`) or for fns /// that explicitly use `anyerror`. For fns whose error set is inferred /// (the common `pub fn foo() !void` shape), use `expectFnInferredError`. pub fn expectFn( comptime kind: []const u8, comptime mod_name: []const u8, comptime Container: type, comptime decl_name: []const u8, comptime ExpectedT: type, comptime expected_signature: []const u8, ) void { comptime { if (!@hasDecl(Container, decl_name)) { @compileError(kind ++ " `" ++ mod_name ++ "` is missing `" ++ decl_name ++ "`. Expected:\n " ++ expected_signature); } const ActualT = @TypeOf(@field(Container, decl_name)); if (ActualT != ExpectedT) { @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ "` has wrong signature.\n Expected: " ++ @typeName(ExpectedT) ++ "\n Got: " ++ @typeName(ActualT) ++ "\n Full expected declaration:\n " ++ expected_signature); } } } /// Assert a fallible function decl exists on `Container` whose /// parameter types match `expected_params` and whose return is an /// error union ending in `ExpectedReturn`. The error set itself is /// NOT checked — Zig infers a per-fn empty error set for `pub fn foo() /// !void` whose name varies per call site, so exact-equality fails. /// This loosens equality to "params match, return is `!Return`". pub fn expectFnInferredError( comptime kind: []const u8, comptime mod_name: []const u8, comptime Container: type, comptime decl_name: []const u8, comptime expected_params: []const type, comptime ExpectedReturn: type, comptime expected_signature: []const u8, ) void { comptime { if (!@hasDecl(Container, decl_name)) { @compileError(kind ++ " `" ++ mod_name ++ "` is missing `" ++ decl_name ++ "`. Expected:\n " ++ expected_signature); } const ActualT = @TypeOf(@field(Container, decl_name)); const info = @typeInfo(ActualT); if (info != .@"fn") { @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ "` must be a function, got " ++ @typeName(ActualT) ++ "\n Full expected declaration:\n " ++ expected_signature); } const fn_info = info.@"fn"; // Verify params count + types. if (fn_info.params.len != expected_params.len) { @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ "` has wrong arity.\n Got: " ++ @typeName(ActualT) ++ "\n Full expected declaration:\n " ++ expected_signature); } for (expected_params, 0..) |Expected, i| { const ActualParam = fn_info.params[i].type orelse { @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ "` parameter " ++ std.fmt.comptimePrint("{d}", .{i}) ++ " is `anytype`, but the contract requires `" ++ @typeName(Expected) ++ "`.\n" ++ " Full expected declaration:\n " ++ expected_signature); }; if (ActualParam != Expected) { @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ "` parameter " ++ std.fmt.comptimePrint("{d}", .{i}) ++ " has wrong type.\n Expected: " ++ @typeName(Expected) ++ "\n Got: " ++ @typeName(ActualParam) ++ "\n Full expected declaration:\n " ++ expected_signature); } } // Verify return is an error union ending in ExpectedReturn. const ActualReturn = fn_info.return_type orelse { @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ "` has no return type.\n Full expected declaration:\n " ++ expected_signature); }; const ret_info = @typeInfo(ActualReturn); if (ret_info != .error_union) { @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ "` must return an error union (`!" ++ @typeName(ExpectedReturn) ++ "`), got " ++ @typeName(ActualReturn) ++ "\n Full expected declaration:\n " ++ expected_signature); } if (ret_info.error_union.payload != ExpectedReturn) { @compileError(kind ++ " `" ++ mod_name ++ "`: `" ++ decl_name ++ "` returns wrong payload type.\n Expected: !" ++ @typeName(ExpectedReturn) ++ "\n Got: " ++ @typeName(ActualReturn) ++ "\n Full expected declaration:\n " ++ expected_signature); } } } // ── Tests ───────────────────────────────────────────────────── // // The validators themselves are comptime-only — runtime tests can // only exercise the happy path (a decl with the right shape passes // silently). The error paths are covered by the fact that every // existing tab module compiles, so any breakage to these helpers // surfaces immediately at `zig build`. const testing = std.testing; test "expectDeclWithType: matching type passes" { const M = struct { pub const label: []const u8 = "ok"; }; comptime expectDeclWithType("Test", "M", M, "label", []const u8, "pub const label: []const u8 = \"...\";"); } test "expectFn: matching fn signature passes" { const M = struct { pub fn handler(_: u32) void {} }; comptime expectFn("Test", "M", M, "handler", fn (u32) void, "pub fn handler(x: u32) void"); } test "expectFnInferredError: !void function with correct params passes" { const M = struct { pub fn doit(_: u32, _: []const u8) !void {} }; comptime expectFnInferredError( "Test", "M", M, "doit", &.{ u32, []const u8 }, void, "pub fn doit(x: u32, s: []const u8) !void", ); } test "expectFnInferredError: !T payload check works for non-void" { const M = struct { pub fn doit(_: u32) !u64 { return 0; } }; comptime expectFnInferredError( "Test", "M", M, "doit", &.{u32}, u64, "pub fn doit(x: u32) !u64", ); }