diff --git a/src/Cli.zig b/src/Cli.zig new file mode 100644 index 0000000000000000000000000000000000000000..39ee68fcf7bf3361b25ee74cb1c27923ad37e1df --- /dev/null +++ b/src/Cli.zig @@ -0,0 +1,659 @@ +//! Configuration for an command line interface, which can be executed against +//! an argument slice using `.parse`. None of the parsing strategies require an +//! allocator, however it expects incoming arguments to be UTF-8 formatted which +//! on Windows will require an allocator. +//! +//! See `fn main` for a basic usage example. +options: []const Option = &.{}, +positionals: Positionals = .none, +help: HelpConfig = .{}, + +pub const Option = struct { + /// Should be formatted as the flag name: '--dump-ir' + name: []const u8, + /// Displayed in generated help menu + desc: []const u8, + kind: Kind = .boolean, + /// A short code allows single dash argument parsing. + short: ?u8 = null, + + const Kind = union(enum) { + /// UTF-8 validated + string, + boolean, + @"enum": type, + + pub fn Type(k: Kind) type { + return switch (k) { + .string => []const u8, + .boolean => bool, + .@"enum" => |t| t, + }; + } + }; + + pub fn optional(comptime cond: bool, opts: []const Option) []const Option { + return if (cond) opts else &.{}; + } + + const help: Option = .{ + .name = "--help", + .short = 'h', + .desc = "Print this help and exit", + }; +}; + +pub const Positionals = union(enum) { + none, + /// Store a single positional: ?[]const u8 + single, + /// Store the start and count of positionals. Call + /// 'parsed.positionals.iterator(args)' with the + /// original argument array to read positionals. + unlimited, + /// Same strategy as .unlimited, with a limit + limited: usize, + /// Use a bounded array to store positionals, using + /// a stack allocated buffer that can hold enough + /// '[]const u8's as allowed by the bound. + bounded: usize, + + pub fn Type(p: Positionals) type { + return switch (p) { + .none => void, + .single => ?[]const u8, + .limited => |limit| switch (limit) { + 0 => void, + else => Multi, + }, + .unlimited => Multi, + .bounded => |n| std.BoundedArray([]const u8, n), + }; + } + + pub inline fn maxCount(p: Positionals) usize { + return switch (p) { + .none => 0, + .single => 1, + inline .limited, .bounded => |limit| limit, + .unlimited => std.math.maxInt(usize), + }; + } + + pub const Multi = struct { + /// The index of the first positional + first: usize, + /// The number of total positionals. This can + /// be used to pre-allocate resources. + len: usize, + + first_is_after_double_dash: bool, + + pub const empty = .{ + .first = 0, + .len = 0, + .first_is_after_double_dash = false, + }; + + pub fn iterator(pos: @This(), all_args: []const []const u8) PositionalIterator { + return .{ + .args = all_args, + .i = pos.first, + .left = pos.len, + .after_double_dash = pos.first_is_after_double_dash, + }; + } + }; +}; + +pub const HelpConfig = struct { + /// If set true, registers an addition '--help' / '-h' flag + /// These will show the help menu and exit the program. + /// + /// Help menu can always be displayed manually with .showHelp() + option: bool = true, + + /// Override the usage string in the first line. + usage: ?[]const u8 = null, + + /// Hide a list of flag names. + hide_options: []const []const u8 = &.{}, +}; + +pub const PositionalIterator = struct { + args: []const []const u8, + i: usize, + left: usize, + after_double_dash: bool, + + pub fn peek(it: PositionalIterator) ?[]const u8 { + return if (it.left > 0) it.args[it.i] else null; + } + + pub fn next(it: *PositionalIterator) ?[]const u8 { + if (it.left > 0) { + defer it.advance(); + return it.args[it.i]; + } else { + return null; + } + } + + pub fn advance(it: *PositionalIterator) void { + it.left -= 1; + if (it.left == 0) return; + if (it.after_double_dash) { + it.i += 1; + } else while (mem.startsWith(u8, it.args[it.i], "-")) { + defer it.i += 1; + if (it.args[it.i].len == 2 and it.args[it.i][1] == '-') { + it.after_double_dash = true; + return; + } + } + } +}; + +inline fn fieldName(comptime name: []const u8) [:0]const u8 { + std.debug.assert(name[0] == '-' and name[1] == '-'); + for (name[2..]) |char| switch (char) { + 'a'...'z', '-' => {}, + else => |ch| @compileError(std.fmt.comptimePrint("Unexpected char '{c}' in option name '{s}'", .{ ch, name })), + }; + + const replacement_size = mem.replacementSize(u8, name[2..], "-", "_"); + const replaced = brk: { + var buf: [replacement_size:0]u8 = undefined; + _ = mem.replace(u8, name[2..], "-", "_", &buf); + break :brk buf; + }; + return &replaced; +} + +/// Every option name is converted into snake case, then +/// an additional field name 'positionals' is added. +pub fn Parsed(p: Cli) type { + const fields = brk: { + var fields: [p.options.len + 1]Type.StructField = undefined; + + for (p.options, fields[0..p.options.len]) |def, *field| { + const Wrapped = ?def.kind.Type(); + field.* = .{ + .name = fieldName(def.name), + .alignment = @alignOf(Wrapped), + .default_value = &@as(Wrapped, null), + .is_comptime = false, + .type = Wrapped, + }; + } + + const Pos = p.positionals.Type(); + fields[fields.len - 1] = .{ + .name = "positionals", + .alignment = @alignOf(Pos), + .type = Pos, + .default_value = &@as(Pos, switch (p.positionals) { + .none => {}, + .single => null, + .limited, .unlimited => .empty, + .bounded => .{}, + }), + .is_comptime = false, + }; + + break :brk fields; + }; + + return @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +} + +const StdErrWriter = struct { + file: std.fs.File, + buffered_writer: std.io.BufferedWriter(4096, std.fs.File.Writer), + tty: std.io.tty.Config, + writer_ptr: std.io.BufferedWriter(4096, std.fs.File.Writer).Writer, + + pub fn init() StdErrWriter { + const stderr = std.io.getStdErr(); + const tty = std.io.tty.detectConfig(stderr); + return .{ + .file = stderr, + .tty = tty, + .buffered_writer = std.io.bufferedWriter(stderr.writer()), + .writer_ptr = undefined, + }; + } + + pub fn get(w: *StdErrWriter) std.io.AnyWriter { + if (@import("builtin").os.tag == .windows and w.tty == .windows_api) { + // all data must be flushed before changing colors using + // the Windows API. thankfully, modern windows terminals + // will not hit this path. + return w.buffered_writer.unbuffered_writer.any(); + } else { + w.writer_ptr = w.buffered_writer.writer(); // uaf unless stored + return w.writer_ptr.any(); + } + } +}; + +const ParseError = error{ InvalidArguments, DisplayedHelpMenu }; + +/// This will quit the program with usage information if invalid arguments are +/// received and the `--help` option is enabled. This is the most common use +/// case. If manual error handling is desired, `.parseWithErrorWriter` can +/// be used instead. +pub fn parse( + comptime p: Cli, + program_name: []const u8, + args: []const []const u8, +) p.Parsed() { + var stderr = StdErrWriter.init(); + defer stderr.buffered_writer.flush() catch {}; + return parseWithErrorWriter( + p, + program_name, + args, + stderr.get(), + stderr.tty, + true, + true, + ) catch |e| switch (e) { + error.InvalidArguments, + error.DisplayedHelpMenu, + => { + stderr.buffered_writer.flush() catch {}; + std.process.exit(1); + }, + }; +} + +pub fn parseWithErrorWriter( + comptime p: Cli, + program_name: []const u8, + args: []const []const u8, + err: std.io.AnyWriter, + tty: std.io.tty.Config, + comptime show_help_on_error: bool, + comptime exit_on_error: bool, +) ParseError!p.Parsed() { + var out: p.Parsed() = .{}; + var had_errors = false; + var positionals_counted: usize = 0; + var first_positional: usize = 0; + var double_dash: ?usize = null; + + var show_help = false; + + const max_positionals = p.positionals.maxCount(); + + outer: for (args, 0..) |arg, i| { + if (mem.startsWith(u8, arg, "--")) { + if (arg.len == 2) { + if (max_positionals == 0) { + writeError(err, tty); + err.writeAll("unexpected end-of-parameter mark: --\n") catch {}; + } + + double_dash = i; + const remain = args[i + 1 ..]; + if (positionals_counted == 0 and remain.len > 0) + first_positional = i + 1; + + if (p.positionals == .bounded) { + out.positionals.appendSliceAssumeCapacity(remain[0..@min(remain.len, max_positionals -| positionals_counted)]); + } + + positionals_counted += remain.len; + + if (positionals_counted > max_positionals) { + had_errors = true; + const diff = @min(remain.len, positionals_counted - max_positionals); + for (remain[remain.len - diff ..]) |pos| { + writeError(err, tty); + err.print("unrecognized argument '{s}'\n", .{pos}) catch {}; + } + } + + break; + } + + inline for (p.options) |def| try_this_def: { + const prefix = def.name; + const kind = def.kind; + const dest = &@field(out, fieldName(def.name)); + + const is_blank = arg.len == prefix.len; + if (!(mem.startsWith(u8, arg[2..], prefix[2..]) and (is_blank or arg[prefix.len] == '='))) + break :try_this_def; + + if (kind != .boolean and is_blank) { + had_errors = true; + writeError(err, tty); + err.print(prefix ++ " requires a value\n", .{}) catch {}; + continue :outer; + } + + const value = if (is_blank) "" else arg[prefix.len + 1 ..]; + switch (kind) { + .boolean => if (value.len == 0) { + dest.* = true; + } else if (mem.eql(u8, value, "false")) { + dest.* = false; + } else if (mem.eql(u8, value, "true")) { + dest.* = true; + } else { + had_errors = true; + writeError(err, tty); + err.print(prefix ++ " only accepts true or false, got '{s}'\n", .{ + arg[prefix.len + 1 ..], + }) catch {}; + }, + + .string => if (std.unicode.utf8ValidateSlice(value)) { + dest.* = value; + } else { + had_errors = true; + writeError(err, tty); + err.print(prefix ++ " requires valid UTF-8 input\n", .{}) catch {}; + }, + + .@"enum" => |E| { + dest.* = std.meta.stringToEnum(E, value) orelse { + had_errors = true; + const values_list = comptime brk: { + var str: []const u8 = ""; + for (std.enums.values(E)) |val| { + str = str ++ " " ++ @tagName(val) ++ "\n"; + } + break :brk str; + }; + writeError(err, tty); + err.print(prefix ++ " must be one of the following values, got {s}:\n{s}", .{ + arg[prefix.len + 1 ..], + values_list, + }) catch {}; + continue :outer; + }; + }, + } + + continue :outer; + } + + if (p.help.option) { + if (mem.startsWith(u8, arg[2..], "help") and (arg.len == "--help".len or arg["--help".len] == '=')) { + show_help = true; + if (arg.len > "--help".len) { + had_errors = true; + err.print("--help does not take a value\n", .{}) catch {}; + } + continue :outer; + } + } + + had_errors = true; + writeError(err, tty); + err.print("unrecognized argument '{s}'\n", .{arg}) catch {}; + } else if (arg.len > 1 and arg[0] == '-') { + per_char_in_short: for (arg[1..]) |ch| { + inline for (p.options) |def| try_this_def: { + const short = def.short orelse break :try_this_def; + if (def.kind != .boolean) { + @compileError("TODO: non-boolean shorthand arguments"); + } + if (ch == short) { + const dest = &@field(out, fieldName(def.name)); + dest.* = true; + continue :per_char_in_short; + } + if (p.help.option and ch == 'h') { + show_help = true; + } + } + } + } else { + if (positionals_counted == 0) first_positional = i; + positionals_counted += 1; + if (positionals_counted > max_positionals) { + had_errors = true; + writeError(err, tty); + err.print("unrecognized argument '{s}'\n", .{arg}) catch {}; + } else if (p.positionals == .bounded) { + out.positionals.appendAssumeCapacity(arg); + } + } + } + + if (had_errors) { + if (positionals_counted > max_positionals) { + writeNote(err, tty); + err.writeAll(switch (max_positionals) { + 0 => "this program does not take positional arguments\n", + 1 => "this program accepts one positional argument\n", + else => |n| std.fmt.comptimePrint( + "this program accepts up to {d} positional arguments\n", + .{n}, + ), + }) catch {}; + } + if (show_help or show_help_on_error) { + p.showHelpWithWriter(program_name, err, tty); + } + if (exit_on_error) { + return error.DisplayedHelpMenu; + } + return error.InvalidArguments; + } + + if (show_help) { + p.showHelpWithWriter(program_name, err, tty); + return error.DisplayedHelpMenu; + } + + out.positionals = switch (p.positionals) { + .none => {}, + .single => if (positionals_counted > 0) + args[first_positional] + else + null, + .bounded => out.positionals, // already correct + .unlimited, .limited => if (max_positionals > 0) .{ + .first = first_positional, + .len = positionals_counted, + .first_is_after_double_dash = if (double_dash) |dd| + dd < first_positional + else + false, + }, + }; + + return out; +} + +pub fn showHelp(comptime p: Cli, program_name: []const u8) void { + var stderr = StdErrWriter.init(); + defer stderr.buffered_writer.flush() catch {}; + return showHelpWithWriter( + p, + program_name, + stderr.get(), + stderr.tty, + ); +} + +pub fn showHelpWithWriter( + comptime p: Cli, + program_name: []const u8, + w: std.io.AnyWriter, + tty: std.io.tty.Config, +) void { + const usage = p.help.usage orelse default: { + const opts_usage = if (p.options.len > 0 or p.help.option) "[options...]" else ""; + break :default switch (p.positionals.maxCount()) { + 0 => opts_usage, + 1 => mem.trimLeft(u8, opts_usage ++ " [argument]", " "), + else => mem.trimLeft(u8, opts_usage ++ " [args...]", " "), + }; + }; + + w.print("usage: {s} {s}\n", .{ program_name, usage }) catch return; + + if (p.options.len > 0 or p.help.option) { + w.writeAll("\n") catch return; + tty.setColor(w, .bold) catch return; + w.writeAll("options:") catch return; + tty.setColor(w, .reset) catch return; + w.writeAll(comptime brk: { + var width = 0; + var any_short_options = false; + + const all_opts = if (p.help.option) + p.options ++ [_]Option{.help} + else + p.options; + for (all_opts) |opt| { + width = @max(width, opt.name.len); + any_short_options = any_short_options or opt.short != null; + } + + width += " ".len; + if (any_short_options) width += "-h ".len; + const sub = if (any_short_options) 4 else 1; + + var out: []const u8 = "\n"; + for (all_opts) |opt| { + out = out ++ " "; + if (any_short_options) { + out = out ++ if (opt.short) |s| [_]u8{ '-', s, ' ' } else " "; + } + out = out ++ opt.name ++ (" " ** (width - opt.name.len - sub)); + // TODO: wrapping at 72 columns + out = out ++ opt.desc ++ "\n"; + } + break :brk out ++ "\n"; + }) catch return; + } +} + +pub fn showHelpAndExit(comptime p: Cli, program_name: []const u8) noreturn { + p.showHelp(program_name); + std.process.exit(1); +} + +fn writeError(err: std.io.AnyWriter, tty: std.io.tty.Config) void { + tty.setColor(err, .red) catch return; + tty.setColor(err, .bold) catch return; + err.writeAll("error") catch return; + tty.setColor(err, .reset) catch return; + tty.setColor(err, .dim) catch return; + err.writeAll(": ") catch return; + tty.setColor(err, .reset) catch return; +} + +fn writeNote(err: std.io.AnyWriter, tty: std.io.tty.Config) void { + tty.setColor(err, .blue) catch return; + tty.setColor(err, .bold) catch return; + err.writeAll("note") catch return; + tty.setColor(err, .reset) catch return; + tty.setColor(err, .dim) catch return; + err.writeAll(": ") catch return; + tty.setColor(err, .reset) catch return; +} + +/// Splits args[0] containing the program name +/// +/// const program_name, const args = programArgsAlloc(); +pub fn programArgsAlloc(allocator: std.mem.Allocator, fallback_name: []const u8) !struct { []const u8, [][:0]u8 } { + const magic_number = @alignOf([*][:0]u8); + var raw_args = try std.process.argsAlloc(allocator); + switch (raw_args.len) { + 0 => { + raw_args.ptr = @ptrFromInt(magic_number); + return .{ fallback_name, raw_args }; + }, + else => return .{ + std.fs.path.basename(raw_args[0]), + raw_args[1..], + }, + } +} + +pub fn programArgsFree(allocator: std.mem.Allocator, args: [][:0]u8) void { + const magic_number = @alignOf([*][:0]u8); + if (args.len == 0 and @intFromPtr(args.ptr) == magic_number) return; + const raw_args = (args.ptr - 1)[0 .. args.len + 1]; + std.process.argsFree(allocator, raw_args); +} + +pub fn fatalWithHelp(p: Cli, program_name: []const u8, comptime fmt: []const u8, args: anytype) noreturn { + print: { + const stderr = std.io.getStdErr(); + const tty = std.io.tty.detectConfig(stderr); + var bufw = std.io.bufferedWriter(stderr.writer()); + writeError(bufw.writer().any(), tty); + bufw.writer().print(fmt ++ "\n", args) catch break :print; + p.showHelpWithWriter(program_name, bufw.writer().any(), tty); + bufw.flush() catch break :print; + } + std.process.exit(1); +} + +pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn { + const stderr = std.io.getStdErr(); + const tty = std.io.tty.detectConfig(stderr); + var bufw = std.io.bufferedWriter(stderr.writer()); + writeError(bufw.writer().any(), tty); + bufw.writer().print(fmt ++ "\n", args); + bufw.flush() catch {}; // ignoring I/O errors + std.process.exit(1); +} + +pub fn main() !void { + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + defer _ = gpa.deinit(); + const alloc = gpa.allocator(); + + const config: Cli = .{ + .options = &.{ + .{ .name = "--flag", .desc = "check for something" }, + .{ .name = "--str", .desc = "pass a string", .kind = .string }, + .{ + .name = "--enum", + .desc = "enum", + .kind = .{ .@"enum" = std.builtin.OptimizeMode }, + }, + }, + .positionals = .{ .bounded = 2 }, + }; + + const program_name, const args = try Cli.programArgsAlloc(alloc); + defer Cli.programArgsFree(alloc, args); + + const parsed = config.parse(program_name, args[1..]); + + std.debug.print("--flag: {any}\n", .{parsed.flag}); + std.debug.print("--str: {?s}\n", .{parsed.str}); + std.debug.print("--enum: {any}\n", .{parsed.@"enum"}); + std.debug.print("positionals({d}):\n", .{parsed.positionals.len}); + + // if using '.positionals = .unlimited' + // var it = parsed.positionals.iterator(args[1..]); + // while (it.next()) |item| { + // std.debug.print(" - {s}\n", .{item}); + // } + + for (parsed.positionals.slice()) |item| { + std.debug.print(" - {s}\n", .{item}); + } +} + +const Cli = @This(); +const std = @import("std"); +const mem = std.mem; +const Type = std.builtin.Type; diff --git a/src/cli.zig b/src/cli.zig deleted file mode 100644 index 39ee68fcf7bf3361b25ee74cb1c27923ad37e1df..0000000000000000000000000000000000000000 --- a/src/cli.zig +++ /dev/null @@ -1,659 +0,0 @@ -//! Configuration for an command line interface, which can be executed against -//! an argument slice using `.parse`. None of the parsing strategies require an -//! allocator, however it expects incoming arguments to be UTF-8 formatted which -//! on Windows will require an allocator. -//! -//! See `fn main` for a basic usage example. -options: []const Option = &.{}, -positionals: Positionals = .none, -help: HelpConfig = .{}, - -pub const Option = struct { - /// Should be formatted as the flag name: '--dump-ir' - name: []const u8, - /// Displayed in generated help menu - desc: []const u8, - kind: Kind = .boolean, - /// A short code allows single dash argument parsing. - short: ?u8 = null, - - const Kind = union(enum) { - /// UTF-8 validated - string, - boolean, - @"enum": type, - - pub fn Type(k: Kind) type { - return switch (k) { - .string => []const u8, - .boolean => bool, - .@"enum" => |t| t, - }; - } - }; - - pub fn optional(comptime cond: bool, opts: []const Option) []const Option { - return if (cond) opts else &.{}; - } - - const help: Option = .{ - .name = "--help", - .short = 'h', - .desc = "Print this help and exit", - }; -}; - -pub const Positionals = union(enum) { - none, - /// Store a single positional: ?[]const u8 - single, - /// Store the start and count of positionals. Call - /// 'parsed.positionals.iterator(args)' with the - /// original argument array to read positionals. - unlimited, - /// Same strategy as .unlimited, with a limit - limited: usize, - /// Use a bounded array to store positionals, using - /// a stack allocated buffer that can hold enough - /// '[]const u8's as allowed by the bound. - bounded: usize, - - pub fn Type(p: Positionals) type { - return switch (p) { - .none => void, - .single => ?[]const u8, - .limited => |limit| switch (limit) { - 0 => void, - else => Multi, - }, - .unlimited => Multi, - .bounded => |n| std.BoundedArray([]const u8, n), - }; - } - - pub inline fn maxCount(p: Positionals) usize { - return switch (p) { - .none => 0, - .single => 1, - inline .limited, .bounded => |limit| limit, - .unlimited => std.math.maxInt(usize), - }; - } - - pub const Multi = struct { - /// The index of the first positional - first: usize, - /// The number of total positionals. This can - /// be used to pre-allocate resources. - len: usize, - - first_is_after_double_dash: bool, - - pub const empty = .{ - .first = 0, - .len = 0, - .first_is_after_double_dash = false, - }; - - pub fn iterator(pos: @This(), all_args: []const []const u8) PositionalIterator { - return .{ - .args = all_args, - .i = pos.first, - .left = pos.len, - .after_double_dash = pos.first_is_after_double_dash, - }; - } - }; -}; - -pub const HelpConfig = struct { - /// If set true, registers an addition '--help' / '-h' flag - /// These will show the help menu and exit the program. - /// - /// Help menu can always be displayed manually with .showHelp() - option: bool = true, - - /// Override the usage string in the first line. - usage: ?[]const u8 = null, - - /// Hide a list of flag names. - hide_options: []const []const u8 = &.{}, -}; - -pub const PositionalIterator = struct { - args: []const []const u8, - i: usize, - left: usize, - after_double_dash: bool, - - pub fn peek(it: PositionalIterator) ?[]const u8 { - return if (it.left > 0) it.args[it.i] else null; - } - - pub fn next(it: *PositionalIterator) ?[]const u8 { - if (it.left > 0) { - defer it.advance(); - return it.args[it.i]; - } else { - return null; - } - } - - pub fn advance(it: *PositionalIterator) void { - it.left -= 1; - if (it.left == 0) return; - if (it.after_double_dash) { - it.i += 1; - } else while (mem.startsWith(u8, it.args[it.i], "-")) { - defer it.i += 1; - if (it.args[it.i].len == 2 and it.args[it.i][1] == '-') { - it.after_double_dash = true; - return; - } - } - } -}; - -inline fn fieldName(comptime name: []const u8) [:0]const u8 { - std.debug.assert(name[0] == '-' and name[1] == '-'); - for (name[2..]) |char| switch (char) { - 'a'...'z', '-' => {}, - else => |ch| @compileError(std.fmt.comptimePrint("Unexpected char '{c}' in option name '{s}'", .{ ch, name })), - }; - - const replacement_size = mem.replacementSize(u8, name[2..], "-", "_"); - const replaced = brk: { - var buf: [replacement_size:0]u8 = undefined; - _ = mem.replace(u8, name[2..], "-", "_", &buf); - break :brk buf; - }; - return &replaced; -} - -/// Every option name is converted into snake case, then -/// an additional field name 'positionals' is added. -pub fn Parsed(p: Cli) type { - const fields = brk: { - var fields: [p.options.len + 1]Type.StructField = undefined; - - for (p.options, fields[0..p.options.len]) |def, *field| { - const Wrapped = ?def.kind.Type(); - field.* = .{ - .name = fieldName(def.name), - .alignment = @alignOf(Wrapped), - .default_value = &@as(Wrapped, null), - .is_comptime = false, - .type = Wrapped, - }; - } - - const Pos = p.positionals.Type(); - fields[fields.len - 1] = .{ - .name = "positionals", - .alignment = @alignOf(Pos), - .type = Pos, - .default_value = &@as(Pos, switch (p.positionals) { - .none => {}, - .single => null, - .limited, .unlimited => .empty, - .bounded => .{}, - }), - .is_comptime = false, - }; - - break :brk fields; - }; - - return @Type(.{ .@"struct" = .{ - .layout = .auto, - .fields = &fields, - .decls = &.{}, - .is_tuple = false, - } }); -} - -const StdErrWriter = struct { - file: std.fs.File, - buffered_writer: std.io.BufferedWriter(4096, std.fs.File.Writer), - tty: std.io.tty.Config, - writer_ptr: std.io.BufferedWriter(4096, std.fs.File.Writer).Writer, - - pub fn init() StdErrWriter { - const stderr = std.io.getStdErr(); - const tty = std.io.tty.detectConfig(stderr); - return .{ - .file = stderr, - .tty = tty, - .buffered_writer = std.io.bufferedWriter(stderr.writer()), - .writer_ptr = undefined, - }; - } - - pub fn get(w: *StdErrWriter) std.io.AnyWriter { - if (@import("builtin").os.tag == .windows and w.tty == .windows_api) { - // all data must be flushed before changing colors using - // the Windows API. thankfully, modern windows terminals - // will not hit this path. - return w.buffered_writer.unbuffered_writer.any(); - } else { - w.writer_ptr = w.buffered_writer.writer(); // uaf unless stored - return w.writer_ptr.any(); - } - } -}; - -const ParseError = error{ InvalidArguments, DisplayedHelpMenu }; - -/// This will quit the program with usage information if invalid arguments are -/// received and the `--help` option is enabled. This is the most common use -/// case. If manual error handling is desired, `.parseWithErrorWriter` can -/// be used instead. -pub fn parse( - comptime p: Cli, - program_name: []const u8, - args: []const []const u8, -) p.Parsed() { - var stderr = StdErrWriter.init(); - defer stderr.buffered_writer.flush() catch {}; - return parseWithErrorWriter( - p, - program_name, - args, - stderr.get(), - stderr.tty, - true, - true, - ) catch |e| switch (e) { - error.InvalidArguments, - error.DisplayedHelpMenu, - => { - stderr.buffered_writer.flush() catch {}; - std.process.exit(1); - }, - }; -} - -pub fn parseWithErrorWriter( - comptime p: Cli, - program_name: []const u8, - args: []const []const u8, - err: std.io.AnyWriter, - tty: std.io.tty.Config, - comptime show_help_on_error: bool, - comptime exit_on_error: bool, -) ParseError!p.Parsed() { - var out: p.Parsed() = .{}; - var had_errors = false; - var positionals_counted: usize = 0; - var first_positional: usize = 0; - var double_dash: ?usize = null; - - var show_help = false; - - const max_positionals = p.positionals.maxCount(); - - outer: for (args, 0..) |arg, i| { - if (mem.startsWith(u8, arg, "--")) { - if (arg.len == 2) { - if (max_positionals == 0) { - writeError(err, tty); - err.writeAll("unexpected end-of-parameter mark: --\n") catch {}; - } - - double_dash = i; - const remain = args[i + 1 ..]; - if (positionals_counted == 0 and remain.len > 0) - first_positional = i + 1; - - if (p.positionals == .bounded) { - out.positionals.appendSliceAssumeCapacity(remain[0..@min(remain.len, max_positionals -| positionals_counted)]); - } - - positionals_counted += remain.len; - - if (positionals_counted > max_positionals) { - had_errors = true; - const diff = @min(remain.len, positionals_counted - max_positionals); - for (remain[remain.len - diff ..]) |pos| { - writeError(err, tty); - err.print("unrecognized argument '{s}'\n", .{pos}) catch {}; - } - } - - break; - } - - inline for (p.options) |def| try_this_def: { - const prefix = def.name; - const kind = def.kind; - const dest = &@field(out, fieldName(def.name)); - - const is_blank = arg.len == prefix.len; - if (!(mem.startsWith(u8, arg[2..], prefix[2..]) and (is_blank or arg[prefix.len] == '='))) - break :try_this_def; - - if (kind != .boolean and is_blank) { - had_errors = true; - writeError(err, tty); - err.print(prefix ++ " requires a value\n", .{}) catch {}; - continue :outer; - } - - const value = if (is_blank) "" else arg[prefix.len + 1 ..]; - switch (kind) { - .boolean => if (value.len == 0) { - dest.* = true; - } else if (mem.eql(u8, value, "false")) { - dest.* = false; - } else if (mem.eql(u8, value, "true")) { - dest.* = true; - } else { - had_errors = true; - writeError(err, tty); - err.print(prefix ++ " only accepts true or false, got '{s}'\n", .{ - arg[prefix.len + 1 ..], - }) catch {}; - }, - - .string => if (std.unicode.utf8ValidateSlice(value)) { - dest.* = value; - } else { - had_errors = true; - writeError(err, tty); - err.print(prefix ++ " requires valid UTF-8 input\n", .{}) catch {}; - }, - - .@"enum" => |E| { - dest.* = std.meta.stringToEnum(E, value) orelse { - had_errors = true; - const values_list = comptime brk: { - var str: []const u8 = ""; - for (std.enums.values(E)) |val| { - str = str ++ " " ++ @tagName(val) ++ "\n"; - } - break :brk str; - }; - writeError(err, tty); - err.print(prefix ++ " must be one of the following values, got {s}:\n{s}", .{ - arg[prefix.len + 1 ..], - values_list, - }) catch {}; - continue :outer; - }; - }, - } - - continue :outer; - } - - if (p.help.option) { - if (mem.startsWith(u8, arg[2..], "help") and (arg.len == "--help".len or arg["--help".len] == '=')) { - show_help = true; - if (arg.len > "--help".len) { - had_errors = true; - err.print("--help does not take a value\n", .{}) catch {}; - } - continue :outer; - } - } - - had_errors = true; - writeError(err, tty); - err.print("unrecognized argument '{s}'\n", .{arg}) catch {}; - } else if (arg.len > 1 and arg[0] == '-') { - per_char_in_short: for (arg[1..]) |ch| { - inline for (p.options) |def| try_this_def: { - const short = def.short orelse break :try_this_def; - if (def.kind != .boolean) { - @compileError("TODO: non-boolean shorthand arguments"); - } - if (ch == short) { - const dest = &@field(out, fieldName(def.name)); - dest.* = true; - continue :per_char_in_short; - } - if (p.help.option and ch == 'h') { - show_help = true; - } - } - } - } else { - if (positionals_counted == 0) first_positional = i; - positionals_counted += 1; - if (positionals_counted > max_positionals) { - had_errors = true; - writeError(err, tty); - err.print("unrecognized argument '{s}'\n", .{arg}) catch {}; - } else if (p.positionals == .bounded) { - out.positionals.appendAssumeCapacity(arg); - } - } - } - - if (had_errors) { - if (positionals_counted > max_positionals) { - writeNote(err, tty); - err.writeAll(switch (max_positionals) { - 0 => "this program does not take positional arguments\n", - 1 => "this program accepts one positional argument\n", - else => |n| std.fmt.comptimePrint( - "this program accepts up to {d} positional arguments\n", - .{n}, - ), - }) catch {}; - } - if (show_help or show_help_on_error) { - p.showHelpWithWriter(program_name, err, tty); - } - if (exit_on_error) { - return error.DisplayedHelpMenu; - } - return error.InvalidArguments; - } - - if (show_help) { - p.showHelpWithWriter(program_name, err, tty); - return error.DisplayedHelpMenu; - } - - out.positionals = switch (p.positionals) { - .none => {}, - .single => if (positionals_counted > 0) - args[first_positional] - else - null, - .bounded => out.positionals, // already correct - .unlimited, .limited => if (max_positionals > 0) .{ - .first = first_positional, - .len = positionals_counted, - .first_is_after_double_dash = if (double_dash) |dd| - dd < first_positional - else - false, - }, - }; - - return out; -} - -pub fn showHelp(comptime p: Cli, program_name: []const u8) void { - var stderr = StdErrWriter.init(); - defer stderr.buffered_writer.flush() catch {}; - return showHelpWithWriter( - p, - program_name, - stderr.get(), - stderr.tty, - ); -} - -pub fn showHelpWithWriter( - comptime p: Cli, - program_name: []const u8, - w: std.io.AnyWriter, - tty: std.io.tty.Config, -) void { - const usage = p.help.usage orelse default: { - const opts_usage = if (p.options.len > 0 or p.help.option) "[options...]" else ""; - break :default switch (p.positionals.maxCount()) { - 0 => opts_usage, - 1 => mem.trimLeft(u8, opts_usage ++ " [argument]", " "), - else => mem.trimLeft(u8, opts_usage ++ " [args...]", " "), - }; - }; - - w.print("usage: {s} {s}\n", .{ program_name, usage }) catch return; - - if (p.options.len > 0 or p.help.option) { - w.writeAll("\n") catch return; - tty.setColor(w, .bold) catch return; - w.writeAll("options:") catch return; - tty.setColor(w, .reset) catch return; - w.writeAll(comptime brk: { - var width = 0; - var any_short_options = false; - - const all_opts = if (p.help.option) - p.options ++ [_]Option{.help} - else - p.options; - for (all_opts) |opt| { - width = @max(width, opt.name.len); - any_short_options = any_short_options or opt.short != null; - } - - width += " ".len; - if (any_short_options) width += "-h ".len; - const sub = if (any_short_options) 4 else 1; - - var out: []const u8 = "\n"; - for (all_opts) |opt| { - out = out ++ " "; - if (any_short_options) { - out = out ++ if (opt.short) |s| [_]u8{ '-', s, ' ' } else " "; - } - out = out ++ opt.name ++ (" " ** (width - opt.name.len - sub)); - // TODO: wrapping at 72 columns - out = out ++ opt.desc ++ "\n"; - } - break :brk out ++ "\n"; - }) catch return; - } -} - -pub fn showHelpAndExit(comptime p: Cli, program_name: []const u8) noreturn { - p.showHelp(program_name); - std.process.exit(1); -} - -fn writeError(err: std.io.AnyWriter, tty: std.io.tty.Config) void { - tty.setColor(err, .red) catch return; - tty.setColor(err, .bold) catch return; - err.writeAll("error") catch return; - tty.setColor(err, .reset) catch return; - tty.setColor(err, .dim) catch return; - err.writeAll(": ") catch return; - tty.setColor(err, .reset) catch return; -} - -fn writeNote(err: std.io.AnyWriter, tty: std.io.tty.Config) void { - tty.setColor(err, .blue) catch return; - tty.setColor(err, .bold) catch return; - err.writeAll("note") catch return; - tty.setColor(err, .reset) catch return; - tty.setColor(err, .dim) catch return; - err.writeAll(": ") catch return; - tty.setColor(err, .reset) catch return; -} - -/// Splits args[0] containing the program name -/// -/// const program_name, const args = programArgsAlloc(); -pub fn programArgsAlloc(allocator: std.mem.Allocator, fallback_name: []const u8) !struct { []const u8, [][:0]u8 } { - const magic_number = @alignOf([*][:0]u8); - var raw_args = try std.process.argsAlloc(allocator); - switch (raw_args.len) { - 0 => { - raw_args.ptr = @ptrFromInt(magic_number); - return .{ fallback_name, raw_args }; - }, - else => return .{ - std.fs.path.basename(raw_args[0]), - raw_args[1..], - }, - } -} - -pub fn programArgsFree(allocator: std.mem.Allocator, args: [][:0]u8) void { - const magic_number = @alignOf([*][:0]u8); - if (args.len == 0 and @intFromPtr(args.ptr) == magic_number) return; - const raw_args = (args.ptr - 1)[0 .. args.len + 1]; - std.process.argsFree(allocator, raw_args); -} - -pub fn fatalWithHelp(p: Cli, program_name: []const u8, comptime fmt: []const u8, args: anytype) noreturn { - print: { - const stderr = std.io.getStdErr(); - const tty = std.io.tty.detectConfig(stderr); - var bufw = std.io.bufferedWriter(stderr.writer()); - writeError(bufw.writer().any(), tty); - bufw.writer().print(fmt ++ "\n", args) catch break :print; - p.showHelpWithWriter(program_name, bufw.writer().any(), tty); - bufw.flush() catch break :print; - } - std.process.exit(1); -} - -pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn { - const stderr = std.io.getStdErr(); - const tty = std.io.tty.detectConfig(stderr); - var bufw = std.io.bufferedWriter(stderr.writer()); - writeError(bufw.writer().any(), tty); - bufw.writer().print(fmt ++ "\n", args); - bufw.flush() catch {}; // ignoring I/O errors - std.process.exit(1); -} - -pub fn main() !void { - var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); - - const config: Cli = .{ - .options = &.{ - .{ .name = "--flag", .desc = "check for something" }, - .{ .name = "--str", .desc = "pass a string", .kind = .string }, - .{ - .name = "--enum", - .desc = "enum", - .kind = .{ .@"enum" = std.builtin.OptimizeMode }, - }, - }, - .positionals = .{ .bounded = 2 }, - }; - - const program_name, const args = try Cli.programArgsAlloc(alloc); - defer Cli.programArgsFree(alloc, args); - - const parsed = config.parse(program_name, args[1..]); - - std.debug.print("--flag: {any}\n", .{parsed.flag}); - std.debug.print("--str: {?s}\n", .{parsed.str}); - std.debug.print("--enum: {any}\n", .{parsed.@"enum"}); - std.debug.print("positionals({d}):\n", .{parsed.positionals.len}); - - // if using '.positionals = .unlimited' - // var it = parsed.positionals.iterator(args[1..]); - // while (it.next()) |item| { - // std.debug.print(" - {s}\n", .{item}); - // } - - for (parsed.positionals.slice()) |item| { - std.debug.print(" - {s}\n", .{item}); - } -} - -const Cli = @This(); -const std = @import("std"); -const mem = std.mem; -const Type = std.builtin.Type; diff --git a/src/cli_compiler.zig b/src/cli_compiler.zig index 4e1a6a189acbea0aa9b2d29bd0f9e23f5845872e..55374767c5dd3648247599a0d1f820849206d0c2 100644 --- a/src/cli_compiler.zig +++ b/src/cli_compiler.zig @@ -8,6 +8,7 @@ const arg_config: Cli = .{ .{ .short = 'i', .name = "--dump-ir", .desc = "Dump intermediate representations" }, .{ .name = "--dump-state", .desc = "Dump compiler state to JSON", .kind = .string }, .{ .short = 'r', .name = "--report-trace", .desc = "Print stack traces to errors" }, + .{ .short = 't', .name = "--tests", .desc = "Build unit tests instead of the main function" }, .{ .short = 'L', .name = "--no-leak-checks", .desc = "" }, }, .positionals = .single,