9f3ec679
feat: cliflags grows a positional hook, short aliases, and --version
a73x 2026-08-27 06:21
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -306,7 +306,7 @@ const mod_table = [_]ModSpec{ | |||
| 306 | // daemon downstream has to notice and refuse. Layer 5 since `mux wall` | 306 | // daemon downstream has to notice and refuse. Layer 5 since `mux wall` |
| 307 | // pulled in wallview (layer 4); wall rides along for the no-arg wall | 307 | // pulled in wallview (layer 4); wall rides along for the no-arg wall |
| 308 | // (the state file the browser hub builds). | 308 | // (the state file the browser hub builds). |
| 309 | .{ .name = "mux", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 309 | .{ .name = "mux", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 310 | }; | 310 | }; |
| 311 | 311 | ||
| 312 | /// Comptime row lookup. Every hand-written module name in this file goes | 312 | /// Comptime row lookup. Every hand-written module name in this file goes |
src/cli/flags.zig
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,9 @@ pub const Outcome = union(enum) { | |||
| 10 | /// `--help` or `-h`, wherever it sits: the caller prints its usage to | 10 | /// `--help` or `-h`, wherever it sits: the caller prints its usage to |
| 11 | /// stdout and exits 0. | 11 | /// stdout and exits 0. |
| 12 | help, | 12 | help, |
| 13 | /// `--version`, wherever it sits: the caller prints its version to | ||
| 14 | /// stdout and exits 0. | ||
| 15 | version, | ||
| 13 | unknown_arg: []const u8, | 16 | unknown_arg: []const u8, |
| 14 | /// A value-taking flag at the end of argv, with nothing left to consume. | 17 | /// A value-taking flag at the end of argv, with nothing left to consume. |
| 15 | missing_value: []const u8, | 18 | missing_value: []const u8, |
| @@ -25,8 +28,9 @@ fn Bare(comptime F: type) type { | |||
| 25 | }; | 28 | }; |
| 26 | } | 29 | } |
| 27 | 30 | ||
| 28 | /// A flag given twice: the last wins. Nothing stops the scan — every word is | 31 | /// A flag given twice: the last wins. A word that is not a flag is offered |
| 29 | /// a flag or a mistake, because a positional here could only be a typo. | 32 | /// to `T.positional` when T declares one; without that decl there are no |
| 33 | /// positional arguments, because a bare word here could only be a typo. | ||
| 30 | pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { | 34 | pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { |
| 31 | const fields = @typeInfo(T).@"struct".fields; | 35 | const fields = @typeInfo(T).@"struct".fields; |
| 32 | comptime for (fields) |f| { | 36 | comptime for (fields) |f| { |
| @@ -35,11 +39,20 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { | |||
| 35 | if (B != bool and B != []const u8 and @typeInfo(B) != .int) | 39 | if (B != bool and B != []const u8 and @typeInfo(B) != .int) |
| 36 | @compileError("cliflags: no arity for field `" ++ f.name ++ "`: " ++ @typeName(f.type)); | 40 | @compileError("cliflags: no arity for field `" ++ f.name ++ "`: " ++ @typeName(f.type)); |
| 37 | }; | 41 | }; |
| 42 | comptime { | ||
| 43 | if (@hasDecl(T, "aliases")) for (T.aliases) |pair| { | ||
| 44 | if (!@hasField(T, pair[1])) | ||
| 45 | @compileError("cliflags: alias `" ++ pair[0] ++ "` names no field `" ++ pair[1] ++ "`"); | ||
| 46 | }; | ||
| 47 | } | ||
| 38 | 48 | ||
| 39 | // Asked for before anything is read, so that a `--help` sitting where a | 49 | // Asked for before anything is read, so that a `--help` sitting where a |
| 40 | // value belongs still answers with the usage instead of being eaten. | 50 | // value belongs still answers with the usage instead of being eaten. |
| 51 | // `--version` shares the pass for the same reason: asking a binary its | ||
| 52 | // version must answer a line that would otherwise be refused. | ||
| 41 | for (args) |a| { | 53 | for (args) |a| { |
| 42 | if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) return .help; | 54 | if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) return .help; |
| 55 | if (std.mem.eql(u8, a, "--version")) return .version; | ||
| 43 | } | 56 | } |
| 44 | 57 | ||
| 45 | var i: usize = 0; | 58 | var i: usize = 0; |
| @@ -47,7 +60,9 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { | |||
| 47 | const a = args[i]; | 60 | const a = args[i]; |
| 48 | var known = false; | 61 | var known = false; |
| 49 | inline for (fields) |f| { | 62 | inline for (fields) |f| { |
| 50 | if (f.name[0] != '_' and !known and std.mem.eql(u8, a, comptime flagName(f.name))) { | 63 | const hit = f.name[0] != '_' and !known and |
| 64 | (std.mem.eql(u8, a, comptime flagName(f.name)) or aliasHit(T, f.name, a)); | ||
| 65 | if (hit) { | ||
| 51 | known = true; | 66 | known = true; |
| 52 | const B = Bare(f.type); | 67 | const B = Bare(f.type); |
| 53 | if (B == bool) { | 68 | if (B == bool) { |
| @@ -62,11 +77,30 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { | |||
| 62 | } | 77 | } |
| 63 | } | 78 | } |
| 64 | } | 79 | } |
| 65 | if (!known) return .{ .unknown_arg = a }; | 80 | if (!known) { |
| 81 | // Only a word with no leading dash is offered: an unnamed flag is | ||
| 82 | // a mistake, never a value. A refusal makes the word unknown — | ||
| 83 | // one the program will not take is one it does not know. | ||
| 84 | if (a.len > 0 and a[0] != '-' and @hasDecl(T, "positional")) { | ||
| 85 | if (dst.positional(a)) continue; | ||
| 86 | } | ||
| 87 | return .{ .unknown_arg = a }; | ||
| 88 | } | ||
| 66 | } | 89 | } |
| 67 | return .ok; | 90 | return .ok; |
| 68 | } | 91 | } |
| 69 | 92 | ||
| 93 | /// An alias is a second spelling of one field, so it carries no arity of | ||
| 94 | /// its own: `-A` is `--agent` because `agent` is the field it names. | ||
| 95 | fn aliasHit(comptime T: type, comptime field: []const u8, a: []const u8) bool { | ||
| 96 | if (!@hasDecl(T, "aliases")) return false; | ||
| 97 | inline for (T.aliases) |pair| { | ||
| 98 | if (comptime !std.mem.eql(u8, pair[1], field)) continue; | ||
| 99 | if (std.mem.eql(u8, pair[0], a)) return true; | ||
| 100 | } | ||
| 101 | return false; | ||
| 102 | } | ||
| 103 | |||
| 70 | pub fn flagName(comptime field: []const u8) []const u8 { | 104 | pub fn flagName(comptime field: []const u8) []const u8 { |
| 71 | comptime var name: []const u8 = "--"; | 105 | comptime var name: []const u8 = "--"; |
| 72 | inline for (field) |c| name = name ++ [_]u8{if (c == '_') '-' else c}; | 106 | inline for (field) |c| name = name ++ [_]u8{if (c == '_') '-' else c}; |
| @@ -90,11 +124,23 @@ pub fn documented(name: []const u8, usage: []const u8) bool { | |||
| 90 | return false; | 124 | return false; |
| 91 | } | 125 | } |
| 92 | 126 | ||
| 127 | /// Either spelling answers for the field: prose that offers `-A` and never | ||
| 128 | /// `--agent` has documented the flag its readers will type. | ||
| 129 | fn documentedField(comptime T: type, comptime field: []const u8, comptime usage: []const u8) bool { | ||
| 130 | if (documented(flagName(field), usage)) return true; | ||
| 131 | if (@hasDecl(T, "aliases")) { | ||
| 132 | for (T.aliases) |pair| { | ||
| 133 | if (std.mem.eql(u8, pair[1], field) and documented(pair[0], usage)) return true; | ||
| 134 | } | ||
| 135 | } | ||
| 136 | return false; | ||
| 137 | } | ||
| 138 | |||
| 93 | /// Kills parser-to-prose drift at build time: a flag added to T and forgotten | 139 | /// Kills parser-to-prose drift at build time: a flag added to T and forgotten |
| 94 | /// in the text fails the compile. The mirror leg is NOT claimed — prose may | 140 | /// in the text fails the compile. The mirror leg is NOT claimed — prose may |
| 95 | /// name a flag no field has, and only a reader will notice. | 141 | /// name a flag no field has, and only a reader will notice. |
| 96 | pub fn assertDocumented(comptime T: type, comptime usage: []const u8, comptime hidden: []const []const u8) void { | 142 | pub fn assertDocumented(comptime T: type, comptime usage: []const u8, comptime hidden: []const []const u8) void { |
| 97 | @setEvalBranchQuota(50_000); | 143 | @setEvalBranchQuota(200_000); |
| 98 | comptime for (@typeInfo(T).@"struct".fields) |f| { | 144 | comptime for (@typeInfo(T).@"struct".fields) |f| { |
| 99 | if (f.name[0] == '_') continue; | 145 | if (f.name[0] == '_') continue; |
| 100 | var skip = false; | 146 | var skip = false; |
| @@ -102,7 +148,7 @@ pub fn assertDocumented(comptime T: type, comptime usage: []const u8, comptime h | |||
| 102 | if (std.mem.eql(u8, h, f.name)) skip = true; | 148 | if (std.mem.eql(u8, h, f.name)) skip = true; |
| 103 | } | 149 | } |
| 104 | if (skip) continue; | 150 | if (skip) continue; |
| 105 | if (!documented(flagName(f.name), usage)) | 151 | if (!documentedField(T, f.name, usage)) |
| 106 | @compileError("cliflags: usage text never names " ++ flagName(f.name)); | 152 | @compileError("cliflags: usage text never names " ++ flagName(f.name)); |
| 107 | }; | 153 | }; |
| 108 | } | 154 | } |
| @@ -211,3 +257,79 @@ test "assertDocumented: every visible flag is named in the prose" { | |||
| 211 | // prose on purpose, so the assertion must not demand it. | 257 | // prose on purpose, so the assertion must not demand it. |
| 212 | comptime assertDocumented(Demo, text, &.{"shell"}); | 258 | comptime assertDocumented(Demo, text, &.{"shell"}); |
| 213 | } | 259 | } |
| 260 | |||
| 261 | const Positional = struct { | ||
| 262 | vt: bool = false, | ||
| 263 | agent: bool = false, | ||
| 264 | sock: ?[]const u8 = null, | ||
| 265 | _host: ?[]const u8 = null, | ||
| 266 | _refused: bool = false, | ||
| 267 | |||
| 268 | pub const aliases = .{ .{ "-A", "agent" }, .{ "-s", "sock" } }; | ||
| 269 | |||
| 270 | pub fn positional(self: *Positional, word: []const u8) bool { | ||
| 271 | if (word.len == 0) return false; | ||
| 272 | if (word[0] == '!') { | ||
| 273 | self._refused = true; | ||
| 274 | return false; | ||
| 275 | } | ||
| 276 | self._host = word; | ||
| 277 | return true; | ||
| 278 | } | ||
| 279 | }; | ||
| 280 | |||
| 281 | test "parse: a bare word goes to the program's positional hook" { | ||
| 282 | var o: Positional = .{}; | ||
| 283 | try std.testing.expect(parse(Positional, &o, &.{ "--vt", "vm1" }) == .ok); | ||
| 284 | try std.testing.expectEqualStrings("vm1", o._host.?); | ||
| 285 | |||
| 286 | // A word the program will not take is unknown to it, and the hook has | ||
| 287 | // already seen it — a refusal is a decision, not a filter. | ||
| 288 | var r: Positional = .{}; | ||
| 289 | const bad = parse(Positional, &r, &.{"!nope"}); | ||
| 290 | try std.testing.expect(bad == .unknown_arg); | ||
| 291 | try std.testing.expectEqualStrings("!nope", bad.unknown_arg); | ||
| 292 | try std.testing.expect(r._refused); | ||
| 293 | |||
| 294 | // A dashed word is never offered: an unnamed flag is a mistake, and the | ||
| 295 | // hook must not get the chance to read it as a value. | ||
| 296 | var d: Positional = .{}; | ||
| 297 | try std.testing.expect(parse(Positional, &d, &.{"--wat"}) == .unknown_arg); | ||
| 298 | try std.testing.expect(d._host == null); | ||
| 299 | } | ||
| 300 | |||
| 301 | test "parse: an alias is the field's flag, with the field's arity" { | ||
| 302 | var o: Positional = .{}; | ||
| 303 | try std.testing.expect(parse(Positional, &o, &.{ "-A", "-s", "/tmp/x.sock" }) == .ok); | ||
| 304 | try std.testing.expect(o.agent); | ||
| 305 | try std.testing.expectEqualStrings("/tmp/x.sock", o.sock.?); | ||
| 306 | |||
| 307 | // The long spelling still works, and an alias that takes a value is | ||
| 308 | // missing_value at the end of argv like the flag it stands for. | ||
| 309 | var l: Positional = .{}; | ||
| 310 | try std.testing.expect(parse(Positional, &l, &.{"--agent"}) == .ok); | ||
| 311 | try std.testing.expect(l.agent); | ||
| 312 | try std.testing.expect(parse(Positional, &l, &.{"-s"}) == .missing_value); | ||
| 313 | |||
| 314 | // A struct with no aliases decl is unmoved by another struct's. | ||
| 315 | var demo: Demo = .{}; | ||
| 316 | try std.testing.expect(parse(Demo, &demo, &.{"-A"}) == .unknown_arg); | ||
| 317 | } | ||
| 318 | |||
| 319 | test "parse: --version is its own outcome, wherever it sits" { | ||
| 320 | var o: Demo = .{}; | ||
| 321 | try std.testing.expect(parse(Demo, &o, &.{"--version"}) == .version); | ||
| 322 | try std.testing.expect(parse(Demo, &o, &.{ "--vt", "--version" }) == .version); | ||
| 323 | // Even beside a word that would otherwise be refused, or where a value | ||
| 324 | // belongs: asking a binary its version is not a way to mistype a flag. | ||
| 325 | try std.testing.expect(parse(Demo, &o, &.{ "--version", "--wat" }) == .version); | ||
| 326 | try std.testing.expect(parse(Demo, &o, &.{ "--sock", "--version" }) == .version); | ||
| 327 | } | ||
| 328 | |||
| 329 | test "assertDocumented: an alias documents its field" { | ||
| 330 | const text = | ||
| 331 | \\ demo [--vt] [-A] [--sock PATH] | ||
| 332 | \\ | ||
| 333 | ; | ||
| 334 | comptime assertDocumented(Positional, text, &.{}); | ||
| 335 | } | ||
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -222,6 +222,10 @@ fn parseArgs(args: []const [:0]const u8) ParseResult { | |||
| 222 | switch (cliflags.parse(Opts, &o, args[2..])) { | 222 | switch (cliflags.parse(Opts, &o, args[2..])) { |
| 223 | .ok => {}, | 223 | .ok => {}, |
| 224 | .help => return .{ .err = .help }, | 224 | .help => return .{ .err = .help }, |
| 225 | // The verb the line opened with is discarded: `--version` anywhere | ||
| 226 | // means the same thing the bare command row means, and answering it | ||
| 227 | // must not need a runtime dir or a daemon. | ||
| 228 | .version => return .{ .ok = .{ ._cmd = .version } }, | ||
| 225 | .unknown_arg => |a| return .{ .err = .{ .unknown_arg = a } }, | 229 | .unknown_arg => |a| return .{ .err = .{ .unknown_arg = a } }, |
| 226 | .missing_value => |f| return .{ .err = .{ .missing_value = f } }, | 230 | .missing_value => |f| return .{ .err = .{ .missing_value = f } }, |
| 227 | .bad_number => |f| return .{ .err = .{ .bad_number = f } }, | 231 | .bad_number => |f| return .{ .err = .{ .bad_number = f } }, |
| @@ -1461,6 +1465,14 @@ test "parseArgs: --version is a command, not a flag on one" { | |||
| 1461 | const r = parse(&.{ "muxd", "--version" }); | 1465 | const r = parse(&.{ "muxd", "--version" }); |
| 1462 | try std.testing.expect(r == .ok); | 1466 | try std.testing.expect(r == .ok); |
| 1463 | try std.testing.expect(r.ok._cmd == .version); | 1467 | try std.testing.expect(r.ok._cmd == .version); |
| 1468 | |||
| 1469 | // Typed onto a verb it becomes that same command, so `muxd run | ||
| 1470 | // --version` answers instead of refusing an unknown flag. Asserted on | ||
| 1471 | // the parse rather than on `main`, which writes the version to STDOUT | ||
| 1472 | // and would hang the build runner's IPC. | ||
| 1473 | const on_run = parse(&.{ "muxd", "run", "--sock", "/x", "--version" }); | ||
| 1474 | try std.testing.expect(on_run == .ok); | ||
| 1475 | try std.testing.expect(on_run.ok._cmd == .version); | ||
| 1464 | } | 1476 | } |
| 1465 | 1477 | ||
| 1466 | test "parseArgs: --help is a command, and a flag on one, and both exit 0 on stdout" { | 1478 | test "parseArgs: --help is a command, and a flag on one, and both exit 0 on stdout" { |