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..f9a6de1 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,8 +1,8 @@ .{ .name = .flags, - .version = "0.10.0", + .version = "0.12.0", .fingerprint = 0xb0541bade61ff6b, - .minimum_zig_version = "0.14.0-dev.2802+257054a14", + .minimum_zig_version = "0.16.0", // 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/colors.zig b/examples/colors.zig index 00239a3..36f1724 100644 --- a/examples/colors.zig +++ b/examples/colors.zig @@ -1,17 +1,13 @@ const std = @import("std"); const flags = @import("flags"); -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}).init; - defer _ = gpa.deinit(); +pub fn main(init: std.process.Init) !void { + const args = try init.minimal.args.toSlice(init.arena.allocator()); - const args = try std.process.argsAlloc(gpa.allocator()); - defer std.process.argsFree(gpa.allocator(), args); - - _ = flags.parse(args, "colors", Flags, .{ + _ = flags.parse(init.io, args, "colors", Flags, .{ // Use the `colors` option to provide a colorscheme for the error/help messages. // Specifying this as empty: `.colors = &.{}` will disable colors. - // Each field is a list of type `std.io.tty.Color`. + // Each field is a list of type `std.Io.Terminal.Color`. .colors = &flags.ColorScheme{ .error_label = &.{ .bright_red, .bold }, .command_name = &.{.bright_green}, diff --git a/examples/optional_command.zig b/examples/optional_command.zig new file mode 100644 index 0000000..8dbc45d --- /dev/null +++ b/examples/optional_command.zig @@ -0,0 +1,65 @@ +const std = @import("std"); +const flags = @import("flags"); + +pub fn main(init: std.process.Init) !void { + const io = init.io; + const args = try init.minimal.args.toSlice(init.arena.allocator()); + + const options = flags.parse(io, args, "overview", Flags, .{}); + + var stdout_buf: [1024]u8 = undefined; + var stdout_writer = std.Io.File.stdout().writer(io, &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..d2b5ee3 100644 --- a/examples/overview.zig +++ b/examples/overview.zig @@ -1,19 +1,21 @@ const std = @import("std"); const flags = @import("flags"); -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}).init; - defer _ = gpa.deinit(); +pub fn main(init: std.process.Init) !void { + const io = init.io; + const args = try init.minimal.args.toSlice(init.arena.allocator()); - const args = try std.process.argsAlloc(gpa.allocator()); - defer std.process.argsFree(gpa.allocator(), args); + const options: Flags = flags.parse(io, args, "overview", Flags, .{}); - const options = flags.parse(args, "overview", Flags, .{}); + var stdout_buf: [1024]u8 = undefined; + var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buf); + const stdout = &stdout_writer.interface; + defer stdout.flush() catch {}; - try std.json.stringify( + try std.json.Stringify.value( options, .{ .whitespace = .indent_2 }, - std.io.getStdOut().writer(), + stdout, ); } @@ -76,6 +78,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..68fefe5 100644 --- a/examples/trailing.zig +++ b/examples/trailing.zig @@ -1,19 +1,21 @@ const std = @import("std"); const flags = @import("flags"); -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}).init; - defer _ = gpa.deinit(); +pub fn main(init: std.process.Init) !void { + const io = init.io; + const args = try init.minimal.args.toSlice(init.arena.allocator()); - const args = try std.process.argsAlloc(gpa.allocator()); - defer std.process.argsFree(gpa.allocator(), args); + const options = flags.parse(io, args, "trailing", Flags, .{}); - const options = flags.parse(args, "trailing", Flags, .{}); + var stdout_buf: [1024]u8 = undefined; + var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buf); + const stdout = &stdout_writer.interface; + defer stdout.flush() catch {}; - try std.json.stringify( + try std.json.Stringify.value( options, .{ .whitespace = .indent_2 }, - std.io.getStdOut().writer(), + stdout, ); } diff --git a/src/ColorScheme.zig b/src/ColorScheme.zig index 8b95bf7..59755ce 100644 --- a/src/ColorScheme.zig +++ b/src/ColorScheme.zig @@ -2,7 +2,7 @@ const ColorScheme = @This(); const std = @import("std"); -const Color = std.io.tty.Color; +const Color = std.Io.Terminal.Color; pub const Style = []const Color; diff --git a/src/Help.zig b/src/Help.zig index 75011db..1a7a7d5 100644 --- a/src/Help.zig +++ b/src/Help.zig @@ -3,7 +3,7 @@ const Help = @This(); const std = @import("std"); const meta = @import("meta.zig"); -const File = std.fs.File; +const File = std.Io.File; const ColorScheme = @import("ColorScheme.zig"); const Terminal = @import("Terminal.zig"); @@ -17,15 +17,20 @@ pub const Usage = struct { command: []const u8, body: []const u8, - pub fn render(usage: Usage, stdout: File, colors: *const ColorScheme) void { - const term = Terminal.init(stdout); - usage.renderToTerminal(term, colors); + pub fn render(usage: Usage, io: std.Io, stdout: File, colors: *const ColorScheme) void { + var term = Terminal.init(io, stdout); + term.fixSelfRef(); + 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 +104,11 @@ 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, io: std.Io, writer: File, colors: *const ColorScheme) void { + var term = Terminal.init(io, writer); + term.fixSelfRef(); + defer term.flush(); + help.usage.renderToTerminal(&term, colors); if (help.description) |description| { term.print(colors.command_description, "\n{s}\n", .{description}); @@ -181,7 +188,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 +210,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..1c2009b 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -13,17 +13,24 @@ pub const Terminal = @import("Terminal.zig"); args: []const [:0]const u8, current_arg: usize, colors: *const ColorScheme, - -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); +/// The current Help of the command being parsed +help: Help, + +fn fatal(parser: *const Parser, io: std.Io, comptime fmt: []const u8, args: anytype) noreturn { + var term = Terminal.init(io, std.Io.File.stderr()); + term.fixSelfRef(); + term.print(parser.colors.error_label, "Error: ", .{}); + term.print(parser.colors.error_message, fmt ++ "\n\n", args); + term.flush(); + parser.help.render(io, std.Io.File.stderr(), parser.colors); std.process.exit(1); } -pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Flags { +/// 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, io: std.Io, 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) = .{}; @@ -37,18 +44,18 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl next_arg: while (parser.nextArg()) |arg| { if (arg.len == 0) { - parser.fatal("empty argument", .{}); + parser.fatal(io, "empty argument", .{}); } if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { - help.render(std.io.getStdOut(), parser.colors); + parser.help.render(io, std.Io.File.stdout(), parser.colors); std.process.exit(0); } if (std.mem.eql(u8, arg, "--")) { // Blindly treat remaining arguments as positional. while (parser.nextArg()) |positional| { - if (parser.parsePositional(positional, positional_index, info.positionals, &flags) == .consumed_all) { + if (parser.parsePositional(io, positional, positional_index, info.positionals, &flags) == .consumed_all) { break :next_arg; } positional_index += 1; @@ -57,17 +64,17 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl if (std.mem.startsWith(u8, arg, "--")) { inline for (info.flags) |flag| if (std.mem.eql(u8, arg, flag.flag_name)) { - @field(flags, flag.field_name) = parser.parseOption(flag.type, flag.flag_name); + @field(flags, flag.field_name) = parser.parseOption(io, flag.type, flag.flag_name); @field(passed, flag.field_name) = true; continue :next_arg; }; - parser.fatal("unrecognized flag: {s}", .{arg}); + parser.fatal(io, "unrecognized flag: {s}", .{arg}); } if (std.mem.startsWith(u8, arg, "-")) { if (arg.len == 1) { - parser.fatal("unrecognized argument: '-'", .{}); + parser.fatal(io, "unrecognized argument: '-'", .{}); } const switch_set = arg[1..]; @@ -77,9 +84,10 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl // Removing this check would allow formats like: // `$ -abc value-for-a value-for-b value-for-c` if (flag.type != bool and i < switch_set.len - 1) { - parser.fatal("missing value after switch: {c}", .{switch_char}); + parser.fatal(io, "missing value after switch: {c}", .{switch_char}); } @field(flags, flag.field_name) = parser.parseOption( + io, flag.type, &.{ '-', switch_char }, ); @@ -87,21 +95,21 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl continue :next_switch; } }; - parser.fatal("unrecognized switch: {c}", .{ch}); + parser.fatal(io, "unrecognized switch: {c}", .{ch}); } continue :next_arg; } 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); + const cmd_flags = parser.parse(io, cmd.type, command_name ++ " " ++ cmd.command_name); + flags.command = @unionInit(meta.unwrapOptional(@TypeOf(flags.command)), cmd.field_name, cmd_flags); passed.command = true; continue :next_arg; } } - if (parser.parsePositional(arg, positional_index, info.positionals, &flags) == .consumed_all) { + if (parser.parsePositional(io, arg, positional_index, info.positionals, &flags) == .consumed_all) { break :next_arg; } positional_index += 1; @@ -113,7 +121,7 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl .bool => false, .optional => null, else => { - parser.fatal("missing required flag: {s}", .{flag.flag_name}); + parser.fatal(io, "missing required flag: {s}", .{flag.flag_name}); }, }; }; @@ -124,14 +132,18 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl switch (@typeInfo(pos.type)) { .optional => null, else => { - parser.fatal("missing required argument: {s}", .{pos.arg_name}); + parser.fatal(io, "missing required argument: {s}", .{pos.arg_name}); }, }; } } if (info.subcommands.len > 0 and !passed.command) { - parser.fatal("missing subcommand", .{}); + if (info.optional_commands) { + flags.command = null; + } else { + parser.fatal(io, "missing subcommand", .{}); + } } return flags; @@ -139,6 +151,7 @@ pub fn parse(parser: *Parser, Flags: type, comptime command_name: []const u8) Fl fn parsePositional( parser: *Parser, + io: std.Io, arg: [:0]const u8, index: usize, comptime positionals: []const meta.Positional, @@ -150,14 +163,14 @@ fn parsePositional( parser.current_arg = parser.args.len; return .consumed_all; } - parser.fatal("unexpected argument: {s}", .{arg}); + parser.fatal(io, "unexpected argument: {s}", .{arg}); } switch (index) { inline 0...positionals.len - 1 => |i| { const positional = positionals[i]; const T = meta.unwrapOptional(positional.type); - @field(flags.positional, positional.field_name) = parser.parseValue(T, arg); + @field(flags.positional, positional.field_name) = parser.parseValue(io, T, arg); return .consumed_one; }, @@ -165,27 +178,29 @@ fn parsePositional( } } -fn parseOption(parser: *Parser, T: type, option_name: []const u8) T { +fn parseOption(parser: *Parser, io: std.Io, T: type, option_name: []const u8) T { if (T == bool) return true; const value = parser.nextArg() orelse { - parser.fatal("missing value for '{s}'", .{option_name}); + parser.fatal(io, "missing value for '{s}'", .{option_name}); }; - return parser.parseValue(meta.unwrapOptional(T), value); + return parser.parseValue(io, meta.unwrapOptional(T), value); } -fn parseValue(parser: *const Parser, T: type, arg: [:0]const u8) T { +fn parseValue(parser: *const Parser, io: std.Io, T: type, arg: [:0]const u8) T { if (T == []const u8 or T == [:0]const u8) return arg; switch (@typeInfo(T)) { .int => |info| return std.fmt.parseInt(T, arg, 10) catch |err| { switch (err) { error.Overflow => parser.fatal( + io, "value out of bounds for {d}-bit {s} integer: {s}", .{ info.bits, @tagName(info.signedness), arg }, ), error.InvalidCharacter => parser.fatal( + io, "expected integer number, found '{s}'", .{arg}, ), @@ -194,7 +209,7 @@ fn parseValue(parser: *const Parser, T: type, arg: [:0]const u8) T { .float => return std.fmt.parseFloat(T, arg) catch |err| switch (err) { error.InvalidCharacter => { - parser.fatal("expected numerical value, found '{s}'", .{arg}); + parser.fatal(io, "expected numerical value, found '{s}'", .{arg}); }, }, @@ -205,7 +220,7 @@ fn parseValue(parser: *const Parser, T: type, arg: [:0]const u8) T { } } - parser.fatal("unrecognized option: '{s}'", .{arg}); + parser.fatal(io, "unrecognized option: '{s}'", .{arg}); }, else => comptime meta.compileError("invalid flag type: {s}", .{@typeName(T)}), diff --git a/src/Terminal.zig b/src/Terminal.zig index 442e0f8..025f02d 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -3,32 +3,51 @@ const Terminal = @This(); const std = @import("std"); const ColorScheme = @import("ColorScheme.zig"); -const tty = std.io.tty; -const File = std.fs.File; +const File = std.Io.File; -writer: File.Writer, -config: tty.Config, +write_buffer: [1024]u8 = undefined, +file_writer: std.Io.File.Writer = undefined, +file: File, +tty: std.Io.Terminal = undefined, -pub fn init(file: File) Terminal { - return .{ - .writer = file.writer(), - .config = tty.detectConfig(file), +pub fn init(io: std.Io, file: File) Terminal { + var term = Terminal{ + .file = file, + .tty = .{ + .writer = undefined, + .mode = std.Io.Terminal.Mode.detect(io, file, false, false) catch .escape_codes, + }, }; + term.file_writer = term.file.writer(io, &term.write_buffer); + term.tty.writer = &term.file_writer.interface; + return term; +} + +pub fn fixSelfRef(self: *Terminal) void { + self.file_writer.interface.buffer = &self.write_buffer; + self.tty.writer = &self.file_writer.interface; } pub fn print( - terminal: Terminal, + term: *Terminal, style: ColorScheme.Style, comptime format: []const u8, args: anytype, ) void { + const writer: *std.Io.Writer = term.tty.writer; for (style) |color| { - terminal.config.setColor(terminal.writer, color) catch {}; + term.tty.setColor(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.tty.setColor(.reset) catch @panic("Can't set color!"); } + + writer.flush() catch @panic("Flush failed!"); +} + +pub fn flush(term: *Terminal) void { + term.tty.writer.flush() catch @panic("Flush failed!"); } diff --git a/src/flags.zig b/src/flags.zig index eeb74c9..ec4fd15 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, @@ -12,6 +13,7 @@ pub const Options = struct { }; pub fn parse( + io: std.Io, args: []const [:0]const u8, /// The name of your program. comptime exe_name: []const u8, @@ -22,7 +24,18 @@ 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); + return parser.parse(io, Flags, exe_name); +} + +pub fn printHelp( + io: std.Io, + comptime exe_name: []const u8, + Flags: type, + options: Options, +) void { + const help = comptime Help.generate(Flags, meta.info(Flags), exe_name); + help.render(io, std.Io.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,