src/cli/flags.zig
Ref: Size: 24.3 KiB History
//! A flag parser whose schema is a struct: each field name determines the flag
//! spelling and each field type determines whether the flag takes a value.
//! The grammar is `--flag VALUE`, with `--` ending flag parsing. Semantic
//! validation remains with the caller or with a field type that implements
//! `parseCLI`.
// Rationale: `/bin/sh` is the fallback executable name passed as
// argv[0], not a command string interpreted with `sh -c`.
const std = @import("std");
pub const ParseOutcome = union(enum) {
ok,
/// `--help` or `-h`, wherever it sits: the caller prints its usage to
/// stdout and exits 0.
help,
/// `--version`, wherever it sits: the caller prints its version to
/// stdout and exits 0.
version,
unknown_arg: []const u8,
/// A value-taking flag at the end of argv, with nothing left to consume.
missing_value: []const u8,
/// The field's type would not hold the word after the flag.
bad_value: []const u8,
};
/// The simplified outcomes returned by `parseStrict`: invalid syntax, a help
/// request, or a version request.
pub const ParseError = error{ Usage, Help, Version };
/// `parse` as an error union, for the callers that want a `try` instead of
/// a five-arm switch each.
pub fn parseStrict(comptime T: type, dst: *T, args: []const [:0]const u8) ParseError!void {
switch (parse(T, dst, args)) {
.ok => {},
.help => return error.Help,
.version => return error.Version,
.unknown_arg, .missing_value, .bad_value => return error.Usage,
}
}
/// Print the appropriate response for a `parseStrict` error and return its exit
/// code. Callers handle mode-specific errors before calling this function.
pub fn exitFor(e: ParseError, usage: []const u8, prog: []const u8, ver: []const u8) u8 {
return exitForTo(e, usage, prog, ver, std.posix.STDOUT_FILENO, std.posix.STDERR_FILENO);
}
/// `exitFor` with explicit output descriptors, allowing tests to verify which
/// stream each response uses.
pub fn exitForTo(
e: ParseError,
usage: []const u8,
prog: []const u8,
ver: []const u8,
out: std.posix.fd_t,
err: std.posix.fd_t,
) u8 {
return switch (e) {
error.Help => helpTo(out, usage),
error.Version => versionTo(out, prog, ver),
// Usage errors are diagnostics; help and version are requested output.
error.Usage => blk: {
writeTo(err, usage);
break :blk 2;
},
};
}
/// An optional field is its child type: null is a default, not an arity.
fn Bare(comptime F: type) type {
return if (@typeInfo(F) == .optional) @typeInfo(F).optional.child else F;
}
/// Parse flags into `dst`. Repeated flags use the last value. Bare words are
/// passed to `T.positional` when present, and otherwise are unknown arguments.
/// Unknown flag-shaped words are passed to `T.extra`, which returns the number
/// of arguments it consumed or zero to reject the flag.
pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) ParseOutcome {
const fields = @typeInfo(T).@"struct".fields;
comptime for (fields) |f| {
if (f.name[0] == '_') continue;
const B = Bare(f.type);
// Struct fields take one value only when their type implements
// `parseCLI`; invalid values return `error.Invalid`.
const owns = @typeInfo(B) == .@"struct" and @hasDecl(B, "parseCLI");
if (B != bool and B != []const u8 and @typeInfo(B) != .int and !owns)
@compileError("cliflags: no arity for field `" ++ f.name ++ "`: " ++ @typeName(f.type));
};
if (@hasDecl(T, "aliases")) comptime for (T.aliases) |pair| {
if (!@hasField(T, pair[1]))
@compileError("cliflags: alias `" ++ pair[0] ++ "` names no field `" ++ pair[1] ++ "`");
};
// Help and version take precedence over syntax errors, even when they occur
// where a value would otherwise be required. Scanning stops at `--`, after
// which words are payload; for example, `mux a send -- --help` sends the
// literal text `--help` to the session.
for (args) |a| {
if (std.mem.eql(u8, a, "--")) break;
if (isHelp(a)) return .help;
if (isVersion(a)) return .version;
}
var i: usize = 0;
var payload = false;
while (i < args.len) : (i += 1) {
const a = args[i];
if (!payload and std.mem.eql(u8, a, "--")) {
payload = true;
continue;
}
var known = false;
if (!payload) inline for (fields) |f| {
const hit = f.name[0] != '_' and !known and
(std.mem.eql(u8, a, comptime flagName(f.name)) or aliasHit(T, f.name, a));
if (hit) {
known = true;
const B = Bare(f.type);
if (B == bool) {
@field(dst, f.name) = true;
} else {
if (i + 1 >= args.len) return .{ .missing_value = a };
i += 1;
@field(dst, f.name) = switch (@typeInfo(B)) {
.int => std.fmt.parseInt(B, args[i], 10) catch return .{ .bad_value = a },
.@"struct" => B.parseCLI(args[i]) catch return .{ .bad_value = a },
else => args[i],
};
}
}
};
if (!known) {
// Before `--`, only words without a leading dash are positional.
// After `--`, every word is positional. A hook that returns false
// leaves the word as an unknown argument.
if ((payload or (a.len > 0 and a[0] != '-')) and @hasDecl(T, "positional")) {
if (dst.positional(a)) continue;
}
// `T.extra` handles flag grammars that cannot be represented by
// fields, such as a two-word `--sock PATH` target. Returning zero
// leaves the flag as an unknown argument.
if (!payload and a.len > 0 and a[0] == '-' and @hasDecl(T, "extra")) {
const n = dst.extra(args[i..]);
if (n > 0) {
i += n - 1;
continue;
}
}
return .{ .unknown_arg = a };
}
}
return .ok;
}
/// An alias is a second spelling of one field, so it carries no arity of
/// its own: `-A` is `--agent` because `agent` is the field it names.
fn aliasHit(comptime T: type, comptime field: []const u8, a: []const u8) bool {
if (!@hasDecl(T, "aliases")) return false;
inline for (T.aliases) |pair| {
if (comptime !std.mem.eql(u8, pair[1], field)) continue;
if (std.mem.eql(u8, pair[0], a)) return true;
}
return false;
}
pub fn isHelp(a: []const u8) bool {
return std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h");
}
pub fn isVersion(a: []const u8) bool {
return std.mem.eql(u8, a, "--version");
}
pub fn help(usage: []const u8) u8 {
return helpTo(std.posix.STDOUT_FILENO, usage);
}
fn helpTo(fd: std.posix.fd_t, usage: []const u8) u8 {
// Requested help and version text goes to stdout so it can be piped. Usage
// errors go to stderr in `exitForTo`.
writeTo(fd, usage);
return 0;
}
pub fn version(prog: []const u8, ver: []const u8) u8 {
return versionTo(std.posix.STDOUT_FILENO, prog, ver);
}
fn versionTo(fd: std.posix.fd_t, prog: []const u8, ver: []const u8) u8 {
// Both strings are the caller's: cliflags imports nothing at all, and
// that includes build_options, where the version it prints lives.
var buf: [64]u8 = undefined;
writeTo(fd, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ prog, ver }) catch unreachable);
return 0;
}
/// Retry short writes until all bytes are written or the descriptor fails.
fn writeTo(fd: std.posix.fd_t, bytes: []const u8) void {
var off: usize = 0;
while (off < bytes.len) off += std.posix.write(fd, bytes[off..]) catch return;
}
pub fn flagName(comptime field: []const u8) []const u8 {
comptime var name: []const u8 = "--";
inline for (field) |c| name = name ++ [_]u8{if (c == '_') '-' else c};
return name;
}
/// Match a complete flag name, so documenting `--socket` does not satisfy a
/// check for `--sock`.
pub fn documented(name: []const u8, usage: []const u8) bool {
var at: usize = 0;
while (std.mem.indexOfPos(u8, usage, at, name)) |i| : (at = i + 1) {
const end = i + name.len;
if (end == usage.len) return true;
switch (usage[end]) {
' ', '\n', ']' => return true,
else => {},
}
}
return false;
}
/// A documented alias, such as `-A`, also documents its underlying field.
fn documentedField(comptime T: type, comptime field: []const u8, comptime usage: []const u8) bool {
if (documented(flagName(field), usage)) return true;
if (@hasDecl(T, "aliases")) {
for (T.aliases) |pair| {
if (std.mem.eql(u8, pair[1], field) and documented(pair[0], usage)) return true;
}
}
return false;
}
/// Fail compilation when a visible field has no corresponding flag in the
/// usage text. This checks fields against prose, but does not detect prose that
/// names a nonexistent field.
pub fn assertDocumented(comptime T: type, comptime usage: []const u8, comptime hidden: []const []const u8) void {
@setEvalBranchQuota(200_000);
comptime for (@typeInfo(T).@"struct".fields) |f| {
if (f.name[0] == '_') continue;
var skip = false;
for (hidden) |h| {
if (std.mem.eql(u8, h, f.name)) skip = true;
}
if (skip) continue;
if (!documentedField(T, f.name, usage))
@compileError("cliflags: usage text never names " ++ flagName(f.name));
};
}
const DemoOptions = struct {
_cmd: u8 = 0,
vt: bool = false,
sock: ?[]const u8 = null,
// folder rule 5 exemption: This is the fallback shell executable, invoked directly as argv.
shell: []const u8 = "/bin/sh",
cols: u16 = 80,
quic_idle_ms: u32 = 15_000,
};
test "parse: a bool field is a bare flag, a string field takes the next word, an integer field parses it" {
var o: DemoOptions = .{};
const args: []const [:0]const u8 = &.{ "--vt", "--sock", "/tmp/x.sock", "--shell", "/bin/dash", "--cols", "120" };
try std.testing.expect(parse(DemoOptions, &o, args) == .ok);
try std.testing.expect(o.vt);
try std.testing.expectEqualStrings("/tmp/x.sock", o.sock.?);
try std.testing.expectEqualStrings("/bin/dash", o.shell);
try std.testing.expectEqual(@as(u16, 120), o.cols);
// Untouched fields keep the struct's own defaults.
var d: DemoOptions = .{};
try std.testing.expect(parse(DemoOptions, &d, &.{}) == .ok);
try std.testing.expect(!d.vt);
try std.testing.expect(d.sock == null);
try std.testing.expectEqual(@as(u32, 15_000), d.quic_idle_ms);
}
test "parse: a value flag at the end of argv is missing_value, not unknown_arg" {
var o: DemoOptions = .{};
const r = parse(DemoOptions, &o, &.{"--sock"});
try std.testing.expect(r == .missing_value);
try std.testing.expectEqualStrings("--sock", r.missing_value);
}
test "parse: an unknown flag names itself" {
var o: DemoOptions = .{};
const r = parse(DemoOptions, &o, &.{ "--vt", "--wat" });
try std.testing.expect(r == .unknown_arg);
try std.testing.expectEqualStrings("--wat", r.unknown_arg);
// A bare word is a mistake too: there are no positional arguments here.
var p: DemoOptions = .{};
try std.testing.expect(parse(DemoOptions, &p, &.{"run"}) == .unknown_arg);
}
test "parse: --help and -h are the help outcome, before or after other flags" {
var o: DemoOptions = .{};
try std.testing.expect(parse(DemoOptions, &o, &.{"--help"}) == .help);
try std.testing.expect(parse(DemoOptions, &o, &.{"-h"}) == .help);
try std.testing.expect(parse(DemoOptions, &o, &.{ "--vt", "--help" }) == .help);
try std.testing.expect(parse(DemoOptions, &o, &.{ "--help", "--wat" }) == .help);
// Even where a value would be read: help outranks the grammar.
try std.testing.expect(parse(DemoOptions, &o, &.{ "--sock", "--help" }) == .help);
}
test "parse: a leading-underscore field is not a flag" {
var o: DemoOptions = .{};
const r = parse(DemoOptions, &o, &.{ "--cmd", "run" });
try std.testing.expect(r == .unknown_arg);
try std.testing.expectEqualStrings("--cmd", r.unknown_arg);
try std.testing.expectEqual(@as(u8, 0), o._cmd);
}
test "parse: a flag given twice, the last wins" {
var o: DemoOptions = .{};
try std.testing.expect(parse(DemoOptions, &o, &.{ "--cols", "100", "--cols", "42" }) == .ok);
try std.testing.expectEqual(@as(u16, 42), o.cols);
}
test "parse: a value rejected by the field type is bad_value naming the flag" {
var o: DemoOptions = .{};
const r = parse(DemoOptions, &o, &.{ "--cols", "wide" });
try std.testing.expect(r == .bad_value);
try std.testing.expectEqualStrings("--cols", r.bad_value);
// Out of the field's range, and negative into an unsigned, are the same
// mistake: the flag cannot hold what was typed.
var p: DemoOptions = .{};
try std.testing.expect(parse(DemoOptions, &p, &.{ "--cols", "99999" }) == .bad_value);
try std.testing.expect(parse(DemoOptions, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_value);
}
test "parseStrict: syntax errors become Usage; help and version remain distinct" {
var o: DemoOptions = .{};
try parseStrict(DemoOptions, &o, &.{ "--vt", "--cols", "120" });
try std.testing.expect(o.vt);
try std.testing.expectEqual(@as(u16, 120), o.cols);
try std.testing.expectError(error.Usage, parseStrict(DemoOptions, &o, &.{"--wat"}));
try std.testing.expectError(error.Usage, parseStrict(DemoOptions, &o, &.{"--sock"}));
try std.testing.expectError(error.Usage, parseStrict(DemoOptions, &o, &.{ "--cols", "wide" }));
try std.testing.expectError(error.Help, parseStrict(DemoOptions, &o, &.{"-h"}));
try std.testing.expectError(error.Version, parseStrict(DemoOptions, &o, &.{"--version"}));
}
test "exitFor: requested output uses stdout rc 0; usage errors use stderr rc 2" {
// Capture explicit descriptors so the test verifies stream selection as
// well as content. This matters to `mux a`, whose stdout must contain only
// its single JSON response.
const Run = struct {
out: []const u8,
err: []const u8,
rc: u8,
fn of(e: ParseError, usage: []const u8, ob: []u8, eb: []u8) !@This() {
const o = try std.posix.pipe();
const r = try std.posix.pipe();
const rc = exitForTo(e, usage, "mux", "0", o[1], r[1]);
std.posix.close(o[1]);
std.posix.close(r[1]);
defer std.posix.close(o[0]);
defer std.posix.close(r[0]);
return .{
.out = ob[0..try std.posix.read(o[0], ob)],
.err = eb[0..try std.posix.read(r[0], eb)],
.rc = rc,
};
}
};
var ob: [256]u8 = undefined;
var eb: [256]u8 = undefined;
const page = "usage: demo [--sock PATH]\n";
const refused = try Run.of(error.Usage, page, &ob, &eb);
try std.testing.expectEqualStrings(page, refused.err);
try std.testing.expectEqualStrings("", refused.out);
try std.testing.expectEqual(@as(u8, 2), refused.rc);
const asked = try Run.of(error.Help, page, &ob, &eb);
try std.testing.expectEqualStrings(page, asked.out);
try std.testing.expectEqualStrings("", asked.err);
try std.testing.expectEqual(@as(u8, 0), asked.rc);
const ver = try Run.of(error.Version, page, &ob, &eb);
try std.testing.expectEqualStrings("mux 0\n", ver.out);
try std.testing.expectEqualStrings("", ver.err);
try std.testing.expectEqual(@as(u8, 0), ver.rc);
}
test "flagName: underscores become dashes" {
try std.testing.expectEqualStrings("--quic-idle-ms", flagName("quic_idle_ms"));
try std.testing.expectEqualStrings("--vt", flagName("vt"));
}
test "documented: a prefix of a longer flag does not count" {
try std.testing.expect(!documented("--sock", " mux d start [--socket PATH]\n"));
try std.testing.expect(documented("--sock", " mux d start [--sock PATH]\n"));
try std.testing.expect(documented("--vt", " mux d dump [--vt]\n"));
try std.testing.expect(documented("--vt", " mux d dump --vt\n"));
try std.testing.expect(documented("--vt", " mux d dump --vt"));
try std.testing.expect(!documented("--rows", "nothing here\n"));
}
test "assertDocumented: every visible flag is named in the prose" {
const text =
\\ demo [--vt] [--sock PATH] [--cols N] [--quic-idle-ms N]
\\
;
// `shell` stands in for the daemon's machine-written flags: hidden from the
// prose on purpose, so the assertion must not demand it.
comptime assertDocumented(DemoOptions, text, &.{"shell"});
}
const PositionalOptions = struct {
vt: bool = false,
agent: bool = false,
sock: ?[]const u8 = null,
_host: ?[]const u8 = null,
_refused: bool = false,
pub const aliases = .{ .{ "-A", "agent" }, .{ "-s", "sock" } };
pub fn positional(self: *PositionalOptions, word: []const u8) bool {
if (word.len == 0) return false;
if (word[0] == '!') {
self._refused = true;
return false;
}
self._host = word;
return true;
}
};
test "parse: a bare word goes to the program's positional hook" {
var o: PositionalOptions = .{};
try std.testing.expect(parse(PositionalOptions, &o, &.{ "--vt", "vm1" }) == .ok);
try std.testing.expectEqualStrings("vm1", o._host.?);
// A positional hook that returns false leaves the word as unknown after
// recording that it was inspected.
var r: PositionalOptions = .{};
const bad = parse(PositionalOptions, &r, &.{"!nope"});
try std.testing.expect(bad == .unknown_arg);
try std.testing.expectEqualStrings("!nope", bad.unknown_arg);
try std.testing.expect(r._refused);
// A dashed word is never offered: an unnamed flag is a mistake, and the
// hook must not get the chance to read it as a value.
var d: PositionalOptions = .{};
try std.testing.expect(parse(PositionalOptions, &d, &.{"--wat"}) == .unknown_arg);
try std.testing.expect(d._host == null);
}
test "parse: an alias is the field's flag, with the field's arity" {
var o: PositionalOptions = .{};
try std.testing.expect(parse(PositionalOptions, &o, &.{ "-A", "-s", "/tmp/x.sock" }) == .ok);
try std.testing.expect(o.agent);
try std.testing.expectEqualStrings("/tmp/x.sock", o.sock.?);
// The long spelling still works, and an alias that takes a value is
// missing_value at the end of argv like the flag it stands for.
var l: PositionalOptions = .{};
try std.testing.expect(parse(PositionalOptions, &l, &.{"--agent"}) == .ok);
try std.testing.expect(l.agent);
try std.testing.expect(parse(PositionalOptions, &l, &.{"-s"}) == .missing_value);
// Aliases belong only to the type that declares them.
var demo: DemoOptions = .{};
try std.testing.expect(parse(DemoOptions, &demo, &.{"-A"}) == .unknown_arg);
}
test "parse: --version is its own outcome, wherever it sits" {
var o: DemoOptions = .{};
try std.testing.expect(parse(DemoOptions, &o, &.{"--version"}) == .version);
try std.testing.expect(parse(DemoOptions, &o, &.{ "--vt", "--version" }) == .version);
// Version takes precedence over an adjacent unknown flag or missing value.
try std.testing.expect(parse(DemoOptions, &o, &.{ "--version", "--wat" }) == .version);
try std.testing.expect(parse(DemoOptions, &o, &.{ "--sock", "--version" }) == .version);
}
test "parse: a bare -- ends the flags and every word after it is payload" {
var o: PositionalOptions = .{};
try std.testing.expect(parse(PositionalOptions, &o, &.{ "--vt", "--", "--vt" }) == .ok);
// The flag before the fence was read; the same word after it was not.
try std.testing.expect(o.vt);
try std.testing.expectEqualStrings("--vt", o._host.?);
// A struct with no positional hook has nowhere to put payload, so the
// fence buys it nothing: the word after is still a word it cannot take.
var d: DemoOptions = .{};
const bad = parse(DemoOptions, &d, &.{ "--", "run" });
try std.testing.expect(bad == .unknown_arg);
try std.testing.expectEqualStrings("run", bad.unknown_arg);
}
test "parse: --help after -- is payload, not a request for the usage" {
var o: PositionalOptions = .{};
try std.testing.expect(parse(PositionalOptions, &o, &.{ "--", "--help" }) == .ok);
try std.testing.expectEqualStrings("--help", o._host.?);
var v: PositionalOptions = .{};
try std.testing.expect(parse(PositionalOptions, &v, &.{ "--", "--version" }) == .ok);
try std.testing.expectEqualStrings("--version", v._host.?);
// Before `--`, the same word is still parsed as help.
var b: PositionalOptions = .{};
try std.testing.expect(parse(PositionalOptions, &b, &.{ "--help", "--", "x" }) == .help);
}
const ExtraOptions = struct {
vt: bool = false,
_sock: ?[]const u8 = null,
_seen: ?[]const u8 = null,
/// Model the wall grammar, where `--sock PATH` is one target represented by
/// two command-line arguments. No other flag is accepted by this hook.
pub fn extra(self: *ExtraOptions, rest: []const [:0]const u8) usize {
self._seen = rest[0];
if (!std.mem.eql(u8, rest[0], "--sock")) return 0;
if (rest.len < 2) return 0;
self._sock = rest[1];
return 2;
}
};
test "parse: a flag the table does not own goes to the program's extra hook" {
var o: ExtraOptions = .{};
try std.testing.expect(parse(ExtraOptions, &o, &.{ "--sock", "/tmp/x.sock", "--vt" }) == .ok);
try std.testing.expectEqualStrings("/tmp/x.sock", o._sock.?);
// Parsing resumes after every argument consumed by the hook.
try std.testing.expect(o.vt);
// Returning zero reports the original flag as unknown.
var r: ExtraOptions = .{};
const bad = parse(ExtraOptions, &r, &.{"--wat"});
try std.testing.expect(bad == .unknown_arg);
try std.testing.expectEqualStrings("--wat", bad.unknown_arg);
try std.testing.expectEqualStrings("--wat", r._seen.?);
// Past the fence the hook is not consulted: payload is not a flag.
var p: ExtraOptions = .{};
const fenced = parse(ExtraOptions, &p, &.{ "--", "--sock", "/tmp/y.sock" });
try std.testing.expect(fenced == .unknown_arg);
try std.testing.expect(p._sock == null);
// Without the decl, an unowned flag is unknown as it always was.
var d: DemoOptions = .{};
try std.testing.expect(parse(DemoOptions, &d, &.{"--wat"}) == .unknown_arg);
}
test "assertDocumented: an alias documents its field" {
const text =
\\ demo [--vt] [-A] [--sock PATH]
\\
;
comptime assertDocumented(PositionalOptions, text, &.{});
}
test "parse: a field type with parseCLI validates its value as bad_value" {
// Model `quic.IdleMs`: validation travels with the field type instead of
// being duplicated in each caller.
const Port = struct {
n: u16 = 8080,
pub fn parseCLI(s: []const u8) error{Invalid}!@This() {
const n = std.fmt.parseInt(u16, s, 10) catch return error.Invalid;
if (n == 0) return error.Invalid;
return .{ .n = n };
}
};
const Typed = struct { port: Port = .{}, alt: ?Port = null };
var o: Typed = .{};
try std.testing.expect(parse(Typed, &o, &.{ "--port", "9000", "--alt", "81" }) == .ok);
try std.testing.expectEqual(@as(u16, 9000), o.port.n);
try std.testing.expectEqual(@as(u16, 81), o.alt.?.n);
// Untouched fields retain their declared defaults: the type's value for the
// required field and null for the optional field.
var d: Typed = .{};
try std.testing.expect(parse(Typed, &d, &.{}) == .ok);
try std.testing.expectEqual(@as(u16, 8080), d.port.n);
try std.testing.expect(d.alt == null);
// Zero fits in u16 but fails `parseCLI`; the result identifies the flag
// whose value was invalid.
const r = parse(Typed, &d, &.{ "--port", "0" });
try std.testing.expect(r == .bad_value);
try std.testing.expectEqualStrings("--port", r.bad_value);
try std.testing.expect(parse(Typed, &d, &.{ "--port", "wat" }) == .bad_value);
// A rejected value leaves the field at its default.
try std.testing.expectEqual(@as(u16, 8080), d.port.n);
// It takes one value the way a string field does, so at the end of argv
// it is missing_value and not a bare flag quietly set.
try std.testing.expect(parse(Typed, &d, &.{"--port"}) == .missing_value);
}