authorgravatar for thejoshwolfe@gmail.comJosh Wolfe <thejoshwolfe@gmail.com> 2025-08-15 14:42:58-04:00
committergravatar for thejoshwolfe@gmail.comJosh Wolfe <thejoshwolfe@gmail.com> 2025-08-30 22:36:30-04:00
log622264b02b222db72eddcb616f2dd1f0fb3c8720
tree6a5f2512f02d93342df405a63f6b927bddc9a4df
parent7fdd60df1c40bc36a8e0b35be3e35f81dd97fbce

[std] add minimal CLI parser `std.cli`


2 files changed, 1156 insertions(+), 0 deletions(-)

lib/std/cli.zig created+1155
......@@ -0,0 +1,1155 @@
1const std = @import("std.zig");
2const debug = std.debug;
3const assert = debug.assert;
4const testing = std.testing;
5const ArrayList = std.ArrayList;
6const isAlphabetic = std.ascii.isAlphabetic;
7const Writer = std.Io.Writer;
8const ArgIterator = std.process.ArgIterator;
9const ArenaAllocator = std.heap.ArenaAllocator;
10const mem = std.mem;
11const Allocator = mem.Allocator;
12
13pub const Options = struct {
14 /// When returning error.Usage, print a short error message to this writer, defaults to stderr.
15 /// When returning error.Help, print the long help documentation to this writer, defaults to stdout.
16 /// Any error while writing is silently ignored.
17 writer: ?*Writer = null,
18
19 /// The program name used in the help output, e.g. "my-command" in "usage: my-command [options] ...".
20 /// By default uses the last path component of the process's first argument (`argv[0]`).
21 /// When there is no `argv[0]` (such as with `parseSlice`), the default is `"<prog>"`.
22 prog: ?[]const u8 = null,
23};
24
25pub const Error = error{
26 /// Caused by unrecognized option names, values that cannot be parsed into the appropriate field type,
27 /// missing arguments for fields with no default value, and other similar parsing errors.
28 Usage,
29 /// The --help argument was given.
30 Help,
31} || Allocator.Error;
32
33/// Parses CLI args from a `std.process.ArgIterator` according to the configuration in `Args`.
34/// Args is a struct that you define looking like this:
35/// ```
36/// const Args = struct {
37/// named: struct {
38/// // ...
39/// },
40/// positional: []const []const u8 = &.{},
41/// };
42/// ```
43/// The `named` and `positional` fields are required, although `named` need not have any subfields.
44/// `positional` may instead have type `[]const [:0]const u8`.
45///
46/// The sequence of arg strings from the `ArgIterator` is parsed to determine named and positional arguments.
47///
48/// Each arg string takes one of these forms:
49/// ```
50/// --<name> (1)
51/// --no-<name> (2)
52/// --<name>=<value> (3)
53/// --help (4)
54/// -<alpha><any> (5) always an error
55/// -- (6)
56/// <other> (7)
57/// ```
58/// Forms (1), (2), and (3) must correspond to a field `Args.named.<name>`; see below for named argument handling.
59/// Form (4) immediately prints the long help documentation and returns `error.Help`.
60/// Form (6) signals that all following arg strings are positional.
61/// Form (7) and all arg strings following form (6) are appended into the `positional` array in order.
62///
63/// Form (5) is always an error.
64/// This API does not support single letter aliases like `-v` or `-lA` or named arguments prefixed by only a single hyphen like `-flag`.
65/// Form (5) is defined by any arg string where the first byte is '-' and the second byte is `'A'...'Z', 'a'...'z'`
66/// (and any following bytes are ignored).
67/// A `-9` or other second byte outside the ascii-alpha range is Form (7).
68///
69/// For forms (1), (2), and (3), let `T` be the type of `Args.named.<name>`.
70/// `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,
71/// any string that `[:0]const u8` can coerce into such as `[]const u8`,
72/// or a slice that `[]C` can coerce into such as `[]const C` where `C` is one of:
73/// any integer, any float, or any string that `[:0]const u8` can coerce into.
74/// Note that slice of bool and slice of enum are not allowed; see https://github.com/ziglang/zig/issues/24601 for discussion.
75///
76/// If `T` is `bool`, then form (1) sets it to `true`, form (2) sets it to `false`, and form (3) is not allowed.
77/// Otherwise, form (3) specifies the `<value>`, form (1) must be immediately followed by another string arg which is the `<value>`,
78/// and form (2) is not allowed.
79/// For non-bool `T` or for `C` in slice types, the `<value>` is parsed from its string representation:
80/// for integers using `std.fmt.parseInt` with base `0`; for floats using `std.fmt.parseFloat`;
81/// for enums using `std.meta.stringToEnum`; and for strings no modification or copying is done.
82///
83/// Each `Args.named.<name>` may have a default value, which makes the `--<name>` argument optional.
84/// Slice arguments `[]const C` (where `C` is not `u8`) must have a default value, usually `&.{}`.
85/// If a bool argument has no default value, then at least one of `--<name>` or `--no-<name>` must be given.
86///
87/// It's possible to override the automatically-generated long help documentation by declaring a public constant named `help` in `Args`.
88/// The value must coerce to `[]const u8`.
89///
90/// ```
91/// const Args = struct {
92/// pub const help =
93/// \\usage: your-command --your-usage goes-here
94/// \\
95/// \\arguments:
96/// \\ [...]
97/// \\ --help
98/// \\
99/// ;
100/// named: struct {
101/// // [...]
102/// },
103/// positional: []const []const u8 = &.{},
104/// };
105/// ```
106///
107/// The first arg returned by the `ArgIterator` (`argv[0]`) is skipped by all the above parsing logic.
108/// If `options.prog` is `null`, then the final path component of `argv[0]` is used by default.
109///
110/// It is not possible to precisely deallocate the memory allocated by this function.
111/// An `ArenaAllocator` is recommended to prevent memory leaks.
112pub fn parse(comptime Args: type, arena: Allocator, options: Options) Error!Args {
113 var iter: ArgIterator = try .initWithAllocator(arena);
114 // Do not call iter.deinit(). It holds the string data returned in the Args.
115 return parseIter(Args, arena, &iter, options);
116}
117
118test parse {
119 const Args = struct {
120 named: struct {
121 /// Specified as `--output path.txt` or `--output=path.txt`
122 output: [:0]const u8 = "",
123 /// Supports `--level=9`, `--level -12`, `--level=0x7f`, etc.
124 level: i8 = -1,
125 /// Parsed as the name of the member `--color=never`.
126 color: enum { auto, never, always } = .auto,
127 /// --seed=0x<something> is actually passed in by the `zig test` system (as of 0.14.1), which we receive here.
128 seed: u32 = 0,
129 },
130 /// Receives the rest of the arguments.
131 positional: []const [:0]const u8 = &.{},
132 };
133
134 var arena: ArenaAllocator = .init(testing.allocator);
135 defer arena.deinit();
136 const args = try std.cli.parse(Args, arena.allocator(), .{});
137
138 try testing.expectEqual(@as(i8, -1), args.named.level);
139}
140
141/// Like `parse`, but allows specifying a custom arg iterator.
142/// `iter` is typically a mutable pointer to a struct and must have a method:
143/// ```
144/// pub fn next(self: *Self) ?String { ... }
145/// ```
146/// Where `String` is `[]const u8` or `[:0]const u8`, or something else that coerces to `[]const u8`.
147/// If `String` does not coerce to `[:0]const u8`, then `Args` cannot have `[:0]const u8` fields.
148///
149/// The first string arg returned by the `iter` (`argv[0]`) is skipped by all the parsing logic.
150/// If `options.prog` is `null`, then the final path component of `argv[0]` is used by default.
151///
152/// An `ArenaAllocator` is recommended to cleanup the memory allocated from this function;
153/// however, it's also possible to free all the memory by freeing every slice field `[]const C` (other than `u8`)
154/// in the returned `args.named` as well as freeing `args.positional`.
155pub fn parseIter(comptime Args: type, arena: Allocator, iter: anytype, options: Options) Error!Args {
156 const prog = options.prog orelse if (iter.next()) |arg0| std.fs.path.basename(arg0) else "<prog>";
157 return innerParse(Args, arena, iter, prog, options.writer);
158}
159
160/// Like `parse`, but takes a slice of strings in place of using an `ArgIterator`.
161/// `argv` must be either be a slice of `String` or a single-item pointer to an array of `String`,
162/// where `String` is `[]const u8` or `[:0]const u8` or something that coerces to `[]const u8`.
163/// If `String` does not coerce to `[:0]const u8`, then `Args` cannot have `[:0]const u8` fields.
164///
165/// Unlike `parse` and `parseIter`, this function does not skip the first item of `argv`.
166/// Use `options.prog` instead.
167///
168/// An `ArenaAllocator` is recommended to cleanup the memory allocated from this function;
169/// however, it's also possible to free all the memory by freeing every slice field `[]const C` (other than `u8`)
170/// in the returned `args.named` as well as freeing `args.positional`.
171pub fn parseSlice(comptime Args: type, arena: Allocator, argv: anytype, options: Options) Error!Args {
172 const argvInfo = @typeInfo(@TypeOf(argv)).pointer;
173 const String = if (argvInfo.size == .one)
174 @typeInfo(argvInfo.child).array.child
175 else if (argvInfo.size == .slice)
176 argvInfo.child
177 else
178 @compileError("expected argv to be `*const [_]String` or `[]const String` where `String` is `[]const u8` or similar");
179 var iter = ArgIteratorSlice(String){ .slice = argv };
180 return innerParse(Args, arena, &iter, options.prog orelse "<prog>", options.writer);
181}
182
183test parseSlice {
184 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
185 defer arena.deinit();
186 const allocator = arena.allocator();
187
188 const Args = struct {
189 named: struct {
190 example_required: []const u8,
191 example_optional: []const u8 = "-",
192 level: i32 = -1,
193 flag: bool = true,
194 @"enum-option": enum { auto, always, never } = .auto,
195 },
196 positional: []const []const u8 = &.{},
197 };
198 const args = try parseSlice(Args, allocator, &[_][]const u8{
199 "--example_required", "a.txt",
200 // --example_optional not given
201 "--level=0xff", "--no-flag",
202 "--enum-option", "always",
203 "positional1", "positional2",
204 "-12345678", "--",
205 "--positional4", "--positional=5",
206 }, .{});
207
208 try testing.expectEqualDeep(Args{
209 .named = .{
210 .example_required = "a.txt",
211 .example_optional = "-",
212 .level = 255,
213 .flag = false,
214 .@"enum-option" = .always,
215 },
216 .positional = &.{ "positional1", "positional2", "-12345678", "--positional4", "--positional=5" },
217 }, args);
218}
219
220fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: []const u8, writer: ?*Writer) Error!Args {
221 // arg0 has already been consumed.
222
223 // Do all comptime checks up front so that we can be sure any compile error the user sees is the one we wrote.
224 comptime checkArgsType(Args);
225
226 var result: Args = undefined;
227 var positional: ArrayList(@typeInfo(@TypeOf(result.positional)).pointer.child) = .{};
228
229 const ArgsNamed = @TypeOf(result.named);
230 const named_info = @typeInfo(ArgsNamed).@"struct";
231
232 // Declare and initialize an ArrayList(C) for every []const C field (other than u8).
233 var fields_seen = [_]bool{false} ** named_info.fields.len;
234 comptime var array_list_fields: []const std.builtin.Type.StructField = &.{};
235 inline for (named_info.fields) |field| {
236 const info = @typeInfo(field.type);
237 if (info == .pointer) {
238 comptime assert(info.pointer.size == .slice);
239 if (info.pointer.child == u8) {
240 // String. skip.
241 } else {
242 // Array of scalar.
243 array_list_fields = array_list_fields ++ @as([]const std.builtin.Type.StructField, &.{.{
244 .name = field.name,
245 .type = ArrayList(info.pointer.child),
246 .default_value_ptr = null,
247 .is_comptime = false,
248 .alignment = @alignOf(ArrayList(info.pointer.child)),
249 }});
250 }
251 }
252 }
253 var array_lists: @Type(.{ .@"struct" = .{ .layout = .auto, .fields = array_list_fields, .decls = &.{}, .is_tuple = false } }) = undefined;
254 inline for (@typeInfo(@TypeOf(array_lists)).@"struct".fields) |field| {
255 @field(array_lists, field.name) = .{};
256 }
257
258 while (iter.next()) |arg| {
259 if (mem.eql(u8, arg, "--help")) {
260 if (@hasDecl(Args, "help")) {
261 // Custom help.
262 if (writer) |w| {
263 w.writeAll(Args.help) catch {};
264 w.flush() catch {};
265 } else {
266 var file_writer = std.fs.File.stdout().writer(&.{});
267 file_writer.interface.writeAll(Args.help) catch {};
268 file_writer.interface.flush() catch {};
269 }
270 } else {
271 printGeneratedHelp(writer, prog, named_info);
272 }
273 return error.Help;
274 }
275
276 if (arg.len >= 2 and arg[0] == '-' and isAlphabetic(arg[1])) {
277 // Always invalid.
278 // Examples: -h, -flag, -I/path
279 return usageError(writer, "unrecognized argument: {s}", .{arg});
280 }
281 if (mem.eql(u8, arg, "--")) {
282 // Stop recognizing named arguments. Everything else is positional.
283 while (iter.next()) |arg2| {
284 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`.
285 }
286 break;
287 }
288 if (!(arg.len >= 3 and arg[0] == '-' and arg[1] == '-')) {
289 // Positional.
290 // Examples: "", "a", "-", "-1",
291 try positional.append(allocator, arg);
292 continue;
293 }
294
295 // Named.
296 const arg_name, const immediate_value, const no_prefixed = blk: {
297 if (mem.startsWith(u8, arg, "--no-")) {
298 break :blk .{ arg["--no-".len..], null, true };
299 }
300 if (mem.indexOfScalarPos(u8, arg, "--".len, '=')) |index| {
301 if (@typeInfo(@TypeOf(arg)).pointer.sentinel_ptr != null) {
302 break :blk .{ arg["--".len..index], arg[index + 1 .. :0], false };
303 } else {
304 break :blk .{ arg["--".len..index], arg[index + 1 ..], false };
305 }
306 }
307 break :blk .{ arg["--".len..], null, false };
308 };
309
310 inline for (named_info.fields, 0..) |field, i| {
311 if (mem.eql(u8, field.name, arg_name)) {
312 if (field.type == bool) {
313 if (immediate_value != null) return usageError(writer, "cannot specify value for bool argument: {s}", .{arg});
314 @field(result.named, field.name) = !no_prefixed;
315 fields_seen[i] = true;
316 break;
317 }
318 if (no_prefixed) return usageError(writer, "unrecognized argument: {s}", .{arg});
319
320 // All other argument types require a value.
321 const arg_value = immediate_value orelse iter.next() orelse return usageError(writer, "expected argument after --{s}", .{field.name});
322
323 switch (@typeInfo(field.type)) {
324 .bool => unreachable, // Handled above.
325 .float => {
326 @field(result.named, field.name) = std.fmt.parseFloat(field.type, arg_value) catch |err| {
327 return usageError(writer, "unable to parse --{s}={s}: {s}", .{ field.name, arg_value, @errorName(err) });
328 };
329 },
330 .int => {
331 @field(result.named, field.name) = std.fmt.parseInt(field.type, arg_value, 0) catch |err| {
332 return usageError(writer, "unable to parse --{s}={s}: {s}", .{ field.name, arg_value, @errorName(err) });
333 };
334 },
335 .@"enum" => {
336 @field(result.named, field.name) = std.meta.stringToEnum(field.type, arg_value) orelse {
337 return usageError(writer, "unrecognized value: --{s}={s}, expected one of: {s}", .{ field.name, arg_value, enumValuesExpr(field.type) });
338 };
339 },
340 .pointer => |ptrInfo| {
341 comptime assert(ptrInfo.size == .slice);
342 if (ptrInfo.child == u8) {
343 @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`.
344 } else {
345 const array_list = &@field(array_lists, field.name);
346 switch (@typeInfo(ptrInfo.child)) {
347 .bool => comptime unreachable, // Nicer compile error emitted in checkArgsType().
348 .float => {
349 try array_list.append(allocator, std.fmt.parseFloat(ptrInfo.child, arg_value) catch |err| {
350 return usageError(writer, "unable to parse --{s}={s}: {s}", .{ field.name, arg_value, @errorName(err) });
351 });
352 },
353 .int => {
354 try array_list.append(allocator, std.fmt.parseInt(ptrInfo.child, arg_value, 0) catch |err| {
355 return usageError(writer, "unable to parse --{s}={s}: {s}", .{ field.name, arg_value, @errorName(err) });
356 });
357 },
358 .@"enum" => comptime unreachable,
359 .pointer => |ptrInfo2| {
360 comptime assert(ptrInfo2.size == .slice);
361 if (ptrInfo2.child == u8) {
362 // String.
363 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`.
364 } else comptime unreachable;
365 },
366 else => comptime unreachable,
367 }
368 }
369 },
370 else => comptime unreachable,
371 }
372 fields_seen[i] = true;
373 break;
374 }
375 } else {
376 // Didn't match anything.
377 return usageError(writer, "unrecognized argument: {s}", .{arg});
378 }
379 }
380
381 // Fill default values.
382 inline for (named_info.fields, 0..) |field, i| {
383 if (!fields_seen[i]) {
384 if (field.defaultValue()) |default| {
385 @field(result.named, field.name) = default;
386 } else {
387 if (field.type == bool) {
388 return usageError(writer, "missing required argument: --" ++ field.name ++ " or --no-" ++ field.name, .{});
389 } else {
390 return usageError(writer, "missing required argument: --" ++ field.name, .{});
391 }
392 }
393 }
394 }
395
396 // Finalize the array lists.
397 result.positional = try positional.toOwnedSlice(allocator);
398 inline for (@typeInfo(@TypeOf(array_lists)).@"struct".fields) |field| {
399 @field(result.named, field.name) = try @field(array_lists, field.name).toOwnedSlice(allocator);
400 }
401
402 return result;
403}
404
405fn checkArgsType(comptime Args: type) void {
406 const args_fields = @typeInfo(Args).@"struct".fields;
407 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");
408 if (args_fields[1].default_value_ptr == null) @compileError("Args.positional must have a default value");
409
410 inline for (@typeInfo(args_fields[0].type).@"struct".fields) |field| {
411 if (field.is_comptime) @compileError("comptime fields are not supported: " ++ field.name);
412 if (comptime mem.eql(u8, field.name, "help")) @compileError("A field named help is not allowed. to provide custom help formatting, give options.writer and handle error.Help");
413 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.");
414 if (comptime mem.indexOfScalar(u8, field.name, '=') != null) @compileError("Field name contains @\"=\": " ++ field.name);
415
416 switch (@typeInfo(field.type)) {
417 .bool => {},
418 .float => {},
419 .int => {},
420 .@"enum" => {
421 if (@typeInfo(field.type).@"enum".fields.len == 0) @compileError("Empty enums not allowed");
422 },
423 .pointer => |ptrInfo| {
424 if (ptrInfo.size != .slice) @compileError("Unsupported field type: " ++ @typeName(field.type));
425 if (ptrInfo.child == u8) {
426 // String.
427 } else {
428 // Array.
429 if (field.default_value_ptr == null) @compileError("Array arguments must have a default value: " ++ field.name);
430 switch (@typeInfo(ptrInfo.child)) {
431 .bool => @compileError("Unsupported field type: " ++ @typeName(field.type)),
432 .float => {},
433 .int => {},
434 .@"enum" => @compileError("Unsupported field type: " ++ @typeName(field.type)),
435 .pointer => |ptrInfo2| {
436 if (ptrInfo2.size != .slice) @compileError("Unsupported field type: " ++ @typeName(field.type));
437 if (ptrInfo2.child == u8) {
438 // String.
439 } else {
440 @compileError("Unsupported field type: " ++ @typeName(field.type));
441 }
442 },
443 else => @compileError("Unsupported field type: " ++ @typeName(field.type)),
444 }
445 }
446 },
447 else => @compileError("Unsupported field type: " ++ @typeName(field.type)),
448 }
449 }
450}
451
452/// If you do your own validation after getting an `args` from `parse` or similar,
453/// call this function to produce the same error behavior as if this API's validation failed.
454/// An error message will be written to `options.writer` or stderr by default, and `error.Usage` is returned.
455/// The given `msg` template is prefixed by `"error: "` and suffixed by a newline and a prompt to try passing in `--help`.
456/// `options.prog` is not used by this function, but could be in the future.
457pub fn @"error"(comptime msg: []const u8, args: anytype, options: Options) error{Usage} {
458 return usageError(options.writer, msg, args);
459}
460
461test @"error" {
462 const Args = struct {
463 named: struct {
464 output: []const u8 = "",
465 },
466 positional: []const []const u8 = &.{},
467 };
468
469 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
470 defer arena.deinit();
471 const args = try parseSlice(Args, arena.allocator(), &[_][]const u8{ "--output=o.txt", "i.txt" }, .{});
472
473 if (std.fs.path.isAbsolutePosix(args.named.output)) {
474 return std.cli.@"error"("--output must not be absolute: {s}", .{args.named.output}, .{});
475 }
476 if (args.positional.len > 1) {
477 return std.cli.@"error"("expected exactly 1 positional arg", .{}, .{});
478 }
479}
480
481fn usageError(writer: ?*Writer, comptime msg: []const u8, args: anytype) error{Usage} {
482 const whole_msg =
483 "error: " ++ msg ++ "\n" ++
484 \\try --help for full help info
485 \\
486 ;
487 if (writer) |w| {
488 w.print(whole_msg, args) catch {};
489 } else {
490 std.debug.print(whole_msg, args);
491 }
492 return error.Usage;
493}
494
495fn ArgIteratorSlice(comptime String: type) type {
496 return struct {
497 slice: []const String,
498 index: usize = 0,
499
500 pub fn next(self: *@This()) ?String {
501 if (self.index >= self.slice.len) return null;
502 const result = self.slice[self.index];
503 self.index += 1;
504 return result;
505 }
506 };
507}
508
509fn enumValuesExpr(comptime Enum: type) []const u8 {
510 comptime var values_str: []const u8 = "{";
511 inline for (@typeInfo(Enum).@"enum".fields) |enum_field| {
512 if (values_str.len > 1) {
513 values_str = values_str ++ ",";
514 }
515 values_str = values_str ++ enum_field.name;
516 }
517 values_str = values_str ++ "}";
518 return values_str;
519}
520
521fn printGeneratedHelp(writer: ?*Writer, prog: []const u8, comptime named_info: std.builtin.Type.Struct) void {
522 const msg = //
523 \\usage: {s} [options] [arg...]
524 \\
525 \\arguments:{s}
526 \\ --help
527 \\
528 ;
529 comptime var arguments_str: []const u8 = "";
530 inline for (named_info.fields) |field| {
531 switch (@typeInfo(field.type)) {
532 .bool => {
533 if (field.defaultValue()) |default| {
534 if (default) {
535 arguments_str = arguments_str ++ "\n --no-" ++ field.name ++ " default: --" ++ field.name;
536 } else {
537 arguments_str = arguments_str ++ "\n --" ++ field.name ++ " default: --no-" ++ field.name;
538 }
539 } else {
540 arguments_str = arguments_str ++ "\n --" ++ field.name ++ " or --no-" ++ field.name ++ " required";
541 }
542 },
543 .int, .float => {
544 arguments_str = arguments_str ++ "\n --" ++ field.name ++ " " ++ @typeName(field.type);
545 if (field.defaultValue()) |default| {
546 arguments_str = arguments_str ++ " default: " ++ std.fmt.comptimePrint("{}", .{default});
547 } else {
548 arguments_str = arguments_str ++ " required";
549 }
550 },
551 .@"enum" => {
552 arguments_str = arguments_str ++ "\n --" ++ field.name ++ " " ++ comptime enumValuesExpr(field.type);
553 if (field.defaultValue()) |default| {
554 arguments_str = arguments_str ++ " default: " ++ quoteIfEmpty(@tagName(default));
555 } else {
556 arguments_str = arguments_str ++ " required";
557 }
558 },
559 .pointer => |ptrInfo| {
560 if (ptrInfo.size == .slice and ptrInfo.child == u8) {
561 // String.
562 arguments_str = arguments_str ++ "\n --" ++ field.name ++ " string";
563 if (field.defaultValue()) |default| {
564 arguments_str = arguments_str ++ " default: " ++ quoteIfEmpty(default);
565 } else {
566 arguments_str = arguments_str ++ " required";
567 }
568 } else {
569 // Array
570 const type_name = switch (@typeInfo(ptrInfo.child)) {
571 .bool => comptime unreachable,
572 .int, .float => @typeName(ptrInfo.child),
573 .@"enum" => comptime unreachable,
574 .pointer => "string", // The array-of-pointer that doesn't cause compile errors elsewhere.
575 else => comptime unreachable,
576 };
577 arguments_str = arguments_str ++ "\n " ++ //
578 "--" ++ field.name ++ " " ++ type_name ++ " " ++ //
579 "[--" ++ field.name ++ " " ++ type_name ++ " ...]";
580 }
581 },
582 else => @compileError("Unsupported field type: " ++ @typeName(field.type)),
583 }
584 }
585 if (writer) |w| {
586 w.print(msg, .{ prog, arguments_str }) catch {};
587 w.flush() catch {};
588 } else {
589 var buffer: [0x100]u8 = undefined;
590 var file_writer = std.fs.File.stdout().writer(&buffer);
591 file_writer.interface.print(msg, .{ prog, arguments_str }) catch {};
592 file_writer.interface.flush() catch {};
593 }
594}
595
596inline fn quoteIfEmpty(comptime s: []const u8) []const u8 {
597 if (s.len == 0) return "''";
598 return s;
599}
600
601var failing_writer: Writer = .failing;
602const silent_options = Options{ .writer = &failing_writer };
603
604test "usage errors" {
605 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
606 defer arena.deinit();
607 const allocator = arena.allocator();
608 var aw: Writer.Allocating = .init(allocator);
609 const options = Options{ .prog = "test-prog", .writer = &aw.writer };
610
611 // unrecognized argument
612 aw.clearRetainingCapacity();
613 try testing.expectError(error.Usage, parseSlice(struct {
614 named: struct {
615 name: []const u8 = "",
616 },
617 positional: []const []const u8 = &.{},
618 }, allocator, &[_][]const u8{"--bogus"}, options));
619 try testing.expect(mem.indexOf(u8, aw.written(), "--bogus") != null);
620
621 // expected argument
622 aw.clearRetainingCapacity();
623 try testing.expectError(error.Usage, parseSlice(struct {
624 named: struct {
625 name: []const u8 = "",
626 },
627 positional: []const []const u8 = &.{},
628 }, allocator, &[_][]const u8{"--name"}, options));
629 try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null);
630
631 // --no-<name> for non-bool.
632 aw.clearRetainingCapacity();
633 try testing.expectError(error.Usage, parseSlice(struct {
634 named: struct {
635 name: []const u8 = "",
636 },
637 positional: []const []const u8 = &.{},
638 }, allocator, &[_][]const u8{"--no-name"}, options));
639 try testing.expect(mem.indexOf(u8, aw.written(), "--no-name") != null);
640
641 // --name=false for bool
642 aw.clearRetainingCapacity();
643 try testing.expectError(error.Usage, parseSlice(struct {
644 named: struct {
645 name: bool = false,
646 },
647 positional: []const []const u8 = &.{},
648 }, allocator, &[_][]const u8{"--name=true"}, options));
649 try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null);
650
651 // missing required argument
652 aw.clearRetainingCapacity();
653 try testing.expectError(error.Usage, parseSlice(struct {
654 named: struct {
655 name: []const u8,
656 },
657 positional: []const []const u8 = &.{},
658 }, allocator, &[_][]const u8{}, options));
659 try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null);
660
661 // parse int error
662 aw.clearRetainingCapacity();
663 try testing.expectError(error.Usage, parseSlice(struct {
664 named: struct {
665 name: i32,
666 },
667 positional: []const []const u8 = &.{},
668 }, allocator, &[_][]const u8{"--name=abc"}, options));
669 try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null);
670 aw.clearRetainingCapacity();
671 try testing.expectError(error.Usage, parseSlice(struct {
672 named: struct {
673 name: []const i32 = &.{},
674 },
675 positional: []const []const u8 = &.{},
676 }, allocator, &[_][]const u8{"--name=abc"}, options));
677 try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null);
678
679 // parse float error
680 aw.clearRetainingCapacity();
681 try testing.expectError(error.Usage, parseSlice(struct {
682 named: struct {
683 name: f32,
684 },
685 positional: []const []const u8 = &.{},
686 }, allocator, &[_][]const u8{"--name=abc"}, options));
687 try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null);
688 aw.clearRetainingCapacity();
689 try testing.expectError(error.Usage, parseSlice(struct {
690 named: struct {
691 name: []const f32 = &.{},
692 },
693 positional: []const []const u8 = &.{},
694 }, allocator, &[_][]const u8{"--name=abc"}, options));
695 try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null);
696
697 // parse enum error
698 aw.clearRetainingCapacity();
699 try testing.expectError(error.Usage, parseSlice(struct {
700 named: struct {
701 name: enum { auto, never, always },
702 },
703 positional: []const []const u8 = &.{},
704 }, allocator, &[_][]const u8{"--name=abc"}, options));
705 try testing.expect(mem.indexOf(u8, aw.written(), "--name") != null);
706 try testing.expect(mem.indexOf(u8, aw.written(), "abc") != null);
707 // Error should suggest the set of options.
708 try testing.expect(mem.indexOf(u8, aw.written(), "always") != null);
709
710 // reject single-letter alias-looking arguments
711 aw.clearRetainingCapacity();
712 try testing.expectError(error.Usage, parseSlice(struct {
713 named: struct {
714 z: bool = false,
715 },
716 positional: []const []const u8 = &.{},
717 }, allocator, &[_][]const u8{"-z"}, options));
718 try testing.expect(mem.indexOf(u8, aw.written(), "-z") != null);
719}
720
721test "ints and floats" {
722 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
723 defer arena.deinit();
724 const allocator = arena.allocator();
725
726 const Args = struct {
727 named: struct {
728 int_u32: u32,
729 int_i32: i32,
730 int_u8: u8,
731 int_u256: u256,
732 float_f32: f32,
733 float_f64: f64,
734 inf_f32: f32,
735 ninf_f64: f64,
736 },
737 positional: []const []const u8 = &.{},
738 };
739 const args = try parseSlice(Args, allocator, &[_][]const u8{
740 "--int_u32", "0xffffffff",
741 "--int_i32", "-0x80000000",
742 "--int_u8", "0o310",
743 "--int_u256", "115792089237316195423570985008687907853269984665640564039457584007913129639935",
744 "--float_f32", "1.25",
745 "--float_f64", "-0xab.cdef012345p-12",
746 "--inf_f32", "inf",
747 "--ninf_f64", "-INF",
748 }, .{});
749
750 try testing.expectEqualDeep(Args{
751 .named = .{
752 .int_u32 = 0xffffffff,
753 .int_i32 = -0x80000000,
754 .int_u8 = 0o310,
755 .int_u256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935,
756 .float_f32 = 1.25,
757 .float_f64 = -0xab.cdef012345p-12,
758 .inf_f32 = std.math.inf(f32),
759 .ninf_f64 = -std.math.inf(f64),
760 },
761 .positional = &.{},
762 }, args);
763
764 const Args2 = struct {
765 named: struct {
766 nan: f64,
767 },
768 positional: []const []const u8 = &.{},
769 };
770 const args2 = try parseSlice(Args2, allocator, &[_][]const u8{
771 "--nan", "nAN",
772 }, .{});
773
774 try testing.expect(std.math.isNan(args2.named.nan));
775}
776
777test "bool" {
778 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
779 defer arena.deinit();
780 const allocator = arena.allocator();
781
782 const Args = struct {
783 named: struct {
784 b: bool,
785 },
786 positional: []const []const u8 = &.{},
787 };
788
789 try testing.expectEqualDeep(Args{ .named = .{ .b = true } }, try parseSlice(Args, allocator, &[_][]const u8{"--b"}, .{}));
790 try testing.expectEqualDeep(Args{ .named = .{ .b = false } }, try parseSlice(Args, allocator, &[_][]const u8{"--no-b"}, .{}));
791 try testing.expectEqualDeep(Args{ .named = .{ .b = true } }, try parseSlice(Args, allocator, &[_][]const u8{ "--no-b", "--b" }, .{}));
792 try testing.expectEqualDeep(Args{ .named = .{ .b = false } }, try parseSlice(Args, allocator, &[_][]const u8{ "--b", "--no-b" }, .{}));
793
794 try testing.expectError(error.Usage, parseSlice(Args, allocator, &[_][]const u8{"--b=true"}, silent_options));
795 try testing.expectError(error.Usage, parseSlice(Args, allocator, &[_][]const u8{"--b=false"}, silent_options));
796}
797
798test "string" {
799 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
800 defer arena.deinit();
801 const allocator = arena.allocator();
802
803 const Args = struct {
804 named: struct {
805 a: []const u8,
806 b: [:0]const u8,
807 },
808 positional: []const []const u8 = &.{},
809 };
810 const args = try parseSlice(Args, allocator, &[_][:0]const u8{
811 "--a", "a",
812 "--b", "b",
813 }, .{});
814
815 try testing.expectEqualDeep(Args{
816 .named = .{
817 .a = "a",
818 .b = "b",
819 },
820 .positional = &.{},
821 }, args);
822}
823
824test "array" {
825 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
826 defer arena.deinit();
827 const allocator = arena.allocator();
828
829 const Args = struct {
830 named: struct {
831 path: []const []const u8 = &.{},
832 id: []const i32 = &.{},
833 },
834 positional: []const []const u8 = &.{},
835 };
836 const args = try parseSlice(Args, allocator, &[_][]const u8{
837 "--path", "a",
838 "--path", "b",
839 "--path", "a",
840 "--id", "1",
841 "--id", "-12",
842 }, .{});
843
844 try testing.expectEqualDeep(Args{
845 .named = .{
846 .path = &[_][]const u8{ "a", "b", "a" },
847 .id = &[_]i32{ 1, -12 },
848 },
849 .positional = &.{},
850 }, args);
851}
852
853test "enum" {
854 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
855 defer arena.deinit();
856 const allocator = arena.allocator();
857
858 const Args = struct {
859 named: struct {
860 color: enum {
861 always,
862 never,
863 auto,
864 },
865 guess: enum {
866 @"the-only-option",
867 },
868 signal: enum(u8) {
869 KILL = 9,
870 TERM = 15,
871 VTALRM = 26,
872 },
873 },
874 positional: []const []const u8 = &.{},
875 };
876 const args = try parseSlice(Args, allocator, &[_][]const u8{
877 "--color", "always",
878 "--guess", "the-only-option",
879 "--signal", "TERM",
880 }, .{});
881
882 try testing.expectEqualDeep(Args{
883 .named = .{
884 .color = .always,
885 .guess = .@"the-only-option",
886 .signal = .TERM,
887 },
888 .positional = &.{},
889 }, args);
890}
891
892test "defaults" {
893 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
894 defer arena.deinit();
895 const allocator = arena.allocator();
896
897 const Args = struct {
898 named: struct {
899 level: i8 = -1,
900 ratio: f32 = 0.5,
901 path: []const u8 = "-",
902 color: enum {
903 always,
904 never,
905 auto,
906 } = .auto,
907 file: []const []const u8 = &.{},
908 force: bool = false,
909 cleanup: bool = true,
910 },
911 positional: []const []const u8 = &.{},
912 };
913
914 try testing.expectEqualDeep(Args{
915 .named = .{},
916 .positional = &.{},
917 }, try parseSlice(Args, allocator, &[_][]const u8{}, .{}));
918 try testing.expectEqualDeep(Args{
919 .named = .{
920 .color = .always,
921 },
922 .positional = &.{},
923 }, try parseSlice(Args, allocator, &[_][]const u8{ "--color", "always" }, .{}));
924 try testing.expectEqualDeep(Args{
925 .named = .{
926 .file = &[_][]const u8{"file.txt"},
927 },
928 .positional = &.{},
929 }, try parseSlice(Args, allocator, &[_][]const u8{ "--file", "file.txt" }, .{}));
930
931 try testing.expectEqualDeep(Args{
932 .named = .{
933 .force = true,
934 .cleanup = false,
935 },
936 .positional = &.{},
937 }, try parseSlice(Args, allocator, &[_][]const u8{ "--force", "--no-cleanup" }, .{}));
938}
939
940test "help" {
941 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
942 defer arena.deinit();
943 const allocator = arena.allocator();
944
945 var aw: Writer.Allocating = .init(allocator);
946 const options = Options{ .prog = "test-prog", .writer = &aw.writer };
947
948 try testing.expectError(error.Help, parseSlice(struct {
949 named: struct {
950 str: []const u8,
951 int: i32,
952 flag: bool,
953 },
954 positional: []const []const u8 = &.{},
955 }, allocator, &[_][]const u8{"--help"}, options));
956 // Because the help output is primarily for humans, don't get too strict in the unit test.
957 // Only verify that we see the important stuff that should definitely be there somewhere,
958 // but otherwise allow maintainers to adjust the layout, formatting, notation, etc. without causing friction here.
959 try testing.expect(mem.indexOf(u8, aw.written(), "test-prog") != null);
960 try testing.expect(mem.indexOf(u8, aw.written(), "--str string") != null);
961 try testing.expect(mem.indexOf(u8, aw.written(), "--int") != null);
962 try testing.expect(mem.indexOf(u8, aw.written(), "--flag") != null);
963 try testing.expect(mem.indexOf(u8, aw.written(), "--no-flag") != null);
964 try testing.expect(mem.indexOf(u8, aw.written(), "--help") != null);
965
966 aw.clearRetainingCapacity();
967 try testing.expectError(error.Help, parseSlice(struct {
968 named: struct {
969 color: enum { never, auto, always } = .auto,
970 },
971 positional: []const []const u8 = &.{},
972 }, allocator, &[_][]const u8{"--help"}, options));
973 // All allowed values for an enum should be spelled out.
974 try testing.expect(mem.indexOf(u8, aw.written(), "--color") != null);
975 try testing.expect(mem.indexOf(u8, aw.written(), "never") != null);
976 try testing.expect(mem.indexOf(u8, aw.written(), "auto") != null);
977 try testing.expect(mem.indexOf(u8, aw.written(), "always") != null);
978
979 // Test that arrays are represented differently from scalars somehow.
980 aw.clearRetainingCapacity();
981 try testing.expectError(error.Help, parseSlice(struct {
982 named: struct {
983 name: []const u8,
984 },
985 positional: []const []const u8 = &.{},
986 }, allocator, &[_][]const u8{"--help"}, options));
987 const scalar_help = try aw.toOwnedSlice();
988 try testing.expectError(error.Help, parseSlice(struct {
989 named: struct {
990 name: []const []const u8 = &.{},
991 },
992 positional: []const []const u8 = &.{},
993 }, allocator, &[_][]const u8{"--help"}, options));
994 try testing.expect(!mem.eql(u8, scalar_help, aw.written()));
995
996 // Default values should be rendered somehow.
997 aw.clearRetainingCapacity();
998 try testing.expectError(error.Help, parseSlice(struct {
999 named: struct {
1000 str: []const u8 = "hello",
1001 int: i32 = 3,
1002 f: f32 = 1.25,
1003 },
1004 positional: []const []const u8 = &.{},
1005 }, allocator, &[_][]const u8{"--help"}, options));
1006 try testing.expect(mem.indexOf(u8, aw.written(), "hello") != null);
1007 try testing.expect(mem.indexOf(u8, aw.written(), "3") != null);
1008 try testing.expect(mem.indexOf(u8, aw.written(), "1.25") != null);
1009
1010 // Test that bool arguments express the default somehow.
1011 aw.clearRetainingCapacity();
1012 try testing.expectError(error.Help, parseSlice(struct {
1013 named: struct {
1014 b: bool,
1015 },
1016 positional: []const []const u8 = &.{},
1017 }, allocator, &[_][]const u8{"--help"}, options));
1018 const bool_required_help = try aw.toOwnedSlice();
1019 try testing.expectError(error.Help, parseSlice(struct {
1020 named: struct {
1021 b: bool = true,
1022 },
1023 positional: []const []const u8 = &.{},
1024 }, allocator, &[_][]const u8{"--help"}, options));
1025 const default_true_help = try aw.toOwnedSlice();
1026 try testing.expectError(error.Help, parseSlice(struct {
1027 named: struct {
1028 b: bool = false,
1029 },
1030 positional: []const []const u8 = &.{},
1031 }, allocator, &[_][]const u8{"--help"}, options));
1032 const default_false_help = try aw.toOwnedSlice();
1033 try testing.expect(!mem.eql(u8, bool_required_help, default_true_help));
1034 try testing.expect(!mem.eql(u8, bool_required_help, default_false_help));
1035 try testing.expect(!mem.eql(u8, default_true_help, default_false_help));
1036
1037 // Test that enum arguments express the default somehow.
1038 aw.clearRetainingCapacity();
1039 try testing.expectError(error.Help, parseSlice(struct {
1040 named: struct {
1041 color: enum { never, auto, always },
1042 },
1043 positional: []const []const u8 = &.{},
1044 }, allocator, &[_][]const u8{"--help"}, options));
1045 const enum_required_help = try aw.toOwnedSlice();
1046 try testing.expectError(error.Help, parseSlice(struct {
1047 named: struct {
1048 color: enum { never, auto, always } = .auto,
1049 },
1050 positional: []const []const u8 = &.{},
1051 }, allocator, &[_][]const u8{"--help"}, options));
1052 const default_auto_help = try aw.toOwnedSlice();
1053 try testing.expectError(error.Help, parseSlice(struct {
1054 named: struct {
1055 color: enum { never, auto, always } = .never,
1056 },
1057 positional: []const []const u8 = &.{},
1058 }, allocator, &[_][]const u8{"--help"}, options));
1059 const default_never_help = try aw.toOwnedSlice();
1060 try testing.expect(!mem.eql(u8, enum_required_help, default_auto_help));
1061 try testing.expect(!mem.eql(u8, enum_required_help, default_never_help));
1062 try testing.expect(!mem.eql(u8, default_auto_help, default_never_help));
1063}
1064
1065test "minimal" {
1066 const Args = struct {
1067 named: struct {},
1068 positional: []const []const u8 = &.{},
1069 };
1070
1071 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
1072 defer arena.deinit();
1073 const args = try parseSlice(Args, arena.allocator(), &[_][]const u8{}, .{});
1074
1075 try testing.expectEqual(@as(usize, 0), args.positional.len);
1076}
1077
1078test "manual deinit" {
1079 const Args = struct {
1080 named: struct {
1081 str_arr: []const []const u8 = &.{},
1082 int_arr: []const i32 = &.{},
1083 empty_arr: []const []const u8 = &.{},
1084 },
1085 positional: []const []const u8 = &.{},
1086 };
1087
1088 const args = try parseSlice(Args, testing.allocator, &[_][]const u8{
1089 "--str_arr=hello1", "--str_arr", "hello2",
1090 "--int_arr=123456", "--int_arr", "789012",
1091 "positional-12345", "--", "positi",
1092 }, .{});
1093
1094 try testing.expectEqualDeep(Args{
1095 .named = .{
1096 .str_arr = &.{ "hello1", "hello2" },
1097 .int_arr = &.{ 123456, 789012 },
1098 },
1099 .positional = &.{ "positional-12345", "positi" },
1100 }, args);
1101
1102 // Surgically cleanup memory.
1103 testing.allocator.free(args.named.str_arr);
1104 testing.allocator.free(args.named.int_arr);
1105 testing.allocator.free(args.named.empty_arr);
1106 testing.allocator.free(args.positional);
1107 // Should be no memory leak errors now.
1108}
1109
1110test "actually calling error" {
1111 const Args = struct {
1112 named: struct {
1113 output: []const u8 = "",
1114 },
1115 positional: []const []const u8 = &.{},
1116 };
1117
1118 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
1119 defer arena.deinit();
1120 const args = try parseSlice(Args, arena.allocator(), &[_][]const u8{
1121 "--output=/absolute/path", "too", "many", "other", "args",
1122 }, .{});
1123
1124 try testing.expectEqual(error.Usage, std.cli.@"error"("--output must not be absolute: {s}", .{args.named.output}, silent_options));
1125 try testing.expectEqual(error.Usage, std.cli.@"error"("expected exactly 1 positional arg", .{}, silent_options));
1126}
1127
1128test "custom help" {
1129 var arena: std.heap.ArenaAllocator = .init(testing.allocator);
1130 defer arena.deinit();
1131 const allocator = arena.allocator();
1132
1133 var aw: Writer.Allocating = .init(allocator);
1134 const options = Options{ .prog = "unused-prog", .writer = &aw.writer };
1135
1136 const Args = struct {
1137 pub const help =
1138 \\usage: the-zip-thing --output path [options] input.zip
1139 \\
1140 \\arguments:
1141 \\ --output path where to write the output stuff
1142 \\ --[no-]force overwrite output if already exists
1143 \\ input.zip the zip file to read
1144 \\ --help print this help and exit
1145 \\
1146 ;
1147 named: struct {
1148 output: []const u8,
1149 force: bool = false,
1150 },
1151 positional: []const []const u8 = &.{},
1152 };
1153 try testing.expectError(error.Help, parseSlice(Args, allocator, &[_][]const u8{"--help"}, options));
1154 try testing.expectEqualStrings(Args.help, aw.written());
1155}
lib/std/std.zig+1
......@@ -63,6 +63,7 @@ pub const base64 = @import("base64.zig");
6363pub const bit_set = @import("bit_set.zig");
6464pub const builtin = @import("builtin.zig");
6565pub const c = @import("c.zig");
66pub const cli = @import("cli.zig");
6667pub const coff = @import("coff.zig");
6768pub const compress = @import("compress.zig");
6869pub const static_string_map = @import("static_string_map.zig");