zig/lib/std/cli.zig
2025-08-30 22:36:30 -04:00

1346 lines
54 KiB
Zig

const std = @import("std.zig");
const debug = std.debug;
const assert = debug.assert;
const testing = std.testing;
const ArrayList = std.ArrayList;
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;
pub const Options = struct {
/// Parsing/validation errors and the long `--help` documentation will be written to this writer.
/// By default, parsing/validation errors are written to stderr, and the long `--help` documentation is written to stdout.
/// Any error while writing is silently ignored.
writer: ?*Writer = null,
/// The program name used in the help output, e.g. "my-command" in "usage: my-command [options] ...".
/// By default uses the last path component of the process's first argument (`argv[0]`).
/// When there is no `argv[0]` (such as with `parseSlice`), the default is `"<prog>"`.
prog: ?[]const u8 = null,
/// Call `std.process.exit` with an error status instead of returning `error.Usage` or `error.Help`.
/// The default is `true` for `parse` and `@"error"`, and `false` otherwise.
exit: ?bool = null,
};
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 (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:
/// ```
/// const Args = struct {
/// named: struct {
/// // ...
/// },
/// positional: struct {
/// // ...
/// },
/// };
/// ```
/// 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.
///
/// Each arg string takes one of these forms:
/// ```
/// --<name> (1)
/// --no-<name> (2)
/// --<name>=<value> (3)
/// --help (4)
/// -<alpha><any> (5) always an error
/// -- (6)
/// <other> (7)
/// ```
/// Forms (1), (2), and (3) must correspond to a field `Args.named.<name>`; 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 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`.
/// Form (5) is defined by any arg string where the first byte is '-' and the second byte is `'A'...'Z', 'a'...'z'`
/// (and any following bytes are ignored).
/// A `-9` or other second byte outside the ascii-alpha range is Form (7).
///
/// For forms (1), (2), and (3), let `T` be the type of `Args.named.<name>`.
/// `T` may be any of the following: `bool`, any integer such as `i32`, any float such as `f64`, any `enum` with at least 1 member,
/// any string that `[:0]const u8` can coerce into such as `[]const u8`,
/// or a slice that `[]C` can coerce into such as `[]const C` where `C` is one of:
/// any integer, any float, or any string that `[:0]const u8` can coerce into.
/// Note that slice of bool and slice of enum are not allowed; see https://github.com/ziglang/zig/issues/24601 for discussion.
///
/// If `T` is `bool`, then form (1) sets it to `true`, form (2) sets it to `false`, and form (3) is not allowed.
/// Otherwise, form (3) specifies the `<value>`, form (1) must be immediately followed by another string arg which is the `<value>`,
/// and form (2) is not allowed.
/// For non-bool `T` or for `C` in slice types, the `<value>` is parsed from its string representation:
/// for integers using `std.fmt.parseInt` with base `0`; for floats using `std.fmt.parseFloat`;
/// for enums using `std.meta.stringToEnum`; and for strings no modification or copying is done.
///
/// Each `Args.named.<name>` may have a default value, which makes the `--<name>` argument optional.
/// 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 `--<name>` or `--no-<name>` 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`.
///
/// ```
/// const Args = struct {
/// pub const help =
/// \\usage: your-command --your-usage goes-here
/// \\
/// \\arguments:
/// \\ [...]
/// \\ --help
/// \\
/// ;
/// named: struct {
/// // [...]
/// },
/// };
/// ```
///
/// The first arg returned by the `ArgIterator` (`argv[0]`) is skipped by all the above parsing logic.
/// If `options.prog` is `null`, then the final path component of `argv[0]` is used by default.
///
/// If a parsing/validation error occurs or the `--help` arg is given,
/// this function calls `std.process.exit` with `1` and `0` respectively unless `options.exit` is set to `false`,
/// in which case parsing/validation errors return `error.Usage` and `--help` returns `error.Help`.
/// Allocator errors are always returned from the function.
///
/// It is not possible to precisely deallocate the memory allocated by this function.
/// An `ArenaAllocator` is recommended to prevent memory leaks.
pub fn parse(comptime Args: type, arena: Allocator, options: Options) (Error || ArgIterator.InitError)!Args {
var iter: ArgIterator = try .initWithAllocator(arena);
// Do not call iter.deinit(). It holds the string data returned in the Args.
const argv0 = iter.next();
const prog = options.prog orelse if (argv0) |arg| std.fs.path.basename(arg) else "<prog>";
return innerParse(Args, arena, &iter, prog, options.writer, options.exit orelse true);
}
test parse {
const Args = struct {
named: struct {
/// Specified as `--output path.txt` or `--output=path.txt`
output: [:0]const u8 = "",
/// Supports `--level=9`, `--level -12`, `--level=0x7f`, etc.
level: i8 = -1,
/// Parsed as the name of the member `--color=never`.
color: enum { auto, never, always } = .auto,
// 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 = "",
},
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);
defer arena.deinit();
const args = try std.cli.parse(Args, arena.allocator(), .{});
try testing.expectEqual(@as(i8, -1), args.named.level);
}
/// Like `parse`, but allows specifying a custom arg iterator.
/// `iter` is typically a mutable pointer to a struct and must have a method:
/// ```
/// 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 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.
///
/// If a parsing/validation error occurs or the `--help` arg is given,
/// this function returns `error.Usage` or `error.Help` respectively,
/// unless `options.exit` is set to `true`, in which case `std.process.exit` is called with `1` or `0` respectively.
/// Allocator errors are always returned from the function.
///
/// 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` 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 "<prog>";
return innerParse(Args, arena, iter, prog, options.writer, options.exit orelse false);
}
/// 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 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`.
/// Use `options.prog` instead.
///
/// If a parsing/validation error occurs or the `--help` arg is given,
/// this function returns `error.Usage` or `error.Help` respectively,
/// unless `options.exit` is set to `true`, in which case `std.process.exit` is called with `1` or `0` respectively.
/// Allocator errors are always returned from the function.
///
/// 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` 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)
@typeInfo(argvInfo.child).array.child
else if (argvInfo.size == .slice)
argvInfo.child
else
@compileError("expected argv to be `*const [_]String` or `[]const String` where `String` is `[]const u8` or similar");
var iter = ArgIteratorSlice(String){ .slice = argv };
return innerParse(Args, arena, &iter, options.prog orelse "<prog>", options.writer, options.exit orelse false);
}
test parseSlice {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const Args = struct {
named: struct {
example_required: []const u8,
example_optional: []const u8 = "-",
level: i32 = -1,
flag: bool = true,
@"enum-option": enum { auto, always, never } = .auto,
},
positional: struct {
args: []const []const u8 = &.{},
},
};
const args = try parseSlice(Args, allocator, &[_][]const u8{
"--example_required", "a.txt",
// --example_optional not given
"--level=0xff", "--no-flag",
"--enum-option", "always",
"positional1", "positional2",
"-12345678", "--",
"--positional4", "--positional=5",
}, .{});
try testing.expectEqualDeep(Args{
.named = .{
.example_required = "a.txt",
.example_optional = "-",
.level = 255,
.flag = false,
.@"enum-option" = .always,
},
.positional = .{ .args = &.{ "positional1", "positional2", "-12345678", "--positional4", "--positional=5" } },
}, args);
}
fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: []const u8, writer: ?*Writer, exit_on_error: bool) Error!Args {
// 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.
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 named_fields_seen = [_]bool{false} ** named_fields.len;
var positional_field_index: usize = 0;
var the_rest_is_positional = false;
while (iter.next()) |arg| {
if (!the_rest_is_positional and mem.eql(u8, arg, "--help")) {
if (@hasDecl(Args, "help")) {
// Custom help.
if (writer) |w| {
w.writeAll(Args.help) catch {};
w.flush() catch {};
} else {
var file_writer = std.fs.File.stdout().writer(&.{});
file_writer.interface.writeAll(Args.help) catch {};
file_writer.interface.flush() catch {};
}
} else {
printGeneratedHelp(writer, prog, named_fields);
}
if (exit_on_error) {
std.process.exit(0);
}
return error.Help;
}
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 (!the_rest_is_positional and mem.eql(u8, arg, "--")) {
// Stop recognizing named arguments. Everything else is positional.
the_rest_is_positional = true;
continue;
}
if (the_rest_is_positional or !(arg.len >= 3 and arg[0] == '-' and arg[1] == '-')) {
// Positional.
// 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;
}
// Named.
const arg_name, const immediate_value, const no_prefixed = blk: {
if (mem.startsWith(u8, arg, "--no-")) {
break :blk .{ arg["--no-".len..], null, true };
}
if (mem.indexOfScalarPos(u8, arg, "--".len, '=')) |index| {
if (@typeInfo(@TypeOf(arg)).pointer.sentinel_ptr != null) {
break :blk .{ arg["--".len..index], arg[index + 1 .. :0], false };
} else {
break :blk .{ arg["--".len..index], arg[index + 1 ..], false };
}
}
break :blk .{ arg["--".len..], null, false };
};
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;
break;
}
if (no_prefixed) return usageError(writer, "unrecognized argument: {s}", .{arg}, exit_on_error);
// 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);
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);
}
break;
}
} else {
// Didn't match anything.
return usageError(writer, "unrecognized argument: {s}", .{arg}, exit_on_error);
}
}
// Fill default values.
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 {
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);
}
}
}
}
return result;
}
/// 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,
}
}
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);
}
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 --<name> and --no-<name> 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,
/// call this function to produce the same error behavior as if this API's validation failed.
/// An error message will be written to `options.writer` or stderr by default, and `error.Usage` is returned.
/// The given `msg` template is prefixed by `"error: "` and suffixed by a newline and a prompt to try passing in `--help`.
/// `options.prog` is not used by this function, but could be in the future.
///
/// This function calls `std.process.exit` with an error status unless `options.exit` is set to `false`, in which case it returns `error.Usage`.
/// This matches the default behavior of `parse`, not `parseIter` or `parseSlice`.
pub fn @"error"(comptime msg: []const u8, args: anytype, options: Options) error{Usage} {
return usageError(options.writer, msg, args, options.exit orelse true);
}
test @"error" {
const Args = struct {
named: struct {
output: []const u8 = "",
},
positional: struct {
input: []const u8,
},
};
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const args = try parseSlice(Args, arena.allocator(), &[_][]const u8{ "--output=o.txt", "i.txt" }, .{});
if (std.fs.path.isAbsolute(args.named.output)) {
return std.cli.@"error"("--output must not be absolute: {s}", .{args.named.output}, .{ .exit = false });
}
}
fn usageError(writer: ?*Writer, comptime msg: []const u8, args: anytype, exit_on_error: bool) error{Usage} {
const whole_msg =
"error: " ++ msg ++ "\n" ++
\\try --help for full help info
\\
;
if (writer) |w| {
w.print(whole_msg, args) catch {};
} else {
std.debug.print(whole_msg, args);
}
if (exit_on_error) {
std.process.exit(1);
}
return error.Usage;
}
fn ArgIteratorSlice(comptime String: type) type {
return struct {
slice: []const String,
index: usize = 0,
pub fn next(self: *@This()) ?String {
if (self.index >= self.slice.len) return null;
const result = self.slice[self.index];
self.index += 1;
return result;
}
};
}
fn enumValuesExpr(comptime Enum: type) []const u8 {
comptime var values_str: []const u8 = "{";
inline for (@typeInfo(Enum).@"enum".fields) |enum_field| {
if (values_str.len > 1) {
values_str = values_str ++ ",";
}
values_str = values_str ++ enum_field.name;
}
values_str = values_str ++ "}";
return values_str;
}
fn printGeneratedHelp(writer: ?*Writer, prog: []const u8, comptime named_fields: []const StructField) void {
const msg = //
\\usage: {s} [options] [arg...]
\\
\\arguments:{s}
\\ --help
\\
;
comptime var arguments_str: []const u8 = "";
inline for (named_fields) |field| {
switch (@typeInfo(field.type)) {
.bool => {
if (field.defaultValue()) |default| {
if (default) {
arguments_str = arguments_str ++ "\n --no-" ++ field.name ++ " default: --" ++ field.name;
} else {
arguments_str = arguments_str ++ "\n --" ++ field.name ++ " default: --no-" ++ field.name;
}
} else {
arguments_str = arguments_str ++ "\n --" ++ field.name ++ " or --no-" ++ field.name ++ " required";
}
},
.int, .float => {
arguments_str = arguments_str ++ "\n --" ++ field.name ++ " " ++ @typeName(field.type);
if (field.defaultValue()) |default| {
arguments_str = arguments_str ++ " default: " ++ std.fmt.comptimePrint("{}", .{default});
} else {
arguments_str = arguments_str ++ " required";
}
},
.@"enum" => {
arguments_str = arguments_str ++ "\n --" ++ field.name ++ " " ++ comptime enumValuesExpr(field.type);
if (field.defaultValue()) |default| {
arguments_str = arguments_str ++ " default: " ++ quoteIfEmpty(@tagName(default));
} else {
arguments_str = arguments_str ++ " required";
}
},
.pointer => |ptrInfo| {
if (ptrInfo.size == .slice and ptrInfo.child == u8) {
// String.
arguments_str = arguments_str ++ "\n --" ++ field.name ++ " string";
if (field.defaultValue()) |default| {
arguments_str = arguments_str ++ " default: " ++ quoteIfEmpty(default);
} else {
arguments_str = arguments_str ++ " required";
}
} else {
// Array
const type_name = switch (@typeInfo(ptrInfo.child)) {
.bool => comptime unreachable,
.int, .float => @typeName(ptrInfo.child),
.@"enum" => comptime unreachable,
.pointer => "string", // The array-of-pointer that doesn't cause compile errors elsewhere.
else => comptime unreachable,
};
arguments_str = arguments_str ++ "\n " ++ //
"--" ++ field.name ++ " " ++ type_name ++ " " ++ //
"[--" ++ field.name ++ " " ++ type_name ++ " ...]";
}
},
else => @compileError("Unsupported field type: " ++ @typeName(field.type)),
}
}
if (writer) |w| {
w.print(msg, .{ prog, arguments_str }) catch {};
w.flush() catch {};
} else {
var buffer: [0x100]u8 = undefined;
var file_writer = std.fs.File.stdout().writer(&buffer);
file_writer.interface.print(msg, .{ prog, arguments_str }) catch {};
file_writer.interface.flush() catch {};
}
}
inline fn quoteIfEmpty(comptime s: []const u8) []const u8 {
if (s.len == 0) return "''";
return s;
}
var failing_writer: Writer = .failing;
const silent_options = Options{ .writer = &failing_writer, .exit = false };
test "bool" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const Args = struct {
named: struct {
b: bool,
},
};
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,
},
};
const args = try parseSlice(Args, allocator, &[_][:0]const u8{
"--a", "a",
"--b", "b",
}, .{});
try testing.expectEqualDeep(Args{
.named = .{
.a = "a",
.b = "b",
},
}, args);
}
test "ints and floats" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const Args = struct {
named: struct {
int_u32: u32,
int_i32: i32,
int_u8: u8,
int_u256: u256,
float_f32: f32,
float_f64: f64,
inf_f32: f32,
ninf_f64: f64,
},
};
const args = try parseSlice(Args, allocator, &[_][]const u8{
"--int_u32", "0xffffffff",
"--int_i32", "-0x80000000",
"--int_u8", "0o310",
"--int_u256", "115792089237316195423570985008687907853269984665640564039457584007913129639935",
"--float_f32", "1.25",
"--float_f64", "-0xab.cdef012345p-12",
"--inf_f32", "inf",
"--ninf_f64", "-INF",
}, .{});
try testing.expectEqualDeep(Args{
.named = .{
.int_u32 = 0xffffffff,
.int_i32 = -0x80000000,
.int_u8 = 0o310,
.int_u256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935,
.float_f32 = 1.25,
.float_f64 = -0xab.cdef012345p-12,
.inf_f32 = std.math.inf(f32),
.ninf_f64 = -std.math.inf(f64),
},
}, args);
const Args2 = struct {
named: struct {
nan: f64,
},
};
const args2 = try parseSlice(Args2, allocator, &[_][]const u8{
"--nan", "nAN",
}, .{});
try testing.expect(std.math.isNan(args2.named.nan));
}
test "array" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const Args = struct {
named: struct {
path: []const []const u8 = &.{},
id: []const i32 = &.{},
},
positional: struct {
args: []const []const u8 = &.{},
},
};
try testing.expectEqualDeep(Args{
.named = .{
.path = &[_][]const u8{ "a", "b", "a" },
.id = &[_]i32{ 1, -12 },
},
.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" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const Args = struct {
named: struct {
color: enum {
always,
never,
auto,
},
guess: enum {
@"the-only-option",
},
signal: enum(u8) {
KILL = 9,
TERM = 15,
VTALRM = 26,
},
},
};
const args = try parseSlice(Args, allocator, &[_][]const u8{
"--color", "always",
"--guess", "the-only-option",
"--signal", "TERM",
}, .{});
try testing.expectEqualDeep(Args{
.named = .{
.color = .always,
.guess = .@"the-only-option",
.signal = .TERM,
},
}, args);
}
test "defaults" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const Args = struct {
named: struct {
level: i8 = -1,
ratio: f32 = 0.5,
path: []const u8 = "-",
color: enum {
always,
never,
auto,
} = .auto,
file: []const []const u8 = &.{},
force: bool = false,
cleanup: bool = true,
},
};
try testing.expectEqualDeep(Args{
.named = .{},
}, try parseSlice(Args, allocator, &[_][]const u8{}, .{}));
try testing.expectEqualDeep(Args{
.named = .{
.color = .always,
},
}, try parseSlice(Args, allocator, &[_][]const u8{ "--color", "always" }, .{}));
try testing.expectEqualDeep(Args{
.named = .{
.file = &[_][]const u8{"file.txt"},
},
}, try parseSlice(Args, allocator, &[_][]const u8{ "--file", "file.txt" }, .{}));
try testing.expectEqualDeep(Args{
.named = .{
.force = true,
.cleanup = false,
},
}, 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-<name> 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();
const allocator = arena.allocator();
var aw: Writer.Allocating = .init(allocator);
const options = Options{ .prog = "test-prog", .writer = &aw.writer };
try testing.expectError(error.Help, parseSlice(struct {
named: struct {
str: []const u8,
int: i32,
flag: bool,
},
}, 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,
// but otherwise allow maintainers to adjust the layout, formatting, notation, etc. without causing friction here.
try testing.expect(mem.indexOf(u8, aw.written(), "test-prog") != null);
try testing.expect(mem.indexOf(u8, aw.written(), "--str string") != null);
try testing.expect(mem.indexOf(u8, aw.written(), "--int") != null);
try testing.expect(mem.indexOf(u8, aw.written(), "--flag") != null);
try testing.expect(mem.indexOf(u8, aw.written(), "--no-flag") != null);
try testing.expect(mem.indexOf(u8, aw.written(), "--help") != null);
aw.clearRetainingCapacity();
try testing.expectError(error.Help, parseSlice(struct {
named: struct {
color: enum { never, auto, always } = .auto,
},
}, 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);
try testing.expect(mem.indexOf(u8, aw.written(), "never") != null);
try testing.expect(mem.indexOf(u8, aw.written(), "auto") != null);
try testing.expect(mem.indexOf(u8, aw.written(), "always") != null);
// Test that arrays are represented differently from scalars somehow.
aw.clearRetainingCapacity();
try testing.expectError(error.Help, parseSlice(struct {
named: struct {
name: []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 = &.{},
},
}, allocator, &[_][]const u8{"--help"}, options));
try testing.expect(!mem.eql(u8, scalar_help, aw.written()));
// Default values should be rendered somehow.
aw.clearRetainingCapacity();
try testing.expectError(error.Help, parseSlice(struct {
named: struct {
str: []const u8 = "hello",
int: i32 = 3,
f: f32 = 1.25,
},
}, 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);
try testing.expect(mem.indexOf(u8, aw.written(), "1.25") != null);
// Test that bool arguments express the default somehow.
aw.clearRetainingCapacity();
try testing.expectError(error.Help, parseSlice(struct {
named: struct {
b: bool,
},
}, allocator, &[_][]const u8{"--help"}, options));
const bool_required_help = try aw.toOwnedSlice();
try testing.expectError(error.Help, parseSlice(struct {
named: struct {
b: bool = true,
},
}, allocator, &[_][]const u8{"--help"}, options));
const default_true_help = try aw.toOwnedSlice();
try testing.expectError(error.Help, parseSlice(struct {
named: struct {
b: bool = false,
},
}, allocator, &[_][]const u8{"--help"}, options));
const default_false_help = try aw.toOwnedSlice();
try testing.expect(!mem.eql(u8, bool_required_help, default_true_help));
try testing.expect(!mem.eql(u8, bool_required_help, default_false_help));
try testing.expect(!mem.eql(u8, default_true_help, default_false_help));
// Test that enum arguments express the default somehow.
aw.clearRetainingCapacity();
try testing.expectError(error.Help, parseSlice(struct {
named: struct {
color: enum { never, auto, always },
},
}, 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,
},
}, 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,
},
}, allocator, &[_][]const u8{"--help"}, options));
const default_never_help = try aw.toOwnedSlice();
try testing.expect(!mem.eql(u8, enum_required_help, default_auto_help));
try testing.expect(!mem.eql(u8, enum_required_help, default_never_help));
try testing.expect(!mem.eql(u8, default_auto_help, default_never_help));
}
test "minimal" {
const Args = struct {};
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
_ = try parseSlice(Args, arena.allocator(), &[_][]const u8{}, .{});
}
test "manual deinit" {
const Args = struct {
named: struct {
str_arr: []const []const u8 = &.{},
int_arr: []const i32 = &.{},
empty_arr: []const []const u8 = &.{},
},
positional: struct {
args: []const []const u8 = &.{},
},
};
const args = try parseSlice(Args, testing.allocator, &[_][]const u8{
"--str_arr=hello1", "--str_arr", "hello2",
"--int_arr=123456", "--int_arr", "789012",
"positional-12345", "--", "positi",
}, .{});
try testing.expectEqualDeep(Args{
.named = .{
.str_arr = &.{ "hello1", "hello2" },
.int_arr = &.{ 123456, 789012 },
},
.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.args);
// Should be no memory leak errors now.
}
test "actually calling error" {
const Args = struct {
named: struct {
output: []const u8 = "",
},
positional: struct {
args: []const []const u8 = &.{},
},
};
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const args = try parseSlice(Args, arena.allocator(), &[_][]const u8{
"--output=/absolute/path", "too", "many", "other", "args",
}, .{});
try testing.expectEqual(error.Usage, std.cli.@"error"("--output must not be absolute: {s}", .{args.named.output}, silent_options));
try testing.expectEqual(error.Usage, std.cli.@"error"("expected exactly 1 positional arg", .{}, silent_options));
}
test "custom help" {
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 = "unused-prog", .writer = &aw.writer };
const Args = struct {
pub const help =
\\usage: the-zip-thing --output path [options] input.zip
\\
\\arguments:
\\ --output path where to write the output stuff
\\ --[no-]force overwrite output if already exists
\\ input.zip the zip file to read
\\ --help print this help and exit
\\
;
named: struct {
output: []const u8,
force: bool = false,
},
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());
}