From 0d5dd9b36c29a3e93cd1a4e1bbce6d2f74d5e425 Mon Sep 17 00:00:00 2001 From: torque Date: Sun, 2 Apr 2023 17:15:37 -0700 Subject: [PATCH] help: implement subcommand descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I believe we've produced a superset of the functionality that was present before rewriting all of the code. There are still a lot of fiddly little details that need to be thought through in order to produce something that is righteously flexible, but I think this is in reasonable shape to start inventing real-world uses for it. Adding some tests, cleaning up a little bit of the allocation handling (make better use of the arena allocators—we are definitely sort of leaking memory at the moment), and writing documentation are still on the roadmap. --- demo/demo.zig | 10 +- source/errors.zig | 2 +- source/help.zig | 271 +++++++++++++++++++++++++++++------------- source/meta.zig | 20 ++++ source/parameters.zig | 6 +- source/parser.zig | 58 ++++++--- 6 files changed, 258 insertions(+), 109 deletions(-) diff --git a/demo/demo.zig b/demo/demo.zig index 012710b..7e5d73e 100644 --- a/demo/demo.zig +++ b/demo/demo.zig @@ -9,7 +9,7 @@ const cli = cmd: { var cmd = CommandBuilder(u32).init( \\The definitive noclip demonstration utility \\ - \\This command demonstrates the functionality of the noclip library. cool!! + \\This command demonstrates the functionality of the noclip library. cool! ); cmd.add_option(.{ .OutputType = struct { u8, u8 } }, .{ .name = "test", @@ -41,7 +41,6 @@ const cli = cmd: { .name = "multi", .short_tag = "-m", .long_tag = "--multi", - .env_var = "NOCLIP_MULTI", .description = "multiple specification test option", }); cmd.add_flag(.{}, .{ @@ -54,11 +53,16 @@ const cli = cmd: { cmd.add_flag(.{ .multi = true }, .{ .name = "multiflag", .truthy = .{ .short_tag = "-M" }, - .env_var = "NOCLIP_MULTIFLAG", .description = "multiple specification test flag ", }); + cmd.add_option(.{ .OutputType = u8 }, .{ + .name = "env", + .env_var = "NOCLIP_ENVIRON", + .description = "environment variable only option", + }); cmd.add_argument(.{ .OutputType = []const u8 }, .{ .name = "arg", + .description = "This is an argument that doesn't really do anything, but it's very important.", }); break :cmd cmd; diff --git a/source/errors.zig b/source/errors.zig index d54e11a..f07bfab 100644 --- a/source/errors.zig +++ b/source/errors.zig @@ -1,4 +1,4 @@ -pub const ConversionError = error { +pub const ConversionError = error{ ConversionFailed, }; diff --git a/source/help.zig b/source/help.zig index 714644c..0ee5972 100644 --- a/source/help.zig +++ b/source/help.zig @@ -4,8 +4,6 @@ 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, @@ -31,84 +29,129 @@ pub fn HelpBuilder(comptime command: anytype) type { pub fn build_message( self: *@This(), name: []const u8, - subcommands: std.hash_map.StringHashMap(parser.ParserInterface), + subcommands: parser.CommandMap, ) ![]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 "", + self.option_brief(), + try self.args_brief(), + self.subcommands_brief(subcommands), }, ); try writer.writeAll(std.mem.trim(u8, command.description, " \n")); try writer.writeAll("\n\n"); + const arguments = try self.describe_arguments(); const options = try self.describe_options(); - if (options.pairs.len > 0) { - try writer.writeAll("Options:\n"); - } + const env_vars = try self.describe_env(); + const subcs = try self.describe_subcommands(subcommands); + const max_just = @max(arguments.just, @max(options.just, @max(env_vars.just, subcs.just))); - 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("]"); + if (arguments.pairs.len > 0) { + try writer.writeAll("Arguments:\n"); + for (arguments.pairs) |pair| { + try writer.print( + " {[0]s: <[1]}{[2]s}\n", + .{ pair.left, max_just + 3, pair.right }, + ); } - return buffer.toOwnedSlice(); + try writer.writeAll("\n"); } + + 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, max_just + 3, pair.right }, + ); + } + + try writer.writeAll("\n"); + } + + if (env_vars.pairs.len > 0) { + try writer.writeAll("Environment variables:\n"); + for (env_vars.pairs) |pair| { + try writer.print( + " {[0]s: <[1]}{[2]s}\n", + .{ pair.left, max_just + 3, pair.right }, + ); + } + + try writer.writeAll("\n"); + } + + if (subcs.pairs.len > 0) { + try writer.writeAll("Subcommands:\n"); + for (subcs.pairs) |pair| { + try writer.print( + " {[0]s: <[1]}{[2]s}\n", + .{ pair.left, max_just + 3, pair.right }, + ); + } + + try writer.writeAll("\n"); + } + + return self.writebuffer.toOwnedSlice(); + } + + fn option_brief(_: @This()) []const u8 { + return if (comptime help_info.options.len > 0) + " [options...]" + else + ""; + } + + fn args_brief(self: @This()) ![]const u8 { + var buf = std.ArrayList(u8).init(self.writebuffer.allocator); + const writer = buf.writer(); + + for (comptime help_info.arguments) |arg| { + try writer.writeAll(" "); + if (!arg.required) try writer.writeAll("["); + try writer.print("<{s}>", .{arg.name}); + if (!arg.required) try writer.writeAll("]"); + } + + return buf.toOwnedSlice(); + } + + fn subcommands_brief( + _: @This(), + subcommands: parser.CommandMap, + ) []const u8 { + return if (subcommands.count() > 0) + " " + else + ""; + } + + fn describe_arguments(self: @This()) !OptionDescription { + var pairs = std.ArrayList(AlignablePair).init(self.writebuffer.allocator); + + var just: usize = 0; + for (comptime help_info.arguments) |arg| { + if (arg.description.len == 0) continue; + + const pair: AlignablePair = .{ + .left = arg.name, + .right = arg.description, + }; + if (pair.left.len > just) just = pair.left.len; + try pairs.append(pair); + } + + return .{ + .pairs = try pairs.toOwnedSlice(), + .just = just, + }; } fn describe_options(self: @This()) !OptionDescription { @@ -163,23 +206,73 @@ pub fn HelpBuilder(comptime command: anytype) type { const left = try buffer.toOwnedSlice(); + if (comptime opt.required) { + try writer.writeAll("[required]"); + } + if (comptime opt.description.len > 0) { + if (buffer.items.len > 0) try writer.writeAll(" "); try writer.writeAll(opt.description); } + if (comptime opt.env_var) |env| { + if (buffer.items.len > 0) try writer.writeAll(" "); + try writer.print("(env: {s})", .{env}); + } + 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 }; } + + fn describe_env(self: @This()) !OptionDescription { + var pairs = std.ArrayList(AlignablePair).init(self.writebuffer.allocator); + + var just: usize = 0; + for (comptime help_info.env_vars) |env| { + if (env.description.len == 0) continue; + + const pair: AlignablePair = .{ + .left = env.env_var, + .right = env.description, + }; + if (pair.left.len > just) just = pair.left.len; + try pairs.append(pair); + } + + return .{ + .pairs = try pairs.toOwnedSlice(), + .just = just, + }; + } + + fn describe_subcommands(self: @This(), subcommands: parser.CommandMap) !OptionDescription { + var pairs = std.ArrayList(AlignablePair).init(self.writebuffer.allocator); + + var just: usize = 0; + var iter = subcommands.keyIterator(); + while (iter.next()) |key| { + const subif = subcommands.get(key.*).?; + const short = ncmeta.partition(u8, subif.describe(), "\n"); + + const pair: AlignablePair = .{ + .left = key.*, + .right = short[0], + }; + if (pair.left.len > just) just = pair.left.len; + try pairs.append(pair); + } + + return .{ + .pairs = try pairs.toOwnedSlice(), + .just = just, + }; + } }; } @@ -197,6 +290,7 @@ const OptHelp = struct { env_var: ?[]const u8 = null, description: []const u8 = "", type_name: []const u8 = "", + extra: []const u8 = "", default: ?[]const u8 = null, // this is the pivot value_count: FixedCount = 0, @@ -212,6 +306,7 @@ const EnvHelp = struct { const ArgHelp = struct { name: []const u8 = "", + description: []const u8 = "", type_name: []const u8 = "", multi: bool = false, required: bool = true, @@ -228,22 +323,28 @@ pub fn opt_info(comptime command: anytype) CommandHelp { paramloop: for (command) |param| { const PType = @TypeOf(param); - if (PType.param_type != .Nominal) continue :paramloop; + if (PType.param_type == .Ordinal) { + arguments = arguments ++ &[_]ArgHelp{.{ + .name = param.name, + .description = param.description, + .type_name = param.nice_type_name, + .multi = PType.multi, + .required = param.required, + }}; + + 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 { + if (env_only(last_option)) { env_vars = env_vars ++ &[_]EnvHelp{.{ .env_var = last_option.env_var, .description = last_option.description, .default = last_option.default, }}; + } else { + options = options ++ &[_]OptHelp{last_option}; } } last_name = param.name; @@ -272,6 +373,7 @@ pub fn opt_info(comptime command: anytype) CommandHelp { 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(); @@ -284,18 +386,14 @@ pub fn opt_info(comptime command: anytype) CommandHelp { } 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 { + if (env_only(last_option)) { env_vars = env_vars ++ &[_]EnvHelp{.{ - .env_var = last_option.env_var, + .env_var = last_option.env_var.?, .description = last_option.description, .default = last_option.default, }}; + } else { + options = options ++ &[_]OptHelp{last_option}; } } @@ -306,3 +404,10 @@ pub fn opt_info(comptime command: anytype) CommandHelp { }; } } + +inline fn env_only(option: OptHelp) bool { + return option.short_truthy == null and + option.long_truthy == null and + option.short_falsy == null and + option.long_falsy == null; +} diff --git a/source/meta.zig b/source/meta.zig index 2a0003f..e1016e8 100644 --- a/source/meta.zig +++ b/source/meta.zig @@ -52,6 +52,26 @@ pub fn enum_length(comptime T: type) comptime_int { return @typeInfo(T).Enum.fields.len; } +pub fn partition(comptime T: type, input: []const T, wedge: []const T) [3][]const T { + for (input, 0..) |candidate, idx| { + for (wedge) |splitter| { + if (candidate == splitter) { + return [3][]const T{ + input[0..idx], + input[idx..(idx + 1)], + input[(idx + 1)..], + }; + } + } + } + + return [3][]const T{ + input[0..], + input[input.len..], + input[input.len..], + }; +} + pub fn ComptimeWriter( comptime Context: type, comptime writeFn: fn (comptime context: Context, comptime bytes: []const u8) error{}!usize, diff --git a/source/parameters.zig b/source/parameters.zig index 09d9f14..f5efb5e 100644 --- a/source/parameters.zig +++ b/source/parameters.zig @@ -3,11 +3,7 @@ const std = @import("std"); const converters = @import("./converters.zig"); const ConverterSignature = converters.ConverterSignature; -const ParameterType = enum { - Nominal, - Ordinal, - Executable, -}; +const ParameterType = enum { Nominal, Ordinal }; pub const FixedCount = u32; diff --git a/source/parser.zig b/source/parser.zig index 4e480ef..8ef325b 100644 --- a/source/parser.zig +++ b/source/parser.zig @@ -10,8 +10,9 @@ const NoclipError = errors.NoclipError; pub const ParserInterface = struct { const Vtable = struct { execute: *const fn (parser: *anyopaque, context: *anyopaque) anyerror!void, - parse: *const fn (parser: *anyopaque, context: *anyopaque, args: [][:0]u8, env: std.process.EnvMap) anyerror!void, + parse: *const fn (parser: *anyopaque, context: *anyopaque, name: []const u8, args: [][:0]u8, env: std.process.EnvMap) anyerror!void, finish: *const fn (parser: *anyopaque, context: *anyopaque) anyerror!void, + describe: *const fn () []const u8, }; parser: *anyopaque, @@ -22,13 +23,17 @@ pub const ParserInterface = struct { return try self.methods.execute(self.parser, self.context); } - pub fn parse(self: @This(), args: [][:0]u8, env: std.process.EnvMap) anyerror!void { - return try self.methods.parse(self.parser, self.context, args, env); + pub fn parse(self: @This(), name: []const u8, args: [][:0]u8, env: std.process.EnvMap) anyerror!void { + return try self.methods.parse(self.parser, self.context, name, args, env); } pub fn finish(self: @This()) anyerror!void { return try self.methods.finish(self.parser, self.context); } + + pub fn describe(self: @This()) []const u8 { + return self.methods.describe(); + } }; fn InterfaceGen(comptime ParserType: type, comptime UserContext: type) type { @@ -41,6 +46,7 @@ fn InterfaceGen(comptime ParserType: type, comptime UserContext: type) type { .execute = ParserType.wrap_execute, .parse = ParserType.wrap_parse, .finish = ParserType.wrap_finish, + .describe = ParserType.describe, }, }; } @@ -53,12 +59,15 @@ fn InterfaceGen(comptime ParserType: type, comptime UserContext: type) type { .execute = ParserType.wrap_execute, .parse = ParserType.wrap_parse, .finish = ParserType.wrap_finish, + .describe = ParserType.describe, }, }; } }; } +pub const CommandMap = std.hash_map.StringHashMap(ParserInterface); + // the parser is generated by the bind method of the CommandBuilder, so we can // be extremely type-sloppy here, which simplifies the signature. pub fn Parser(comptime command: anytype, comptime callback: anytype) type { @@ -74,7 +83,7 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { progname: ?[]const u8 = null, has_global_tags: bool = false, allocator: std.mem.Allocator, - subcommands: std.hash_map.StringHashMap(ParserInterface), + subcommands: CommandMap, subcommand: ?ParserInterface = null, help_builder: help.HelpBuilder(command), @@ -98,13 +107,13 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { return try self.execute(context); } - fn wrap_parse(parser: *anyopaque, ctx: *anyopaque, args: [][:0]u8, env: std.process.EnvMap) anyerror!void { + fn wrap_parse(parser: *anyopaque, ctx: *anyopaque, name: []const u8, args: [][:0]u8, env: std.process.EnvMap) anyerror!void { const self = @ptrCast(*@This(), @alignCast(@alignOf(@This()), parser)); const context = if (@alignOf(UserContext) > 0) @ptrCast(*UserContext, @alignCast(@alignOf(UserContext), ctx)) else @ptrCast(*UserContext, ctx); - return try self.subparse(context, args, env); + return try self.subparse(context, name, args, env); } fn wrap_finish(parser: *anyopaque, ctx: *anyopaque) anyerror!void { @@ -116,12 +125,23 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { return try self.finish(context); } - pub fn subparse(self: *@This(), context: *UserContext, args: [][:0]u8, env: std.process.EnvMap) anyerror!void { - const sliceto = try self.parse(args); + fn describe() []const u8 { + return command.description; + } + + pub fn subparse(self: *@This(), context: *UserContext, name: []const u8, args: [][:0]u8, env: std.process.EnvMap) anyerror!void { + const sliceto = try self.parse(name, args); try self.read_environment(env); try self.convert_eager(context); - if (self.subcommand) |verb| try verb.parse(args[sliceto..], env); + if (self.subcommand) |verb| { + const verbname = try std.mem.join( + self.allocator, + " ", + &[_][]const u8{ name, args[sliceto - 1] }, + ); + try verb.parse(verbname, args[sliceto..], env); + } } pub fn finish(self: *@This(), context: *UserContext) anyerror!void { @@ -138,9 +158,9 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { if (args.len < 1) return ParseError.EmptyArgs; - self.progname = args[0]; + self.progname = std.fs.path.basename(args[0]); - try self.subparse(context, args[1..], env); + try self.subparse(context, self.progname.?, args[1..], env); try self.finish(context); } @@ -158,6 +178,7 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { pub fn parse( self: *@This(), + name: []const u8, args: [][:0]u8, ) anyerror!usize { // run pre-parse pass if we have any global parameters @@ -201,11 +222,11 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { if (!forced_ordinal and arg.len > 1 and arg[0] == '-') { if (arg.len > 2 and arg[1] == '-') { - try self.parse_long_tag(arg, &argit); + try self.parse_long_tag(name, arg, &argit); continue :argloop; } else if (arg.len > 1) { for (arg[1..], 1..) |short, idx| { - try self.parse_short_tag(short, arg.len - idx - 1, &argit); + try self.parse_short_tag(name, short, arg.len - idx - 1, &argit); } continue :argloop; } @@ -227,12 +248,13 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { inline fn parse_long_tag( self: *@This(), + name: []const u8, arg: []const u8, argit: *ncmeta.SliceIterator([][:0]u8), ) ParseError!void { if (comptime command.help_flag.long_tag) |long| if (std.mem.eql(u8, arg, long)) - self.print_help(); + self.print_help(name); inline for (comptime parameters) |param| { const PType = @TypeOf(param); @@ -256,13 +278,14 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { inline fn parse_short_tag( self: *@This(), + name: []const u8, arg: u8, remaining: usize, argit: *ncmeta.SliceIterator([][:0]u8), ) ParseError!void { if (comptime command.help_flag.short_tag) |short| if (arg == short[1]) - self.print_help(); + self.print_help(name); inline for (comptime parameters) |param| { const PType = @TypeOf(param); @@ -416,10 +439,11 @@ pub fn Parser(comptime command: anytype, comptime callback: anytype) type { } } - fn print_help(self: *@This()) noreturn { + fn print_help(self: *@This(), name: []const u8) noreturn { defer std.process.exit(0); + const stderr = std.io.getStdErr().writer(); - if (self.help_builder.build_message("test", self.subcommands)) |message| + if (self.help_builder.build_message(name, self.subcommands)) |message| stderr.writeAll(message) catch return else |_| stderr.writeAll("There was a problem generating the help.") catch return;