diff --git a/build.zig b/build.zig index 2d77d5d..b7dc76e 100644 --- a/build.zig +++ b/build.zig @@ -15,8 +15,7 @@ pub fn build(b: *std.Build) void { const tests_step = b.step("test", "Run tests"); const tests = b.addTest(.{ - .root_source_file = root, - .target = target, + .root_module = mod, }); const tests_run = b.addRunArtifact(tests); @@ -29,6 +28,7 @@ pub fn build(b: *std.Build) void { overview, colors, trailing, + optional_command, }, "example", "Example to run for example step (default = overview)", @@ -36,9 +36,11 @@ pub fn build(b: *std.Build) void { const example = b.addExecutable(.{ .name = "example", - .root_source_file = b.path(b.fmt("examples/{s}.zig", .{@tagName(example_option)})), - .target = target, - .optimize = optimize, + .root_module = b.createModule(.{ + .root_source_file = b.path(b.fmt("examples/{s}.zig", .{@tagName(example_option)})), + .target = target, + .optimize = optimize, + }), }); example.root_module.addImport("flags", mod); const run_example = b.addRunArtifact(example); diff --git a/build.zig.zon b/build.zig.zon index 4580318..2956b79 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,8 +1,8 @@ .{ .name = .flags, - .version = "0.10.0", + .version = "0.11.0", .fingerprint = 0xb0541bade61ff6b, - .minimum_zig_version = "0.14.0-dev.2802+257054a14", + .minimum_zig_version = "0.15.1", // Want to limit these to only the things that really constitute "the library". // The hash needn't be updated by some small change to the README. .paths = .{ diff --git a/examples/optional_command.zig b/examples/optional_command.zig new file mode 100644 index 0000000..b6be20e --- /dev/null +++ b/examples/optional_command.zig @@ -0,0 +1,68 @@ +const std = @import("std"); +const flags = @import("flags"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}).init; + defer _ = gpa.deinit(); + + const args = try std.process.argsAlloc(gpa.allocator()); + defer std.process.argsFree(gpa.allocator(), args); + + const options = flags.parse(args, "overview", Flags, .{}); + + var stdout_buf: [1024]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buf); + const stdout = &stdout_writer.interface; + defer stdout.flush() catch {}; + + try std.json.Stringify.value( + options, + .{ .whitespace = .indent_2 }, + stdout, + ); +} + +const Flags = struct { + // Optional description of the program. + pub const description = + \\This is a dummy command for testing purposes. + \\There are a bunch of options for demonstration purposes. + ; + + // Optional description of some or all of the flags (must match field names in the struct). + pub const descriptions = .{ + .force = "Use the force", + }; + + force: bool, // Set to `true` only if '--force' is passed. + + // Subcommands can be defined through the `command` field, which should be a union with struct + // fields which are defined the same way this struct is. Subcommands may be nested. + // Subcommands (this union) can be made optional. + command: ?union(enum) { + frobnicate: struct { + pub const descriptions = .{ + .level = "Frobnication level", + }; + + level: u8, + + positional: struct { + trailing: []const []const u8, + }, + }, + defrabulise: struct { + supercharge: bool, + }, + + pub const descriptions = .{ + .frobnicate = "Frobnicate everywhere", + .defrabulise = "Defrabulise everyone", + }; + }, + + // Optional declaration to define shorthands. These can be chained e.g '-fs large'. + pub const switches = .{ + .force = 'f', + }; +}; diff --git a/examples/overview.zig b/examples/overview.zig index 16be5b6..0b3987a 100644 --- a/examples/overview.zig +++ b/examples/overview.zig @@ -8,12 +8,17 @@ pub fn main() !void { const args = try std.process.argsAlloc(gpa.allocator()); defer std.process.argsFree(gpa.allocator(), args); - const options = flags.parse(args, "overview", Flags, .{}); + const options: Flags = flags.parse(args, "overview", Flags, .{}); - try std.json.stringify( + var stdout_buf: [1024]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buf); + const stdout = &stdout_writer.interface; + defer stdout.flush() catch {}; + + try std.json.Stringify.value( options, .{ .whitespace = .indent_2 }, - std.io.getStdOut().writer(), + stdout, ); } @@ -76,6 +81,7 @@ const Flags = struct { // Subcommands can be defined through the `command` field, which should be a union with struct // fields which are defined the same way this struct is. Subcommands may be nested. + // Subcommands (this union) can be made optional. command: union(enum) { frobnicate: struct { pub const descriptions = .{ diff --git a/examples/trailing.zig b/examples/trailing.zig index c76b0c3..29795f9 100644 --- a/examples/trailing.zig +++ b/examples/trailing.zig @@ -10,10 +10,15 @@ pub fn main() !void { const options = flags.parse(args, "trailing", Flags, .{}); - try std.json.stringify( + var stdout_buf: [1024]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buf); + const stdout = &stdout_writer.interface; + defer stdout.flush() catch {}; + + try std.json.Stringify.value( options, .{ .whitespace = .indent_2 }, - std.io.getStdOut().writer(), + stdout, ); } diff --git a/src/Help.zig b/src/Help.zig index 75011db..c08938c 100644 --- a/src/Help.zig +++ b/src/Help.zig @@ -18,14 +18,18 @@ pub const Usage = struct { body: []const u8, pub fn render(usage: Usage, stdout: File, colors: *const ColorScheme) void { - const term = Terminal.init(stdout); - usage.renderToTerminal(term, colors); + var term = Terminal.init(stdout); + defer term.flush(); + usage.renderToTerminal(&term, colors); } - pub fn renderToTerminal(usage: Usage, term: Terminal, colors: *const ColorScheme) void { + pub fn renderToTerminal(usage: Usage, term: *Terminal, colors: *const ColorScheme) void { term.print(colors.header, "Usage: ", .{}); + term.flush(); term.print(colors.command_name, "{s}", .{usage.command}); + term.flush(); term.print(colors.usage, "{s}\n", .{usage.body}); + term.flush(); } pub fn generate(Flags: type, info: meta.FlagsInfo, command: []const u8) Usage { @@ -99,9 +103,10 @@ const Section = struct { } }; -pub fn render(help: *const Help, stdout: File, colors: *const ColorScheme) void { - const term = Terminal.init(stdout); - help.usage.renderToTerminal(term, colors); +pub fn render(help: *const Help, writer: File, colors: *const ColorScheme) void { + var term = Terminal.init(writer); + defer term.flush(); + help.usage.renderToTerminal(&term, colors); if (help.description) |description| { term.print(colors.command_description, "\n{s}\n", .{description}); @@ -181,7 +186,7 @@ pub fn generate(Flags: type, info: meta.FlagsInfo, command: []const u8) Help { help.sections = help.sections ++ .{options}; if (info.positionals.len > 0) { - const pos_descriptions = meta.getDescriptions(std.meta.FieldType(Flags, .positional)); + const pos_descriptions = meta.getDescriptions(@FieldType(Flags, "positional")); var arguments = Section{ .header = "Arguments:" }; for (info.positionals) |arg| { arguments.add(.{ @@ -203,8 +208,9 @@ pub fn generate(Flags: type, info: meta.FlagsInfo, command: []const u8) Help { help.sections = help.sections ++ .{arguments}; } if (info.subcommands.len > 0) { - const cmd_descriptions = meta.getDescriptions(std.meta.FieldType(Flags, .command)); - var commands = Section{ .header = "Commands:" }; + const T = meta.unwrapOptional(@FieldType(Flags, "command")); + const cmd_descriptions = meta.getDescriptions(T); + var commands = Section{ .header = if (info.optional_commands) "Commands: [Optional]" else "Commands:" }; for (info.subcommands) |cmd| commands.add(.{ .name = cmd.command_name, .desc = @field(cmd_descriptions, cmd.field_name), diff --git a/src/Parser.zig b/src/Parser.zig index 453ac65..adbd880 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -13,17 +13,23 @@ pub const Terminal = @import("Terminal.zig"); args: []const [:0]const u8, current_arg: usize, colors: *const ColorScheme, +/// The current Help of the command being parsed +help: Help, fn fatal(parser: *const Parser, comptime fmt: []const u8, args: anytype) noreturn { - const stderr = Terminal.init(std.io.getStdErr()); - stderr.print(parser.colors.error_label, "Error: ", .{}); - stderr.print(parser.colors.error_message, fmt ++ "\n", args); + var term = Terminal.init(std.fs.File.stderr()); + term.print(parser.colors.error_label, "Error: ", .{}); + term.print(parser.colors.error_message, fmt ++ "\n\n", args); + term.flush(); + parser.help.render(std.fs.File.stderr(), parser.colors); std.process.exit(1); } +/// Parse the Flags struct and return the parsed result. +/// If an error is encounterd, the error is displayed, followed by the help menu. pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Flags { const info = comptime meta.info(Flags); - const help = comptime Help.generate(Flags, info, command_name); + parser.help = comptime Help.generate(Flags, info, command_name); var flags: Flags = undefined; var passed: std.enums.EnumFieldStruct(std.meta.FieldEnum(Flags), bool, false) = .{}; @@ -41,7 +47,7 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl } if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { - help.render(std.io.getStdOut(), parser.colors); + parser.help.render(std.fs.File.stdout(), parser.colors); std.process.exit(0); } @@ -95,7 +101,7 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl inline for (info.subcommands) |cmd| { if (std.mem.eql(u8, arg, cmd.command_name)) { const cmd_flags = parser.parse(cmd.type, command_name ++ " " ++ cmd.command_name); - flags.command = @unionInit(@TypeOf(flags.command), cmd.field_name, cmd_flags); + flags.command = @unionInit(meta.unwrapOptional(@TypeOf(flags.command)), cmd.field_name, cmd_flags); passed.command = true; continue :next_arg; } @@ -131,7 +137,11 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl } if (info.subcommands.len > 0 and !passed.command) { - parser.fatal("missing subcommand", .{}); + if (info.optional_commands) { + flags.command = null; + } else { + parser.fatal("missing subcommand", .{}); + } } return flags; diff --git a/src/Terminal.zig b/src/Terminal.zig index 442e0f8..6bb197e 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -6,29 +6,40 @@ const ColorScheme = @import("ColorScheme.zig"); const tty = std.io.tty; const File = std.fs.File; -writer: File.Writer, -config: tty.Config, +write_buffer: [1024]u8 = undefined, +file: File, +writer: std.fs.File.Writer = undefined, +config: tty.Config = undefined, pub fn init(file: File) Terminal { - return .{ - .writer = file.writer(), + var term = Terminal{ + .file = file, .config = tty.detectConfig(file), }; + term.writer = term.file.writer(&term.write_buffer); + return term; } pub fn print( - terminal: Terminal, + term: *Terminal, style: ColorScheme.Style, comptime format: []const u8, args: anytype, ) void { + const writer: *std.Io.Writer = &term.writer.interface; for (style) |color| { - terminal.config.setColor(terminal.writer, color) catch {}; + term.config.setColor(writer, color) catch @panic("Can't set color!"); } - terminal.writer.print(format, args) catch {}; + writer.print(format, args) catch @panic("Print failed!"); if (style.len > 0) { - terminal.config.setColor(terminal.writer, .reset) catch {}; + term.config.setColor(writer, .reset) catch @panic("Can't set color!"); } + + writer.flush() catch @panic("Flush failed!"); +} + +pub fn flush(term: *Terminal) void { + term.writer.interface.flush() catch @panic("Flush failed!"); } diff --git a/src/flags.zig b/src/flags.zig index eeb74c9..bb06202 100644 --- a/src/flags.zig +++ b/src/flags.zig @@ -3,6 +3,7 @@ const std = @import("std"); pub const ColorScheme = @import("ColorScheme.zig"); const Parser = @import("Parser.zig"); const Help = @import("Help.zig"); +const meta = @import("meta.zig"); pub const Options = struct { skip_first_arg: bool = true, @@ -22,7 +23,17 @@ pub fn parse( .args = args, .current_arg = if (options.skip_first_arg) 1 else 0, .colors = options.colors, + .help = comptime Help.generate(Flags, meta.info(Flags), exe_name), }; return parser.parse(Flags, exe_name); } + +pub fn printHelp( + comptime exe_name: []const u8, + Flags: type, + options: Options, +) void { + const help = comptime Help.generate(Flags, meta.info(Flags), exe_name); + help.render(std.fs.File.stdout(), options.colors); +} diff --git a/src/meta.zig b/src/meta.zig index 7f9eda7..6e3509f 100644 --- a/src/meta.zig +++ b/src/meta.zig @@ -4,6 +4,7 @@ pub const FlagsInfo = struct { flags: []const Flag = &.{}, positionals: []const Positional = &.{}, subcommands: []const SubCommand = &.{}, + optional_commands: bool = false, }; const SubCommand = struct { @@ -79,12 +80,24 @@ pub fn info(comptime Flags: type) FlagsInfo { }}; } } else if (std.mem.eql(u8, field.name, "command")) { - if (@typeInfo(field.type) != .@"union") compileError( - "command field type is not a union: {s}", - .{@typeName(field.type)}, - ); - - for (@typeInfo(field.type).@"union".fields) |cmd| { + const cmd_type = @typeInfo(field.type); + switch (cmd_type) { + .@"union" => {}, + .optional => |o| { + const opt_cmd_type = @typeInfo(o.child); + if (opt_cmd_type != .@"union") compileError( + "command field type is not a union: {s}", + .{@typeName(field.type)}, + ); + command.optional_commands = true; + }, + else => compileError( + "command field type is not a union: {s}", + .{@typeName(field.type)}, + ), + } + const u = @typeInfo(unwrapOptional(field.type)).@"union"; + for (u.fields) |cmd| { command.subcommands = command.subcommands ++ .{SubCommand{ .type = cmd.type, .field_name = cmd.name,