a73x

405b71d9

feat: cliflags stops at -- and hands unowned flags to the program

a73x   2026-08-27 06:31

Commit message
feat: cliflags stops at -- and hands unowned flags to the program

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]