Merge pull request #2275 from lightpanda-io/nikneym/cli-variants

`cli`: introduce `variants` + fix `--wait-script-file` regression
This commit is contained in:
Karl Seguin
2026-04-28 07:32:35 +08:00
committed by GitHub
2 changed files with 238 additions and 160 deletions

View File

@@ -51,7 +51,7 @@ fn logFilterScopesValidator(allocator: Allocator, args: *std.process.ArgIterator
var it = std.mem.splitScalar(u8, str, ',');
while (it.next()) |part| {
const v = std.meta.stringToEnum(log.Scope, part) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = "log-filter-scopes", .value = part });
log.fatal(.app, "invalid option choice", .{ .arg = "--log-filter-scopes", .value = part });
return error.InvalidOption;
};
@@ -106,6 +106,18 @@ fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat {
return .html;
}
fn waitScriptFileValidator(allocator: Allocator, args: *std.process.ArgIterator) !?[:0]const u8 {
const path = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--wait-script-file" });
return error.InvalidArgument;
};
return std.fs.cwd().readFileAllocOptions(allocator, path, 1024 * 1024, null, .of(u8), 0) catch |err| {
log.fatal(.app, "failed to read file", .{ .arg = "--wait-script-file", .path = path, .err = err });
return error.InvalidArgument;
};
}
/// Definition for all the commands and its arguments. See @cli.zig for further.
const Commands = cli.Builder(.{
.{
@@ -131,7 +143,13 @@ const Commands = cli.Builder(.{
.{ .name = "strip_mode", .type = dump.Opts.Strip, .default = dump.Opts.Strip{} },
.{ .name = "wait_ms", .type = u32, .default = 5_000 },
.{ .name = "wait_until", .type = ?WaitUntil },
.{ .name = "wait_script", .type = ?[:0]const u8 },
.{
.name = "wait_script",
.type = ?[:0]const u8,
.variants = .{
.{ .name = "wait_script_file", .validator = waitScriptFileValidator },
},
},
.{ .name = "wait_selector", .type = ?[:0]const u8 },
.{ .name = "terminate_ms", .type = ?u32 },
},

View File

@@ -339,6 +339,198 @@ pub fn Builder(comptime commands: anytype) type {
return error.UnknownCommand;
}
/// Returns the type for validator function.
pub fn ValidatorFn(comptime T: type, comptime is_multiple: bool) type {
if (is_multiple) {
return *const fn (Allocator, *std.process.ArgIterator, *std.ArrayList(T)) anyerror!void;
}
return *const fn (Allocator, *std.process.ArgIterator) anyerror!T;
}
/// Turns a snake_case string to kebab-case in comptime.
fn toKebabCase(comptime str: []const u8) [str.len]u8 {
var output: [str.len]u8 = str[0..str.len].*;
for (&output) |*c| if (c.* == '_') {
c.* = '-';
};
return output;
}
fn parseValue(
allocator: Allocator,
args: *std.process.ArgIterator,
/// Pointer to field; *T.
target: anytype,
/// `Option` doesn't have a concrete type; this field expects:
/// ```zig
/// Option{
/// .name = "option_name",
/// .type = T,
/// .multiple = ?bool,
/// .validator = ?ValidatorFn(T, is_multiple),
/// };
/// ```
option: anytype,
) !void {
const kebab_cased = "--" ++ comptime toKebabCase(option.name);
const OptionType = @TypeOf(option);
const is_multiple = @hasField(OptionType, "multiple") and option.multiple;
const has_validator = @hasField(OptionType, "validator");
// Prefer validator for parsing if provided.
if (has_validator) {
const validator = option.validator;
if (is_multiple) {
// Pass the list.
try @call(.auto, validator, .{ allocator, args, target });
} else {
// Receive the value from return.
const v = try @call(.auto, validator, .{ allocator, args });
target.* = v;
}
return;
}
// Extract type info.
const T = option.type;
const option_info = blk: {
const info = @typeInfo(T);
// If wrapped in optional, prefer the child type.
if (info == .optional) break :blk @typeInfo(info.optional.child);
break :blk info;
};
// Parse by type.
return switch (option_info) {
.int => |int| {
const Int = std.meta.Int(int.signedness, int.bits);
const str = args.next() orelse return error.MissingArgument;
const v = std.fmt.parseInt(Int, str, 10) catch |err| {
switch (err) {
error.Overflow => log.fatal(.app, "range overflow", .{ .arg = kebab_cased, .value = str }),
error.InvalidCharacter => log.fatal(.app, "invalid character", .{ .arg = kebab_cased, .value = str }),
}
return error.InvalidArgument;
};
if (is_multiple) {
// Push to ArrayList.
try target.append(allocator, v);
} else {
target.* = v;
}
},
.pointer => |pointer| {
const not_u8_slice = pointer.child != u8 or pointer.size != .slice;
if (not_u8_slice) {
@compileError("Only []u8, []const u8, [:sentinel]u8 and [:sentinel]const u8 pointers are supported");
}
const v = blk: {
const str = args.next() orelse return error.MissingArgument;
// DupeZ branch.
if (comptime pointer.sentinel()) |sentinel| {
const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len + 1);
@memcpy(buf[0..str.len], str);
buf[str.len] = sentinel;
break :blk buf[0..str.len :sentinel];
}
// Dupe branch.
const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len);
@memcpy(buf, str);
break :blk buf;
};
if (is_multiple) {
try target.append(allocator, v);
} else {
target.* = v;
}
},
.@"struct" => |_struct| {
// Don't support multiple for structs for now.
if (is_multiple) {
@compileError("multiple option is not supported for structs");
}
const not_packed = _struct.layout != .@"packed";
if (not_packed) {
@compileError("only packed structs are allowed");
}
const str = args.next() orelse return error.MissingArgument;
if (std.mem.eql(u8, str, "full")) {
// "full" sets all the fields of packed struct.
const Int = _struct.backing_integer orelse @compileError("packed struct must provide a backing integer");
target.* = @bitCast(@as(Int, std.math.maxInt(Int)));
} else {
// Parse given args.
var it = std.mem.tokenizeScalar(u8, str, ',');
outer: while (it.next()) |part| {
const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace);
inline for (_struct.fields) |f| {
lp.assert(f.type == bool, "all fields of packed struct must be boolean", .{
.option = option.name,
.field = f.name,
});
if (std.mem.eql(u8, trimmed, @as([]const u8, f.name))) {
@field(target, f.name) = true;
continue :outer;
}
}
// Invalid option choice.
log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = trimmed });
return error.InvalidArgument;
}
}
},
.@"enum" => {
const E = switch (@typeInfo(T)) {
.optional => |optional| optional.child,
inline else => T,
};
const str = args.next() orelse return error.MissingArgument;
const v = std.meta.stringToEnum(E, str) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = str });
return error.InvalidArgument;
};
if (is_multiple) {
try target.append(allocator, v);
} else {
target.* = v;
}
},
.bool => {
if (is_multiple) {
@compileError("multiple option is not supported for booleans");
}
const default = blk: {
if (@hasField(@TypeOf(option), "default")) {
break :blk option.default;
}
break :blk false;
};
// Set opposite of the default.
target.* = !default;
},
else => unreachable,
};
}
/// Parses the command with its options.
fn parseCommand(
allocator: Allocator,
@@ -359,171 +551,39 @@ pub fn Builder(comptime commands: anytype) type {
inline for (options) |option| {
// We allow both `--my-option` and `--my_option` variants;
// assuming given `option` struct prefer snake_case for `name`.
const kebab_cased = comptime casing: {
var output: [option.name.len]u8 = undefined;
@memcpy(&output, option.name);
std.mem.replaceScalar(u8, &output, '_', '-');
break :casing "--" ++ output;
};
// Match an option.
const match =
std.mem.eql(u8, option_name, "--" ++ option.name) or
std.mem.eql(u8, option_name, kebab_cased);
if (std.mem.eql(u8, option_name, "--" ++ option.name) or
std.mem.eql(u8, option_name, "--" ++ comptime toKebabCase(option.name)))
{
try parseValue(allocator, args, &@field(c, option.name), option);
continue :iter_args;
}
if (match) {
const T = option.type;
const option_info = blk: {
const info = @typeInfo(T);
// If wrapped in optional, prefer the child type.
if (info == .optional) break :blk @typeInfo(info.optional.child);
break :blk info;
};
const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple;
const has_validator = @hasField(@TypeOf(option), "validator");
// Prefer custom validator logic instead.
if (has_validator) {
const validator = option.validator;
if (is_multiple) {
// Pass the list.
try @call(.auto, validator, .{ allocator, args, &@field(c, option.name) });
} else {
// Receive the value from return.
const v = try @call(.auto, validator, .{ allocator, args });
@field(c, option.name) = v;
}
} else {
switch (option_info) {
.int => |int| {
const Int = std.meta.Int(int.signedness, int.bits);
const str = args.next() orelse return error.MissingArgument;
const v = std.fmt.parseInt(Int, str, 10) catch |err| {
switch (err) {
error.Overflow => log.fatal(.app, "range overflow", .{ .arg = kebab_cased, .value = str }),
error.InvalidCharacter => log.fatal(.app, "invalid character", .{ .arg = kebab_cased, .value = str }),
}
return error.InvalidArgument;
};
if (is_multiple) {
// Push to ArrayList.
try @field(c, option.name).append(allocator, v);
} else {
@field(c, option.name) = v;
}
},
.pointer => |pointer| {
const not_u8_slice = pointer.child != u8 or pointer.size != .slice;
if (not_u8_slice) {
@compileError("Only []u8, []const u8, [:sentinel]u8 and [:sentinel]const u8 pointers are supported");
const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple;
// Parse for variants if there are.
const has_variants = @hasField(@TypeOf(option), "variants");
if (has_variants) {
inline for (option.variants) |variant| {
if (std.mem.eql(u8, option_name, "--" ++ variant.name) or
std.mem.eql(u8, option_name, "--" ++ comptime toKebabCase(variant.name)))
{
const opts = blk: {
if (@hasField(@TypeOf(variant), "validator")) {
break :blk .{
.name = variant.name,
.type = option.type,
.multiple = is_multiple,
.validator = variant.validator,
};
}
const v = blk: {
const str = args.next() orelse return error.MissingArgument;
break :blk .{ .name = variant.name, .type = option.type, .multiple = is_multiple };
};
// DupeZ branch.
if (comptime pointer.sentinel()) |sentinel| {
const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len + 1);
@memcpy(buf[0..str.len], str);
buf[str.len] = sentinel;
break :blk buf[0..str.len :sentinel];
}
// Dupe branch.
const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len);
@memcpy(buf, str);
break :blk buf;
};
if (is_multiple) {
try @field(c, option.name).append(allocator, v);
} else {
@field(c, option.name) = v;
}
},
.@"struct" => |_struct| {
// Don't support multiple for structs for now.
if (is_multiple) {
@compileError("multiple option is not supported for structs");
}
const not_packed = _struct.layout != .@"packed";
if (not_packed) {
@compileError("only packed structs are allowed");
}
const str = args.next() orelse return error.MissingArgument;
if (std.mem.eql(u8, str, "full")) {
// "full" sets all the fields of packed struct.
const Int = _struct.backing_integer.?;
@field(c, option.name) = @bitCast(@as(Int, std.math.maxInt(Int)));
} else {
// Parse given args.
var it = std.mem.tokenizeScalar(u8, str, ',');
outer: while (it.next()) |part| {
const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace);
inline for (_struct.fields) |f| {
lp.assert(f.type == bool, "all fields of packed struct must be boolean", .{
.option = option.name,
.field = f.name,
});
if (std.mem.eql(u8, trimmed, @as([]const u8, f.name))) {
@field(@field(c, option.name), f.name) = true;
continue :outer;
}
}
// Invalid option choice.
log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = trimmed });
return error.InvalidArgument;
}
}
},
.@"enum" => {
const E = switch (@typeInfo(T)) {
.optional => |optional| optional.child,
inline else => T,
};
const str = args.next() orelse return error.MissingArgument;
const v = std.meta.stringToEnum(E, str) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = str });
return error.InvalidArgument;
};
if (is_multiple) {
try @field(c, option.name).append(allocator, v);
} else {
@field(c, option.name) = v;
}
},
.bool => {
if (is_multiple) {
@compileError("multiple option is not supported for booleans");
}
const default = blk: {
if (@hasField(@TypeOf(option), "default")) {
break :blk option.default;
}
break :blk false;
};
// Set opposite of the default.
@field(c, option.name) = !default;
},
else => {},
try parseValue(allocator, args, &@field(c, option.name), opts);
continue :iter_args;
}
}
continue :iter_args;
}
}