a73x

ed7c14fd

refactor: muxweb parses its flags with cliflags

a73x   2026-08-27 06:51

Commit message
refactor: muxweb parses its flags with cliflags

build.zig
Old New
@@ -299,7 +299,7 @@ const mod_table = [_]ModSpec{
299 // one grammar, not three. Resolution itself now lives in the Hub, so 299 // one grammar, not three. Resolution itself now lives in the Hub, so
300 // handoff/protocol left with it; sockpath stays for the one startup 300 // handoff/protocol left with it; sockpath stays for the one startup
301 // message that names the sun_path bound. 301 // message that names the sun_path bound.
302 .{ .name = "webhub_main", .path = "src/cli/webhub_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "webhub", "wall", "xdg", "sockpath" }, .quic_tests = true }, 302 .{ .name = "webhub_main", .path = "src/cli/webhub_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "webhub", "wall", "xdg", "sockpath", "cliflags" }, .quic_tests = true },
303 // sockpath is the sun_path bound only; the client binds no socket itself. 303 // sockpath is the sun_path bound only; the client binds no socket itself.
304 // protocol is the session-name validator alone (validSessionName): a bad 304 // protocol is the session-name validator alone (validSessionName): a bad
305 // --session has to be a usage error here, at parse, not bytes some 305 // --session has to be a usage error here, at parse, not bytes some
src/cli/webhub_main.zig
Old New
@@ -21,6 +21,7 @@ const wall = @import("wall");
21 const build_options = @import("build_options"); 21 const build_options = @import("build_options");
22 const xdg = @import("xdg"); 22 const xdg = @import("xdg");
23 const sockpath = @import("sockpath"); 23 const sockpath = @import("sockpath");
24 const cliflags = @import("cliflags");
24 25
25 const usage = 26 const usage =
26 \\usage: muxweb [TARGET[#SESSION] ...] [--port N] 27 \\usage: muxweb [TARGET[#SESSION] ...] [--port N]
@@ -36,7 +37,7 @@ const usage =
36 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed 37 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed
37 \\ --port N serves on 127.0.0.1:N (default 7681); localhost only, 38 \\ --port N serves on 127.0.0.1:N (default 7681); localhost only,
38 \\ remote viewing is `ssh -L` 39 \\ remote viewing is `ssh -L`
39 \\ --version prints the version 40 \\ --version prints the version, --help this page
40 \\ 41 \\
41 ; 42 ;
42 43
@@ -85,8 +86,56 @@ const Parsed = struct {
85 const ParseResult = union(enum) { 86 const ParseResult = union(enum) {
86 serve: Parsed, 87 serve: Parsed,
87 version, 88 version,
89 help,
88 }; 90 };
89 91
92 /// The command line, read off the struct: the field's type is the flag's
93 /// arity and its name is the flag's spelling. Everything a flag is FOR
94 /// stays in the post-checks below.
95 const Opts = struct {
96 port: u16 = webhub.default_port,
97 key: ?[]const u8 = null,
98 quic_idle_ms: u32 = client.quic_idle_ms_default,
99 _alloc: std.mem.Allocator,
100 _tiles: std.ArrayList([]const u8) = .empty,
101 /// A hook answers yes or no, so a hook that fails for a reason has
102 /// nowhere to say so: it leaves it here for `parseArgs` to re-raise.
103 _err: ?ParseError = null,
104
105 /// Bare HOST and quic:// are already wall spellings verbatim.
106 pub fn positional(self: *Opts, word: []const u8) bool {
107 addSpelling(self._alloc, &self._tiles, word) catch |e| {
108 self._err = e;
109 return false;
110 };
111 return true;
112 }
113
114 /// `--sock PATH` is one tile in two words, a grammar no flag table can
115 /// hold. wall owns the join so `mux wall` accepts the same two forms.
116 pub fn extra(self: *Opts, rest: []const [:0]const u8) usize {
117 const n = wall.spellingFromArgv(self._alloc, rest, 0) catch |err| switch (err) {
118 // Not a tile this program refuses, but one it never saw: left
119 // for cliflags to name as the unknown flag it is.
120 error.FlagLikeTarget => return 0,
121 else => |e| {
122 self._err = if (e == error.MissingSockPath) error.Usage else error.OutOfMemory;
123 return 0;
124 },
125 };
126 defer self._alloc.free(n.spelling);
127 addSpelling(self._alloc, &self._tiles, n.spelling) catch |e| {
128 self._err = e;
129 return 0;
130 };
131 return n.consumed;
132 }
133 };
134
135 comptime {
136 cliflags.assertDocumented(Opts, usage, &.{});
137 }
138
90 /// A usage mistake is an ERROR, not a third result: it is the one outcome 139 /// A usage mistake is an ERROR, not a third result: it is the one outcome
91 /// with nothing to hand back, and saying so lets the single errdefer own 140 /// with nothing to hand back, and saying so lets the single errdefer own
92 /// the tile list on every refusing path. Spelling it as a value meant a 141 /// the tile list on every refusing path. Spelling it as a value meant a
@@ -99,59 +148,44 @@ fn parseArgs(
99 args: []const [:0]const u8, 148 args: []const [:0]const u8,
100 env_key: ?[]const u8, 149 env_key: ?[]const u8,
101 ) ParseError!ParseResult { 150 ) ParseError!ParseResult {
151 var o = Opts{ ._alloc = alloc };
102 var p = Parsed{ .tiles = .empty }; 152 var p = Parsed{ .tiles = .empty };
103 errdefer p.deinit(alloc); 153 errdefer {
104 var key: ?[]const u8 = null; 154 p.tiles = o._tiles;
105 155 p.deinit(alloc);
106 var i: usize = 1; 156 }
107 while (i < args.len) : (i += 1) { 157
108 const a = args[i]; 158 const outcome = cliflags.parse(Opts, &o, args[1..]);
109 if (std.mem.eql(u8, a, "--version")) { 159 // Read before the outcome: a hook that refused for a REASON has already
110 // The one non-error early return, so the one that still frees 160 // named it, and that reason outranks the bare "unknown word" cliflags
111 // for itself: errdefer does not run on the way out with a 161 // saw when the hook said no.
112 // result in hand. 162 if (o._err) |e| return e;
163 switch (outcome) {
164 .ok => {},
165 .unknown_arg, .missing_value, .bad_number => return error.Usage,
166 // The two non-error early returns, so the two that still free for
167 // themselves: errdefer does not run on the way out with a result
168 // in hand.
169 .help, .version => {
170 p.tiles = o._tiles;
113 p.deinit(alloc); 171 p.deinit(alloc);
114 return .version; 172 return if (outcome == .help) .help else .version;
115 } else if (std.mem.eql(u8, a, "--sock") or std.mem.startsWith(u8, a, "--sock ")) { 173 },
116 // The flag and its value become ONE spelling — `--sock ` is
117 // part of the grammar wall.zig reads, not a shape only argv
118 // has. Two spellings of the same tile would be two parsers.
119 // wall owns the join so `mux wall` accepts the same two forms.
120 const n = wall.spellingFromArgv(alloc, args, i) catch |err| switch (err) {
121 error.MissingSockPath, error.FlagLikeTarget => return error.Usage,
122 else => |e| return e,
123 };
124 defer alloc.free(n.spelling);
125 i += n.consumed - 1;
126 try addSpelling(alloc, &p.tiles, n.spelling);
127 } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) {
128 i += 1;
129 p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage;
130 // Port 0 asks the kernel to choose, and the hub prints the port
131 // it was asked for as the door to open — a door nobody could
132 // find. Refused like `--quic-idle-ms 0` and for the same
133 // reason: the number inverts what typing it means.
134 if (p.port == 0) return error.Usage;
135 } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) {
136 i += 1;
137 key = args[i];
138 } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) {
139 i += 1;
140 const n = std.fmt.parseInt(u32, args[i], 10) catch return error.Usage;
141 if (n == 0) return error.Usage;
142 p.idle_ms = n;
143 } else if (std.mem.startsWith(u8, a, "quic://") or (a.len > 0 and a[0] != '-')) {
144 // Bare HOST and quic:// are already wall spellings verbatim.
145 try addSpelling(alloc, &p.tiles, a);
146 } else {
147 return error.Usage;
148 }
149 } 174 }
175 // Port 0 asks the kernel to choose, and the hub prints the port it was
176 // asked for as the door to open — a door nobody could find. Refused
177 // like `--quic-idle-ms 0` and for the same reason: the number inverts
178 // what typing it means.
179 if (o.port == 0) return error.Usage;
180 if (o.quic_idle_ms == 0) return error.Usage;
150 181
151 // No targets is not a usage error any more: it asks for the wall the 182 // No targets is not a usage error any more: it asks for the wall the
152 // last run persisted. main decides what an empty argv means; the parse 183 // last run persisted. main decides what an empty argv means; the parse
153 // only reports what was on the line. 184 // only reports what was on the line.
154 p.key = xdg.pickKey(key, env_key); 185 p.tiles = o._tiles;
186 p.port = o.port;
187 p.idle_ms = o.quic_idle_ms;
188 p.key = xdg.pickKey(o.key, env_key);
155 return .{ .serve = p }; 189 return .{ .serve = p };
156 } 190 }
157 191
@@ -172,6 +206,12 @@ pub fn main() !u8 {
172 else => |e| return e, 206 else => |e| return e,
173 }; 207 };
174 var parsed = switch (result) { 208 var parsed = switch (result) {
209 // stdout, unlike the refusal above: a usage someone asked for is
210 // output, and they may well have piped it into a pager.
211 .help => {
212 _ = std.posix.write(std.posix.STDOUT_FILENO, usage) catch {};
213 return 0;
214 },
175 .version => { 215 .version => {
176 var vbuf: [64]u8 = undefined; 216 var vbuf: [64]u8 = undefined;
177 const s = std.fmt.bufPrint(&vbuf, "muxweb {s}\n", .{build_options.version}) catch unreachable; 217 const s = std.fmt.bufPrint(&vbuf, "muxweb {s}\n", .{build_options.version}) catch unreachable;
@@ -426,6 +466,19 @@ test "tiles: a bad session name after # is still a usage error at parse" {
426 ); 466 );
427 } 467 }
428 468
469 test "help is an answer, not a refusal, and -- fences the tiles from the flags" {
470 const alloc = std.testing.allocator;
471 try std.testing.expect(try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--help" }, null) == .help);
472 try std.testing.expect(try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "-h" }, null) == .help);
473
474 // Past `--` a word is a tile whatever it is spelled like: the escape a
475 // host whose name reads as a flag would otherwise have none of.
476 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--", "host" }, null)).serve;
477 defer r.deinit(alloc);
478 try std.testing.expectEqual(@as(usize, 1), r.tiles.items.len);
479 try std.testing.expectEqualStrings("host", r.tiles.items[0]);
480 }
481
429 test "version short-circuits everything else on the line" { 482 test "version short-circuits everything else on the line" {
430 const alloc = std.testing.allocator; 483 const alloc = std.testing.allocator;
431 const r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--version", "--bogus" }, null); 484 const r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--version", "--bogus" }, null);