1//! Configuration for an command line interface, which can be executed against
2//! an argument slice using `.parse`. None of the parsing strategies require an
3//! allocator, however it expects incoming arguments to be UTF-8 formatted which
4//! on Windows will require an allocator.
5//!
6//! See `fn main` for a basic usage example. Parsing subcommands is done in
7//! `main.zig`, utilizing a different `Cli` for each option set.
8const Cli = @This();
9
10options: []const Option,
11positionals: Positionals,
12help: HelpConfig = .{},
13
14pub const empty: Cli = .{
15 .options = &.{},
16 .positionals = .none,
17 .help = .{ .option = false },
18};
19
20pub const Option = struct {
21 /// Should be formatted as the flag name: '--dump-ir'
22 name: []const u8,
23 /// Displayed in generated help menu
24 desc: []const u8,
25 kind: Kind = .boolean,
26 /// A short code allows single dash argument parsing.
27 short: ?u8 = null,
28
29 const Kind = union(enum) {
30 /// UTF-8 validated
31 string,
32 boolean,
33 @"enum": type,
34
35 pub fn Type(k: Kind) type {
36 return switch (k) {
37 .string => []const u8,
38 .boolean => bool,
39 .@"enum" => |t| t,
40 };
41 }
42 };
43
44 pub fn optional(comptime cond: bool, opts: []const Option) []const Option {
45 return if (cond) opts else &.{};
46 }
47
48 const help: Option = .{
49 .name = "--help",
50 .short = 'h',
51 .desc = "Print this help and exit",
52 };
53};
54
55pub const Positionals = union(enum) {
56 none,
57 /// Store a single positional: ?[]const u8
58 single,
59 /// Store the start and count of positionals. Call
60 /// 'parsed.positionals.iterator(args)' with the
61 /// original argument array to read positionals.
62 limited: usize,
63 /// Use a bounded array to store positionals, using
64 /// a stack allocated buffer that can hold enough
65 /// '[]const u8's as allowed by the bound.
66 bounded: usize,
67
68 /// Shorthand for an unlimited amount of positionals.
69 const unlimited: Positionals = .{ .limited = std.math.maxInt(usize) };
70
71 pub fn Type(p: Positionals) type {
72 return switch (p) {
73 .none => void,
74 .single => ?[]const u8,
75 .limited => |limit| switch (limit) {
76 0 => void,
77 else => Multi,
78 },
79 .bounded => |n| std.BoundedArray([]const u8, n),
80 };
81 }
82
83 pub inline fn maxCount(p: Positionals) usize {
84 return switch (p) {
85 .none => 0,
86 .single => 1,
87 inline .limited, .bounded => |limit| limit,
88 };
89 }
90
91 pub const Multi = struct {
92 /// The index of the first positional
93 first: usize,
94 /// The number of total positionals. This can
95 /// be used to pre-allocate resources.
96 len: usize,
97
98 first_is_after_double_dash: bool,
99
100 pub const empty = .{
101 .first = 0,
102 .len = 0,
103 .first_is_after_double_dash = false,
104 };
105
106 pub fn iterator(pos: @This(), all_args: []const []const u8) PositionalIterator {
107 return .{
108 .args = all_args,
109 .i = pos.first,
110 .left = pos.len,
111 .after_double_dash = pos.first_is_after_double_dash,
112 };
113 }
114 };
115};
116
117pub const HelpConfig = struct {
118 /// If set true, registers an addition '--help' / '-h' flag
119 /// These will show the help menu and exit the program.
120 ///
121 /// Help menu can always be displayed manually with .showHelp()
122 option: bool = true,
123
124 /// Override the usage string in the first line.
125 usage: ?[]const u8 = null,
126
127 /// Hide a list of flag names.
128 hide_options: []const []const u8 = &.{},
129};
130
131pub const PositionalIterator = struct {
132 args: []const []const u8,
133 i: usize,
134 left: usize,
135 after_double_dash: bool,
136
137 pub fn peek(it: PositionalIterator) ?[]const u8 {
138 return if (it.left > 0) it.args[it.i] else null;
139 }
140
141 pub fn next(it: *PositionalIterator) ?[]const u8 {
142 if (it.left > 0) {
143 defer it.advance();
144 return it.args[it.i];
145 } else {
146 return null;
147 }
148 }
149
150 pub fn advance(it: *PositionalIterator) void {
151 it.left -= 1;
152 if (it.left == 0) return;
153 if (it.after_double_dash) {
154 it.i += 1;
155 } else while (mem.startsWith(u8, it.args[it.i], "-")) {
156 defer it.i += 1;
157 if (it.args[it.i].len == 2 and it.args[it.i][1] == '-') {
158 it.after_double_dash = true;
159 return;
160 }
161 }
162 }
163};
164
165inline fn fieldName(comptime name: []const u8) [:0]const u8 {
166 std.debug.assert(name[0] == '-' and name[1] == '-');
167 for (name[2..]) |char| switch (char) {
168 'a'...'z', '-' => {},
169 else => |ch| @compileError(std.fmt.comptimePrint("Unexpected char '{c}' in option name '{s}'", .{ ch, name })),
170 };
171
172 const replacement_size = mem.replacementSize(u8, name[2..], "-", "_");
173 const replaced = brk: {
174 var buf: [replacement_size:0]u8 = undefined;
175 _ = mem.replace(u8, name[2..], "-", "_", &buf);
176 break :brk buf;
177 };
178 return &replaced;
179}
180
181/// Every option name is converted into snake case, then
182/// an additional field name 'positionals' is added.
183pub fn Parsed(p: Cli) type {
184 const fields = brk: {
185 var fields: [p.options.len + 1]Type.StructField = undefined;
186
187 for (p.options, fields[0..p.options.len]) |def, *field| {
188 const Wrapped = ?def.kind.Type();
189 field.* = .{
190 .name = fieldName(def.name),
191 .alignment = @alignOf(Wrapped),
192 .default_value_ptr = &@as(Wrapped, null),
193 .is_comptime = false,
194 .type = Wrapped,
195 };
196 }
197
198 const Pos = p.positionals.Type();
199 fields[fields.len - 1] = .{
200 .name = "positionals",
201 .alignment = @alignOf(Pos),
202 .type = Pos,
203 .default_value_ptr = &@as(Pos, switch (p.positionals) {
204 .none => {},
205 .single => null,
206 .limited => .empty,
207 .bounded => .{},
208 }),
209 .is_comptime = false,
210 };
211
212 break :brk fields;
213 };
214
215 return @Type(.{ .@"struct" = .{
216 .layout = .auto,
217 .fields = &fields,
218 .decls = &.{},
219 .is_tuple = false,
220 } });
221}
222
223const StdErrWriter = struct {
224 file: std.fs.File,
225 buffered_writer: std.io.BufferedWriter(4096, std.fs.File.Writer),
226 tty: std.io.tty.Config,
227 writer_ptr: std.io.BufferedWriter(4096, std.fs.File.Writer).Writer,
228
229 pub fn init() StdErrWriter {
230 const stderr = std.io.getStdErr();
231 const tty = std.io.tty.detectConfig(stderr);
232 return .{
233 .file = stderr,
234 .tty = tty,
235 .buffered_writer = std.io.bufferedWriter(stderr.writer()),
236 .writer_ptr = undefined,
237 };
238 }
239
240 pub fn get(w: *StdErrWriter) std.io.AnyWriter {
241 if (@import("builtin").os.tag == .windows and w.tty == .windows_api) {
242 // all data must be flushed before changing colors using
243 // the Windows API. thankfully, modern windows terminals
244 // will not hit this path.
245 return w.buffered_writer.unbuffered_writer.any();
246 } else {
247 w.writer_ptr = w.buffered_writer.writer(); // uaf unless stored
248 return w.writer_ptr.any();
249 }
250 }
251};
252
253const ParseError = error{ InvalidArguments, DisplayedHelpMenu };
254
255/// This will quit the program with usage information if invalid arguments are
256/// received and the `--help` option is enabled. This is the most common use
257/// case. If manual error handling is desired, `.parseWithErrorWriter` can
258/// be used instead.
259pub fn parse(
260 comptime p: Cli,
261 program_name: []const u8,
262 args: []const []const u8,
263) p.Parsed() {
264 var stderr = StdErrWriter.init();
265 defer stderr.buffered_writer.flush() catch {};
266 return parseWithErrorWriter(
267 p,
268 program_name,
269 args,
270 stderr.get(),
271 stderr.tty,
272 ) catch |e| switch (e) {
273 error.InvalidArguments => {
274 stderr.buffered_writer.flush() catch {};
275 std.process.exit(1);
276 },
277 error.DisplayedHelpMenu => {
278 stderr.buffered_writer.flush() catch {};
279 std.process.exit(0);
280 },
281 };
282}
283
284pub fn parseWithErrorWriter(
285 comptime p: Cli,
286 program_name: []const u8,
287 args: []const []const u8,
288 err: std.io.AnyWriter,
289 tty: std.io.tty.Config,
290) ParseError!p.Parsed() {
291 @setEvalBranchQuota(std.math.maxInt(u32));
292 var out: p.Parsed() = .{};
293 var had_errors = false;
294 var positionals_counted: usize = 0;
295 var first_positional: usize = 0;
296 var double_dash: ?usize = null;
297
298 var show_help = false;
299
300 const max_positionals = p.positionals.maxCount();
301
302 outer: for (args, 0..) |arg, i| {
303 if (mem.startsWith(u8, arg, "--")) {
304 if (arg.len == 2) {
305 if (max_positionals == 0) {
306 writeError(err, tty);
307 err.writeAll("unexpected end-of-parameter mark: --\n") catch {};
308 }
309
310 double_dash = i;
311 const remain = args[i + 1 ..];
312 if (positionals_counted == 0 and remain.len > 0)
313 first_positional = i + 1;
314
315 if (p.positionals == .bounded) {
316 out.positionals.appendSliceAssumeCapacity(remain[0..@min(remain.len, max_positionals -| positionals_counted)]);
317 }
318
319 positionals_counted += remain.len;
320
321 if (positionals_counted > max_positionals) {
322 p.markFirstError(&had_errors, program_name, err, tty);
323 const diff = @min(remain.len, positionals_counted - max_positionals);
324 for (remain[remain.len - diff ..]) |pos| {
325 writeError(err, tty);
326 err.print("unrecognized argument '{s}'\n", .{pos}) catch {};
327 }
328 }
329
330 break;
331 }
332
333 inline for (p.options) |def| try_this_def: {
334 const prefix = def.name;
335 const kind = def.kind;
336 const dest = &@field(out, fieldName(def.name));
337
338 const is_blank = arg.len == prefix.len;
339 if (!(mem.startsWith(u8, arg[2..], prefix[2..]) and (is_blank or arg[prefix.len] == '=')))
340 break :try_this_def;
341
342 if (kind != .boolean and is_blank) {
343 p.markFirstError(&had_errors, program_name, err, tty);
344 writeError(err, tty);
345 err.print(prefix ++ " requires a value\n", .{}) catch {};
346 continue :outer;
347 }
348
349 const value = if (is_blank) "" else arg[prefix.len + 1 ..];
350 switch (kind) {
351 .boolean => if (value.len == 0) {
352 dest.* = true;
353 } else if (mem.eql(u8, value, "false")) {
354 dest.* = false;
355 } else if (mem.eql(u8, value, "true")) {
356 dest.* = true;
357 } else {
358 p.markFirstError(&had_errors, program_name, err, tty);
359 writeError(err, tty);
360 err.print(prefix ++ " only accepts true or false, got '{s}'\n", .{
361 arg[prefix.len + 1 ..],
362 }) catch {};
363 },
364
365 .string => if (std.unicode.utf8ValidateSlice(value)) {
366 dest.* = value;
367 } else {
368 p.markFirstError(&had_errors, program_name, err, tty);
369 writeError(err, tty);
370 err.print(prefix ++ " requires valid UTF-8 input\n", .{}) catch {};
371 },
372
373 .@"enum" => |E| {
374 dest.* = std.meta.stringToEnum(E, value) orelse {
375 p.markFirstError(&had_errors, program_name, err, tty);
376 const values_list = comptime brk: {
377 var str: []const u8 = "";
378 for (std.enums.values(E)) |val| {
379 str = str ++ " " ++ @tagName(val) ++ "\n";
380 }
381 break :brk str;
382 };
383 writeError(err, tty);
384 err.print(prefix ++ " must be one of the following values, got {s}:\n{s}", .{
385 arg[prefix.len + 1 ..],
386 values_list,
387 }) catch {};
388 continue :outer;
389 };
390 },
391 }
392
393 continue :outer;
394 }
395
396 if (p.help.option) {
397 if (mem.startsWith(u8, arg[2..], "help") and (arg.len == "--help".len or arg["--help".len] == '=')) {
398 show_help = true;
399 if (arg.len > "--help".len) {
400 p.markFirstError(&had_errors, program_name, err, tty);
401 err.print("--help does not take a value\n", .{}) catch {};
402 }
403 continue :outer;
404 }
405 }
406
407 p.markFirstError(&had_errors, program_name, err, tty);
408 writeError(err, tty);
409 err.print("unrecognized argument '{s}'\n", .{arg}) catch {};
410 } else if (arg.len > 1 and arg[0] == '-') {
411 per_char_in_short: for (arg[1..]) |ch| {
412 inline for (p.options) |def| try_this_def: {
413 const short = def.short orelse break :try_this_def;
414 if (def.kind != .boolean) {
415 @compileError("TODO: non-boolean shorthand arguments");
416 }
417 if (ch == short) {
418 const dest = &@field(out, fieldName(def.name));
419 dest.* = true;
420 continue :per_char_in_short;
421 }
422 if (p.help.option and ch == 'h') {
423 show_help = true;
424 }
425 }
426 }
427 } else {
428 if (positionals_counted == 0) first_positional = i;
429 positionals_counted += 1;
430 if (positionals_counted > max_positionals) {
431 p.markFirstError(&had_errors, program_name, err, tty);
432 writeError(err, tty);
433 err.print("unrecognized argument '{s}'\n", .{arg}) catch {};
434 } else if (p.positionals == .bounded) {
435 out.positionals.appendAssumeCapacity(arg);
436 }
437 }
438 }
439
440 if (had_errors) {
441 if (positionals_counted > max_positionals) {
442 writeNote(err, tty);
443 err.writeAll(switch (max_positionals) {
444 0 => "this program does not take positional arguments\n",
445 1 => "this program only accepts one positional argument\n",
446 else => |n| std.fmt.comptimePrint(
447 "this program only accepts up to {d} positional arguments\n",
448 .{n},
449 ),
450 }) catch {};
451 }
452 if (show_help) {
453 p.showHelpWithWriter(program_name, err, tty);
454 }
455 return error.InvalidArguments;
456 }
457
458 if (show_help) {
459 p.showHelpWithWriter(program_name, err, tty);
460 return error.DisplayedHelpMenu;
461 }
462
463 out.positionals = switch (p.positionals) {
464 .none => {},
465 .single => if (positionals_counted > 0)
466 args[first_positional]
467 else
468 null,
469 .bounded => out.positionals, // already correct
470 .limited => if (max_positionals > 0) .{
471 .first = first_positional,
472 .len = positionals_counted,
473 .first_is_after_double_dash = if (double_dash) |dd|
474 dd < first_positional
475 else
476 false,
477 },
478 };
479
480 return out;
481}
482
483pub fn markFirstError(
484 comptime p: Cli,
485 ptr: *bool,
486 program_name: []const u8,
487 err: std.io.AnyWriter,
488 tty: std.io.tty.Config,
489) void {
490 if (ptr.* == true) return;
491 ptr.* = true;
492 p.showHelpWithWriter(program_name, err, tty);
493}
494
495pub fn showHelp(comptime p: Cli, program_name: []const u8) void {
496 var stderr = StdErrWriter.init();
497 defer stderr.buffered_writer.flush() catch {};
498 return showHelpWithWriter(
499 p,
500 program_name,
501 stderr.get(),
502 stderr.tty,
503 );
504}
505
506pub fn showHelpWithWriter(
507 comptime p: Cli,
508 program_name: []const u8,
509 w: std.io.AnyWriter,
510 tty: std.io.tty.Config,
511) void {
512 const usage = p.help.usage orelse default: {
513 const opts_usage = if (p.options.len > 0 or p.help.option) "[options...]" else "";
514 break :default switch (p.positionals.maxCount()) {
515 0 => opts_usage,
516 1 => mem.trimLeft(u8, opts_usage ++ " [argument]", " "),
517 else => mem.trimLeft(u8, opts_usage ++ " [args...]", " "),
518 };
519 };
520
521 tty.setColor(w, .bold) catch return;
522 w.writeAll("usage") catch return;
523 tty.setColor(w, .reset) catch return;
524 w.print(": {s} {s}\n", .{ program_name, usage }) catch return;
525
526 if (p.options.len > 0 or p.help.option) {
527 w.writeAll("\n") catch return;
528 tty.setColor(w, .bold) catch return;
529 w.writeAll("options") catch return;
530 tty.setColor(w, .reset) catch return;
531 w.writeAll(comptime brk: {
532 var out: []const u8 = ":\n\n";
533 var width = 0;
534 var any_short_options = false;
535
536 const all_opts = if (p.help.option)
537 p.options ++ [_]Option{.help}
538 else
539 p.options;
540 for (all_opts) |opt| {
541 width = @max(width, opt.name.len);
542 any_short_options = any_short_options or opt.short != null;
543 }
544
545 width += " ".len;
546 if (any_short_options) width += "-h ".len;
547 const sub = if (any_short_options) 4 else 1;
548
549 for (all_opts) |opt| {
550 out = out ++ " ";
551 if (any_short_options) {
552 out = out ++ if (opt.short) |s| [_]u8{ '-', s, ' ' } else " ";
553 }
554 out = out ++ opt.name ++ (" " ** (width - opt.name.len - sub));
555 // TODO: wrapping at 72 columns
556 out = out ++ opt.desc ++ "\n";
557 }
558 break :brk out ++ "\n";
559 }) catch return;
560 }
561}
562
563pub fn showHelpAndExit(comptime p: Cli, program_name: []const u8) noreturn {
564 p.showHelp(program_name);
565 std.process.exit(1);
566}
567
568fn writeError(err: std.io.AnyWriter, tty: std.io.tty.Config) void {
569 tty.setColor(err, .red) catch return;
570 tty.setColor(err, .bold) catch return;
571 err.writeAll("error") catch return;
572 tty.setColor(err, .reset) catch return;
573 tty.setColor(err, .dim) catch return;
574 err.writeAll(": ") catch return;
575 tty.setColor(err, .reset) catch return;
576}
577
578fn writeNote(err: std.io.AnyWriter, tty: std.io.tty.Config) void {
579 tty.setColor(err, .blue) catch return;
580 tty.setColor(err, .bold) catch return;
581 err.writeAll("note") catch return;
582 tty.setColor(err, .reset) catch return;
583 tty.setColor(err, .dim) catch return;
584 err.writeAll(": ") catch return;
585 tty.setColor(err, .reset) catch return;
586}
587
588/// Splits args[0] containing the program name
589///
590/// const program_name, const args = programArgsAlloc();
591pub fn programArgsAlloc(allocator: std.mem.Allocator, fallback_name: []const u8) !struct { []const u8, [][:0]u8 } {
592 const magic_number = @alignOf([*][:0]u8);
593 var raw_args = try std.process.argsAlloc(allocator);
594 switch (raw_args.len) {
595 0 => {
596 raw_args.ptr = @ptrFromInt(magic_number);
597 return .{ fallback_name, raw_args };
598 },
599 else => return .{
600 std.fs.path.basename(raw_args[0]),
601 raw_args[1..],
602 },
603 }
604}
605
606pub fn programArgsFree(allocator: std.mem.Allocator, args: [][:0]u8) void {
607 const magic_number = @alignOf([*][:0]u8);
608 if (args.len == 0 and @intFromPtr(args.ptr) == magic_number) return;
609 const raw_args = (args.ptr - 1)[0 .. args.len + 1];
610 std.process.argsFree(allocator, raw_args);
611}
612
613pub fn fatalWithHelp(p: Cli, program_name: []const u8, comptime fmt: []const u8, args: anytype) noreturn {
614 print: {
615 const stderr = std.io.getStdErr();
616 const tty = std.io.tty.detectConfig(stderr);
617 var bufw = std.io.bufferedWriter(stderr.writer());
618 writeError(bufw.writer().any(), tty);
619 bufw.writer().print(fmt ++ "\n", args) catch break :print;
620 p.showHelpWithWriter(program_name, bufw.writer().any(), tty);
621 bufw.flush() catch break :print;
622 }
623 std.process.exit(1);
624}
625
626pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
627 print: {
628 const stderr = std.io.getStdErr();
629 const tty = std.io.tty.detectConfig(stderr);
630 var bufw = std.io.bufferedWriter(stderr.writer());
631 writeError(bufw.writer().any(), tty);
632 bufw.writer().print(fmt ++ "\n", args) catch break :print;
633 bufw.flush() catch break :print;
634 }
635 std.process.exit(1);
636}
637
638pub fn main() !void {
639 var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
640 defer _ = gpa.deinit();
641 const alloc = gpa.allocator();
642
643 const config: Cli = .{
644 .options = &.{
645 .{ .name = "--flag", .desc = "check for something" },
646 .{ .name = "--str", .desc = "pass a string", .kind = .string },
647 .{
648 .name = "--enum",
649 .desc = "enum",
650 .kind = .{ .@"enum" = std.builtin.OptimizeMode },
651 },
652 },
653 .positionals = .{ .bounded = 2 },
654 };
655
656 const program_name, const args = try Cli.programArgsAlloc(alloc);
657 defer Cli.programArgsFree(alloc, args);
658
659 const parsed = config.parse(program_name, args[1..]);
660
661 std.debug.print("--flag: {any}\n", .{parsed.flag});
662 std.debug.print("--str: {?s}\n", .{parsed.str});
663 std.debug.print("--enum: {any}\n", .{parsed.@"enum"});
664 std.debug.print("positionals({d}):\n", .{parsed.positionals.len});
665
666 // if using '.positionals = .limited'
667 // var it = parsed.positionals.iterator(args[1..]);
668 // while (it.next()) |item| {
669 // std.debug.print(" - {s}\n", .{item});
670 // }
671
672 for (parsed.positionals.slice()) |item| {
673 std.debug.print(" - {s}\n", .{item});
674 }
675}
676
677const std = @import("std");
678const mem = std.mem;
679const Type = std.builtin.Type;