From 89b08c67ea7a4626550dbb9668edd703d811f1da Mon Sep 17 00:00:00 2001 From: tsne Date: Wed, 3 Sep 2025 22:19:22 +0200 Subject: initial --- zig/cli.zig | 1162 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1162 insertions(+) create mode 100644 zig/cli.zig (limited to 'zig/cli.zig') diff --git a/zig/cli.zig b/zig/cli.zig new file mode 100644 index 0000000..8c5e37a --- /dev/null +++ b/zig/cli.zig @@ -0,0 +1,1162 @@ +//! 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 ++ "'"; + }, + }; +} -- cgit v1.2.3