From 6aa675822c110059f6589cbc21d4f30bd19ed69c Mon Sep 17 00:00:00 2001 From: JacobCrabill Date: Sat, 2 Aug 2025 20:12:23 -0700 Subject: [PATCH 1/6] Enable optional commands The 'command' field of a Flags struct can now be a '?union' in addition to a plain 'union'. --- build.zig | 1 + examples/optional_command.zig | 63 +++++++++++++++++++++++++++++++++++ examples/overview.zig | 1 + src/Help.zig | 5 +-- src/Parser.zig | 4 +-- src/meta.zig | 25 ++++++++++---- 6 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 examples/optional_command.zig diff --git a/build.zig b/build.zig index 2d77d5d..0809f1b 100644 --- a/build.zig +++ b/build.zig @@ -29,6 +29,7 @@ pub fn build(b: *std.Build) void { overview, colors, trailing, + optional_command, }, "example", "Example to run for example step (default = overview)", diff --git a/examples/optional_command.zig b/examples/optional_command.zig new file mode 100644 index 0000000..43290f4 --- /dev/null +++ b/examples/optional_command.zig @@ -0,0 +1,63 @@ +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, .{}); + + try std.json.stringify( + options, + .{ .whitespace = .indent_2 }, + std.io.getStdOut().writer(), + ); +} + +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..744ce34 100644 --- a/examples/overview.zig +++ b/examples/overview.zig @@ -76,6 +76,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/src/Help.zig b/src/Help.zig index 75011db..31cdcc8 100644 --- a/src/Help.zig +++ b/src/Help.zig @@ -203,8 +203,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..2bc4119 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -95,7 +95,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; } @@ -130,7 +130,7 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl } } - if (info.subcommands.len > 0 and !passed.command) { + if (!info.optional_commands and info.subcommands.len > 0 and !passed.command) { parser.fatal("missing subcommand", .{}); } 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, From fb4e65266b9c3a48fc5eb4e71de616e919783ca0 Mon Sep 17 00:00:00 2001 From: JacobCrabill Date: Sat, 2 Aug 2025 20:29:26 -0700 Subject: [PATCH 2/6] Add 'printHelp()' function --- src/flags.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/flags.zig b/src/flags.zig index eeb74c9..333ad6e 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, @@ -26,3 +27,12 @@ pub fn parse( 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.io.getStdOut(), options.colors); +} From c0e9215d6405fe4fa534f2ccd1b94d5e07c097da Mon Sep 17 00:00:00 2001 From: JacobCrabill Date: Tue, 5 Aug 2025 20:32:59 -0700 Subject: [PATCH 3/6] Parser: Bug fix optional command --- src/Parser.zig | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Parser.zig b/src/Parser.zig index 2bc4119..33091ad 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -130,8 +130,12 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl } } - if (!info.optional_commands and info.subcommands.len > 0 and !passed.command) { - parser.fatal("missing subcommand", .{}); + if (info.subcommands.len > 0 and !passed.command) { + if (info.optional_commands) { + flags.command = null; + } else { + parser.fatal("missing subcommand", .{}); + } } return flags; From 2839f30a02485f863f5780170eef290c8ccf8d53 Mon Sep 17 00:00:00 2001 From: JacobCrabill Date: Tue, 19 Aug 2025 07:48:25 -0700 Subject: [PATCH 4/6] parser: Print help on error Since 'parse' no longer returns an error, and instead exits directly, be helpful by printing the active Help menu after the error message, for context on where the error came from and what options exist. --- src/Parser.zig | 11 ++++++++--- src/flags.zig | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Parser.zig b/src/Parser.zig index 33091ad..e806620 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -13,17 +13,22 @@ 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); + stderr.print(parser.colors.error_message, fmt ++ "\n\n", args); + parser.help.render(std.io.getStdErr(), 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 +46,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.io.getStdOut(), parser.colors); std.process.exit(0); } diff --git a/src/flags.zig b/src/flags.zig index 333ad6e..9a4f5ef 100644 --- a/src/flags.zig +++ b/src/flags.zig @@ -23,6 +23,7 @@ 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); From 1a5e7569bda623129da212d9fb1215994245fce4 Mon Sep 17 00:00:00 2001 From: JacobCrabill Date: Wed, 20 Aug 2025 20:10:02 -0700 Subject: [PATCH 5/6] Update for Zig 0.15 --- build.zig | 11 ++++++----- build.zig.zon | 4 ++-- examples/optional_command.zig | 9 +++++++-- examples/overview.zig | 11 ++++++++--- examples/trailing.zig | 9 +++++++-- src/Help.zig | 16 +++++++++------- src/Parser.zig | 11 ++++++----- src/Terminal.zig | 25 +++++++++++++++++-------- src/flags.zig | 2 +- 9 files changed, 63 insertions(+), 35 deletions(-) diff --git a/build.zig b/build.zig index 0809f1b..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); @@ -37,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 index 43290f4..b6be20e 100644 --- a/examples/optional_command.zig +++ b/examples/optional_command.zig @@ -10,10 +10,15 @@ pub fn main() !void { const options = 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, ); } diff --git a/examples/overview.zig b/examples/overview.zig index 744ce34..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, ); } 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 31cdcc8..c766bde 100644 --- a/src/Help.zig +++ b/src/Help.zig @@ -18,11 +18,12 @@ 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.print(colors.command_name, "{s}", .{usage.command}); term.print(colors.usage, "{s}\n", .{usage.body}); @@ -99,9 +100,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 +183,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(.{ diff --git a/src/Parser.zig b/src/Parser.zig index e806620..adbd880 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -17,10 +17,11 @@ colors: *const ColorScheme, 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\n", args); - parser.help.render(std.io.getStdErr(), parser.colors); + 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); } @@ -46,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")) { - parser.help.render(std.io.getStdOut(), parser.colors); + parser.help.render(std.fs.File.stdout(), parser.colors); std.process.exit(0); } diff --git a/src/Terminal.zig b/src/Terminal.zig index 442e0f8..a4f3af8 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -6,29 +6,38 @@ 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 = &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!"); } } + +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 9a4f5ef..bb06202 100644 --- a/src/flags.zig +++ b/src/flags.zig @@ -35,5 +35,5 @@ pub fn printHelp( options: Options, ) void { const help = comptime Help.generate(Flags, meta.info(Flags), exe_name); - help.render(std.io.getStdOut(), options.colors); + help.render(std.fs.File.stdout(), options.colors); } From 8f65f0d5bf0d92b950ddf16afb34533491831c7d Mon Sep 17 00:00:00 2001 From: JacobCrabill Date: Sun, 24 Aug 2025 09:50:30 -0700 Subject: [PATCH 6/6] flush more often I think I was overflowing the write buffer w/o more frequent flushing --- src/Help.zig | 3 +++ src/Terminal.zig | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Help.zig b/src/Help.zig index c766bde..c08938c 100644 --- a/src/Help.zig +++ b/src/Help.zig @@ -25,8 +25,11 @@ pub const Usage = struct { 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 { diff --git a/src/Terminal.zig b/src/Terminal.zig index a4f3af8..6bb197e 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -26,7 +26,7 @@ pub fn print( comptime format: []const u8, args: anytype, ) void { - const writer = &term.writer.interface; + const writer: *std.Io.Writer = &term.writer.interface; for (style) |color| { term.config.setColor(writer, color) catch @panic("Can't set color!"); } @@ -36,6 +36,8 @@ pub fn print( if (style.len > 0) { term.config.setColor(writer, .reset) catch @panic("Can't set color!"); } + + writer.flush() catch @panic("Flush failed!"); } pub fn flush(term: *Terminal) void {