694fffd5
refactor: one owner for the parse-outcome switch and the usage exit
a73x 2026-08-29 10:01
Commit message
src/cli/flags.zig
| Old | New | ||
|---|---|---|---|
| @@ -26,6 +26,51 @@ pub const Outcome = union(enum) { | |||
| 26 | bad_value: []const u8, | 26 | bad_value: []const u8, |
| 27 | }; | 27 | }; |
| 28 | 28 | ||
| 29 | /// The three answers a mode's `main` gives for a line that produced no | ||
| 30 | /// options: one refusal for every way of mistyping a flag, because the | ||
| 31 | /// answer to all of them is the same usage page, and the two questions that | ||
| 32 | /// are output rather than diagnostics. | ||
| 33 | pub const ParseError = error{ Usage, Help, Version }; | ||
| 34 | |||
| 35 | /// `parse` as an error union, for the callers that want a `try` instead of | ||
| 36 | /// a five-arm switch each. | ||
| 37 | pub fn parseStrict(comptime T: type, dst: *T, args: []const [:0]const u8) ParseError!void { | ||
| 38 | switch (parse(T, dst, args)) { | ||
| 39 | .ok => {}, | ||
| 40 | .help => return error.Help, | ||
| 41 | .version => return error.Version, | ||
| 42 | .unknown_arg, .missing_value, .bad_value => return error.Usage, | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | /// The exit a mode makes of a `parseStrict` refusal. A mode with a refusal | ||
| 47 | /// of its own answers that one first and hands the rest here. | ||
| 48 | pub fn exitFor(e: ParseError, usage: []const u8, prog: []const u8, ver: []const u8) u8 { | ||
| 49 | return exitForTo(e, usage, prog, ver, std.posix.STDOUT_FILENO, std.posix.STDERR_FILENO); | ||
| 50 | } | ||
| 51 | |||
| 52 | /// `exitFor` against named fds, so a test can assert WHICH fd an arm | ||
| 53 | /// chose, over pipes it owns and the runner does not. | ||
| 54 | pub fn exitForTo( | ||
| 55 | e: ParseError, | ||
| 56 | usage: []const u8, | ||
| 57 | prog: []const u8, | ||
| 58 | ver: []const u8, | ||
| 59 | out: std.posix.fd_t, | ||
| 60 | err: std.posix.fd_t, | ||
| 61 | ) u8 { | ||
| 62 | return switch (e) { | ||
| 63 | error.Help => helpTo(out, usage), | ||
| 64 | error.Version => versionTo(out, prog, ver), | ||
| 65 | // stderr, like every refusal (`helpTo` says why the other two | ||
| 66 | // are not). | ||
| 67 | error.Usage => blk: { | ||
| 68 | writeTo(err, usage); | ||
| 69 | break :blk 2; | ||
| 70 | }, | ||
| 71 | }; | ||
| 72 | } | ||
| 73 | |||
| 29 | /// An optional field is its child type: null is a default, not an arity. | 74 | /// An optional field is its child type: null is a default, not an arity. |
| 30 | fn Bare(comptime F: type) type { | 75 | fn Bare(comptime F: type) type { |
| 31 | return if (@typeInfo(F) == .optional) @typeInfo(F).optional.child else F; | 76 | return if (@typeInfo(F) == .optional) @typeInfo(F).optional.child else F; |
| @@ -138,25 +183,33 @@ pub fn isVersion(a: []const u8) bool { | |||
| 138 | } | 183 | } |
| 139 | 184 | ||
| 140 | pub fn help(usage: []const u8) u8 { | 185 | pub fn help(usage: []const u8) u8 { |
| 186 | return helpTo(std.posix.STDOUT_FILENO, usage); | ||
| 187 | } | ||
| 188 | |||
| 189 | fn helpTo(fd: std.posix.fd_t, usage: []const u8) u8 { | ||
| 141 | // stdout, unlike every refusal: a usage someone ASKED for is output, | 190 | // stdout, unlike every refusal: a usage someone ASKED for is output, |
| 142 | // and they may well have piped it into a pager. version shares both | 191 | // and they may well have piped it into a pager. version shares both |
| 143 | // the rule and the reason. | 192 | // the rule and the reason. |
| 144 | writeOut(usage); | 193 | writeTo(fd, usage); |
| 145 | return 0; | 194 | return 0; |
| 146 | } | 195 | } |
| 147 | 196 | ||
| 148 | pub fn version(prog: []const u8, ver: []const u8) u8 { | 197 | pub fn version(prog: []const u8, ver: []const u8) u8 { |
| 198 | return versionTo(std.posix.STDOUT_FILENO, prog, ver); | ||
| 199 | } | ||
| 200 | |||
| 201 | fn versionTo(fd: std.posix.fd_t, prog: []const u8, ver: []const u8) u8 { | ||
| 149 | // Both strings are the caller's: cliflags is layer 0 and cannot see | 202 | // Both strings are the caller's: cliflags is layer 0 and cannot see |
| 150 | // build_options, where the version it prints lives. | 203 | // build_options, where the version it prints lives. |
| 151 | var buf: [64]u8 = undefined; | 204 | var buf: [64]u8 = undefined; |
| 152 | writeOut(std.fmt.bufPrint(&buf, "{s} {s}\n", .{ prog, ver }) catch unreachable); | 205 | writeTo(fd, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ prog, ver }) catch unreachable); |
| 153 | return 0; | 206 | return 0; |
| 154 | } | 207 | } |
| 155 | 208 | ||
| 156 | /// A short write is not an error: a usage page down a pipe can take two. | 209 | /// A short write is not an error: a usage page down a pipe can take two. |
| 157 | fn writeOut(bytes: []const u8) void { | 210 | fn writeTo(fd: std.posix.fd_t, bytes: []const u8) void { |
| 158 | var off: usize = 0; | 211 | var off: usize = 0; |
| 159 | while (off < bytes.len) off += std.posix.write(std.posix.STDOUT_FILENO, bytes[off..]) catch return; | 212 | while (off < bytes.len) off += std.posix.write(fd, bytes[off..]) catch return; |
| 160 | } | 213 | } |
| 161 | 214 | ||
| 162 | pub fn flagName(comptime field: []const u8) []const u8 { | 215 | pub fn flagName(comptime field: []const u8) []const u8 { |
| @@ -290,6 +343,64 @@ test "parse: a value the field's type refuses is bad_value naming the flag" { | |||
| 290 | try std.testing.expect(parse(Demo, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_value); | 343 | try std.testing.expect(parse(Demo, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_value); |
| 291 | } | 344 | } |
| 292 | 345 | ||
| 346 | test "parseStrict: every way of mistyping a flag is one Usage; help and version keep their own" { | ||
| 347 | var o: Demo = .{}; | ||
| 348 | try parseStrict(Demo, &o, &.{ "--vt", "--cols", "120" }); | ||
| 349 | try std.testing.expect(o.vt); | ||
| 350 | try std.testing.expectEqual(@as(u16, 120), o.cols); | ||
| 351 | |||
| 352 | try std.testing.expectError(error.Usage, parseStrict(Demo, &o, &.{"--wat"})); | ||
| 353 | try std.testing.expectError(error.Usage, parseStrict(Demo, &o, &.{"--sock"})); | ||
| 354 | try std.testing.expectError(error.Usage, parseStrict(Demo, &o, &.{ "--cols", "wide" })); | ||
| 355 | try std.testing.expectError(error.Help, parseStrict(Demo, &o, &.{"-h"})); | ||
| 356 | try std.testing.expectError(error.Version, parseStrict(Demo, &o, &.{"--version"})); | ||
| 357 | } | ||
| 358 | |||
| 359 | test "exitFor: an answer the user asked for is stdout rc 0, a refusal is stderr rc 2" { | ||
| 360 | // Pipes the test owns, never the process's own fds: the fd each arm | ||
| 361 | // picks is the only choice `exitFor` makes, so a run that writes to | ||
| 362 | // fd 1 and looks away asserts nothing. A usage page on stdout would | ||
| 363 | // put a second thing in `mux a`'s one-JSON-object stream. | ||
| 364 | const Run = struct { | ||
| 365 | out: []const u8, | ||
| 366 | err: []const u8, | ||
| 367 | rc: u8, | ||
| 368 | |||
| 369 | fn of(e: ParseError, usage: []const u8, ob: []u8, eb: []u8) !@This() { | ||
| 370 | const o = try std.posix.pipe(); | ||
| 371 | const r = try std.posix.pipe(); | ||
| 372 | const rc = exitForTo(e, usage, "mux", "0", o[1], r[1]); | ||
| 373 | std.posix.close(o[1]); | ||
| 374 | std.posix.close(r[1]); | ||
| 375 | defer std.posix.close(o[0]); | ||
| 376 | defer std.posix.close(r[0]); | ||
| 377 | return .{ | ||
| 378 | .out = ob[0..try std.posix.read(o[0], ob)], | ||
| 379 | .err = eb[0..try std.posix.read(r[0], eb)], | ||
| 380 | .rc = rc, | ||
| 381 | }; | ||
| 382 | } | ||
| 383 | }; | ||
| 384 | var ob: [256]u8 = undefined; | ||
| 385 | var eb: [256]u8 = undefined; | ||
| 386 | const page = "usage: demo [--sock PATH]\n"; | ||
| 387 | |||
| 388 | const refused = try Run.of(error.Usage, page, &ob, &eb); | ||
| 389 | try std.testing.expectEqualStrings(page, refused.err); | ||
| 390 | try std.testing.expectEqualStrings("", refused.out); | ||
| 391 | try std.testing.expectEqual(@as(u8, 2), refused.rc); | ||
| 392 | |||
| 393 | const asked = try Run.of(error.Help, page, &ob, &eb); | ||
| 394 | try std.testing.expectEqualStrings(page, asked.out); | ||
| 395 | try std.testing.expectEqualStrings("", asked.err); | ||
| 396 | try std.testing.expectEqual(@as(u8, 0), asked.rc); | ||
| 397 | |||
| 398 | const ver = try Run.of(error.Version, page, &ob, &eb); | ||
| 399 | try std.testing.expectEqualStrings("mux 0\n", ver.out); | ||
| 400 | try std.testing.expectEqualStrings("", ver.err); | ||
| 401 | try std.testing.expectEqual(@as(u8, 0), ver.rc); | ||
| 402 | } | ||
| 403 | |||
| 293 | test "flagName: underscores become dashes" { | 404 | test "flagName: underscores become dashes" { |
| 294 | try std.testing.expectEqualStrings("--quic-idle-ms", flagName("quic_idle_ms")); | 405 | try std.testing.expectEqualStrings("--quic-idle-ms", flagName("quic_idle_ms")); |
| 295 | try std.testing.expectEqualStrings("--vt", flagName("vt")); | 406 | try std.testing.expectEqualStrings("--vt", flagName("vt")); |
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -100,11 +100,10 @@ const ParseResult = union(enum) { | |||
| 100 | quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false }, | 100 | quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false }, |
| 101 | }; | 101 | }; |
| 102 | 102 | ||
| 103 | /// Help and Version short-circuit the rest of the parse rather than being | 103 | /// Conflict is this mode's own refusal: more than one transport named, a |
| 104 | /// reconciled with it, and main answers both on stdout with an exit 0 — | 104 | /// request that cannot be honoured rather than one to reconcile. The other |
| 105 | /// unlike the two refusals. Conflict is more than one transport named: a | 105 | /// three are `cliflags.exitFor`'s. |
| 106 | /// request that cannot be honoured rather than one to reconcile. | 106 | const ParseError = cliflags.ParseError || error{Conflict}; |
| 107 | const ParseError = error{ Usage, Help, Version, Conflict }; | ||
| 108 | 107 | ||
| 109 | /// `SSH_AGENTC_REQUEST_IDENTITIES` in the ssh-agent framing: a 4-byte | 108 | /// `SSH_AGENTC_REQUEST_IDENTITIES` in the ssh-agent framing: a 4-byte |
| 110 | /// big-endian length, then the message type. `ssh-add -l` sends exactly | 109 | /// big-endian length, then the message type. `ssh-add -l` sends exactly |
| @@ -207,12 +206,7 @@ comptime { | |||
| 207 | 206 | ||
| 208 | fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseError!ParseResult { | 207 | fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseError!ParseResult { |
| 209 | var o: Opts = .{}; | 208 | var o: Opts = .{}; |
| 210 | switch (cliflags.parse(Opts, &o, args[1..])) { | 209 | try cliflags.parseStrict(Opts, &o, args[1..]); |
| 211 | .ok => {}, | ||
| 212 | .help => return error.Help, | ||
| 213 | .version => return error.Version, | ||
| 214 | .unknown_arg, .missing_value, .bad_value => return error.Usage, | ||
| 215 | } | ||
| 216 | 210 | ||
| 217 | // Every pairing is two transports for one session, and two bare words | 211 | // Every pairing is two transports for one session, and two bare words |
| 218 | // are a pairing too — which is why positional counts, not latches. | 212 | // are a pairing too — which is why positional counts, not latches. |
| @@ -267,12 +261,6 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 267 | if (args.len == 1) return wallOfHosts(alloc); | 261 | if (args.len == 1) return wallOfHosts(alloc); |
| 268 | 262 | ||
| 269 | const parsed = parseArgs(args, std.posix.getenv(xdg.key_env)) catch |e| switch (e) { | 263 | const parsed = parseArgs(args, std.posix.getenv(xdg.key_env)) catch |e| switch (e) { |
| 270 | error.Version => return cliflags.version("mux", build_options.version), | ||
| 271 | error.Help => return cliflags.help(usage), | ||
| 272 | error.Usage => { | ||
| 273 | std.debug.print("{s}", .{usage}); | ||
| 274 | return 2; | ||
| 275 | }, | ||
| 276 | error.Conflict => { | 264 | error.Conflict => { |
| 277 | std.debug.print( | 265 | std.debug.print( |
| 278 | "mux: name one transport: HOST, --sock, --via or quic://\n{s}", | 266 | "mux: name one transport: HOST, --sock, --via or quic://\n{s}", |
| @@ -280,6 +268,7 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 280 | ); | 268 | ); |
| 281 | return 2; | 269 | return 2; |
| 282 | }, | 270 | }, |
| 271 | else => |pe| return cliflags.exitFor(pe, usage, "mux", build_options.version), | ||
| 283 | }; | 272 | }; |
| 284 | 273 | ||
| 285 | // `-A` is a promise, and a client with no agent behind it cannot keep | 274 | // `-A` is a promise, and a client with no agent behind it cannot keep |
src/cli/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -91,16 +91,9 @@ comptime { | |||
| 91 | cliflags.assertDocumented(Opts, usage, &.{}); | 91 | cliflags.assertDocumented(Opts, usage, &.{}); |
| 92 | } | 92 | } |
| 93 | 93 | ||
| 94 | const ParseError = error{ Usage, Help, Version }; | 94 | fn parseArgs(args: []const [:0]const u8) cliflags.ParseError!Opts { |
| 95 | |||
| 96 | fn parseArgs(args: []const [:0]const u8) ParseError!Opts { | ||
| 97 | var o: Opts = .{}; | 95 | var o: Opts = .{}; |
| 98 | switch (cliflags.parse(Opts, &o, args[1..])) { | 96 | try cliflags.parseStrict(Opts, &o, args[1..]); |
| 99 | .ok => {}, | ||
| 100 | .help => return error.Help, | ||
| 101 | .version => return error.Version, | ||
| 102 | .unknown_arg, .missing_value, .bad_value => return error.Usage, | ||
| 103 | } | ||
| 104 | if (o._verb == null) return error.Usage; | 97 | if (o._verb == null) return error.Usage; |
| 105 | 98 | ||
| 106 | // Name ONE transport. A `--sock` silently ignored beside a `--quic` | 99 | // Name ONE transport. A `--sock` silently ignored beside a `--quic` |
| @@ -1203,18 +1196,11 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 1203 | defer arena_state.deinit(); | 1196 | defer arena_state.deinit(); |
| 1204 | const alloc = arena_state.allocator(); | 1197 | const alloc = arena_state.allocator(); |
| 1205 | 1198 | ||
| 1206 | const o = parseArgs(args) catch |e| switch (e) { | 1199 | // Help and version go to stdout — the one place this mode otherwise |
| 1207 | // Usage is diagnostic, so it goes to stderr: stdout stays strictly | 1200 | // reserves for JSON, and the only two verbs allowed to spend it. The |
| 1208 | // one JSON object per invocation, even on the argument-error path. | 1201 | // usage is a diagnostic and goes to stderr, so stdout stays strictly one |
| 1209 | error.Usage => { | 1202 | // JSON object per invocation even on the argument-error path. |
| 1210 | proto.writeAllFd(std.posix.STDERR_FILENO, usage) catch {}; | 1203 | const o = parseArgs(args) catch |e| return cliflags.exitFor(e, usage, "mux", build_options.version); |
| 1211 | return 2; | ||
| 1212 | }, | ||
| 1213 | // Help and version go to stdout — the one place this mode otherwise | ||
| 1214 | // reserves for JSON, and the only two verbs allowed to spend it. | ||
| 1215 | error.Help => return cliflags.help(usage), | ||
| 1216 | error.Version => return cliflags.version("mux", build_options.version), | ||
| 1217 | }; | ||
| 1218 | 1204 | ||
| 1219 | // Started BEFORE the connect, not after: over QUIC the handshake is | 1205 | // Started BEFORE the connect, not after: over QUIC the handshake is |
| 1220 | // part of the round trip the caller bounded, and a `--timeout` that | 1206 | // part of the round trip the caller bounded, and a `--timeout` that |
src/cli/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -72,7 +72,7 @@ comptime { | |||
| 72 | /// error: the single errdefer then owns the tile list on every path that | 72 | /// error: the single errdefer then owns the tile list on every path that |
| 73 | /// does not serve. As union arms they wanted a `deinit` beside each | 73 | /// does not serve. As union arms they wanted a `deinit` beside each |
| 74 | /// refusing return, every one of them a chance to forget. | 74 | /// refusing return, every one of them a chance to forget. |
| 75 | const ParseError = error{ Usage, Help, Version } || std.mem.Allocator.Error; | 75 | const ParseError = cliflags.ParseError || std.mem.Allocator.Error; |
| 76 | 76 | ||
| 77 | fn parseArgs( | 77 | fn parseArgs( |
| 78 | alloc: std.mem.Allocator, | 78 | alloc: std.mem.Allocator, |
| @@ -82,7 +82,7 @@ fn parseArgs( | |||
| 82 | var p = Parsed{ ._argv = .{ .alloc = alloc } }; | 82 | var p = Parsed{ ._argv = .{ .alloc = alloc } }; |
| 83 | errdefer p.deinit(); | 83 | errdefer p.deinit(); |
| 84 | 84 | ||
| 85 | const outcome = cliflags.parse(Parsed, &p, args[1..]); | 85 | const outcome = cliflags.parseStrict(Parsed, &p, args[1..]); |
| 86 | // Read before the outcome: a hook that refused for a REASON has already | 86 | // Read before the outcome: a hook that refused for a REASON has already |
| 87 | // named it, and that reason outranks the bare "unknown word" cliflags | 87 | // named it, and that reason outranks the bare "unknown word" cliflags |
| 88 | // saw when the hook said no. The message names the tile — with several | 88 | // saw when the hook said no. The message names the tile — with several |
| @@ -92,12 +92,7 @@ fn parseArgs( | |||
| 92 | std.debug.print("mux web: tile {s}: {s}\n", .{ e.word, wall.reason(e.err) }); | 92 | std.debug.print("mux web: tile {s}: {s}\n", .{ e.word, wall.reason(e.err) }); |
| 93 | return error.Usage; | 93 | return error.Usage; |
| 94 | } | 94 | } |
| 95 | switch (outcome) { | 95 | try outcome; |
| 96 | .ok => {}, | ||
| 97 | .unknown_arg, .missing_value, .bad_value => return error.Usage, | ||
| 98 | .help => return error.Help, | ||
| 99 | .version => return error.Version, | ||
| 100 | } | ||
| 101 | // Port 0 asks the kernel to choose, and the hub prints the port it was | 96 | // Port 0 asks the kernel to choose, and the hub prints the port it was |
| 102 | // asked for as the door to open — a door nobody could find. Refused | 97 | // asked for as the door to open — a door nobody could find. Refused |
| 103 | // like `--quic-idle-ms 0` and for the same reason: the number inverts | 98 | // like `--quic-idle-ms 0` and for the same reason: the number inverts |
| @@ -119,13 +114,8 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 119 | const alloc = gpa.allocator(); | 114 | const alloc = gpa.allocator(); |
| 120 | 115 | ||
| 121 | var parsed = parseArgs(alloc, args, std.posix.getenv(xdg.key_env)) catch |err| switch (err) { | 116 | var parsed = parseArgs(alloc, args, std.posix.getenv(xdg.key_env)) catch |err| switch (err) { |
| 122 | error.Usage => { | 117 | error.OutOfMemory => return err, |
| 123 | std.debug.print("{s}", .{usage}); | 118 | else => |e| return cliflags.exitFor(e, usage, "mux", build_options.version), |
| 124 | return 2; | ||
| 125 | }, | ||
| 126 | error.Help => return cliflags.help(usage), | ||
| 127 | error.Version => return cliflags.version("mux", build_options.version), | ||
| 128 | else => |e| return e, | ||
| 129 | }; | 119 | }; |
| 130 | defer parsed.deinit(); | 120 | defer parsed.deinit(); |
| 131 | 121 | ||