diff --git a/lib/std/cli.zig b/lib/std/cli.zig index bf4cf118b2..5da13dbdfc 100644 --- a/lib/std/cli.zig +++ b/lib/std/cli.zig @@ -7,6 +7,7 @@ const isAlphabetic = std.ascii.isAlphabetic; const Writer = std.Io.Writer; const ArgIterator = std.process.ArgIterator; const ArenaAllocator = std.heap.ArenaAllocator; +const StructField = std.builtin.Type.StructField; const mem = std.mem; const Allocator = mem.Allocator; @@ -29,23 +30,25 @@ pub const Options = struct { pub const Error = error{ /// Caused by unrecognized option names, values that cannot be parsed into the appropriate field type, /// missing arguments for fields with no default value, and other similar parsing errors. + /// See also `options.exit`, which can supersede this error. Usage, - /// The --help argument was given. + /// The --help argument was given (and `options.exit` resolved to `false`). Help, } || Allocator.Error; /// Parses CLI args from a `std.process.ArgIterator` according to the configuration in `Args`. -/// Args is a struct that you define looking like this: +/// `Args` is a struct that you define looking like this: /// ``` /// const Args = struct { /// named: struct { /// // ... /// }, -/// positional: []const []const u8 = &.{}, +/// positional: struct { +/// // ... +/// }, /// }; /// ``` -/// The `named` and `positional` fields are required, although `named` need not have any subfields. -/// `positional` may instead have type `[]const [:0]const u8`. +/// Either or both of `named` and `positional` may be omitted, which is effectively equivalent to them having no fields. /// /// The sequence of arg strings from the `ArgIterator` is parsed to determine named and positional arguments. /// @@ -62,7 +65,7 @@ pub const Error = error{ /// Forms (1), (2), and (3) must correspond to a field `Args.named.`; see below for named argument handling. /// Form (4) immediately prints the long help documentation and exits or returns `error.Help` depending on options.exit. /// Form (6) signals that all following arg strings are positional. -/// Form (7) and all arg strings following form (6) are appended into the `positional` array in order. +/// Form (7) and all arg strings following form (6) are considered positional arguments, discussed below. /// /// Form (5) is always an error. /// This API does not support single letter aliases like `-v` or `-lA` or named arguments prefixed by only a single hyphen like `-flag`. @@ -88,6 +91,17 @@ pub const Error = error{ /// Slice arguments `[]const C` (where `C` is not `u8`) must have a default value, usually `&.{}`. /// If a bool argument has no default value, then at least one of `--` or `--no-` must be given. /// +/// Each positional arg string corresponds to a field in `Args.positional` in declaration order. +/// Each field in `Args.positional` may have a default value, making the corresponding argument optional. +/// Fields for required positional arguments must precede fields for optional arguments. +/// For each field, let `T` be its type. +/// Similar to `Args.named` described above, `T` may be any of the following: +/// any integer, any float, any `enum` with at least 1 member, or any string that `[:0]const u8` can coerce into. +/// Only the last declared field of `Args.positional` may alternatively have type `[]const C` where `C` is one of: +/// any integer, any float, any `enum` with at least 1 member, or any string that `[:0]const u8` can coerce into. +/// Similar to `Args.named`, a positional field declared with such a `[]const C` must have a default value, usually `&.{}`. +/// Such a `[]const C` field corresponds to all positional arguments after the positional arguments for the other fields. +/// /// It's possible to override the automatically-generated long help documentation by declaring a public constant named `help` in `Args`. /// The value must coerce to `[]const u8`. /// @@ -104,7 +118,6 @@ pub const Error = error{ /// named: struct { /// // [...] /// }, -/// positional: []const []const u8 = &.{}, /// }; /// ``` /// @@ -136,13 +149,21 @@ test parse { level: i8 = -1, /// Parsed as the name of the member `--color=never`. color: enum { auto, never, always } = .auto, - /// --seed=0x is actually passed in by the `zig test` system (as of 0.14.1), which we receive here. + + // The below parameters are actually passed into the `zig test` process, + // so we have to receive them here (as of zig 0.15.1). seed: u32 = 0, @"cache-dir": []const u8 = "", listen: []const u8 = "", }, - /// Receives the rest of the arguments. - positional: []const [:0]const u8 = &.{}, + positional: struct { + /// First positional (non-named) argument: + input: [:0]const u8 = "", + /// Second positional argument is declared as optional: + reptitions: u32 = 1, + /// Receives the rest of the positional arguments. + @"the-rest": []const [:0]const u8 = &.{}, + }, }; var arena: ArenaAllocator = .init(testing.allocator); @@ -157,8 +178,8 @@ test parse { /// ``` /// pub fn next(self: *Self) ?String { ... } /// ``` -/// Where `String` is `[]const u8` or `[:0]const u8`, or something else that coerces to `[]const u8`. -/// If `String` does not coerce to `[:0]const u8`, then `Args` cannot have `[:0]const u8` fields. +/// Where `String` is `[]const u8` or `[:0]const u8` or something else that coerces to `[]const u8`. +/// If `String` does not coerce to `[:0]const u8`, then `Args` cannot have any `[:0]const u8` in its fields. /// /// The first string arg returned by the `iter` (`argv[0]`) is skipped by all the parsing logic. /// If `options.prog` is `null`, then the final path component of `argv[0]` is used by default. @@ -170,7 +191,7 @@ test parse { /// /// An `ArenaAllocator` is recommended to cleanup the memory allocated from this function; /// however, it's also possible to free all the memory by freeing every slice field `[]const C` (other than `u8`) -/// in the returned `args.named` as well as freeing `args.positional`. +/// in the returned `args.named` and `args.positional`. pub fn parseIter(comptime Args: type, arena: Allocator, iter: anytype, options: Options) Error!Args { const argv0 = iter.next(); const prog = options.prog orelse if (argv0) |arg| std.fs.path.basename(arg) else ""; @@ -179,7 +200,7 @@ pub fn parseIter(comptime Args: type, arena: Allocator, iter: anytype, options: /// Like `parse`, but takes a slice of strings in place of using an `ArgIterator`. /// `argv` must be either be a slice of `String` or a single-item pointer to an array of `String`, -/// where `String` is `[]const u8` or `[:0]const u8` or something that coerces to `[]const u8`. +/// where `String` is `[]const u8` or `[:0]const u8` or something else that coerces to `[]const u8`. /// If `String` does not coerce to `[:0]const u8`, then `Args` cannot have `[:0]const u8` fields. /// /// Unlike `parse` and `parseIter`, this function does not skip the first item of `argv`. @@ -192,7 +213,7 @@ pub fn parseIter(comptime Args: type, arena: Allocator, iter: anytype, options: /// /// An `ArenaAllocator` is recommended to cleanup the memory allocated from this function; /// however, it's also possible to free all the memory by freeing every slice field `[]const C` (other than `u8`) -/// in the returned `args.named` as well as freeing `args.positional`. +/// in the returned `args.named` and `args.positional`. pub fn parseSlice(comptime Args: type, arena: Allocator, argv: anytype, options: Options) Error!Args { const argvInfo = @typeInfo(@TypeOf(argv)).pointer; const String = if (argvInfo.size == .one) @@ -218,7 +239,9 @@ test parseSlice { flag: bool = true, @"enum-option": enum { auto, always, never } = .auto, }, - positional: []const []const u8 = &.{}, + positional: struct { + args: []const []const u8 = &.{}, + }, }; const args = try parseSlice(Args, allocator, &[_][]const u8{ "--example_required", "a.txt", @@ -238,7 +261,7 @@ test parseSlice { .flag = false, .@"enum-option" = .always, }, - .positional = &.{ "positional1", "positional2", "-12345678", "--positional4", "--positional=5" }, + .positional = .{ .args = &.{ "positional1", "positional2", "-12345678", "--positional4", "--positional=5" } }, }, args); } @@ -246,42 +269,19 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] // argv0 has already been consumed. // Do all comptime checks up front so that we can be sure any compile error the user sees is the one we wrote. - comptime checkArgsType(Args); + const named_fields, const positional_fields = comptime checkArgsType(Args); + + var named_array_lists = arrayListsForFields(named_fields); + var positional_array_lists = arrayListsForFields(positional_fields); var result: Args = undefined; - var positional: ArrayList(@typeInfo(@TypeOf(result.positional)).pointer.child) = .{}; + var named_fields_seen = [_]bool{false} ** named_fields.len; + var positional_field_index: usize = 0; - const ArgsNamed = @TypeOf(result.named); - const named_info = @typeInfo(ArgsNamed).@"struct"; - - // Declare and initialize an ArrayList(C) for every []const C field (other than u8). - var fields_seen = [_]bool{false} ** named_info.fields.len; - comptime var array_list_fields: []const std.builtin.Type.StructField = &.{}; - inline for (named_info.fields) |field| { - const info = @typeInfo(field.type); - if (info == .pointer) { - comptime assert(info.pointer.size == .slice); - if (info.pointer.child == u8) { - // String. skip. - } else { - // Array of scalar. - array_list_fields = array_list_fields ++ @as([]const std.builtin.Type.StructField, &.{.{ - .name = field.name, - .type = ArrayList(info.pointer.child), - .default_value_ptr = null, - .is_comptime = false, - .alignment = @alignOf(ArrayList(info.pointer.child)), - }}); - } - } - } - var array_lists: @Type(.{ .@"struct" = .{ .layout = .auto, .fields = array_list_fields, .decls = &.{}, .is_tuple = false } }) = undefined; - inline for (@typeInfo(@TypeOf(array_lists)).@"struct".fields) |field| { - @field(array_lists, field.name) = .{}; - } + var the_rest_is_positional = false; while (iter.next()) |arg| { - if (mem.eql(u8, arg, "--help")) { + if (!the_rest_is_positional and mem.eql(u8, arg, "--help")) { if (@hasDecl(Args, "help")) { // Custom help. if (writer) |w| { @@ -293,7 +293,7 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] file_writer.interface.flush() catch {}; } } else { - printGeneratedHelp(writer, prog, named_info); + printGeneratedHelp(writer, prog, named_fields); } if (exit_on_error) { std.process.exit(0); @@ -301,22 +301,32 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] return error.Help; } - if (arg.len >= 2 and arg[0] == '-' and isAlphabetic(arg[1])) { + if (!the_rest_is_positional and arg.len >= 2 and arg[0] == '-' and isAlphabetic(arg[1])) { // Always invalid. // Examples: -h, -flag, -I/path return usageError(writer, "unrecognized argument: {s}", .{arg}, exit_on_error); } - if (mem.eql(u8, arg, "--")) { + if (!the_rest_is_positional and mem.eql(u8, arg, "--")) { // Stop recognizing named arguments. Everything else is positional. - while (iter.next()) |arg2| { - try positional.append(allocator, arg2); // To resolve compile errors between `[:0]const u8` and `[]const u8` on this line, ensure the passed-in args are `[:0]const u8`. - } - break; + the_rest_is_positional = true; + continue; } - if (!(arg.len >= 3 and arg[0] == '-' and arg[1] == '-')) { + if (the_rest_is_positional or !(arg.len >= 3 and arg[0] == '-' and arg[1] == '-')) { // Positional. - // Examples: "", "a", "-", "-1", - try positional.append(allocator, arg); + // Examples: "", "a", "-", "-1", "other" + if (positional_field_index >= positional_fields.len) return usageError(writer, "unexpected positional argument: {s}", .{arg}, exit_on_error); + inline for (positional_fields, 0..) |field, i| { + if (positional_field_index == i) { + if (getArrayChild(field.type)) |C| { + try @field(positional_array_lists, field.name).append(allocator, try parseValue(C, arg, field.name, writer, exit_on_error)); + // Don't increment positional_field_index. + } else { + @field(result.positional, field.name) = try parseValue(field.type, arg, field.name, writer, exit_on_error); + positional_field_index += 1; + } + break; + } + } else unreachable; continue; } @@ -335,12 +345,12 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] break :blk .{ arg["--".len..], null, false }; }; - inline for (named_info.fields, 0..) |field, i| { + inline for (named_fields, 0..) |field, i| { if (mem.eql(u8, field.name, arg_name)) { + named_fields_seen[i] = true; if (field.type == bool) { if (immediate_value != null) return usageError(writer, "cannot specify value for bool argument: {s}", .{arg}, exit_on_error); @field(result.named, field.name) = !no_prefixed; - fields_seen[i] = true; break; } if (no_prefixed) return usageError(writer, "unrecognized argument: {s}", .{arg}, exit_on_error); @@ -348,56 +358,11 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] // All other argument types require a value. const arg_value = immediate_value orelse iter.next() orelse return usageError(writer, "expected argument after --{s}", .{field.name}, exit_on_error); - switch (@typeInfo(field.type)) { - .bool => unreachable, // Handled above. - .float => { - @field(result.named, field.name) = std.fmt.parseFloat(field.type, arg_value) catch |err| { - return usageError(writer, "unable to parse --{s}={s}: {s}", .{ field.name, arg_value, @errorName(err) }, exit_on_error); - }; - }, - .int => { - @field(result.named, field.name) = std.fmt.parseInt(field.type, arg_value, 0) catch |err| { - return usageError(writer, "unable to parse --{s}={s}: {s}", .{ field.name, arg_value, @errorName(err) }, exit_on_error); - }; - }, - .@"enum" => { - @field(result.named, field.name) = std.meta.stringToEnum(field.type, arg_value) orelse { - return usageError(writer, "unrecognized value: --{s}={s}, expected one of: {s}", .{ field.name, arg_value, enumValuesExpr(field.type) }, exit_on_error); - }; - }, - .pointer => |ptrInfo| { - comptime assert(ptrInfo.size == .slice); - if (ptrInfo.child == u8) { - @field(result.named, field.name) = arg_value; // To resolve compile errors between `[:0]const u8` and `[]const u8` on this line, ensure the passed-in args are `[:0]const u8`. - } else { - const array_list = &@field(array_lists, field.name); - switch (@typeInfo(ptrInfo.child)) { - .bool => comptime unreachable, // Nicer compile error emitted in checkArgsType(). - .float => { - try array_list.append(allocator, std.fmt.parseFloat(ptrInfo.child, arg_value) catch |err| { - return usageError(writer, "unable to parse --{s}={s}: {s}", .{ field.name, arg_value, @errorName(err) }, exit_on_error); - }); - }, - .int => { - try array_list.append(allocator, std.fmt.parseInt(ptrInfo.child, arg_value, 0) catch |err| { - return usageError(writer, "unable to parse --{s}={s}: {s}", .{ field.name, arg_value, @errorName(err) }, exit_on_error); - }); - }, - .@"enum" => comptime unreachable, - .pointer => |ptrInfo2| { - comptime assert(ptrInfo2.size == .slice); - if (ptrInfo2.child == u8) { - // String. - try array_list.append(allocator, arg_value); // To resolve compile errors between `[:0]const u8` and `[]const u8` on this line, ensure the passed-in args are `[:0]const u8`. - } else comptime unreachable; - }, - else => comptime unreachable, - } - } - }, - else => comptime unreachable, + if (getArrayChild(field.type)) |C| { + try @field(named_array_lists, field.name).append(allocator, try parseValue(C, arg_value, field.name, writer, exit_on_error)); + } else { + @field(result.named, field.name) = try parseValue(field.type, arg_value, field.name, writer, exit_on_error); } - fields_seen[i] = true; break; } } else { @@ -407,74 +372,202 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] } // Fill default values. - inline for (named_info.fields, 0..) |field, i| { - if (!fields_seen[i]) { - if (field.defaultValue()) |default| { - @field(result.named, field.name) = default; - } else { - if (field.type == bool) { - return usageError(writer, "missing required argument: --" ++ field.name ++ " or --no-" ++ field.name, .{}, exit_on_error); + inline for (named_fields, 0..) |field, i| { + if (getArrayChild(field.type)) |_| { + // Array. + @field(result.named, field.name) = try @field(named_array_lists, field.name).toOwnedSlice(allocator); + } else { + // Scalar. + if (!named_fields_seen[i]) { + // Unspecified. + if (field.defaultValue()) |default| { + @field(result.named, field.name) = default; } else { - return usageError(writer, "missing required argument: --" ++ field.name, .{}, exit_on_error); + if (field.type == bool) { + return usageError(writer, "missing required argument: --" ++ field.name ++ " or --no-" ++ field.name, .{}, exit_on_error); + } else { + return usageError(writer, "missing required argument: --" ++ field.name, .{}, exit_on_error); + } + } + } + } + } + inline for (positional_fields, 0..) |field, i| { + if (getArrayChild(field.type)) |_| { + // Array. + @field(result.positional, field.name) = try @field(positional_array_lists, field.name).toOwnedSlice(allocator); + } else { + // Scalar. + if (positional_field_index <= i) { + // Unspecified. + if (field.defaultValue()) |default| { + @field(result.positional, field.name) = default; + } else { + return usageError(writer, "missing required argument: " ++ field.name, .{}, exit_on_error); } } } } - // Finalize the array lists. - result.positional = try positional.toOwnedSlice(allocator); - inline for (@typeInfo(@TypeOf(array_lists)).@"struct".fields) |field| { - @field(result.named, field.name) = try @field(array_lists, field.name).toOwnedSlice(allocator); - } - return result; } -fn checkArgsType(comptime Args: type) void { - const args_fields = @typeInfo(Args).@"struct".fields; - if (!(args_fields.len == 2 and mem.eql(u8, args_fields[0].name, "named") and mem.eql(u8, args_fields[1].name, "positional"))) @compileError("expected Args to have exactly these fields in this order: named, positional"); - if (args_fields[1].default_value_ptr == null) @compileError("Args.positional must have a default value"); +/// arg_value is []const u8 or [:0]const u8. +fn parseValue(comptime T: type, arg_value: anytype, comptime field_name: []const u8, writer: ?*Writer, exit_on_error: bool) !T { + switch (@typeInfo(T)) { + .bool => comptime unreachable, // Handled elsewhere. + .float => { + return std.fmt.parseFloat(T, arg_value) catch |err| { + return usageError(writer, "unable to parse --{s}={s}: {s}", .{ field_name, arg_value, @errorName(err) }, exit_on_error); + }; + }, + .int => { + return std.fmt.parseInt(T, arg_value, 0) catch |err| { + return usageError(writer, "unable to parse --{s}={s}: {s}", .{ field_name, arg_value, @errorName(err) }, exit_on_error); + }; + }, + .@"enum" => { + return std.meta.stringToEnum(T, arg_value) orelse { + return usageError(writer, "unrecognized value: --{s}={s}, expected one of: {s}", .{ field_name, arg_value, enumValuesExpr(T) }, exit_on_error); + }; + }, + .pointer => |ptrInfo| { + comptime assert(ptrInfo.size == .slice); + comptime assert(ptrInfo.child == u8); + return arg_value; // To resolve compile errors between `[:0]const u8` and `[]const u8` on this line, ensure the passed-in args are `[:0]const u8`. + }, + else => comptime unreachable, + } +} - inline for (@typeInfo(args_fields[0].type).@"struct".fields) |field| { - if (field.is_comptime) @compileError("comptime fields are not supported: " ++ field.name); - if (comptime mem.eql(u8, field.name, "help")) @compileError("A field named help is not allowed. add a `pub const help = \"...\";` to your `Args` to provide a custom help string."); - if (comptime mem.startsWith(u8, field.name, "no-")) @compileError("Field name starts with @\"no-\": " ++ field.name ++ ". Note: use a bool type field, and -- and --no- will turn it on and off."); - if (comptime mem.indexOfScalar(u8, field.name, '=') != null) @compileError("Field name contains @\"=\": " ++ field.name); +fn checkArgsType(comptime Args: type) struct { []const StructField, []const StructField } { + var has_named = false; + var has_positional = false; + inline for (@typeInfo(Args).@"struct".fields) |field| { + if (mem.eql(u8, field.name, "named")) { + has_named = true; + } else if (mem.eql(u8, field.name, "positional")) { + has_positional = true; + } else @compileError("unrecognized Args name: " ++ field.name); + } - switch (@typeInfo(field.type)) { - .bool => {}, - .float => {}, - .int => {}, - .@"enum" => { - if (@typeInfo(field.type).@"enum".fields.len == 0) @compileError("Empty enums not allowed"); - }, - .pointer => |ptrInfo| { - if (ptrInfo.size != .slice) @compileError("Unsupported field type: " ++ @typeName(field.type)); - if (ptrInfo.child == u8) { - // String. - } else { - // Array. - if (field.default_value_ptr == null) @compileError("Array arguments must have a default value: " ++ field.name); - switch (@typeInfo(ptrInfo.child)) { - .bool => @compileError("Unsupported field type: " ++ @typeName(field.type)), - .float => {}, - .int => {}, - .@"enum" => @compileError("Unsupported field type: " ++ @typeName(field.type)), - .pointer => |ptrInfo2| { - if (ptrInfo2.size != .slice) @compileError("Unsupported field type: " ++ @typeName(field.type)); - if (ptrInfo2.child == u8) { - // String. - } else { - @compileError("Unsupported field type: " ++ @typeName(field.type)); - } - }, - else => @compileError("Unsupported field type: " ++ @typeName(field.type)), - } - } - }, - else => @compileError("Unsupported field type: " ++ @typeName(field.type)), + const named_fields = if (has_named) @typeInfo(@TypeOf(@as(Args, undefined).named)).@"struct".fields else &.{}; + const positional_fields = if (has_positional) @typeInfo(@TypeOf(@as(Args, undefined).positional)).@"struct".fields else &.{}; + + // Named arguments are more lenient. + inline for (named_fields) |field| { + validateField(field); + } + + // Positional arguments have stricter rules. + var everything_still_required = true; + var everything_still_scalar = true; + inline for (positional_fields) |field| { + if (field.type == bool) @compileError("Args.positional cannot have bool fields: " ++ field.name); + validateField(field); + const is_scalar = getArrayChild(field.type) == null; + + const is_required = field.default_value_ptr == null; + + // There can only be one array parameter, and it must be last. + if (everything_still_scalar) { + if (!is_scalar) { + everything_still_scalar = false; + } + } else @compileError("a positional array argument must be last. found: " ++ field.name); + + // Required positional parameters must come first. + if (everything_still_required) { + if (!is_required) { + everything_still_required = false; + } + } else { + if (is_required) @compileError("cannot have a required positional argument after an optional one: " ++ field.name); } } + + return .{ named_fields, positional_fields }; +} + +fn validateField(field: StructField) void { + if (field.is_comptime) @compileError("comptime fields are not supported: " ++ field.name); + if (comptime mem.eql(u8, field.name, "help")) @compileError("A field named help is not allowed. add a `pub const help = \"...\";` to your `Args` to provide a custom help string."); + if (comptime mem.startsWith(u8, field.name, "no-")) @compileError("Field name starts with @\"no-\": " ++ field.name ++ ". Note: use a bool type field, and -- and --no- will turn it on and off."); + if (comptime mem.indexOfScalar(u8, field.name, '=') != null) @compileError("Field name contains @\"=\": " ++ field.name); + + switch (@typeInfo(field.type)) { + .bool => {}, + .float => {}, + .int => {}, + .@"enum" => { + if (@typeInfo(field.type).@"enum".fields.len == 0) @compileError("Empty enums not allowed"); + }, + .pointer => |ptrInfo| { + if (ptrInfo.size != .slice) @compileError("Unsupported field type: " ++ @typeName(field.type)); + if (ptrInfo.child == u8) { + // String. + } else { + // Array. + if (field.default_value_ptr == null) @compileError("Array arguments must have a default value: " ++ field.name); + switch (@typeInfo(ptrInfo.child)) { + .bool => @compileError("Unsupported field type: " ++ @typeName(field.type)), + .float => {}, + .int => {}, + .@"enum" => @compileError("Unsupported field type: " ++ @typeName(field.type)), + .pointer => |ptrInfo2| { + if (ptrInfo2.size != .slice) @compileError("Unsupported field type: " ++ @typeName(field.type)); + if (ptrInfo2.child == u8) { + // String. + } else { + @compileError("Unsupported field type: " ++ @typeName(field.type)); + } + }, + else => @compileError("Unsupported field type: " ++ @typeName(field.type)), + } + } + }, + else => @compileError("Unsupported field type: " ++ @typeName(field.type)), + } +} + +/// returns null if T is a scalar type. +fn getArrayChild(comptime T: type) ?type { + // This logic assumes the type has already passed validation. + return switch (@typeInfo(T)) { + .pointer => |ptrInfo| if (ptrInfo.child == u8) null else ptrInfo.child, + else => null, + }; +} + +fn arrayListsForFields(comptime fields: []const StructField) ArrayListsForFields(fields) { + var array_lists: ArrayListsForFields(fields) = undefined; + inline for (@typeInfo(@TypeOf(array_lists)).@"struct".fields) |field| { + @field(array_lists, field.name) = .{}; + } + return array_lists; +} +fn ArrayListsForFields(comptime fields: []const StructField) type { + // Declare and initialize an ArrayList(C) for every []const C field (other than u8). + comptime var array_list_fields: []const StructField = &.{}; + inline for (fields) |field| { + const info = @typeInfo(field.type); + if (info == .pointer) { + comptime assert(info.pointer.size == .slice); + if (info.pointer.child == u8) { + // String. skip. + } else { + // Array of scalar. + array_list_fields = array_list_fields ++ @as([]const StructField, &.{.{ + .name = field.name, + .type = ArrayList(info.pointer.child), + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(ArrayList(info.pointer.child)), + }}); + } + } + } + return @Type(.{ .@"struct" = .{ .layout = .auto, .fields = array_list_fields, .decls = &.{}, .is_tuple = false } }); } /// If you do your own validation after getting an `args` from `parse` or similar, @@ -494,7 +587,9 @@ test @"error" { named: struct { output: []const u8 = "", }, - positional: []const []const u8 = &.{}, + positional: struct { + input: []const u8, + }, }; var arena: std.heap.ArenaAllocator = .init(testing.allocator); @@ -504,9 +599,6 @@ test @"error" { if (std.fs.path.isAbsolute(args.named.output)) { return std.cli.@"error"("--output must not be absolute: {s}", .{args.named.output}, .{ .exit = false }); } - if (args.positional.len > 1) { - return std.cli.@"error"("expected exactly 1 positional arg", .{}, .{ .exit = false }); - } } fn usageError(writer: ?*Writer, comptime msg: []const u8, args: anytype, exit_on_error: bool) error{Usage} { @@ -552,7 +644,7 @@ fn enumValuesExpr(comptime Enum: type) []const u8 { return values_str; } -fn printGeneratedHelp(writer: ?*Writer, prog: []const u8, comptime named_info: std.builtin.Type.Struct) void { +fn printGeneratedHelp(writer: ?*Writer, prog: []const u8, comptime named_fields: []const StructField) void { const msg = // \\usage: {s} [options] [arg...] \\ @@ -561,7 +653,7 @@ fn printGeneratedHelp(writer: ?*Writer, prog: []const u8, comptime named_info: s \\ ; comptime var arguments_str: []const u8 = ""; - inline for (named_info.fields) |field| { + inline for (named_fields) |field| { switch (@typeInfo(field.type)) { .bool => { if (field.defaultValue()) |default| { @@ -635,121 +727,48 @@ inline fn quoteIfEmpty(comptime s: []const u8) []const u8 { var failing_writer: Writer = .failing; const silent_options = Options{ .writer = &failing_writer, .exit = false }; -test "usage errors" { +test "bool" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); - var aw: Writer.Allocating = .init(allocator); - const options = Options{ .prog = "test-prog", .writer = &aw.writer }; - // unrecognized argument - aw.clearRetainingCapacity(); - try testing.expectError(error.Usage, parseSlice(struct { + const Args = struct { named: struct { - name: []const u8 = "", + b: bool, }, - positional: []const []const u8 = &.{}, - }, allocator, &[_][]const u8{"--bogus"}, options)); - try testing.expect(mem.indexOf(u8, aw.written(), "--bogus") != null); + }; - // expected argument - aw.clearRetainingCapacity(); - try testing.expectError(error.Usage, parseSlice(struct { - named: struct { - name: []const u8 = "", - }, - positional: []const []const u8 = &.{}, - }, allocator, &[_][]const u8{"--name"}, options)); - try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); + try testing.expectEqualDeep(Args{ .named = .{ .b = true } }, try parseSlice(Args, allocator, &[_][]const u8{"--b"}, .{})); + try testing.expectEqualDeep(Args{ .named = .{ .b = false } }, try parseSlice(Args, allocator, &[_][]const u8{"--no-b"}, .{})); + try testing.expectEqualDeep(Args{ .named = .{ .b = true } }, try parseSlice(Args, allocator, &[_][]const u8{ "--no-b", "--b" }, .{})); + try testing.expectEqualDeep(Args{ .named = .{ .b = false } }, try parseSlice(Args, allocator, &[_][]const u8{ "--b", "--no-b" }, .{})); - // --no- for non-bool. - aw.clearRetainingCapacity(); - try testing.expectError(error.Usage, parseSlice(struct { - named: struct { - name: []const u8 = "", - }, - positional: []const []const u8 = &.{}, - }, allocator, &[_][]const u8{"--no-name"}, options)); - try testing.expect(mem.indexOf(u8, aw.written(), "--no-name") != null); + try testing.expectError(error.Usage, parseSlice(Args, allocator, &[_][]const u8{"--b=true"}, silent_options)); + try testing.expectError(error.Usage, parseSlice(Args, allocator, &[_][]const u8{"--b=false"}, silent_options)); +} - // --name=false for bool - aw.clearRetainingCapacity(); - try testing.expectError(error.Usage, parseSlice(struct { - named: struct { - name: bool = false, - }, - positional: []const []const u8 = &.{}, - }, allocator, &[_][]const u8{"--name=true"}, options)); - try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); +test "string" { + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); - // missing required argument - aw.clearRetainingCapacity(); - try testing.expectError(error.Usage, parseSlice(struct { + const Args = struct { named: struct { - name: []const u8, + a: []const u8, + b: [:0]const u8, }, - positional: []const []const u8 = &.{}, - }, allocator, &[_][]const u8{}, options)); - try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); + }; + const args = try parseSlice(Args, allocator, &[_][:0]const u8{ + "--a", "a", + "--b", "b", + }, .{}); - // parse int error - aw.clearRetainingCapacity(); - try testing.expectError(error.Usage, parseSlice(struct { - named: struct { - name: i32, + try testing.expectEqualDeep(Args{ + .named = .{ + .a = "a", + .b = "b", }, - positional: []const []const u8 = &.{}, - }, allocator, &[_][]const u8{"--name=abc"}, options)); - try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); - aw.clearRetainingCapacity(); - try testing.expectError(error.Usage, parseSlice(struct { - named: struct { - name: []const i32 = &.{}, - }, - positional: []const []const u8 = &.{}, - }, allocator, &[_][]const u8{"--name=abc"}, options)); - try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); - - // parse float error - aw.clearRetainingCapacity(); - try testing.expectError(error.Usage, parseSlice(struct { - named: struct { - name: f32, - }, - positional: []const []const u8 = &.{}, - }, allocator, &[_][]const u8{"--name=abc"}, options)); - try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); - aw.clearRetainingCapacity(); - try testing.expectError(error.Usage, parseSlice(struct { - named: struct { - name: []const f32 = &.{}, - }, - positional: []const []const u8 = &.{}, - }, allocator, &[_][]const u8{"--name=abc"}, options)); - try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); - - // parse enum error - aw.clearRetainingCapacity(); - try testing.expectError(error.Usage, parseSlice(struct { - named: struct { - name: enum { auto, never, always }, - }, - positional: []const []const u8 = &.{}, - }, allocator, &[_][]const u8{"--name=abc"}, options)); - try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); - try testing.expect(mem.indexOf(u8, aw.written(), "abc") != null); - // Error should suggest the set of options. - try testing.expect(mem.indexOf(u8, aw.written(), "always") != null); - - // reject single-letter alias-looking arguments - aw.clearRetainingCapacity(); - try testing.expectError(error.Usage, parseSlice(struct { - named: struct { - z: bool = false, - }, - positional: []const []const u8 = &.{}, - }, allocator, &[_][]const u8{"-z"}, options)); - try testing.expect(mem.indexOf(u8, aw.written(), "-z") != null); + }, args); } test "ints and floats" { @@ -768,7 +787,6 @@ test "ints and floats" { inf_f32: f32, ninf_f64: f64, }, - positional: []const []const u8 = &.{}, }; const args = try parseSlice(Args, allocator, &[_][]const u8{ "--int_u32", "0xffffffff", @@ -792,14 +810,12 @@ test "ints and floats" { .inf_f32 = std.math.inf(f32), .ninf_f64 = -std.math.inf(f64), }, - .positional = &.{}, }, args); const Args2 = struct { named: struct { nan: f64, }, - positional: []const []const u8 = &.{}, }; const args2 = try parseSlice(Args2, allocator, &[_][]const u8{ "--nan", "nAN", @@ -808,53 +824,6 @@ test "ints and floats" { try testing.expect(std.math.isNan(args2.named.nan)); } -test "bool" { - var arena: std.heap.ArenaAllocator = .init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - const Args = struct { - named: struct { - b: bool, - }, - positional: []const []const u8 = &.{}, - }; - - try testing.expectEqualDeep(Args{ .named = .{ .b = true } }, try parseSlice(Args, allocator, &[_][]const u8{"--b"}, .{})); - try testing.expectEqualDeep(Args{ .named = .{ .b = false } }, try parseSlice(Args, allocator, &[_][]const u8{"--no-b"}, .{})); - try testing.expectEqualDeep(Args{ .named = .{ .b = true } }, try parseSlice(Args, allocator, &[_][]const u8{ "--no-b", "--b" }, .{})); - try testing.expectEqualDeep(Args{ .named = .{ .b = false } }, try parseSlice(Args, allocator, &[_][]const u8{ "--b", "--no-b" }, .{})); - - try testing.expectError(error.Usage, parseSlice(Args, allocator, &[_][]const u8{"--b=true"}, silent_options)); - try testing.expectError(error.Usage, parseSlice(Args, allocator, &[_][]const u8{"--b=false"}, silent_options)); -} - -test "string" { - var arena: std.heap.ArenaAllocator = .init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - const Args = struct { - named: struct { - a: []const u8, - b: [:0]const u8, - }, - positional: []const []const u8 = &.{}, - }; - const args = try parseSlice(Args, allocator, &[_][:0]const u8{ - "--a", "a", - "--b", "b", - }, .{}); - - try testing.expectEqualDeep(Args{ - .named = .{ - .a = "a", - .b = "b", - }, - .positional = &.{}, - }, args); -} - test "array" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); @@ -865,23 +834,27 @@ test "array" { path: []const []const u8 = &.{}, id: []const i32 = &.{}, }, - positional: []const []const u8 = &.{}, + positional: struct { + args: []const []const u8 = &.{}, + }, }; - const args = try parseSlice(Args, allocator, &[_][]const u8{ - "--path", "a", - "--path", "b", - "--path", "a", - "--id", "1", - "--id", "-12", - }, .{}); try testing.expectEqualDeep(Args{ .named = .{ .path = &[_][]const u8{ "a", "b", "a" }, .id = &[_]i32{ 1, -12 }, }, - .positional = &.{}, - }, args); + .positional = .{ + .args = &[_][]const u8{ "x", "y" }, + }, + }, try parseSlice(Args, allocator, &[_][]const u8{ + "--path", "a", + "--path", "b", + "--path", "a", + "--id", "1", + "--id", "-12", + "x", "y", + }, .{})); } test "enum" { @@ -905,7 +878,6 @@ test "enum" { VTALRM = 26, }, }, - positional: []const []const u8 = &.{}, }; const args = try parseSlice(Args, allocator, &[_][]const u8{ "--color", "always", @@ -919,7 +891,6 @@ test "enum" { .guess = .@"the-only-option", .signal = .TERM, }, - .positional = &.{}, }, args); } @@ -942,24 +913,20 @@ test "defaults" { force: bool = false, cleanup: bool = true, }, - positional: []const []const u8 = &.{}, }; try testing.expectEqualDeep(Args{ .named = .{}, - .positional = &.{}, }, try parseSlice(Args, allocator, &[_][]const u8{}, .{})); try testing.expectEqualDeep(Args{ .named = .{ .color = .always, }, - .positional = &.{}, }, try parseSlice(Args, allocator, &[_][]const u8{ "--color", "always" }, .{})); try testing.expectEqualDeep(Args{ .named = .{ .file = &[_][]const u8{"file.txt"}, }, - .positional = &.{}, }, try parseSlice(Args, allocator, &[_][]const u8{ "--file", "file.txt" }, .{})); try testing.expectEqualDeep(Args{ @@ -967,10 +934,208 @@ test "defaults" { .force = true, .cleanup = false, }, - .positional = &.{}, }, try parseSlice(Args, allocator, &[_][]const u8{ "--force", "--no-cleanup" }, .{})); } +test "positional" { + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + // defaults + { + const Args = struct { + positional: struct { + level: i8 = -1, + ratio: f32 = 0.5, + path: []const u8 = "-", + color: enum { + always, + never, + auto, + } = .auto, + file: []const []const u8 = &.{}, + }, + }; + + try testing.expectEqualDeep(Args{ + .positional = .{}, + }, try parseSlice(Args, allocator, &[_][]const u8{}, .{})); + try testing.expectEqualDeep(Args{ + .positional = .{ + .level = 1, + .ratio = 2, + .path = "a.txt", + .color = .always, + .file = &[_][]const u8{ "file1", "file2" }, + }, + }, try parseSlice(Args, allocator, &[_][]const u8{ "1", "2", "a.txt", "always", "file1", "file2" }, .{})); + } + + // required + { + const Args = struct { + positional: struct { + level: i8, + ratio: f32, + path: []const u8, + color: enum { + always, + never, + auto, + }, + file: []const []const u8 = &.{}, + }, + }; + + try testing.expectError(error.Usage, parseSlice(Args, allocator, &[_][]const u8{}, silent_options)); + try testing.expectError(error.Usage, parseSlice(Args, allocator, &[_][]const u8{ "1", "2", "a.txt" }, silent_options)); + try testing.expectEqualDeep(Args{ + .positional = .{ + .level = 1, + .ratio = 2, + .path = "a.txt", + .color = .always, + }, + }, try parseSlice(Args, allocator, &[_][]const u8{ "1", "2", "a.txt", "always" }, .{})); + } +} + +test "usage errors" { + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + var aw: Writer.Allocating = .init(allocator); + const options = Options{ .prog = "test-prog", .writer = &aw.writer }; + + // unrecognized argument + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + named: struct { + name: []const u8 = "", + }, + }, allocator, &[_][]const u8{"--bogus"}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "--bogus") != null); + + // expected argument + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + named: struct { + name: []const u8 = "", + }, + }, allocator, &[_][]const u8{"--name"}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); + + // --no- for non-bool. + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + named: struct { + name: []const u8 = "", + }, + }, allocator, &[_][]const u8{"--no-name"}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "--no-name") != null); + + // --name=false for bool + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + named: struct { + name: bool = false, + }, + }, allocator, &[_][]const u8{"--name=true"}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); + + // missing required argument + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + named: struct { + name: []const u8, + }, + }, allocator, &[_][]const u8{}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); + + // parse int error + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + named: struct { + name: i32, + }, + }, allocator, &[_][]const u8{"--name=abc"}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + named: struct { + name: []const i32 = &.{}, + }, + }, allocator, &[_][]const u8{"--name=abc"}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); + + // parse float error + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + named: struct { + name: f32, + }, + }, allocator, &[_][]const u8{"--name=abc"}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + named: struct { + name: []const f32 = &.{}, + }, + }, allocator, &[_][]const u8{"--name=abc"}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); + + // parse enum error + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + named: struct { + name: enum { auto, never, always }, + }, + }, allocator, &[_][]const u8{"--name=abc"}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null); + try testing.expect(mem.indexOf(u8, aw.written(), "abc") != null); + // Error should suggest the set of options. + try testing.expect(mem.indexOf(u8, aw.written(), "always") != null); + + // reject single-letter alias-looking arguments + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + named: struct { + z: bool = false, + }, + positional: struct { + args: []const []const u8 = &.{}, + }, + }, allocator, &[_][]const u8{"-z"}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "-z") != null); + + // expected required positional argument + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + positional: struct { + input_file: []const u8, + }, + }, allocator, &[_][]const u8{}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "input_file") != null); + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + positional: struct { + input_file: []const u8, + output_file: []const u8 = "", + }, + }, allocator, &[_][]const u8{}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "input_file") != null); + aw.clearRetainingCapacity(); + try testing.expectError(error.Usage, parseSlice(struct { + positional: struct { + input_file: []const u8, + output_file: []const u8, + other: []const u8 = "", + }, + }, allocator, &[_][]const u8{"input.txt"}, options)); + try testing.expect(mem.indexOf(u8, aw.written(), "output_file") != null); +} + test "help" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); @@ -985,7 +1150,6 @@ test "help" { int: i32, flag: bool, }, - positional: []const []const u8 = &.{}, }, allocator, &[_][]const u8{"--help"}, options)); // Because the help output is primarily for humans, don't get too strict in the unit test. // Only verify that we see the important stuff that should definitely be there somewhere, @@ -1002,7 +1166,6 @@ test "help" { named: struct { color: enum { never, auto, always } = .auto, }, - positional: []const []const u8 = &.{}, }, allocator, &[_][]const u8{"--help"}, options)); // All allowed values for an enum should be spelled out. try testing.expect(mem.indexOf(u8, aw.written(), "--color") != null); @@ -1016,14 +1179,12 @@ test "help" { named: struct { name: []const u8, }, - positional: []const []const u8 = &.{}, }, allocator, &[_][]const u8{"--help"}, options)); const scalar_help = try aw.toOwnedSlice(); try testing.expectError(error.Help, parseSlice(struct { named: struct { name: []const []const u8 = &.{}, }, - positional: []const []const u8 = &.{}, }, allocator, &[_][]const u8{"--help"}, options)); try testing.expect(!mem.eql(u8, scalar_help, aw.written())); @@ -1035,7 +1196,6 @@ test "help" { int: i32 = 3, f: f32 = 1.25, }, - positional: []const []const u8 = &.{}, }, allocator, &[_][]const u8{"--help"}, options)); try testing.expect(mem.indexOf(u8, aw.written(), "hello") != null); try testing.expect(mem.indexOf(u8, aw.written(), "3") != null); @@ -1047,21 +1207,18 @@ test "help" { named: struct { b: bool, }, - positional: []const []const u8 = &.{}, }, allocator, &[_][]const u8{"--help"}, options)); const bool_required_help = try aw.toOwnedSlice(); try testing.expectError(error.Help, parseSlice(struct { named: struct { b: bool = true, }, - positional: []const []const u8 = &.{}, }, allocator, &[_][]const u8{"--help"}, options)); const default_true_help = try aw.toOwnedSlice(); try testing.expectError(error.Help, parseSlice(struct { named: struct { b: bool = false, }, - positional: []const []const u8 = &.{}, }, allocator, &[_][]const u8{"--help"}, options)); const default_false_help = try aw.toOwnedSlice(); try testing.expect(!mem.eql(u8, bool_required_help, default_true_help)); @@ -1074,21 +1231,18 @@ test "help" { named: struct { color: enum { never, auto, always }, }, - positional: []const []const u8 = &.{}, }, allocator, &[_][]const u8{"--help"}, options)); const enum_required_help = try aw.toOwnedSlice(); try testing.expectError(error.Help, parseSlice(struct { named: struct { color: enum { never, auto, always } = .auto, }, - positional: []const []const u8 = &.{}, }, allocator, &[_][]const u8{"--help"}, options)); const default_auto_help = try aw.toOwnedSlice(); try testing.expectError(error.Help, parseSlice(struct { named: struct { color: enum { never, auto, always } = .never, }, - positional: []const []const u8 = &.{}, }, allocator, &[_][]const u8{"--help"}, options)); const default_never_help = try aw.toOwnedSlice(); try testing.expect(!mem.eql(u8, enum_required_help, default_auto_help)); @@ -1097,16 +1251,11 @@ test "help" { } test "minimal" { - const Args = struct { - named: struct {}, - positional: []const []const u8 = &.{}, - }; + const Args = struct {}; var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); - const args = try parseSlice(Args, arena.allocator(), &[_][]const u8{}, .{}); - - try testing.expectEqual(@as(usize, 0), args.positional.len); + _ = try parseSlice(Args, arena.allocator(), &[_][]const u8{}, .{}); } test "manual deinit" { @@ -1116,7 +1265,9 @@ test "manual deinit" { int_arr: []const i32 = &.{}, empty_arr: []const []const u8 = &.{}, }, - positional: []const []const u8 = &.{}, + positional: struct { + args: []const []const u8 = &.{}, + }, }; const args = try parseSlice(Args, testing.allocator, &[_][]const u8{ @@ -1130,14 +1281,16 @@ test "manual deinit" { .str_arr = &.{ "hello1", "hello2" }, .int_arr = &.{ 123456, 789012 }, }, - .positional = &.{ "positional-12345", "positi" }, + .positional = .{ + .args = &.{ "positional-12345", "positi" }, + }, }, args); // Surgically cleanup memory. testing.allocator.free(args.named.str_arr); testing.allocator.free(args.named.int_arr); testing.allocator.free(args.named.empty_arr); - testing.allocator.free(args.positional); + testing.allocator.free(args.positional.args); // Should be no memory leak errors now. } @@ -1146,7 +1299,9 @@ test "actually calling error" { named: struct { output: []const u8 = "", }, - positional: []const []const u8 = &.{}, + positional: struct { + args: []const []const u8 = &.{}, + }, }; var arena: std.heap.ArenaAllocator = .init(testing.allocator); @@ -1182,7 +1337,9 @@ test "custom help" { output: []const u8, force: bool = false, }, - positional: []const []const u8 = &.{}, + positional: struct { + args: []const []const u8 = &.{}, + }, }; try testing.expectError(error.Help, parseSlice(Args, allocator, &[_][]const u8{"--help"}, options)); try testing.expectEqualStrings(Args.help, aw.written());