a73x

9f3ec679

feat: cliflags grows a positional hook, short aliases, and --version

a73x   2026-08-27 06:21

Commit message
feat: cliflags grows a positional hook, short aliases, and --version

Three extensions the mux attach line needs, none of which muxd's command
grammar could ask for:

- a `positional` decl on the Opts struct claims bare words (mux's HOST and
  quic:// spellings); a refusal makes the word unknown_arg, and a struct
  without the decl keeps rejecting every bare word as before.
- an `aliases` tuple gives a field a second spelling with the same arity
  (`-A` is `--agent`), and assertDocumented accepts prose that names either.
- `--version` is pre-scanned with `--help`, so it answers wherever it sits.

muxd maps the new outcome onto the command row it already had: `muxd run
--version` now prints the version and exits 0 instead of refusing an
unknown flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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" {