diff --git a/build.zig b/build.zig index 245cd78..7c5fb58 100644 --- a/build.zig +++ b/build.zig @@ -17,11 +17,8 @@ pub fn build(b: *std.build.Builder) void { const demo_exe = b.addExecutable(.{ .name = "noclip-demo", - .root_source_file = .{ .path = "demo/demo.zig" }, + .root_source_file = .{ .path = "source/doodle.zig" }, }); - demo_exe.addModule("noclip", b.createModule(.{ - .source_file = .{ .path = "source/noclip.zig" }, - })); const install_demo = b.addInstallArtifact(demo_exe); demo.dependOn(&install_demo.step); diff --git a/source/converters.zig b/source/converters.zig new file mode 100644 index 0000000..b14e544 --- /dev/null +++ b/source/converters.zig @@ -0,0 +1,121 @@ +const std = @import("std"); + +const ParameterGenerics = @import("./doodle.zig").ParameterGenerics; +const CommandError = @import("./doodle.zig").Errors; + +pub const ConversionError = error{ + BadValue, +}; + +pub fn ConverterSignature(comptime gen: ParameterGenerics) type { + return if (gen.no_context()) + *const fn ([]const u8) ConversionError!gen.ResultType() + else + *const fn (gen.ContextType, []const u8) ConversionError!gen.ResultType(); +} + +pub fn default_converter(comptime gen: ParameterGenerics) ?ConverterSignature(gen) { + return switch (@typeInfo(gen.ResultType())) { + .Bool => flag_converter(gen), + .Int => int_converter(gen), + .Pointer => |info| if (info.size == .Slice and info.child == u8) + string_converter(gen) + else + null, + .Enum => choice_converter(gen), + else => null, + }; +} + +fn flag_converter(comptime gen: ParameterGenerics) ConverterSignature(gen) { + return if (gen.no_context()) + struct { + pub fn handler(input: []const u8) ConversionError!bool { + // treat an empty string as falsy + if (input.len == 0) return false; + + if (input.len <= 5) { + var lowerBuf: [5]u8 = undefined; + const comp = std.ascii.lowerString(&lowerBuf, input); + + inline for ([_][]const u8{ "false", "no", "0" }) |candidate| { + if (std.mem.eql(u8, comp, candidate)) return false; + } + } + + return true; + } + }.handler + else + struct { + pub fn handler(_: gen.ContextType, input: []const u8) ConversionError!bool { + // treat an empty string as falsy + if (input.len == 0) return false; + + if (input.len <= 5) { + var lowerBuf: [5]u8 = undefined; + const comp = std.ascii.lowerString(&lowerBuf, input); + + inline for ([_][]const u8{ "false", "no", "0" }) |candidate| { + if (std.mem.eql(u8, comp, candidate)) return false; + } + } + + return true; + } + }.handler; +} + +fn string_converter(comptime gen: ParameterGenerics) ConverterSignature(gen) { + return if (gen.no_context()) + struct { + pub fn handler(value: []const u8) ConversionError![]const u8 { + return value; + } + }.handler + else + struct { + pub fn handler(_: gen.ContextType, value: []const u8) ConversionError![]const u8 { + return value; + } + }.handler; +} + +fn int_converter(comptime gen: ParameterGenerics) ConverterSignature(gen) { + const IntType = gen.ResultType(); + + std.debug.assert(switch (@typeInfo(IntType)) { + .Int => true, + else => false, + }); + + return if (gen.no_context()) + struct { + pub fn handler(value: []const u8) ConversionError!IntType { + return std.fmt.parseInt(IntType, value, 0) catch return ConversionError.BadValue; + } + }.handler + else + struct { + pub fn handler(_: gen.ContextType, value: []const u8) ConversionError!IntType { + return std.fmt.parseInt(IntType, value, 0) catch return ConversionError.BadValue; + } + }.handler; +} + +fn choice_converter(comptime gen: ParameterGenerics) ConverterSignature(gen) { + const EnumType = gen.ResultType(); + + return if (gen.no_context()) + struct { + pub fn handler(value: []const u8) ConversionError!EnumType { + return std.meta.stringToEnum(gen.ResultType(), value) orelse ConversionError.BadValue; + } + }.handler + else + struct { + pub fn handler(_: gen.ContextType, value: []const u8) ConversionError!EnumType { + return std.meta.stringToEnum(gen.ResultType(), value) orelse ConversionError.BadValue; + } + }.handler; +} diff --git a/source/doodle.zig b/source/doodle.zig new file mode 100644 index 0000000..de6d5b0 --- /dev/null +++ b/source/doodle.zig @@ -0,0 +1,694 @@ +const std = @import("std"); +const StructField = std.builtin.Type.StructField; + +const converters = @import("./converters.zig"); +const ncmeta = @import("./meta.zig"); + +const ConverterSignature = converters.ConverterSignature; + +const ParameterType = enum { + Nominal, + Ordinal, + Executable, +}; + +const Errors = error{ + BadConfiguration, + MissingTag, + ArgumentWithTags, + ArgumentWithEnvVar, + MissingDefaultConverter, +}; + +const ParseError = error{ + ValueMissing, + FusedShortTagValueMissing, + UnknownLongTagParameter, + UnknownShortTagParameter, +}; + +const FlagBias = enum { + falsy, + truthy, + unbiased, + + pub fn string(self: @This()) []const u8 { + return switch (self) { + .truthy => "true", + else => @compileLog(self), + }; + } +}; + +const OptionResult = union(enum) { + Value: type, + flag: FlagBias, +}; + +pub const ParameterGenerics = struct { + ContextType: type = void, + result: OptionResult = .{ .Value = []const u8 }, + param_type: ParameterType, + + pub fn no_context(comptime self: @This()) bool { + return self.ContextType == void; + } + + pub fn is_flag(comptime self: @This()) bool { + return self.result == .flag; + } + + pub fn clone(comptime self: @This(), comptime NewResult: type) @This() { + return @This(){ + .ContextType = self.ContextType, + .result = .{ .Value = NewResult }, + }; + } + + pub fn ResultType(comptime self: @This()) type { + return switch (self.result) { + .Value => |res| res, + .flag => bool, + }; + } +}; + +const ValuedGenericsBasis = struct { ContextType: type = void, Result: type }; +const FlagGenericsBasis = struct { ContextType: type = void, flag_bias: FlagBias = .truthy }; + +fn tag_generics(comptime basis: ValuedGenericsBasis) ParameterGenerics { + return ParameterGenerics{ + .ContextType = basis.ContextType, + .result = .{ .Value = basis.Result }, + .param_type = .Nominal, + }; +} + +fn flag_generics(comptime basis: FlagGenericsBasis) ParameterGenerics { + return ParameterGenerics{ + .ContextType = basis.ContextType, + .result = .{ .flag = basis.flag_bias }, + .param_type = .Nominal, + }; +} + +fn arg_generics(comptime basis: ValuedGenericsBasis) ParameterGenerics { + return ParameterGenerics{ + .ContextType = basis.ContextType, + .result = .{ .Value = basis.Result }, + .param_type = .Ordinal, + }; +} + +fn OptionConfig(comptime generics: ParameterGenerics) type { + return struct { + name: []const u8, + + short_tag: ?[]const u8 = null, + long_tag: ?[]const u8 = null, + env_var: ?[]const u8 = null, + + default: ?generics.ResultType() = null, + converter: ?ConverterSignature(generics) = null, + arg_count: u32 = if (generics.is_flag()) 0 else 1, + eager: bool = false, + required: bool = generics.param_type == .Ordinal, + exposed: bool = true, + secret: bool = false, + nice_type_name: []const u8 = @typeName(generics.ResultType()), + }; +} + +fn OptionType(comptime generics: ParameterGenerics) type { + return struct { + pub const gen = generics; + pub const param_type: ParameterType = generics.param_type; + pub const is_flag: bool = generics.is_flag(); + pub const flag_bias: FlagBias = if (generics.is_flag()) generics.result.flag else .unbiased; + + name: []const u8, + short_tag: ?[]const u8, + long_tag: ?[]const u8, + env_var: ?[]const u8, + + default: ?generics.ResultType(), + converter: ConverterSignature(generics), + description: []const u8 = "", // description for output in help text + arg_count: u32, + eager: bool, + required: bool, + exposed: bool, // do not expose the resulting value in the output type. the handler must have side effects for this option to do anything + secret: bool, // do not print help for this parameter + nice_type_name: ?[]const u8 = null, // friendly type name (string better than []const u8) + }; +} + +fn check_short(comptime short_tag: ?[]const u8) void { + if (short_tag) |short| { + if (short.len != 2 or short[0] != '-') @compileError("bad short tag"); + } +} + +fn check_long(comptime long_tag: ?[]const u8) void { + if (long_tag) |long| { + if (long.len < 3 or long[0] != '-' or long[1] != '-') @compileError("bad long tag"); + } +} + +fn make_option(comptime generics: ParameterGenerics, comptime opts: OptionConfig(generics)) OptionType(generics) { + if (opts.short_tag == null and opts.long_tag == null and opts.env_var == null) { + @compileError( + "option " ++ + opts.name ++ + " must have at least one of a short tag, a long tag, or an environment variable", + ); + } + + check_short(opts.short_tag); + check_long(opts.long_tag); + + // perform the logic to create the default converter here? Could be done + // when creating the OptionConfig instead. Need to do it here because there + // may be an error. That's the essential distinction between the OptionType + // and the OptionConfig, is the OptionConfig is just unvalidated parameters, + // whereas the OptionType is an instance of an object that has been + // validated. + const converter = opts.converter orelse converters.default_converter(generics) orelse { + @compileLog(opts); + @compileError("implement me"); + }; + + return OptionType(generics){ + .name = opts.name, + .short_tag = opts.short_tag, + .long_tag = opts.long_tag, + .env_var = opts.env_var, + .default = opts.default, + .converter = converter, + .arg_count = opts.arg_count, + .eager = opts.eager, + .required = opts.required, + .exposed = opts.exposed, + .secret = opts.secret, + }; +} + +fn make_argument(comptime generics: ParameterGenerics, comptime opts: OptionConfig(generics)) OptionType(generics) { + // TODO: it would technically be possible to support specification of + // ordered arguments through environmental variables, but it doesn't really + // make a lot of sense. The algorithm would consume the env var greedily + if (opts.short_tag != null or opts.long_tag != null or opts.env_var != null) { + @compileLog(opts); + @compileError("argument " ++ opts.name ++ " must not have a long or short tag or an env var"); + } + + const converter = opts.converter orelse converters.default_converter(generics) orelse { + @compileLog(opts); + @compileError("implement me"); + }; + + return OptionType(generics){ + .name = opts.name, + .short_tag = opts.short_tag, + .long_tag = opts.long_tag, + .env_var = opts.env_var, + .default = opts.default, + .converter = converter, + .arg_count = opts.arg_count, + .eager = opts.eager, + .required = opts.required, + .exposed = opts.exposed, + .secret = opts.secret, + }; +} + +const ShortLongPair = struct { + short_tag: ?[]const u8 = null, + long_tag: ?[]const u8 = null, +}; + +fn FlagBuilderArgs(comptime ContextType: type) type { + return struct { + name: []const u8, + truthy: ?ShortLongPair = null, + falsy: ?ShortLongPair = null, + env_var: ?[]const u8 = null, + + default: ?bool = null, + converter: ?ConverterSignature(flag_generics(.{ .ContextType = ContextType })) = null, + eager: bool = false, + exposed: bool = true, + required: bool = false, + secret: bool = false, + }; +} + +fn CommandBuilder(comptime ContextType: type) type { + return struct { + param_spec: ncmeta.MutableTuple = .{}, + + pub const UserContextType = ContextType; + + pub fn add_argument( + comptime self: *@This(), + comptime Result: type, + comptime args: OptionConfig(arg_generics(.{ .ContextType = ContextType, .Result = Result })), + ) void { + self.param_spec.add(make_argument( + arg_generics(.{ .ContextType = ContextType, .Result = Result }), + args, + )); + } + + pub fn add_option( + comptime self: *@This(), + comptime Result: type, + comptime args: OptionConfig(tag_generics(.{ .ContextType = ContextType, .Result = Result })), + ) void { + self.param_spec.add(make_option( + tag_generics(.{ .ContextType = ContextType, .Result = Result }), + args, + )); + } + + pub fn add_flag( + comptime self: *@This(), + comptime build_args: FlagBuilderArgs(ContextType), + ) void { + if (build_args.truthy == null and build_args.falsy == null and build_args.env_var == null) { + @compileError( + "flag " ++ + build_args.name ++ + " must have at least one of truthy flags, falsy flags, or env_var flags", + ); + } + + if (build_args.truthy) |truthy_pair| { + if (truthy_pair.short_tag == null and truthy_pair.long_tag == null) { + @compileError( + "flag " ++ + build_args.name ++ + " truthy pair must have at least short or long tags set", + ); + } + + const generics = flag_generics(.{ .ContextType = ContextType, .flag_bias = .truthy }); + + const args = OptionConfig(generics){ + .name = build_args.name, + .short_tag = truthy_pair.short_tag, + .long_tag = truthy_pair.long_tag, + .env_var = null, + .default = build_args.default, + .converter = build_args.converter, + .eager = build_args.eager, + .exposed = build_args.exposed, + .secret = build_args.secret, + }; + + self.param_spec.add(make_option(generics, args)); + } + + if (build_args.falsy) |falsy_pair| { + if (falsy_pair.short_tag == null and falsy_pair.long_tag == null) { + @compileError( + "flag " ++ + build_args.name ++ + " falsy pair must have at least short or long tags set", + ); + } + + const generics = flag_generics(.{ .ContextType = ContextType, .flag_bias = .falsy }); + const args = OptionConfig(generics){ + .name = build_args.name, + .short_tag = falsy_pair.short_tag, + .long_tag = falsy_pair.long_tag, + .env_var = null, + .default = build_args.default, + .converter = build_args.converter, + .eager = build_args.eager, + .secret = build_args.secret, + }; + + self.param_spec.add(make_option(generics, args)); + } + + if (build_args.env_var) |env_var| { + const generics = flag_generics(.{ .ContextType = ContextType, .flag_bias = .unbiased }); + const args = OptionConfig(generics){ + .name = build_args.name, + .env_var = env_var, + .default = build_args.default, + .converter = build_args.converter, + .eager = build_args.eager, + .secret = build_args.secret, + }; + + self.param_spec.add(make_option(generics, args)); + } + } + + fn generate(comptime self: @This()) self.param_spec.TupleType() { + return self.param_spec.realTuple(); + } + + pub fn CallbackSignature(comptime self: @This()) type { + return *const fn (ContextType, self.CommandOutput()) anyerror!void; + } + + pub fn CommandOutput(comptime self: @This()) type { + comptime { + const spec = self.generate(); + var fields: []const StructField = &[0]StructField{}; + var flag_skip = 0; + + paramloop: for (spec, 0..) |param, idx| { + if (!param.exposed) continue :paramloop; + while (flag_skip > 0) { + flag_skip -= 1; + continue :paramloop; + } + + const PType = @TypeOf(param); + if (PType.is_flag) { + var peek = idx + 1; + var bais_seen: [ncmeta.enum_length(FlagBias)]bool = [_]bool{false} ** ncmeta.enum_length(FlagBias); + bais_seen[@enumToInt(PType.flag_bias)] = true; + while (peek < spec.len) : (peek += 1) { + const peek_param = spec[peek]; + const PeekType = @TypeOf(peek_param); + + if (PeekType.is_flag and std.mem.eql(u8, param.name, peek_param.name)) { + if (bais_seen[@enumToInt(PeekType.flag_bias)] == true) { + @compileError("redundant flag!!!! " ++ param.name ++ " and " ++ peek_param.name); + } else { + bais_seen[@enumToInt(PeekType.flag_bias)] = true; + } + flag_skip += 1; + } else { + break; + } + } + } + + // the default field is already the optional type. Stripping + // the optional wrapper is an interesting idea for required + // fields. I do not foresee this greatly increasing complexity here. + const FieldType = if (param.required) + std.meta.Child(std.meta.FieldType(PType, .default)) + else + std.meta.FieldType(PType, .default); + + // the wacky comptime slice extension hack + fields = &(@as([fields.len]StructField, fields[0..fields.len].*) ++ [1]StructField{.{ + .name = param.name, + .type = FieldType, + .default_value = @ptrCast(?*const anyopaque, ¶m.default), + .is_comptime = false, + .alignment = @alignOf(FieldType), + }}); + } + + return @Type(.{ .Struct = .{ + .layout = .Auto, + .fields = fields, + .decls = &.{}, + .is_tuple = false, + } }); + } + } + + pub fn Intermediate(comptime self: @This()) type { + comptime { + const spec = self.generate(); + var fields: []const StructField = &[0]StructField{}; + var flag_skip = 0; + + paramloop: for (spec, 0..) |param, idx| { + while (flag_skip > 0) { + flag_skip -= 1; + continue :paramloop; + } + + const PType = @TypeOf(param); + if (PType.is_flag) { + var peek = idx + 1; + var bais_seen: [ncmeta.enum_length(FlagBias)]bool = [_]bool{false} ** ncmeta.enum_length(FlagBias); + bais_seen[@enumToInt(PType.flag_bias)] = true; + while (peek < spec.len) : (peek += 1) { + const peek_param = spec[peek]; + const PeekType = @TypeOf(peek_param); + + if (PeekType.is_flag and std.mem.eql(u8, param.name, peek_param.name)) { + if (bais_seen[@enumToInt(PeekType.flag_bias)] == true) { + @compileError("redundant flag!!!! " ++ param.name ++ " and " ++ peek_param.name); + } else { + bais_seen[@enumToInt(PeekType.flag_bias)] = true; + } + flag_skip += 1; + } else { + break; + } + } + } + + // the wacky comptime slice extension hack + fields = &(@as([fields.len]StructField, fields[0..fields.len].*) ++ [1]StructField{.{ + .name = param.name, + .type = ?[]const u8, + .default_value = @ptrCast(?*const anyopaque, &@as(?[]const u8, null)), + .is_comptime = false, + .alignment = @alignOf(?[]const u8), + }}); + } + + return @Type(.{ .Struct = .{ + .layout = .Auto, + .fields = fields, + .decls = &.{}, + .is_tuple = false, + } }); + } + } + + pub fn bind(comptime self: @This(), comptime callback: self.CallbackSignature()) Parser(self, callback) { + return Parser(self, callback){}; + } + }; +} + +// the parser is generated by the bind method of the CommandBuilder, so we can +// be extremely type-sloppy here, which simplifies the signature. +fn Parser(comptime command: anytype, comptime callback: anytype) type { + _ = callback; + + return struct { + const ContextType = @TypeOf(command).UserContextType; + // let there be fields! we can move some things to runtime. + // We can get some better behavior if we defer converting non-eager + // options until the entire command line has been parsed. However, + // to do that, we effectively have to store the parameters as strings until the + // entire line has been parsed. + + // a goal is to + + intermediate: command.Intermediate() = .{}, + consumed_args: u32 = 0, + + // pub fn add_subcommand(self: *@This(), verb: []const u8, parser: anytype) void { + // self.subcommands + // } + + pub fn parse( + self: *@This(), + alloc: std.mem.Allocator, + argit: *std.process.ArgIterator, + env: std.process.EnvMap, + context: ContextType, + ) anyerror!void { + _ = alloc; + // _ = context; + + try self.read_environment(env); + + var forced_args = false; + argloop: while (argit.next()) |arg| { + if (!forced_args and std.mem.eql(u8, arg, "--")) { + forced_args = true; + continue :argloop; + } + + parse_tags: { + if (forced_args or arg.len < 1 or arg[0] != '-') break :parse_tags; + + if (arg.len > 2 and arg[1] == '-') { + try self.parse_long_tag(arg, argit, context); + continue :argloop; + } else if (arg.len > 1) { + for (arg[1..], 1..) |short, idx| { + // _ = short; + // _ = idx; + try self.parse_short_tag(short, arg.len - idx - 1, argit, context); + } + continue :argloop; + } + } + + try self.parse_argument(arg, argit); + } + } + + inline fn parse_long_tag( + self: *@This(), + arg: []const u8, + argit: *std.process.ArgIterator, + context: ContextType, + ) ParseError!void { + _ = context; + + inline for (comptime command.generate()) |param| { + const PType = @TypeOf(param); + // removing the comptime here causes the compiler to die + comptime if (PType.param_type != .Nominal or param.long_tag == null) continue; + const tag = param.long_tag.?; + + if (comptime PType.is_flag) { + if (std.mem.eql(u8, arg, tag)) { + @field(self.intermediate, param.name) = if (comptime PType.flag_bias == .truthy) "true" else "false"; + return; + } + } else { + if (std.mem.startsWith(u8, arg, tag)) match: { + // TODO: handle more than one value + const next = if (arg.len == tag.len) + argit.next() orelse return ParseError.ValueMissing + else if (arg[tag.len] == '=') + arg[tag.len + 1 ..] + else + break :match; + + @field(self.intermediate, param.name) = next; + // if (comptime param.eager) { + // try param.converter() + // } + return; + } + } + } + + return ParseError.UnknownLongTagParameter; + } + + inline fn parse_short_tag( + self: *@This(), + arg: u8, + remaining: usize, + argit: *std.process.ArgIterator, + context: ContextType, + ) ParseError!void { + _ = context; + + inline for (comptime command.generate()) |param| { + const PType = @TypeOf(param); + // removing the comptime here causes the compiler to die + comptime if (PType.param_type != .Nominal or param.short_tag == null) continue; + const tag = param.short_tag.?; + + if (comptime PType.is_flag) { + if (arg == tag[1]) { + @field(self.intermediate, param.name) = if (comptime PType.flag_bias == .truthy) "true" else "false"; + return; + } + } else { + if (arg == tag[1]) { + if (remaining > 0) return ParseError.FusedShortTagValueMissing; + const next = argit.next() orelse return ParseError.ValueMissing; + + @field(self.intermediate, param.name) = next; + return; + } + } + } + + return ParseError.UnknownShortTagParameter; + } + + inline fn parse_argument(self: *@This(), arg: []const u8, argit: *std.process.ArgIterator) ParseError!void { + _ = argit; + + comptime var arg_index: u32 = 0; + inline for (comptime command.generate()) |param| { + if (@TypeOf(param).param_type != .Ordinal) continue; + + if (self.consumed_args == arg_index) { + std.debug.print("n: {s}, c: {d}, i: {d}\n", .{ param.name, self.consumed_args, arg_index }); + @field(self.intermediate, param.name) = arg; + self.consumed_args += 1; + return; + } + arg_index += 1; + } + } + + fn read_environment(self: *@This(), env: std.process.EnvMap) !void { + inline for (comptime command.generate()) |param| { + if (comptime param.env_var) |env_var| { + @field(self.intermediate, param.name) = env.get(env_var); + } + } + } + }; +} + +fn HelpBuilder(comptime command: anytype) type { + _ = command; +} + +pub fn command_builder(comptime ContextType: type) CommandBuilder(ContextType) { + return CommandBuilder(ContextType){}; +} + +const Choice = enum { first, second }; + +const cli = cmd: { + var cmd = command_builder(void); + cmd.add_option(u8, .{ + .name = "test", + .short_tag = "-t", + .long_tag = "--test", + .env_var = "NOCLIP_TEST", + }); + cmd.add_flag(.{ + .name = "flag", + .truthy = .{ .short_tag = "-f", .long_tag = "--flag" }, + .falsy = .{ .long_tag = "--no-flag" }, + .env_var = "NOCLIP_FLAG", + }); + cmd.add_argument([]const u8, .{ .name = "arg" }); + cmd.add_argument([]const u8, .{ .name = "argtwo" }); + + break :cmd cmd; +}; + +fn cli_handler(_: void, result: cli.CommandOutput()) !void { + _ = result; +} + +pub fn main() !void { + // std.debug.print("hello\n", .{}); + var parser = cli.bind(cli_handler); + + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + + const allocator = arena.allocator(); + var argit = try std.process.argsWithAllocator(allocator); + var env = try std.process.getEnvMap(allocator); + + _ = argit.next(); + try parser.parse(allocator, &argit, env, {}); + + inline for (@typeInfo(@TypeOf(parser.intermediate)).Struct.fields) |field| { + std.debug.print("{s}: {?s}\n", .{ field.name, @field(parser.intermediate, field.name) }); + } +} diff --git a/source/meta.zig b/source/meta.zig index d68537d..028cff1 100644 --- a/source/meta.zig +++ b/source/meta.zig @@ -48,6 +48,10 @@ pub fn UpdateDefaults(comptime input: type, comptime defaults: anytype) type { } } +pub fn enum_length(comptime T: type) comptime_int { + return @typeInfo(T).Enum.fields.len; +} + /// Stores type-erased pointers to items in comptime extensible data structures, /// which allows e.g. assembling a tuple through multiple calls rather than all /// at once. @@ -81,7 +85,7 @@ pub const MutableTuple = struct { for (self.types, 0..) |Type, idx| { var num_buf: [128]u8 = undefined; fields[idx] = .{ - .name = std.fmt.bufPrint(&num_buf, "{d}", .{idx}) catch unreachable, + .name = std.fmt.bufPrint(&num_buf, "{d}", .{idx}) catch @compileError("failed to write field"), .type = Type, .default_value = null, // TODO: is this the right thing to do?