405b71d9
feat: cliflags stops at -- and hands unowned flags to the program
a73x 2026-08-27 06:31
Commit message
src/cli/flags.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,9 @@ | |||
| 1 | //! A flag parser that reads its table off a struct: the field's TYPE is the | 1 | //! A flag parser that reads its table off a struct: the field's TYPE is the |
| 2 | //! flag's arity and the field's NAME is its spelling, so adding a field adds | 2 | //! flag's arity and the field's NAME is its spelling, so adding a field adds |
| 3 | //! a flag and there is no second list to keep in step. The grammar is | 3 | //! a flag and there is no second list to keep in step. The grammar is |
| 4 | //! `--flag VALUE`, space-separated. What a flag MEANS stays with the caller, | 4 | //! `--flag VALUE`, space-separated, with a bare `--` ending the flags and |
| 5 | //! in post-checks over the parsed struct. | 5 | //! everything after it payload. What a flag MEANS stays with the caller, in |
| 6 | //! post-checks over the parsed struct. | ||
| 6 | const std = @import("std"); | 7 | const std = @import("std"); |
| 7 | 8 | ||
| 8 | pub const Outcome = union(enum) { | 9 | pub const Outcome = union(enum) { |
| @@ -30,7 +31,9 @@ fn Bare(comptime F: type) type { | |||
| 30 | 31 | ||
| 31 | /// A flag given twice: the last wins. A word that is not a flag is offered | 32 | /// A flag given twice: the last wins. A word that is not a flag is offered |
| 32 | /// to `T.positional` when T declares one; without that decl there are no | 33 | /// 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. | 34 | /// positional arguments, because a bare word here could only be a typo. A |
| 35 | /// flag-shaped word this table does not own is offered to `T.extra`, which | ||
| 36 | /// answers with the number of words it consumed, or 0 to refuse. | ||
| 34 | pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { | 37 | pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { |
| 35 | const fields = @typeInfo(T).@"struct".fields; | 38 | const fields = @typeInfo(T).@"struct".fields; |
| 36 | comptime for (fields) |f| { | 39 | comptime for (fields) |f| { |
| @@ -50,16 +53,25 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { | |||
| 50 | // value belongs still answers with the usage instead of being eaten. | 53 | // value belongs still answers with the usage instead of being eaten. |
| 51 | // `--version` shares the pass for the same reason: asking a binary its | 54 | // `--version` shares the pass for the same reason: asking a binary its |
| 52 | // version must answer a line that would otherwise be refused. | 55 | // version must answer a line that would otherwise be refused. |
| 56 | // It stops at `--` because past that point the words are payload: a | ||
| 57 | // `muxa send -- --help` types `--help` AT a session, and a usage page | ||
| 58 | // instead of the keystrokes would be the parser answering for the user. | ||
| 53 | for (args) |a| { | 59 | for (args) |a| { |
| 60 | if (std.mem.eql(u8, a, "--")) break; | ||
| 54 | if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) return .help; | 61 | 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; | 62 | if (std.mem.eql(u8, a, "--version")) return .version; |
| 56 | } | 63 | } |
| 57 | 64 | ||
| 58 | var i: usize = 0; | 65 | var i: usize = 0; |
| 66 | var payload = false; | ||
| 59 | while (i < args.len) : (i += 1) { | 67 | while (i < args.len) : (i += 1) { |
| 60 | const a = args[i]; | 68 | const a = args[i]; |
| 69 | if (!payload and std.mem.eql(u8, a, "--")) { | ||
| 70 | payload = true; | ||
| 71 | continue; | ||
| 72 | } | ||
| 61 | var known = false; | 73 | var known = false; |
| 62 | inline for (fields) |f| { | 74 | if (!payload) inline for (fields) |f| { |
| 63 | const hit = f.name[0] != '_' and !known and | 75 | const hit = f.name[0] != '_' and !known and |
| 64 | (std.mem.eql(u8, a, comptime flagName(f.name)) or aliasHit(T, f.name, a)); | 76 | (std.mem.eql(u8, a, comptime flagName(f.name)) or aliasHit(T, f.name, a)); |
| 65 | if (hit) { | 77 | if (hit) { |
| @@ -76,14 +88,26 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { | |||
| 76 | }; | 88 | }; |
| 77 | } | 89 | } |
| 78 | } | 90 | } |
| 79 | } | 91 | }; |
| 80 | if (!known) { | 92 | if (!known) { |
| 81 | // Only a word with no leading dash is offered: an unnamed flag is | 93 | // Only a word with no leading dash is offered, until `--` says |
| 82 | // a mistake, never a value. A refusal makes the word unknown — | 94 | // every word after it is one: an unnamed flag is a mistake, never |
| 83 | // one the program will not take is one it does not know. | 95 | // a value. A refusal makes the word unknown — one the program |
| 84 | if (a.len > 0 and a[0] != '-' and @hasDecl(T, "positional")) { | 96 | // will not take is one it does not know. |
| 97 | if ((payload or (a.len > 0 and a[0] != '-')) and @hasDecl(T, "positional")) { | ||
| 85 | if (dst.positional(a)) continue; | 98 | if (dst.positional(a)) continue; |
| 86 | } | 99 | } |
| 100 | // A flag this table does not own may still be the program's, in a | ||
| 101 | // grammar the table cannot express — `--sock PATH` is one wall | ||
| 102 | // tile to `mux wall`, two words for one thing. The hook says how | ||
| 103 | // many words it took; taking none is a refusal, not a silent skip. | ||
| 104 | if (!payload and a.len > 0 and a[0] == '-' and @hasDecl(T, "extra")) { | ||
| 105 | const n = dst.extra(args[i..]); | ||
| 106 | if (n > 0) { | ||
| 107 | i += n - 1; | ||
| 108 | continue; | ||
| 109 | } | ||
| 110 | } | ||
| 87 | return .{ .unknown_arg = a }; | 111 | return .{ .unknown_arg = a }; |
| 88 | } | 112 | } |
| 89 | } | 113 | } |
| @@ -326,6 +350,76 @@ test "parse: --version is its own outcome, wherever it sits" { | |||
| 326 | try std.testing.expect(parse(Demo, &o, &.{ "--sock", "--version" }) == .version); | 350 | try std.testing.expect(parse(Demo, &o, &.{ "--sock", "--version" }) == .version); |
| 327 | } | 351 | } |
| 328 | 352 | ||
| 353 | test "parse: a bare -- ends the flags and every word after it is payload" { | ||
| 354 | var o: Positional = .{}; | ||
| 355 | try std.testing.expect(parse(Positional, &o, &.{ "--vt", "--", "--vt" }) == .ok); | ||
| 356 | // The flag before the fence was read; the same word after it was not. | ||
| 357 | try std.testing.expect(o.vt); | ||
| 358 | try std.testing.expectEqualStrings("--vt", o._host.?); | ||
| 359 | |||
| 360 | // A struct with no positional hook has nowhere to put payload, so the | ||
| 361 | // fence buys it nothing: the word after is still a word it cannot take. | ||
| 362 | var d: Demo = .{}; | ||
| 363 | const bad = parse(Demo, &d, &.{ "--", "run" }); | ||
| 364 | try std.testing.expect(bad == .unknown_arg); | ||
| 365 | try std.testing.expectEqualStrings("run", bad.unknown_arg); | ||
| 366 | } | ||
| 367 | |||
| 368 | test "parse: --help after -- is payload, not a request for the usage" { | ||
| 369 | var o: Positional = .{}; | ||
| 370 | try std.testing.expect(parse(Positional, &o, &.{ "--", "--help" }) == .ok); | ||
| 371 | try std.testing.expectEqualStrings("--help", o._host.?); | ||
| 372 | |||
| 373 | var v: Positional = .{}; | ||
| 374 | try std.testing.expect(parse(Positional, &v, &.{ "--", "--version" }) == .ok); | ||
| 375 | try std.testing.expectEqualStrings("--version", v._host.?); | ||
| 376 | |||
| 377 | // Before the fence it is still help: the fence moves, it does not repeal. | ||
| 378 | var b: Positional = .{}; | ||
| 379 | try std.testing.expect(parse(Positional, &b, &.{ "--help", "--", "x" }) == .help); | ||
| 380 | } | ||
| 381 | |||
| 382 | const Extra = struct { | ||
| 383 | vt: bool = false, | ||
| 384 | _sock: ?[]const u8 = null, | ||
| 385 | _seen: ?[]const u8 = null, | ||
| 386 | |||
| 387 | /// Stands in for the wall grammar: `--sock PATH` is one target spelled in | ||
| 388 | /// two words, and nothing else here is the program's to claim. | ||
| 389 | pub fn extra(self: *Extra, rest: []const [:0]const u8) usize { | ||
| 390 | self._seen = rest[0]; | ||
| 391 | if (!std.mem.eql(u8, rest[0], "--sock")) return 0; | ||
| 392 | if (rest.len < 2) return 0; | ||
| 393 | self._sock = rest[1]; | ||
| 394 | return 2; | ||
| 395 | } | ||
| 396 | }; | ||
| 397 | |||
| 398 | test "parse: a flag the table does not own goes to the program's extra hook" { | ||
| 399 | var o: Extra = .{}; | ||
| 400 | try std.testing.expect(parse(Extra, &o, &.{ "--sock", "/tmp/x.sock", "--vt" }) == .ok); | ||
| 401 | try std.testing.expectEqualStrings("/tmp/x.sock", o._sock.?); | ||
| 402 | // Parsing resumes AFTER the words the hook took, not inside them. | ||
| 403 | try std.testing.expect(o.vt); | ||
| 404 | |||
| 405 | // A refusal is the program's decision, reported as the word it refused. | ||
| 406 | var r: Extra = .{}; | ||
| 407 | const bad = parse(Extra, &r, &.{"--wat"}); | ||
| 408 | try std.testing.expect(bad == .unknown_arg); | ||
| 409 | try std.testing.expectEqualStrings("--wat", bad.unknown_arg); | ||
| 410 | try std.testing.expectEqualStrings("--wat", r._seen.?); | ||
| 411 | |||
| 412 | // Past the fence the hook is not consulted: payload is not a flag. | ||
| 413 | var p: Extra = .{}; | ||
| 414 | const fenced = parse(Extra, &p, &.{ "--", "--sock", "/tmp/y.sock" }); | ||
| 415 | try std.testing.expect(fenced == .unknown_arg); | ||
| 416 | try std.testing.expect(p._sock == null); | ||
| 417 | |||
| 418 | // Without the decl, an unowned flag is unknown as it always was. | ||
| 419 | var d: Demo = .{}; | ||
| 420 | try std.testing.expect(parse(Demo, &d, &.{"--wat"}) == .unknown_arg); | ||
| 421 | } | ||
| 422 | |||
| 329 | test "assertDocumented: an alias documents its field" { | 423 | test "assertDocumented: an alias documents its field" { |
| 330 | const text = | 424 | const text = |
| 331 | \\ demo [--vt] [-A] [--sock PATH] | 425 | \\ demo [--vt] [-A] [--sock PATH] |