cli: introduce shared fields

Storage fields can now be shared by CLI arguments; also having a different type for internal representation and CLI is also added.
This commit is contained in:
Halil Durak
2026-07-21 22:55:40 +03:00
parent cbaef301d9
commit 83d3fb45e3

View File

@@ -48,7 +48,10 @@ const log = lp.log;
/// - `options: tuple` — tuple of option descriptors (see below). Use `.{}`
/// for none.
/// - `shared_options: tuple` (optional) — extra options merged into this
/// command. Useful for common flags shared across commands.
/// command. Useful for common flags shared across commands. An option
/// that appears in both with the same field name and type is collapsed
/// into one field (the command's own option wins); reusing a name with
/// a different type is a compile error.
/// - `positional: struct` (optional) — a positional argument with `.name`
/// and `.type` that may appear anywhere in argv. By default it holds a
/// single value: `.type` must be an optional pointer-to-u8 slice (e.g.
@@ -61,7 +64,12 @@ const log = lp.log;
///
/// - `name: []const u8` — snake_case field name. Both `--snake_case` and
/// `--kebab-case` are accepted on the command line.
/// - `field_name: []const u8` (optional) — name of the struct field the
/// value is written to, when it should differ from the flag name.
/// - `type` — the Zig type of the parsed value (see supported types below).
/// When the stored representation differs from the CLI-facing one, use
/// `.{ .cli = T, .memory = T }`; a `validator` must then produce the
/// memory type.
/// - `default` (optional) — compile-time default when the flag is absent.
/// Rules vary by type; see the defaults section below.
/// - `multiple: bool` (optional) — when `true`, the field becomes a
@@ -92,15 +100,20 @@ const log = lp.log;
/// ## Validators
///
/// A `validator` is a custom parse function that takes over argument
/// consumption for an option. Its signature depends on whether `multiple`
/// consumption for an option. It receives a pointer to the generated struct
/// field and writes through it; the pointee depends on whether `multiple`
/// is set:
///
/// - Single: `fn (Allocator, *ArgIterator) !T` — returns the parsed value.
/// - Single: `fn (Allocator, *ArgIterator, *T) !void` — writes the parsed
/// value through the field pointer.
/// - Multiple: `fn (Allocator, *ArgIterator, *std.ArrayList(T)) !void` —
/// appends directly into the list.
///
/// When a validator is present, the built-in type switch is skipped entirely.
/// The validator owns advancing the iterator and is free to peek ahead.
/// Because the validator sees the field itself, a repeated flag can
/// accumulate state in place (e.g. add each certificate to a store the
/// field points at) instead of collecting values in a list.
///
/// ## Variants
///
@@ -196,7 +209,22 @@ pub fn Builder(comptime commands: anytype) type {
// Whether option has a default value.
const has_default = @hasField(@TypeOf(option), "default");
const T = if (is_multiple) std.ArrayList(option.type) else option.type;
const Type = blk: {
if (@typeInfo(@TypeOf(option.type)) == .@"struct") {
break :blk option.type.memory;
}
break :blk option.type;
};
const T = if (is_multiple) std.ArrayList(Type) else Type;
// Prefer `option.field_name` if memory representation will differ.
const name = blk: {
if (@hasField(@TypeOf(option), "field_name")) {
break :blk option.field_name;
}
break :blk option.name;
};
const default = blk: {
if (is_multiple) {
@@ -208,7 +236,7 @@ pub fn Builder(comptime commands: anytype) type {
break :blk @as(*const anyopaque, @ptrCast(&@as(T, .empty)));
}
switch (@typeInfo(option.type)) {
switch (@typeInfo(Type)) {
.optional => |optional| {
if (optional.child == bool) {
@compileError("?bool is not supported, prefer enum");
@@ -228,7 +256,7 @@ pub fn Builder(comptime commands: anytype) type {
},
inline else => {
if (!has_default) {
@compileError("option `" ++ option.name ++ "` is not optional type and has no default value");
@compileError("option `" ++ name ++ "` is not optional type and has no default value");
}
break :blk @as(*const anyopaque, @ptrCast(&@as(T, option.default)));
},
@@ -236,7 +264,7 @@ pub fn Builder(comptime commands: anytype) type {
};
fields[j] = .{
.name = option.name,
.name = name,
.type = T,
.default_value_ptr = default,
.is_comptime = false,
@@ -247,6 +275,34 @@ pub fn Builder(comptime commands: anytype) type {
return fields;
}
/// Drops duplicate fields, keeping the first occurrence. Only an exact
/// duplicate (same name and type) is deduplicated; a name that
/// reappears with a different type is a conflict.
fn dedupeStructFields(comptime fields: []const std.builtin.Type.StructField) []const std.builtin.Type.StructField {
// The pairwise name comparisons blow the default 1000-branch quota.
@setEvalBranchQuota(1000 + fields.len * fields.len * 100);
var out: [fields.len]std.builtin.Type.StructField = undefined;
var len: usize = 0;
outer: for (fields) |field| {
for (out[0..len]) |existing| {
if (!std.mem.eql(u8, existing.name, field.name)) continue;
if (existing.type != field.type) {
@compileError("field `" ++ field.name ++ "` is duplicated with a different type");
}
continue :outer;
}
out[len] = field;
len += 1;
}
const frozen = out;
return frozen[0..len];
}
/// Union type for provided commands.
pub const Union = blk: {
const len = commands.len + 1;
@@ -258,7 +314,7 @@ pub fn Builder(comptime commands: anytype) type {
const Command = @TypeOf(command);
const options = command.options;
const fields = optionsToStructFields(options) ++
const all_fields = optionsToStructFields(options) ++
(if (@hasField(Command, "shared_options"))
optionsToStructFields(command.shared_options)
else
@@ -268,7 +324,14 @@ pub fn Builder(comptime commands: anytype) type {
else
.{});
const T = StructFromFields(&fields);
const T = @Type(.{
.@"struct" = .{
.decls = &.{},
.fields = dedupeStructFields(&all_fields),
.is_tuple = false,
.layout = .auto,
},
});
union_fields[i] = .{ .name = command.name, .type = T, .alignment = @alignOf(T) };
}
@@ -435,7 +498,7 @@ pub fn Builder(comptime commands: anytype) type {
return *const fn (Allocator, *std.process.Args.Iterator, *std.ArrayList(T)) anyerror!void;
}
return *const fn (Allocator, *std.process.Args.Iterator) anyerror!T;
return *const fn (Allocator, *std.process.ArgIterator, *T) anyerror!void;
}
/// Turns a snake_case string to kebab-case in comptime.
@@ -456,7 +519,9 @@ pub fn Builder(comptime commands: anytype) type {
/// ```zig
/// Option{
/// .name = "option_name",
/// .type = T,
/// // If provided, names the struct field written to instead of `name`.
/// .field_name = "struct_field_name",
/// .type = T, // or .{ .cli = T, .memory = T }
/// .multiple = ?bool,
/// .validator = ?ValidatorFn(T, is_multiple),
/// };
@@ -469,23 +534,19 @@ pub fn Builder(comptime commands: anytype) type {
const is_multiple = @hasField(OptionType, "multiple") and option.multiple;
const has_validator = @hasField(OptionType, "validator");
// Prefer validator for parsing if provided.
// Prefer validator for parsing if provided. The validator writes
// through the field pointer (the list itself for multiples).
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;
return @call(.auto, option.validator, .{ allocator, args, target });
}
// Extract type info.
const T = option.type;
// Extract type info. We need the type that's used in the CLI.
const T = blk: {
if (@typeInfo(@TypeOf(option.type)) == .@"struct") {
break :blk option.type.cli;
}
break :blk option.type;
};
const option_info = blk: {
const info = @typeInfo(T);
// If wrapped in optional, prefer the child type.
@@ -639,15 +700,23 @@ pub fn Builder(comptime commands: anytype) type {
};
iter_args: while (args.next()) |option_name| {
inline for (options) |option| {
const name = option.name;
const field_name = blk: {
if (@hasField(@TypeOf(option), "field_name")) {
break :blk option.field_name;
}
break :blk option.name;
};
// We allow both `--my-option` and `--my_option` variants;
// assuming given `option` struct prefer snake_case for `name`.
// Match an option.
const matches_short = comptime @hasField(@TypeOf(option), "short");
if (std.mem.eql(u8, option_name, "--" ++ option.name) or
std.mem.eql(u8, option_name, "--" ++ comptime toKebabCase(option.name)) or
if (std.mem.eql(u8, option_name, "--" ++ name) or
std.mem.eql(u8, option_name, "--" ++ comptime toKebabCase(name)) or
(matches_short and std.mem.eql(u8, option_name, "-" ++ [_]u8{option.short})))
{
try parseValue(allocator, args, &@field(c, option.name), option);
try parseValue(allocator, args, &@field(c, field_name), option);
continue :iter_args;
}
@@ -672,7 +741,7 @@ pub fn Builder(comptime commands: anytype) type {
break :blk .{ .name = variant.name, .type = option.type, .multiple = is_multiple };
};
try parseValue(allocator, args, &@field(c, option.name), opts);
try parseValue(allocator, args, &@field(c, field_name), opts);
continue :iter_args;
}
}