ed7c14fd
refactor: muxweb parses its flags with cliflags
a73x 2026-08-27 06:51
Commit message
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); |