From 9df8a48667af45f67cd12c02d2e77f1dd67ea373 Mon Sep 17 00:00:00 2001 From: Josh Wolfe Date: Sat, 30 Aug 2025 06:08:21 -0400 Subject: [PATCH] nicer usage --- lib/std/cli.zig | 258 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 183 insertions(+), 75 deletions(-) diff --git a/lib/std/cli.zig b/lib/std/cli.zig index 5da13dbdfc..618246d243 100644 --- a/lib/std/cli.zig +++ b/lib/std/cli.zig @@ -293,7 +293,7 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] file_writer.interface.flush() catch {}; } } else { - printGeneratedHelp(writer, prog, named_fields); + printGeneratedHelp(named_fields, positional_fields, writer, prog); } if (exit_on_error) { std.process.exit(0); @@ -304,7 +304,7 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] 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); + return usageError(named_fields, positional_fields, writer, "unrecognized argument: {s}", .{arg}, prog, exit_on_error); } if (!the_rest_is_positional and mem.eql(u8, arg, "--")) { // Stop recognizing named arguments. Everything else is positional. @@ -314,14 +314,14 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] 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); + if (positional_field_index >= positional_fields.len) return usageError(named_fields, positional_fields, writer, "unexpected positional argument: {s}", .{arg}, prog, 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)); + try @field(positional_array_lists, field.name).append(allocator, try parseValue(named_fields, positional_fields, C, arg, field.name, writer, prog, 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); + @field(result.positional, field.name) = try parseValue(named_fields, positional_fields, field.type, arg, field.name, writer, prog, exit_on_error); positional_field_index += 1; } break; @@ -349,25 +349,25 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] 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); + if (immediate_value != null) return usageError(named_fields, positional_fields, writer, "cannot specify value for bool argument: {s}", .{arg}, prog, exit_on_error); @field(result.named, field.name) = !no_prefixed; break; } - if (no_prefixed) return usageError(writer, "unrecognized argument: {s}", .{arg}, exit_on_error); + if (no_prefixed) return usageError(named_fields, positional_fields, writer, "unrecognized argument: {s}", .{arg}, prog, 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); + const arg_value = immediate_value orelse iter.next() orelse return usageError(named_fields, positional_fields, writer, "expected argument after --{s}", .{field.name}, prog, 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)); + try @field(named_array_lists, field.name).append(allocator, try parseValue(named_fields, positional_fields, C, arg_value, field.name, writer, prog, exit_on_error)); } else { - @field(result.named, field.name) = try parseValue(field.type, arg_value, field.name, writer, exit_on_error); + @field(result.named, field.name) = try parseValue(named_fields, positional_fields, field.type, arg_value, field.name, writer, prog, exit_on_error); } break; } } else { // Didn't match anything. - return usageError(writer, "unrecognized argument: {s}", .{arg}, exit_on_error); + return usageError(named_fields, positional_fields, writer, "unrecognized argument: {s}", .{arg}, prog, exit_on_error); } } @@ -384,9 +384,9 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] @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); + return usageError(named_fields, positional_fields, writer, "missing required argument: --" ++ field.name ++ " or --no-" ++ field.name, .{}, prog, exit_on_error); } else { - return usageError(writer, "missing required argument: --" ++ field.name, .{}, exit_on_error); + return usageError(named_fields, positional_fields, writer, "missing required argument: --" ++ field.name, .{}, prog, exit_on_error); } } } @@ -403,7 +403,7 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] if (field.defaultValue()) |default| { @field(result.positional, field.name) = default; } else { - return usageError(writer, "missing required argument: " ++ field.name, .{}, exit_on_error); + return usageError(named_fields, positional_fields, writer, "missing required argument: " ++ field.name, .{}, prog, exit_on_error); } } } @@ -413,22 +413,22 @@ fn innerParse(comptime Args: type, allocator: Allocator, iter: anytype, prog: [] } /// 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 { +fn parseValue(comptime named_fields: []const StructField, comptime positional_fields: []const StructField, comptime T: type, arg_value: anytype, comptime field_name: []const u8, writer: ?*Writer, prog: []const u8, 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); + return usageError(named_fields, positional_fields, writer, "unable to parse --{s}={s}: {s}", .{ field_name, arg_value, @errorName(err) }, prog, 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); + return usageError(named_fields, positional_fields, writer, "unable to parse --{s}={s}: {s}", .{ field_name, arg_value, @errorName(err) }, prog, 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); + return usageError(named_fields, positional_fields, writer, "unrecognized value: --{s}={s}, expected one of: {s}", .{ field_name, arg_value, enumValuesExpr(T) }, prog, exit_on_error); }; }, .pointer => |ptrInfo| { @@ -574,12 +574,20 @@ fn ArrayListsForFields(comptime fields: []const StructField) type { /// 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. +/// `options.prog` is not used by this function, but could be in the future. TODO: yes it is. /// /// 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); +pub fn @"error"(comptime Args: type, comptime msg: []const u8, msg_args: anytype, options: Options) error{Usage} { + const named_fields, const positional_fields = comptime checkArgsType(Args); + var buf: [0x1000]u8 = undefined; + const prog: ?[]const u8 = options.prog orelse blk: { + var fba: std.heap.FixedBufferAllocator = .init(&buf); + var iter = ArgIterator.initWithAllocator(fba.allocator()) catch break :blk null; + const argv0 = iter.next(); + break :blk if (argv0) |arg| std.fs.path.basename(arg) else null; + }; + return usageError(named_fields, positional_fields, options.writer, msg, msg_args, prog orelse "", options.exit orelse true); } test @"error" { @@ -597,27 +605,10 @@ test @"error" { 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 }); + return std.cli.@"error"(Args, "--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, @@ -644,53 +635,125 @@ fn enumValuesExpr(comptime Enum: type) []const u8 { 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 = ""; +fn usageError(comptime named_fields: []const StructField, comptime positional_fields: []const StructField, writer: ?*Writer, comptime msg: []const u8, args: anytype, prog: []const u8, exit_on_error: bool) error{Usage} { + const whole_msg = + "error: " ++ msg ++ "\n" ++ // + "usage: {s} " ++ comptime usageLineFmt(named_fields, positional_fields) ++ "\n" ++ + \\try --help for full help info + \\ + ; + if (writer) |w| { + w.print(whole_msg, args ++ .{prog}) catch {}; + } else { + std.debug.print(whole_msg, args ++ .{prog}); + } + if (exit_on_error) { + std.process.exit(1); + } + return error.Usage; +} + +/// returns a string with all "{" escaped for passing into std.fmt. +fn usageLineFmt(comptime named_fields: []const StructField, comptime positional_fields: []const StructField) []const u8 { + comptime var usage_parts: []const []const u8 = &.{}; + var at_least_one_optional_named_argument = false; + inline for (named_fields) |field| { + if (field.default_value_ptr != null) { + // Don't mention optional named arguments. + at_least_one_optional_named_argument = true; + continue; + } + usage_parts = usage_parts ++ .{switch (@typeInfo(field.type)) { + .bool => "--[no-]" ++ field.name, + .int, .float => "--" ++ field.name ++ "=" ++ @typeName(field.type), + .@"enum" => "--" ++ field.name ++ "=" ++ enumValuesExpr(field.type), + else => blk: { + comptime assert(@typeInfo(field.type).pointer.size == .slice and @typeInfo(field.type).pointer.child == u8); + break :blk "--" ++ field.name ++ "=string"; + }, + }}; + } + + if (at_least_one_optional_named_argument) { + // Prepend with an [options] placeholder. + usage_parts = [_][]const u8{"[options]"} ++ usage_parts; + } + + inline for (positional_fields) |field| { + if (field.default_value_ptr != null) { + if (getArrayChild(field.type) != null) { + // Array + usage_parts = usage_parts ++ .{"[" ++ field.name ++ "...]"}; + } else { + // Scalar + usage_parts = usage_parts ++ .{"[" ++ field.name ++ "]"}; + } + } else { + usage_parts = usage_parts ++ .{field.name}; + } + } + + comptime var usage_str: []const u8 = ""; + inline for (usage_parts) |part| { + if (usage_str.len > 0) { + usage_str = usage_str ++ " "; + } + usage_str = usage_str ++ part; + } + return escapeFmt(usage_str); +} +fn printGeneratedHelp(comptime named_fields: []const StructField, comptime positional_fields: []const StructField, writer: ?*Writer, prog: []const u8) void { + comptime var arguments_table: []const []const []const u8 = &.{}; + + comptime var arguments_str: []const u8 = ""; // TODO: delete + + if (positional_fields.len > 0) { + arguments_table = arguments_table ++ .{&[_][]const u8{"positional arguments:"}}; + } + //inline for (positional_fields) |field| {} + + arguments_table = arguments_table ++ .{&[_][]const u8{"named arguments:"}}; // The --help option is always there. 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; + arguments_table = arguments_table ++ .{&[_][]const u8{ " --no-" ++ field.name, "default: --" ++ field.name }}; } else { - arguments_str = arguments_str ++ "\n --" ++ field.name ++ " default: --no-" ++ field.name; + arguments_table = arguments_table ++ .{&[_][]const u8{ " --" ++ field.name, "default: --no-" ++ field.name }}; } } else { - arguments_str = arguments_str ++ "\n --" ++ field.name ++ " or --no-" ++ field.name ++ " required"; + arguments_table = arguments_table ++ .{&[_][]const u8{ " --[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"; - } + arguments_table = arguments_table ++ .{&[_][]const u8{ + " --" ++ field.name ++ "=" ++ @typeName(field.type), + if (field.defaultValue()) |default| + "default: " ++ std.fmt.comptimePrint("{}", .{default}) + else + "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"; - } + arguments_table = arguments_table ++ .{&[_][]const u8{ + " --" ++ field.name ++ "=" ++ comptime enumValuesExpr(field.type), + if (field.defaultValue()) |default| + "default: " ++ @tagName(default) + else + "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"; - } + // String + arguments_table = arguments_table ++ .{&[_][]const u8{ + " --" ++ field.name ++ "=string", + if (field.defaultValue()) |default| + "default: " ++ quoteIfEmpty(default) + else + "required", + }}; } else { // Array const type_name = switch (@typeInfo(ptrInfo.child)) { @@ -703,18 +766,43 @@ fn printGeneratedHelp(writer: ?*Writer, prog: []const u8, comptime named_fields: arguments_str = arguments_str ++ "\n " ++ // "--" ++ field.name ++ " " ++ type_name ++ " " ++ // "[--" ++ field.name ++ " " ++ type_name ++ " ...]"; + arguments_table = arguments_table ++ .{&[_][]const u8{ + " --" ++ field.name ++ "=" ++ type_name ++ " " ++ // + "[--" ++ field.name ++ "=" ++ type_name ++ " ...]", + }}; } }, - else => @compileError("Unsupported field type: " ++ @typeName(field.type)), + else => comptime unreachable, } } + + arguments_table = arguments_table ++ .{&[_][]const u8{ " --help", "print this help and exit" }}; + + comptime var width = 0; + inline for (arguments_table) |row| { + width = @max(width, row[0].len); + } + + comptime var help_str: []const u8 = ""; + inline for (arguments_table) |row| { + help_str = help_str ++ "\n"; + inline for (row, 0..) |cell, c| { + help_str = help_str ++ cell; + if (c == 0 and row.len > 1) { + help_str = help_str ++ " " ** (width + 2 - cell.len); + } + } + } + + const msg = "usage: {s} " ++ comptime usageLineFmt(named_fields, positional_fields) ++ // + escapeFmt(help_str) ++ "\n"; if (writer) |w| { - w.print(msg, .{ prog, arguments_str }) catch {}; + w.print(msg, .{prog}) 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.print(msg, .{prog}) catch {}; file_writer.interface.flush() catch {}; } } @@ -724,6 +812,26 @@ inline fn quoteIfEmpty(comptime s: []const u8) []const u8 { return s; } +inline fn escapeFmt(comptime s: []const u8) []const u8 { + var result: []const u8 = ""; + comptime var cursor = 0; + for (s, 0..) |c, i| { + switch (c) { + '{' => { + result = result ++ s[cursor..i] ++ "{{"; + cursor = i + 1; + }, + '}' => { + result = result ++ s[cursor..i] ++ "}}"; + cursor = i + 1; + }, + else => {}, + } + } + result = result ++ s[cursor..]; + return result; +} + var failing_writer: Writer = .failing; const silent_options = Options{ .writer = &failing_writer, .exit = false }; @@ -1310,8 +1418,8 @@ test "actually calling error" { "--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)); + try testing.expectEqual(error.Usage, @"error"(Args, "--output must not be absolute: {s}", .{args.named.output}, silent_options)); + try testing.expectEqual(error.Usage, @"error"(Args, "expected exactly 1 positional arg", .{}, silent_options)); } test "custom help" {