From facda6527125497f51bd354c41d49b3c084e9f7a Mon Sep 17 00:00:00 2001 From: torque Date: Sun, 2 Apr 2023 15:11:50 -0700 Subject: [PATCH] help: start grinding away at help text generation I am resisting the urge to try to codegolf this into fewer lines. It's going to end up being a sprawl, but it is what it is. The main part of this that will actually require intelligent thought is the column wrapping and alignment. I think I will probably be implementing a custom writer style thing to handle that. There are a lot of annoying loose odds and ends here. Choice types should list all the choices. But we can't necessarily assume that an enum-typed parameter is a choice type (only if it uses the default converter). Perhaps the conversion stuff should be turned into an interface that can also be responsible for converting the default value and providing additional information. For now I will probably just hack it so that I can move on to other things. --- demo/demo.zig | 12 +- source/command.zig | 2 + source/converters.zig | 2 +- source/help.zig | 308 +++++++++++++++++++++++++++++++++++++++++- source/meta.zig | 59 ++++++++ source/parameters.zig | 8 +- source/parser.zig | 24 ++-- 7 files changed, 394 insertions(+), 21 deletions(-) diff --git a/demo/demo.zig b/demo/demo.zig index 07f0aff..012710b 100644 --- a/demo/demo.zig +++ b/demo/demo.zig @@ -16,12 +16,17 @@ const cli = cmd: { .short_tag = "-t", .long_tag = "--test", .env_var = "NOCLIP_TEST", + .description = "multi-value test option", + .nice_type_name = "int> choice_converter(gen), + .Enum => |info| if (info.is_exhaustive) choice_converter(gen) else null, // TODO: how to handle structs with field defaults? maybe this should only work // for tuples, which I don't think can have defaults. .Struct => |info| if (gen.value_count == .fixed and gen.value_count.fixed == info.fields.len) diff --git a/source/help.zig b/source/help.zig index 3ed22f7..714644c 100644 --- a/source/help.zig +++ b/source/help.zig @@ -1,4 +1,308 @@ +const std = @import("std"); -fn HelpBuilder(comptime command: anytype) type { - _ = command; +const ncmeta = @import("./meta.zig"); +const parser = @import("./parser.zig"); +const FixedCount = @import("./parameters.zig").FixedCount; + +// error.OutOfMemory + +const AlignablePair = struct { + left: []const u8, + right: []const u8, +}; + +const OptionDescription = struct { + pairs: []AlignablePair, + just: usize, +}; + +pub fn HelpBuilder(comptime command: anytype) type { + const help_info = opt_info(command.generate()); + + return struct { + writebuffer: std.ArrayList(u8), + + pub fn init(allocator: std.mem.Allocator) @This() { + return @This(){ + .writebuffer = std.ArrayList(u8).init(allocator), + }; + } + + pub fn build_message( + self: *@This(), + name: []const u8, + subcommands: std.hash_map.StringHashMap(parser.ParserInterface), + ) ![]const u8 { + try self.describe_command(name, subcommands); + + return self.writebuffer.toOwnedSlice(); + } + + fn describe_command( + self: *@This(), + name: []const u8, + subcommands: std.hash_map.StringHashMap(parser.ParserInterface), + ) !void { + const writer = self.writebuffer.writer(); + try writer.print( + "Usage: {s}{s}{s}{s}\n\n", + .{ + name, + try self.option_brief(), + if (help_info.arguments.len > 0) " " else "", + if (subcommands.count() > 0) " [ ...]" else "", + }, + ); + + try writer.writeAll(std.mem.trim(u8, command.description, " \n")); + try writer.writeAll("\n\n"); + + const options = try self.describe_options(); + if (options.pairs.len > 0) { + try writer.writeAll("Options:\n"); + } + + for (options.pairs) |pair| { + try writer.print( + " {[0]s: <[1]}{[2]s}\n", + .{ pair.left, options.just + 3, pair.right }, + ); + } + } + + fn option_brief(self: @This()) ![]const u8 { + if (comptime help_info.options.len > 1) { + return " [options...]"; + } else if (comptime help_info.options.len == 1) { + return " [option]"; + } else if (comptime help_info.options.len == 0) { + return ""; + } else { + var buffer = std.ArrayList(u8).init(self.writebuffer.allocator); + const writer = buffer.writer(); + + for (comptime help_info.options) |opt| { + try writer.writeAll(" ["); + + var written = false; + if (opt.short_truthy) |tag| { + try writer.writeAll(tag); + written = true; + } + if (opt.long_truthy) |tag| { + if (written) try writer.writeAll(" | "); + try writer.writeAll(tag); + written = true; + } + if (opt.short_falsy) |tag| { + if (written) try writer.writeAll(" | "); + try writer.writeAll(tag); + written = true; + } + if (opt.long_falsy) |tag| { + if (written) try writer.writeAll(" | "); + try writer.writeAll(tag); + } + + try writer.writeAll("]"); + } + + return buffer.toOwnedSlice(); + } + } + + fn describe_options(self: @This()) !OptionDescription { + var pairs = std.ArrayList(AlignablePair).init(self.writebuffer.allocator); + + var just: usize = 0; + for (comptime help_info.options) |opt| { + const pair = try self.describe_option(opt); + if (pair.left.len > just) just = pair.left.len; + try pairs.append(pair); + } + + return .{ + .pairs = try pairs.toOwnedSlice(), + .just = just, + }; + } + + fn describe_option(self: @This(), opt: OptHelp) !AlignablePair { + var buffer = std.ArrayList(u8).init(self.writebuffer.allocator); + const writer = buffer.writer(); + + if (comptime opt.short_truthy) |tag| { + if (buffer.items.len > 0) try writer.writeAll(", "); + try writer.writeAll(tag); + } + if (comptime opt.long_truthy) |tag| { + if (buffer.items.len > 0) try writer.writeAll(", "); + try writer.writeAll(tag); + } + + var falsy_seen = false; + if (comptime opt.short_falsy) |tag| { + if (buffer.items.len > 0) + try writer.writeAll(" / ") + else + try writer.writeAll("/ "); + try writer.writeAll(tag); + falsy_seen = true; + } + if (comptime opt.long_falsy) |tag| { + if (falsy_seen) + try writer.writeAll(", ") + else if (buffer.items.len > 0) + try writer.writeAll(" / "); + + try writer.writeAll(tag); + } + if (opt.value_count > 0) { + try writer.print(" <{s}>", .{opt.type_name}); + } + + const left = try buffer.toOwnedSlice(); + + if (comptime opt.description.len > 0) { + try writer.writeAll(opt.description); + } + + if (comptime opt.default) |def| { + if (buffer.items.len > 0) try writer.writeAll(" "); + try writer.print("(default: {s})", .{def}); + } + + if (comptime opt.required) { + if (buffer.items.len > 0) try writer.writeAll(" "); + try writer.writeAll("[required]"); + } + const right = try buffer.toOwnedSlice(); + + return .{ .left = left, .right = right }; + } + }; +} + +const CommandHelp = struct { + options: []const OptHelp, + arguments: []const ArgHelp, + env_vars: []const EnvHelp, +}; + +const OptHelp = struct { + short_truthy: ?[]const u8 = null, + long_truthy: ?[]const u8 = null, + short_falsy: ?[]const u8 = null, + long_falsy: ?[]const u8 = null, + env_var: ?[]const u8 = null, + description: []const u8 = "", + type_name: []const u8 = "", + default: ?[]const u8 = null, + // this is the pivot + value_count: FixedCount = 0, + required: bool = false, + multi: bool = false, +}; + +const EnvHelp = struct { + env_var: []const u8 = "", + description: []const u8 = "", + default: ?[]const u8 = null, +}; + +const ArgHelp = struct { + name: []const u8 = "", + type_name: []const u8 = "", + multi: bool = false, + required: bool = true, +}; + +pub fn opt_info(comptime command: anytype) CommandHelp { + comptime { + var options: []const OptHelp = &[_]OptHelp{}; + var env_vars: []const EnvHelp = &[_]EnvHelp{}; + var arguments: []const ArgHelp = &[_]ArgHelp{}; + + var last_name: []const u8 = ""; + var last_option: OptHelp = .{}; + + paramloop: for (command) |param| { + const PType = @TypeOf(param); + if (PType.param_type != .Nominal) continue :paramloop; + + if (!std.mem.eql(u8, last_name, param.name)) { + if (last_name.len > 0) { + if (last_option.short_truthy != null or + last_option.long_truthy != null or + last_option.short_falsy != null or + last_option.long_falsy != null) + { + options = options ++ &[_]OptHelp{last_option}; + } else { + env_vars = env_vars ++ &[_]EnvHelp{.{ + .env_var = last_option.env_var, + .description = last_option.description, + .default = last_option.default, + }}; + } + } + last_name = param.name; + last_option = .{}; + } + + if (PType.is_flag) { + switch (param.flag_bias) { + .truthy => { + last_option.short_truthy = param.short_tag; + last_option.long_truthy = param.long_tag; + }, + .falsy => { + last_option.short_falsy = param.short_tag; + last_option.long_falsy = param.long_tag; + }, + .unbiased => last_option.env_var = param.env_var, + } + } else { + last_option.short_truthy = param.short_tag; + last_option.long_truthy = param.long_tag; + last_option.env_var = param.env_var; + last_option.value_count = PType.value_count.fixed; + } + last_option.type_name = param.nice_type_name; + last_option.description = param.description; + last_option.required = param.required; + last_option.multi = PType.multi; + if (param.default) |def| { + var buf = ncmeta.ComptimeSliceBuffer{}; + const writer = buf.writer(); + // TODO: this is only acceptable for some types. It behaves poorly on + // enum-based choice types because it prints the whole type name rather + // than just the tag name. Roll our own eventually. + writer.print("{any}", .{def}) catch @compileError("whoah"); + last_option.default = buf.buffer; + } + } + + if (last_name.len > 0) { + if (last_option.short_truthy != null or + last_option.long_truthy != null or + last_option.short_falsy != null or + last_option.long_falsy != null) + { + options = options ++ &[_]OptHelp{last_option}; + } else { + env_vars = env_vars ++ &[_]EnvHelp{.{ + .env_var = last_option.env_var, + .description = last_option.description, + .default = last_option.default, + }}; + } + } + + return .{ + .options = options, + .arguments = arguments, + .env_vars = env_vars, + }; + } } diff --git a/source/meta.zig b/source/meta.zig index d3a8418..2a0003f 100644 --- a/source/meta.zig +++ b/source/meta.zig @@ -52,6 +52,65 @@ pub fn enum_length(comptime T: type) comptime_int { return @typeInfo(T).Enum.fields.len; } +pub fn ComptimeWriter( + comptime Context: type, + comptime writeFn: fn (comptime context: Context, comptime bytes: []const u8) error{}!usize, +) type { + return struct { + context: Context, + + const Self = @This(); + pub const Error = error{}; + + pub fn write(comptime self: Self, comptime bytes: []const u8) Error!usize { + return writeFn(self.context, bytes); + } + + pub fn writeAll(comptime self: Self, comptime bytes: []const u8) Error!void { + var index: usize = 0; + while (index != bytes.len) { + index += try self.write(bytes[index..]); + } + } + + pub fn print(comptime self: Self, comptime format: []const u8, args: anytype) Error!void { + return std.fmt.format(self, format, args) catch @compileError("woah"); + } + + pub fn writeByte(comptime self: Self, byte: u8) Error!void { + const array = [1]u8{byte}; + return self.writeAll(&array); + } + + pub fn writeByteNTimes(comptime self: Self, byte: u8, n: usize) Error!void { + var bytes: [256]u8 = undefined; + std.mem.set(u8, bytes[0..], byte); + + var remaining: usize = n; + while (remaining > 0) { + const to_write = std.math.min(remaining, bytes.len); + try self.writeAll(bytes[0..to_write]); + remaining -= to_write; + } + } + }; +} + +pub const ComptimeSliceBuffer = struct { + buffer: []const u8 = &[_]u8{}, + + const Writer = ComptimeWriter(*@This(), appendslice); + + pub fn writer(comptime self: *@This()) Writer { + return .{ .context = self }; + } + + fn appendslice(comptime self: *@This(), comptime bytes: []const u8) error{}!usize { + self.buffer = self.buffer ++ bytes; + return bytes.len; + } +}; + pub fn SliceIterator(comptime T: type) type { // could be expanded to use std.meta.Elem, perhaps const ResultType = std.meta.Child(T); diff --git a/source/parameters.zig b/source/parameters.zig index 96415c6..09d9f14 100644 --- a/source/parameters.zig +++ b/source/parameters.zig @@ -9,10 +9,12 @@ const ParameterType = enum { Executable, }; +pub const FixedCount = u32; + pub const ValueCount = union(enum) { flag: void, count: void, - fixed: u32, + fixed: FixedCount, }; pub const FlagBias = enum { @@ -227,12 +229,12 @@ fn OptionType(comptime generics: ParameterGenerics) type { fn check_short(comptime short_tag: ?[]const u8) void { const short = comptime short_tag orelse return; - if (short.len != 2 or short[0] != '-') @compileError("bad short tag" ++ short); + if (short.len != 2 or short[0] != '-') @compileError("bad short tag: " ++ short); } fn check_long(comptime long_tag: ?[]const u8) void { const long = comptime long_tag orelse return; - if (long.len < 3 or long[0] != '-' or long[1] != '-') @compileError("bad long tag" ++ long); + if (long.len < 3 or long[0] != '-' or long[1] != '-') @compileError("bad long tag: " ++ long); } pub fn make_option(comptime generics: ParameterGenerics, comptime opts: OptionConfig(generics)) OptionType(generics) { diff --git a/source/parser.zig b/source/parser.zig index 1f550db..4e480ef 100644 --- a/source/parser.zig +++ b/source/parser.zig @@ -1,7 +1,8 @@ const std = @import("std"); -const ncmeta = @import("./meta.zig"); const errors = @import("./errors.zig"); +const help = @import("./help.zig"); +const ncmeta = @import("./meta.zig"); const ParseError = errors.ParseError; const NoclipError = errors.NoclipError; @@ -75,6 +76,7 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { allocator: std.mem.Allocator, subcommands: std.hash_map.StringHashMap(ParserInterface), subcommand: ?ParserInterface = null, + help_builder: help.HelpBuilder(command), pub fn add_subcommand(self: *@This(), verb: []const u8, parser: ParserInterface) !void { try self.subcommands.put(verb, parser); @@ -119,15 +121,6 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { try self.read_environment(env); try self.convert_eager(context); - inline for (@typeInfo(@TypeOf(self.intermediate)).Struct.fields) |field| { - if (@field(self.intermediate, field.name) == null) { - std.debug.print("{s}: null,\n", .{field.name}); - } else { - std.debug.print("{s}: ", .{field.name}); - self.print_value(@field(self.intermediate, field.name).?, ""); - } - } - if (self.subcommand) |verb| try verb.parse(args[sliceto..], env); } @@ -423,10 +416,13 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { } } - fn print_help(self: @This()) void { - _ = self; - std.debug.print("help!!!\n", .{}); - std.process.exit(0); + fn print_help(self: *@This()) noreturn { + defer std.process.exit(0); + const stderr = std.io.getStdErr().writer(); + if (self.help_builder.build_message("test", self.subcommands)) |message| + stderr.writeAll(message) catch return + else |_| + stderr.writeAll("There was a problem generating the help.") catch return; } }; }