//! The CLI is defined by a set of commands with options and subcommands. //! A command is a type where with special declarations and fields. If a //! command has options, it needs to define an `option` field, that is a //! struct where all fields are valid options, i.e. created with `Option`. //! If a command has any subcommands a `Subcommands` type can be declared, //! which needs to be a tagged union that contains a command type for each //! supported subcommand. To provide sufficient help for each command a //! command type must define a `help` declaration of type `Command_Help`. //! //! The basic structure of a command type: //! //! struct { //! pub const help = Command_Help{ ... }; //! //! pub const Subcommands = union(enum) { //! another: Another_Command, //! }; //! //! options: struct{ //! one: Option(u16, .{ ... }), //! two: Option([]const u8, .{ ... }), //! }, //! } //! //! With all the setup above it is possible to "generate" a command help //! during compile time. There are two versions for a help text: a short //! and a long version. The short version displays the usage pattern of //! the command whereas the long version displays a detailed help text. const std = @import("std"); const assert = std.debug.assert; var stdout_buf: [1024]u8 = undefined; var stdout_printer: Printer = .init(std.fs.File.stdout().writer(&stdout_buf)); var stderr_printer: Printer = .init(std.fs.File.stderr().writer(&.{})); pub var stdout = &stdout_printer; pub var stderr = &stderr_printer; pub const Command_Help = struct { pub const Section = struct { /// The heading of the section. This must be a single line with /// where each letter is uppercase. heading: []const u8, /// The text that should be displayed in the body of the section. /// /// Note: Single newlines will be replaced by spaces to format the /// help text according to the terminal width. Multiple consecutive /// newlines will be kept to keep paragraphs. If a line is indented /// it will be printed as separate line. This can be used to display /// lists or code, for example. body: []const u8, }; // The fully qualified command name, which includes the executable // name and all the subcommands. All parts of the fully qualified // name must be divided by a single space only. // // Note: The last part is used to determining the subcommand during // argument parsing. name: []const u8, /// A synopsis of how to invoke the command. For each usage of the /// command a separate invocation can be provided. It should give /// a brief overview of how the command can be used. /// An invocation pattern should be formatted in manpage style /// (e.g. "program mycommand [-f|--foo]"). /// /// Note: For better formatting a single pattern can be split into /// multiple lines. synopsis: []const []const u8, /// A short description of what the command does. In the long help /// it is displayed directly after the command name. In the short /// help it is used to give a brief description of the command. /// /// Note: This must be a single line of text. short_description: []const u8, /// A detailed description of the functioning of the command. This /// text is displayed in the long help. It should explain all the /// details of the command. /// /// Note: Single newlines will be replaced by spaces to format the /// help text according to the terminal width. Multiple consecutive /// newlines will be kept to keep paragraphs. If a line is indented /// it will be printed as separate line. This can be used to display /// lists or code, for example. long_description: []const u8, /// Extra sections that should be displayed at the bottom of the /// command help. extra_sections: []const Section = &.{}, }; pub fn Option_Help(comptime T: type) type { return struct { /// The long name of an option without the leading double-dash. /// /// Note: Only alphanumeric characters or dashes are allowed, but /// it must not start or end with a dash. name: []const u8, /// An option can have an optional short name which can be used as an /// alternative to the long name. A short name is a single character. /// /// Note: A short name must be alphanumeric. short_name: ?u8 = null, /// Every non-boolean option takes an argument as its value. To improve /// help message quality an argument must have a meaningful name that is /// displayed in the help text of the corresponding command. /// For boolean option on the other hand this field defaults to null and /// will be ignored when printing a help text. /// /// Note: This must be a single word, i.e. only characters, numbers, /// dashes, and underscores are allowed. argument_name: ?[]const u8 = null, /// A short description of what the option does. This text is displayed /// in the short help of the corresponding command. It should give a brief /// overview of what the option does. /// /// Note: This must be a single line of text. short_description: []const u8, /// A detailed description of the command option. This text is displayed /// in the long help of the corresponding command. It should explain all /// details of the option and can be as long as needed. /// /// Note: Single newlines will be replaced by spaces to format the /// help text according to the terminal width. Multiple consecutive /// newlines will be kept to keep paragraphs. long_description: []const u8, /// Default value that will be set if the user does not provide a specific /// value. If this field is null the option value will also be null. default_value: ?T = null, }; } pub fn Option(comptime T: type, comptime opt_help: Option_Help(T)) type { comptime { const has_argname = opt_help.argument_name != null and opt_help.argument_name.?.len > 0; if (!has_argname and T != bool) { @compileError("non-boolean options must have an argument name"); } switch (@typeInfo(T)) { .@"enum" => |info| { for (info.fields) |field| { for (field.name) |ch| { switch (ch) { 'a'...'z', 'A'...'Z', '0'...'9' => {}, else => @compileError("invalid enum '" ++ field.name ++ "' value in option " ++ opt_help.name), } } } }, else => {}, } } return struct { pub const Type = T; pub const help: Option_Help(T) = opt_help; const Self = @This(); value: ?T, fn init(self: *Self) void { self.value = null; } pub fn value_or_default(self: Self) T { if (opt_help.default_value == null) { @compileError("option '" ++ opt_help.name ++ "' does not have default value"); } return self.value orelse opt_help.default_value.?; } fn parse(self: *Self, name: []const u8, value: [:0]const u8) void { if (self.value != null) { fatal("duplicate option '{s}'", .{name}); } self.value = switch (@typeInfo(T)) { .bool => val: { if (value.len != 0) { fatal("boolean option '{s}' does not take a value", .{name}); } break :val true; }, .int => val: { if (value.len == 0) { fatal("missing value for option '{s}'", .{name}); } break :val std.fmt.parseInt(T, value, 10) catch |err| switch (err) { error.InvalidCharacter => fatal("option '{s}' requires a numeric value", .{name}), error.Overflow => fatal("option '{s}' exceeds the maximum numeric value", .{name}), }; }, .@"enum" => |info| val: { if (info.fields.len == 0) { @compileError("enum cli option " ++ @typeName(T) ++ " does not have any values"); } if (value.len == 0) { fatal("missing value for option '{s}'", .{name}); } break :val std.meta.stringToEnum(T, value) orelse { fatal("invalid value for option '{s}' (use " ++ join_enum_values(T) ++ ")", .{name}); }; }, .pointer => |info| val: { if (info.child != u8 and info.size != .Slice) { @compileError("pointer cli option " ++ @typeName(T) ++ " not supported"); } break :val value; }, else => @compileError("cli option type " ++ @typeName(T) ++ " not supported"), }; } }; } pub const Args = struct { /// The command we are currently in or null if we reached the end or positional arguments. current_cmd: ?[]const u8, iter: std.process.ArgIterator, exe_name: []const u8, pub fn init(allocator: std.mem.Allocator, comptime exe_name: []const u8, program: anytype) !Args { comptime assert(exe_name.len > 0); var iter = try std.process.ArgIterator.initWithAllocator(allocator); errdefer iter.deinit(); _ = iter.skip(); // skip executable name const cmd_info = Command_Info.from_command(switch (@typeInfo(@TypeOf(program))) { .pointer => |info| info.child, else => @compileError("expected pointer, got " ++ @typeName(@TypeOf(program))), }); const current_cmd = parse_opts(&iter, cmd_info, program); return Args{ .current_cmd = current_cmd, .iter = iter, .exe_name = exe_name, }; } pub fn deinit(self: *Args, allocator: std.mem.Allocator) void { _ = allocator; self.iter.deinit(); } pub fn next_subcommand(self: *Args, comptime Command: type) ?Command.Subcommands { const subcommands = comptime Command_Info.from_subcommand(Command); comptime assert(subcommands.len > 0); const arg = self.current_cmd orelse return null; inline for (subcommands) |subcmd| { if (std.mem.eql(u8, arg, subcmd.name)) { var cmd: subcmd.type = undefined; self.current_cmd = parse_opts(&self.iter, subcmd, &cmd); return @unionInit(Command.Subcommands, subcmd.tag.?, cmd); } } fatal_with_usage(Command, "unknown command '{s}' (see '{s} --help')", .{ arg, self.exe_name }); } pub fn next_positional(self: *Args) ?[]const u8 { if (self.current_cmd) |cmd| { self.current_cmd = null; return cmd; } self.current_cmd = null; return self.iter.next(); } /// Parse and fill all command options and return the current command. fn parse_opts(iter: *std.process.ArgIterator, comptime cmd_info: Command_Info, cmd: *cmd_info.type) ?[]const u8 { inline for (cmd_info.options) |opt| { @field(cmd.options, opt.field).init(); } args_loop: while (iter.next()) |arg| { assert(arg.len > 0); if (arg.len < 2 or arg[0] != '-') { // We have either a single dash or a non-option here. Treat these as // a positional or subcommand (however the executor chooses to use it). return arg; } var opt_name: []const u8 = undefined; if (arg[1] != '-') { // We check for a short option name. // If we have a boolean flag or an option name longer than a // single character, we have both the name and the value in // a single argument. Otherwise we fetch the next argument. opt_name = arg[1..2]; if (opt_name[0] == 'h') { if (arg.len > 2) { fatal_with_usage(cmd_info.type, "unknown option '{s}'", .{arg}); } cmd_info.print_usage(stderr); std.process.exit(0); } inline for (cmd_info.options) |opt| { if (opt.short_name) |short_name| { if (short_name == opt_name[0]) { const value: [:0]const u8 = if (arg.len > 2 or opt.arg_name.len == 0) arg[2..] else next: { const next_arg = iter.next(); if (next_arg == null or next_arg.?[0] == '-') { fatal("missing value for option '{c}'", .{opt_name[0]}); } break :next next_arg.?; }; @field(cmd.options, opt.field).parse(opt_name, value); continue :args_loop; } } } } else if (arg.len > 2) { // We check for a long option name. // If we don't have the format `name=value`, we get the // next argument lazily, because for boolean flags we // don't need any extra argument. opt_name = arg[2..]; var value: ?[:0]const u8 = null; if (std.mem.indexOfScalar(u8, arg, '=')) |eq| { opt_name = arg[2..eq]; value = arg[eq + 1 ..]; } if (std.mem.eql(u8, opt_name, "help")) { cmd_info.print_help(); } inline for (cmd_info.options) |opt| { if (std.mem.eql(u8, opt_name, opt.long_name)) { value = value orelse if (opt.arg_name.len == 0) "" else next_arg: { const next = iter.next(); if (next == null or next.?[0] == '-') { fatal("missing value for option '{s}'", .{opt_name}); } break :next_arg next.?; }; @field(cmd.options, opt.field).parse(opt_name, value.?); continue :args_loop; } } } else { // A single "--" was detected which means the rest of the arguments // are interpreted as positional. We are done parsing. return null; } fatal_with_usage(cmd_info.type, "unknown option '{s}'", .{opt_name}); } return null; } }; pub const Print_Options = struct { /// Indentation of the first line. margin: u16 = 0, /// Indentation of all lines except the first. hanging_indent: u16 = 0, /// The character by which the text should be split into multiple lines. line_split: u8 = ' ', }; const Usage_Column = struct { cells: []const []const u8, width: u16, }; pub const Printer = struct { writer: std.fs.File.Writer, max_width: u16, fn init(w: std.fs.File.Writer) Printer { return Printer{ .writer = w, .max_width = 80, }; } /// Drain the remaining buffered data and puts it to the sink. pub fn flush(self: *Printer) void { self.writer.interface.flush() catch {}; } /// Write a value directly without splitting. This uses the formatting capabilities /// of `std.fmt`. pub fn write(self: *Printer, comptime fmt: []const u8, value: anytype) void { self.writer.interface.print(fmt, value) catch {}; } /// Write a value directly without splitting and append a newline. This uses the /// formatting capabilities of `std.fmt`. pub fn write_line(self: *Printer, comptime fmt: []const u8, value: anytype) void { self.write(fmt ++ "\n", value); } /// Print the given text and split it into multiple lines if it exceeds /// a certain width. The splitting behaviour can be configured using the /// given print options. pub fn print(self: *Printer, text: []const u8, opts: Print_Options) void { if (text.len > 0) { const max_len_first = self.max_width - opts.margin; const max_len_others = self.max_width - opts.margin - opts.hanging_indent; self.put_indent(opts.margin); var rest = self.put_until_eol(text, max_len_first, opts.line_split); while (rest.len > 0) { self.put('\n'); self.put_indent(opts.margin + opts.hanging_indent); rest = self.put_until_eol(rest, max_len_others, opts.line_split); } } } /// Same as `print`, but appends a newline at the end. pub fn print_line(self: *Printer, text: []const u8, opts: Print_Options) void { self.print(text, opts); self.put('\n'); } /// Print a formatted text and split it into multiple lines if if exceeds /// a certain width. The splitting behaviour can be configured using the /// given print options. pub fn printf(self: *Printer, comptime format: []const u8, args: anytype, opts: Print_Options) void { if (@typeInfo(@TypeOf(args)).@"struct".fields.len == 0) { self.print(format, opts); } else { // TODO: Shall we estimate the buffer size depending on the // format string and arguments? var buf: [2048]u8 = undefined; const text = std.fmt.bufPrint(&buf, format, args) catch |err| switch (err) { error.NoSpaceLeft => dots: { buf[buf.len - 3] = '.'; buf[buf.len - 2] = '.'; buf[buf.len - 1] = '.'; break :dots buf[0..]; }, }; self.print(text, opts); } } /// Same as `printf`, but appends a newline at the end. pub fn printf_line(self: *Printer, comptime format: []const u8, args: anytype, opts: Print_Options) void { self.printf(format, args, opts); self.put('\n'); } /// Print multiple lines of text. This essentially calls `print_line` for each /// given line of text. pub fn print_lines(self: *Printer, text: []const u8, opts: Print_Options) void { var rest = text; while (std.mem.indexOfScalar(u8, rest, '\n')) |eol| { self.print_line(rest[0..eol], opts); rest = rest[eol + 1 ..]; } if (rest.len > 0) { self.print_line(rest, opts); } } fn put(self: *Printer, c: u8) void { self.writer.interface.writeByte(c) catch {}; } fn put_str(self: *Printer, text: []const u8) void { self.writer.interface.writeAll(text) catch {}; } fn print_usage_table(self: *Printer, comptime left_column: Usage_Column, comptime right_column: Usage_Column, opts: Print_Options) void { assert(opts.hanging_indent > 0); assert(left_column.cells.len == right_column.cells.len); const sep_len = 2; const max_len: usize = @intCast(@min(left_column.width + sep_len + right_column.width, self.max_width - opts.margin)); if (left_column.width + sep_len > 2 * max_len / 3) { // There is very little space for the right column. So we write each column // to a separate line. const left_margin = opts.margin; const right_margin = opts.margin + opts.hanging_indent; for (left_column.cells, right_column.cells) |left, right| { self.print_line(left, .{ .margin = left_margin }); self.print_line(right, .{ .margin = right_margin }); } } else { // The left and the right column are on the same line. If the right column // gets too long, we print multiple lines for the right column. for (left_column.cells, right_column.cells) |left, right| { assert(left.len <= left_column.width); assert(right.len <= right_column.width); self.put_indent(opts.margin); self.put_str(left); self.put_indent(left_column.width - left.len + sep_len); const right_width = max_len - left_column.width - sep_len; var rest = self.put_until_eol(right, right_width, opts.line_split); while (rest.len > 0) { self.put('\n'); self.put_indent(opts.margin + left_column.width + sep_len); rest = self.put_until_eol(right, right_width, opts.line_split); } self.put('\n'); } } } fn put_until_eol(self: *Printer, text: []const u8, line_len: usize, line_split: u8) []const u8 { const width = @min(text.len, line_len); var eol: usize = undefined; if (text.len <= width) { eol = text.len; } else if (text[width] == line_split) { eol = width; } else if (std.mem.lastIndexOfScalar(u8, text[0..width], line_split)) |last_split| { eol = last_split; if (std.mem.allEqual(u8, text[0..last_split], line_split)) { // There are leading line splits here, so we consider an indentation. // We want to keep indentation intact and print to the next split. eol = std.mem.indexOfScalarPos(u8, text, width, line_split) orelse text.len; } } else { // We don't have a split char in the line. So we extend the line // to the next space. This hopefully will not happen in the wild. eol = std.mem.indexOfScalarPos(u8, text, width, line_split) orelse text.len; } self.put_str(text[0..eol]); return if (eol < text.len) text[eol + 1 ..] else text[0..0]; } fn put_indent(self: *Printer, n: usize) void { const buf = [_]u8{' '} ** 32; var remaining = n; while (remaining > buf.len) : (remaining -= buf.len) { self.put_str(&buf); } self.put_str(buf[0..remaining]); } }; pub fn fatal(comptime format: []const u8, args: anytype) noreturn { stderr.printf_line(format, args, .{}); stderr.flush(); std.process.exit(1); } pub fn fatal_with_usage(comptime Command: type, comptime format: []const u8, args: anytype) noreturn { const info = Command_Info.from_command(Command); stderr.printf_line(format, args, .{}); stderr.put('\n'); info.print_usage(stderr); std.process.exit(1); } /// Print the usage of the given command. /// This function automatically flushes. pub fn print_usage(p: *Printer, comptime Command: type) void { Command_Info.from_command(Command).print_usage(p); p.flush(); } /// Print the help text of the given command. pub fn print_help(comptime Command: type) noreturn { Command_Info.from_command(Command).print_help(); } pub fn ask(comptime prompt: []const u8, args: anytype, buf: []u8) ?[]const u8 { var stdin = std.fs.File.stdin().reader(buf); while (true) { stderr.printf(prompt, args, .{}); stderr.flush(); const line = stdin.interface.takeDelimiterExclusive('\n') catch |err| switch (err) { error.StreamTooLong => { stderr.printf_line("ERROR: The input exceeds the maximum of {} characters.", .{buf.len}, .{}); continue; }, else => unreachable, }; const input = std.mem.trim(u8, line, " \t\r"); return if (input.len == 0) null else input; } } pub fn ask_confirmation(comptime prompt: []const u8, args: anytype, comptime default_answer: enum { yes, no }) bool { comptime assert(prompt.len > 0 and prompt[prompt.len - 1] == '?'); const yes = "yes"; const no = "no"; const default_ans = switch (default_answer) { .yes => yes, .no => no, }; var confirmbuf: [16]u8 = undefined; return while (true) { const ans = ask(prompt ++ " (" ++ yes ++ "/" ++ no ++ ") [" ++ default_ans ++ "]: ", args, &confirmbuf) orelse default_ans; for (ans, 0..) |c, i| { confirmbuf[i] = std.ascii.toLower(c); } if (std.mem.eql(u8, ans, yes[0..1]) or std.mem.eql(u8, ans, yes)) break true; if (std.mem.eql(u8, ans, no[0..1]) or std.mem.eql(u8, ans, no)) break false; }; } const Pager = struct { const Bold = struct { text: []const u8, dumb: bool, pub fn format(self: Bold, w: *std.io.Writer) std.io.Writer.Error!void { if (!self.dumb) try w.writeAll("\x1b[1m"); try w.writeAll(self.text); if (!self.dumb) try w.writeAll("\x1b[m"); } }; pid: ?std.posix.pid_t, printer: Printer, dumb: bool, fn init(out_buf: []u8) Pager { if (!stdout.writer.file.isTty()) { return .{ .pid = null, .printer = stdout_printer, .dumb = true }; } const fds = std.posix.pipe() catch return .{ .pid = null, .printer = stdout_printer, .dumb = true }; const fdin = fds[0]; const fdout = fds[1]; const pid = std.posix.fork() catch { std.posix.close(fdin); std.posix.close(fdout); return .{ .pid = null, .printer = stdout_printer, .dumb = true }; }; if (pid == 0) { // pager process std.posix.close(fdout); std.posix.dup2(fdin, std.posix.STDIN_FILENO) catch unreachable; var env: [512:null]?[*:0]const u8 = undefined; var envlen: usize = 0; const environ = if (std.os.environ.len < 512) std.os.environ else std.os.environ[0..511]; for (environ) |e| { env[envlen] = e; envlen += 1; } env[envlen] = null; // TODO: Allow environment PAGER to define custom pagers. const pager_argv: [*:null]const ?[*:0]const u8 = &.{ "less", "-R", null }; std.posix.execvpeZ(pager_argv[0].?, pager_argv, &env) catch unreachable; std.process.exit(1); } else { // application process std.posix.close(fdin); var out: std.fs.File = .{ .handle = fdout }; return .{ .pid = pid, .printer = Printer.init(out.writer(out_buf)), .dumb = if (std.posix.getenv("TERM")) |term| std.mem.eql(u8, term, "dumb") else true, }; } } fn deinit(self: *Pager) noreturn { self.printer.flush(); if (self.pid) |pid| { self.printer.writer.file.close(); _ = std.posix.waitpid(pid, 0); } std.process.exit(0); } fn bold(self: *Pager, text: []const u8) Bold { return .{ .text = text, .dumb = self.dumb }; } }; /// Information about a single command. const Command_Info = struct { type: type, name: []const u8, // last part of the fully qualified name fq_name: []const u8, // fully qualified name separated by spaces synopsis: []const []const u8, short_description: []const u8, long_description: []const u8, options: []const Option_Info, tag: ?[]const u8, // The tag in the subcommands union. sections: []const Command_Help.Section, fn from_command(comptime Command: type) Command_Info { const help: Command_Help = comptime hlp: { if (!@hasDecl(Command, "help")) { @compileError("missing help declaration in command " ++ @typeName(Command)); } if (@TypeOf(Command.help) != Command_Help) { @compileError("invalid help type in command " ++ @typeName(Command)); } break :hlp Command.help; }; const fq_name: []const u8 = comptime fqn: { if (help.name.len == 0) { @compileError("missing command name for " ++ @typeName(Command)); } var first_non_space: usize = 0; while (first_non_space < help.name.len and help.name[first_non_space] == ' ') { first_non_space += 1; } var last_non_space = first_non_space; if (last_non_space < help.name.len) { var i = last_non_space; while (i < help.name.len) : (i += 1) { switch (help.name[i]) { ' ' => {}, 'a'...'z', '0'...'9', '-' => last_non_space = i, else => @compileError("invalid command name for " ++ @typeName(Command)), } } } if (last_non_space < help.name.len) last_non_space += 1; break :fqn help.name[0..last_non_space]; }; const name: []const u8 = comptime nm: { if (std.mem.lastIndexOfScalar(u8, fq_name, ' ')) |space| { break :nm fq_name[space + 1 ..]; } else { break :nm fq_name; } }; const synopsis: []const []const u8 = comptime usage: { if (help.synopsis.len == 0) { @compileError("missing synopsis in command " ++ @typeName(Command)); } for (help.synopsis) |usage| { if (usage.len == 0) { @compileError("empty usage pattern in command " ++ @typeName(Command)); } } break :usage help.synopsis; }; const short_description: []const u8 = comptime descr: { if (help.short_description.len == 0) { @compileError("missing short help description in command " ++ @typeName(Command)); } if (contains_eol(help.short_description)) { @compileError("short help description in command " ++ @typeName(Command) ++ " must be a single line"); } break :descr sanitize_description(help.short_description); }; const long_description: []const u8 = comptime descr: { if (help.long_description.len == 0) { @compileError("missing long help description in command " ++ @typeName(Command)); } break :descr sanitize_description(help.long_description); }; const options: []const Option_Info = comptime opts: { for (std.meta.fields(Command)) |field| { if (std.mem.eql(u8, field.name, "options")) { break :opts Option_Info.from_options_field(Command, field); } } break :opts &.{}; }; const sections: [help.extra_sections.len]Command_Help.Section = comptime secs: { var extra_sections: [help.extra_sections.len]Command_Help.Section = undefined; for (help.extra_sections, 0..) |sec, i| { if (contains_eol(sec.heading)) { @compileError("help section heading in command " ++ @typeName(Command) ++ " must be a single line"); } if (!is_uppercase(sec.heading)) { @compileError("help section heading in command " ++ @typeName(Command) ++ " must all be uppercase"); } extra_sections[i] = .{ .heading = sec.heading, .body = sanitize_description(sec.body), }; } break :secs extra_sections; }; return Command_Info{ .type = Command, .name = name, .fq_name = fq_name, .synopsis = synopsis, .short_description = short_description, .long_description = long_description, .options = options, .tag = null, .sections = §ions, }; } fn from_subcommand(comptime Command: type) []Command_Info { if (!@hasDecl(Command, "Subcommands")) { return &.{}; } const union_info = switch (@typeInfo(Command.Subcommands)) { .@"union" => |u| u, else => @compileError(@typeName(Command.Subcommands) ++ " is not a union"), }; assert(union_info.tag_type != null); var subcmds: [union_info.fields.len]Command_Info = undefined; for (union_info.fields, 0..) |field, i| { subcmds[i] = Command_Info.from_command(field.type); subcmds[i].tag = field.name; } return &subcmds; } /// Flushes the printer fn print_usage(comptime cmd: Command_Info, p: *Printer) void { const margin0 = 0; const margin1 = 4; const margin2 = 8; const Usage_Table = struct { fn column(comptime Info: type, comptime infos: []const Info, comptime extract: fn (comptime Info) []const u8) Usage_Column { const cells = comptime blk: { var c: [infos.len][]const u8 = undefined; for (infos, 0..) |info, i| { c[i] = extract(info); } break :blk c; }; const width = comptime blk: { var w: usize = 0; for (infos, 0..) |_, i| { w = @max(w, cells[i].len); } break :blk w; }; return .{ .cells = &cells, .width = width }; } }; // usage comptime assert(cmd.synopsis.len > 0); p.print_line("Usage:", .{ .margin = margin0 }); inline for (cmd.synopsis) |usage| { p.print_line(usage, .{ .margin = margin1, .hanging_indent = margin2, .line_split = '\n' }); } p.put('\n'); // subcommands const subcmds = Command_Info.from_subcommand(cmd.type); if (subcmds.len > 0) { const name_col = comptime Usage_Table.column(Command_Info, subcmds, struct { fn extract(comptime subcmd: Command_Info) []const u8 { return subcmd.name; } }.extract); const descr_col = comptime Usage_Table.column(Command_Info, subcmds, struct { fn extract(comptime subcmd: Command_Info) []const u8 { return subcmd.short_description; } }.extract); p.print_line("Commands:", .{ .margin = margin0 }); p.print_usage_table(name_col, descr_col, .{ .margin = margin1, .hanging_indent = margin2 }); p.put('\n'); } // options if (cmd.options.len > 0) { const title_col = comptime Usage_Table.column(Option_Info, cmd.options, struct { fn extract(comptime opt: Option_Info) []const u8 { const argname = if (opt.arg_name.len == 0) "" else " <" ++ opt.arg_name ++ ">"; return if (opt.short_name) |short| std.fmt.comptimePrint("-{c}, --{s}{s}", .{ short, opt.long_name, argname }) else std.fmt.comptimePrint(" --{s}{s}", .{ opt.long_name, argname }); } }.extract); const descr_col = comptime Usage_Table.column(Option_Info, cmd.options, struct { fn extract(comptime opt: Option_Info) []const u8 { return opt.short_description; } }.extract); p.print_line("Options:", .{ .margin = margin0 }); p.print_usage_table(title_col, descr_col, .{ .margin = margin1, .hanging_indent = margin2 }); p.put('\n'); } p.flush(); } fn print_help(comptime cmd: Command_Info) noreturn { var out_buf: [1024]u8 = undefined; var pager = Pager.init(&out_buf); defer pager.deinit(); var p = &pager.printer; const margin0 = 0; const margin1 = 4; const margin2 = 8; p.put('\n'); // name p.printf_line("{f}", .{pager.bold("NAME")}, .{ .margin = margin0 }); p.print_line(cmd.fq_name ++ " - " ++ cmd.short_description, .{ .margin = margin1 }); p.put('\n'); // synopsis p.printf_line("{f}", .{pager.bold("SYNOPSIS")}, .{ .margin = margin0 }); inline for (cmd.synopsis) |usage| { p.print_line(usage, .{ .margin = margin1, .hanging_indent = margin2, .line_split = '\n' }); } p.put('\n'); // description p.printf_line("{f}", .{pager.bold("DESCRIPTION")}, .{ .margin = margin0 }); p.print_lines(cmd.long_description, .{ .margin = margin1 }); p.put('\n'); // options if (cmd.options.len > 0) { p.printf_line("{f}", .{pager.bold("OPTIONS")}, .{ .margin = margin0 }); inline for (cmd.options) |opt| { const argname = if (opt.arg_name.len == 0) "" else " <" ++ opt.arg_name ++ ">"; if (opt.short_name) |short| { p.printf_line("-{f}, --{f}{s}", .{ pager.bold(&[1]u8{short}), pager.bold(opt.long_name), argname }, .{ .margin = margin1 }); } else { p.printf_line("--{f}{s}", .{ pager.bold(opt.long_name), argname }, .{ .margin = margin1 }); } p.print_lines(opt.long_description, .{ .margin = margin2 }); p.put('\n'); } } // subcommands const subcmds = Command_Info.from_subcommand(cmd.type); if (subcmds.len > 0) { p.printf_line("{f}", .{pager.bold("COMMANDS")}, .{ .margin = margin0 }); inline for (subcmds) |subcmd| { p.printf_line("{f}", .{pager.bold(subcmd.name)}, .{ .margin = margin1 }); p.print_lines(subcmd.short_description, .{ .margin = margin2 }); p.put('\n'); } } // sections inline for (cmd.sections) |section| { p.printf_line("{f}", .{pager.bold(section.heading)}, .{ .margin = margin0 }); p.print_lines(section.body, .{ .margin = margin1 }); p.put('\n'); } } }; /// Information about a single option. An option must contain a public declaration `help` /// of type `Option_Help(T)`. const Option_Info = struct { field: []const u8, short_name: ?u8, long_name: []const u8, arg_name: []const u8, // empty for boolean options short_description: []const u8, long_description: []const u8, fn from_options_field(comptime Command: type, comptime options_field: std.builtin.Type.StructField) []const Option_Info { const options = comptime blk: { const struct_info = switch (@typeInfo(options_field.type)) { .@"struct" => |s| s, else => @compileError("options for command " ++ @typeName(Command) ++ " must be a struct"), }; var opts: [struct_info.fields.len]Option_Info = undefined; for (struct_info.fields, 0..) |field, i| { const Opt = field.type; const fq_fieldname = @typeName(Command) ++ "." ++ field.name; if (!@hasDecl(Opt, "help")) { @compileError("missing help declaration in option " ++ fq_fieldname); } const help: Option_Help(Opt.Type) = hlp: { if (@TypeOf(Opt.help) != Option_Help(Opt.Type)) { @compileError("invalid help type in option " ++ fq_fieldname ++ ": " ++ @typeName(@TypeOf(Opt.help)) ++ " (expected " ++ @typeName(Option_Help(Opt.Type)) ++ ")"); } break :hlp Opt.help; }; const short_name: ?u8 = name: { if (help.short_name) |ch| { switch (ch) { 'a'...'z', 'A'...'Z', '0'...'9' => {}, else => @compileError("invalid short name for option " ++ fq_fieldname), } } break :name help.short_name; }; const long_name: []const u8 = name: { if (std.mem.eql(u8, help.name, "help")) { @compileError("option name 'help' is a reserved option name"); } if (help.name.len == 0) { @compileError("missing option name " ++ fq_fieldname); } if (contains_whitespace(help.name)) { @compileError("invalid option name " ++ fq_fieldname); } break :name sanitize_name(help.name); }; const arg_name: []const u8 = name: { const arg = help.argument_name orelse break :name ""; if (contains_whitespace(arg)) { @compileError("invalid argument name of option " ++ fq_fieldname); } break :name arg; }; const short_description: []const u8 = descr: { if (help.short_description.len == 0) { @compileError("missing short help description in option " ++ fq_fieldname); } if (contains_eol(help.short_description)) { @compileError("short help description in option " ++ fq_fieldname ++ " must be a single line"); } break :descr help.short_description; }; const long_description: []const u8 = descr: { if (help.long_description.len == 0) { @compileError("missing long help description in option " ++ fq_fieldname); } break :descr sanitize_description(help.long_description); }; opts[i] = Option_Info{ .field = field.name, .short_name = short_name, .long_name = long_name, .arg_name = arg_name, .short_description = short_description, .long_description = long_description, }; } break :blk opts; }; return &options; } fn contains_whitespace(comptime text: []const u8) bool { for (text) |c| { switch (c) { ' ', '\t', '\r', '\n' => return true, else => {}, } } return false; } }; fn sanitize_name(comptime name: []const u8) []const u8 { const sanitized = comptime blk: { var res: [name.len]u8 = undefined; for (name, 0..) |c, i| { res[i] = switch (c) { 'a'...'z', 'A'...'Z', '0'...'9' => c, '_', '-' => '-', else => @compileError("invalid character in cli name '" ++ name ++ "'"), }; } break :blk res; }; return &sanitized; } fn sanitize_description(comptime text: []const u8) []const u8 { @setEvalBranchQuota(100_000); const sanitized = comptime blk: { var res: [text.len]u8 = undefined; var prev_was_eol: bool = false; for (text, 0..) |c, i| { var ch = c; switch (ch) { '\n' => { if (prev_was_eol) { res[i - 1] = '\n'; // preserve empty line } else { ch = ' '; } prev_was_eol = true; }, '\t', ' ' => { if (prev_was_eol) { res[i - 1] = '\n'; } else { ch = ' '; } prev_was_eol = false; }, else => { prev_was_eol = false; }, } res[i] = ch; } break :blk res; }; return &sanitized; } fn contains_eol(comptime text: []const u8) bool { for (text) |c| { switch (c) { '\r', '\n' => return true, else => {}, } } return false; } fn is_uppercase(comptime text: []const u8) bool { for (text) |c| { if ('a' <= c and c <= 'z') return false; } return true; } fn join_enum_values(comptime E: type) []const u8 { const fields = std.meta.fields(E); return switch (fields.len) { 0 => unreachable, 1 => "'" ++ fields[0].name ++ "'", 2 => "'" ++ fields[0].name ++ "' or '" ++ fields[1].name ++ "'", 3 => str: { var text: []const u8 = ""; for (fields[0 .. fields.len - 1]) |field| { text = text ++ "'" ++ field.name ++ "', "; } break :str text ++ "or '" ++ fields[fields.len - 1].name ++ "'"; }, }; }