diff --git a/lib/std/cli.zig b/lib/std/cli.zig index 17b8b56c3e..ec422659b8 100644 --- a/lib/std/cli.zig +++ b/lib/std/cli.zig @@ -1,23 +1,22 @@ //! Type-driven command-line argument parser. //! //! Structs map to named flags, tuples to positionals, unions to subcommands. -//! Use `.sorted` config to allow flags anywhere; `.default` requires flags first. //! //! ``` //! const Args = struct { -//! struct { verbose: bool = false, output: ?[]const u8 = null }, // flags -//! []const u8, // positional +//! struct { verbose: ?void = null, output: ?[]const u8 = null }, +//! []const u8, //! }; -//! const flags, const input = try std.cli.parse(Args, .sorted, args, allocator); +//! const named, const positionals = try std.cli.parse(Args, args, allocator); //! ``` const std = @import("std"); +const heap = std.heap; const mem = std.mem; const meta = std.meta; const testing = std.testing; const fmt = std.fmt; const Allocator = mem.Allocator; -const Args = std.process.Args; const EnumSet = std.EnumSet; /// Parsing errors returned by `parse`. @@ -30,40 +29,21 @@ pub const Error = error{ UnknownFlag, }; -/// Parser configuration presets. -pub const Config = struct { - /// Reorders args so flags come before positionals. Allows ` file.txt --verbose`. - pub const sorted: @This() = .{ .sort = true }; - /// Expects flags before positionals. Requires strictly ` --verbose file.txt`. - pub const default: @This() = .{ .sort = false }; - - sort: bool = false, -}; - /// Parse command-line arguments into type `T`. Skips first arg (program name). /// /// - `T`: struct for flags, tuple for positionals, union for subcommands -/// - `config`: `.sorted` allows flags after positionals, `.default` requires flags first /// - `args`: Argument slice. see `std.process.Args.toSlice` /// - `allocator`: for string/slice allocations /// /// Returns parsed `T` or `Error`. pub fn parse( comptime T: type, - comptime config: Config, - args: if (config.sort) [][:0]const u8 else []const [:0]const u8, + args: [][:0]const u8, allocator: Allocator, ) (Error || Allocator.Error)!T { - if (config.sort and args.len > 1) partition(args[1..]); - var iter: Iterator = .{ .args = args }; - return parseInner(T, &iter, allocator); -} - -// This indirection is just to enable selective dropping of the first -// arg (program name, subcommand, etc.) -inline fn parseInner(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T { + var iter: Iterator = .init(args); iter.skip() orelse return Error.MissingValue; - return parseValue(T, iter, allocator); + return parseValue(T, &iter, allocator); } fn parseValue(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T { @@ -85,44 +65,37 @@ fn parseValue(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || }; } -// @"struct" rules: -// struct { foo: u32, bar: bool }, expect args like --foo 42 --bar 1 -// field types can be void (presence-only flag), optional, or have default values -// fields without default values or optional types are required fn parseStruct(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T { - const info = @typeInfo(T); - const fields = info.@"struct".fields; + const fields = @typeInfo(T).@"struct".fields; var result: T = undefined; - // First pass for setting defaults - inline for (fields) |field| @field(result, field.name) = field.defaultValue() orelse - if (@typeInfo(field.type) == .optional) null else continue; + iter.partitionRemaining(); + + inline for (fields) |f| @field(result, f.name) = f.defaultValue() orelse + if (@typeInfo(f.type) == .optional) null else continue; const FieldEnum = meta.FieldEnum(T); - var fieldsSeen = EnumSet(FieldEnum).initEmpty(); + var seen = EnumSet(FieldEnum).initEmpty(); - // Parse args - stop on first non-flag (positional) while (iter.peek()) |arg| { - const field_name = flagName(arg) orelse break; + const name = flagName(arg) orelse break; _ = iter.skip(); - - inline for (fields) |field| { - if (mem.eql(u8, field.name, field_name)) { - fieldsSeen.insert(@field(FieldEnum, field.name)); - @field(result, field.name) = try parseValue(field.type, iter, allocator); + inline for (fields) |f| { + if (mem.eql(u8, f.name, name)) { + seen.insert(@field(FieldEnum, f.name)); + @field(result, f.name) = try parseValue(f.type, iter, allocator); break; } - } else { - return Error.UnknownFlag; - } + } else return Error.UnknownFlag; } - // Required fields were set? - inline for (fields) |field| - if (field.default_value_ptr == null and - !(@typeInfo(field.type) == .optional) and - !fieldsSeen.contains(@field(FieldEnum, field.name))) - return Error.MissingValue; + if (iter.peek()) |a| { + if (mem.eql(u8, a, "--")) _ = iter.skip(); + } + + inline for (fields) |f| + if (f.default_value_ptr == null and @typeInfo(f.type) != .optional and + !seen.contains(@field(FieldEnum, f.name))) return Error.MissingValue; return result; } @@ -130,22 +103,25 @@ fn parseStruct(comptime T: type, iter: *Iterator, allocator: Allocator) (Error | fn parseTuple(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T { const fields = @typeInfo(T).@"struct".fields; var result: T = undefined; - inline for (fields) |field| { + + inline for (fields) |field| @field(result, field.name) = try parseValue(field.type, iter, allocator); - } + return result; } fn parseUnion(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T { const info = @typeInfo(T).@"union"; const Tag = info.tag_type orelse @compileError("Non-tagged unions are not supported"); - const arg = iter.peek() orelse return Error.MissingValue; - const tag = meta.stringToEnum(Tag, arg) orelse return Error.InvalidValue; - inline for (info.fields) |field| if (@field(Tag, field.name) == tag) { - const value = try parseInner(field.type, iter, allocator); - return @unionInit(T, field.name, value); - }; + const arg = iter.next() orelse + return Error.MissingValue; + + const tag = meta.stringToEnum(Tag, arg) orelse + return Error.InvalidValue; + + inline for (info.fields) |field| if (@field(Tag, field.name) == tag) + return @unionInit(T, field.name, try parseValue(field.type, iter, allocator)); unreachable; } @@ -153,7 +129,9 @@ fn parseUnion(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || fn parseEnum(comptime T: type, iter: *Iterator) Error!T { const arg = iter.peek() orelse return Error.MissingValue; const val = meta.stringToEnum(T, arg) orelse return Error.InvalidValue; + _ = iter.skip(); + return val; } @@ -164,7 +142,9 @@ fn parseScalar(comptime T: type, iter: *Iterator) Error!T { .float => fmt.parseFloat(T, arg) catch return Error.InvalidValue, else => unreachable, }; + _ = iter.skip(); + return val; } @@ -197,58 +177,49 @@ fn parsePointer(comptime T: type, iter: *Iterator, allocator: Allocator) (Error fn parseArray(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T { const info = @typeInfo(T).array; var result: T = undefined; + inline for (0..info.len) |i| result[i] = try parseValue(info.child, iter, allocator); + return result; } fn parseVector(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T { const info = @typeInfo(T).vector; var result: T = undefined; + inline for (0..info.len) |i| result[i] = try parseValue(info.child, iter, allocator); + return result; } -// bool rules: -// maps {1/0, true/false, yes/no and on/off} to bool // NOTE: bool expects a value. For presence-only flags, use ?void type in struct fn parseBool(comptime T: type, iter: *Iterator) Error!T { const arg = iter.peek() orelse return Error.MissingValue; - if (mem.eql(u8, arg, "1") or - mem.eql(u8, arg, "true") or - mem.eql(u8, arg, "yes") or - mem.eql(u8, arg, "on")) - { + inline for (.{ "1", "true", "yes", "on" }) |s| if (mem.eql(u8, arg, s)) { _ = iter.skip(); return true; - } + }; - if (mem.eql(u8, arg, "0") or - mem.eql(u8, arg, "false") or - mem.eql(u8, arg, "no") or - mem.eql(u8, arg, "off")) - { + inline for (.{ "0", "false", "no", "off" }) |s| if (mem.eql(u8, arg, s)) { _ = iter.skip(); return false; - } + }; return Error.InvalidValue; } fn parseOptional(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T { - const ChildT = @typeInfo(T).optional.child; - - return parseValue(ChildT, iter, allocator) catch |err| switch (err) { + return parseValue(@typeInfo(T).optional.child, iter, allocator) catch |err| switch (err) { Error.MissingValue => null, else => err, }; } -// If arg is a flag, return its name without the prefix, otherwise return null inline fn flagName(arg: []const u8) ?[]const u8 { - if (mem.startsWith(u8, arg, "--")) return arg[2..]; + if (mem.startsWith(u8, arg, "--")) return if (arg.len == 2) null else arg[2..]; if (mem.startsWith(u8, arg, "-") or mem.startsWith(u8, arg, "+")) return arg[1..]; @@ -257,19 +228,20 @@ inline fn flagName(arg: []const u8) ?[]const u8 { } // Stable in-place partition: moves all flag units before positionals +// Stops at "--" (everything after is positional) fn partition(args: [][:0]const u8) void { var write: usize = 0; var read: usize = 0; while (read < args.len) { - if (flagName(args[read]) != null) { - const has_value = mem.indexOfScalar(u8, args[read], '=') == null and - read + 1 < args.len and flagName(args[read + 1]) == null; - const unit_size = @as(usize, @intFromBool(has_value)) + 1; - - mem.rotate([:0]const u8, args[write .. read + unit_size], read - write); - write += unit_size; - read += unit_size; + if (mem.eql(u8, args[read], "--")) break; + if (flagName(args[read])) |_| { + var end = read + 1; + if (mem.indexOfScalar(u8, args[read], '=') == null) + while (end < args.len and flagName(args[end]) == null) : (end += 1) {}; + mem.rotate([:0]const u8, args[write..end], read - write); + write += end - read; + read = end; } else read += 1; } } @@ -278,282 +250,254 @@ fn partition(args: [][:0]const u8) void { // index position or in the same index position separated by '=', serving a rather // different purpose than std.process.Args.Iterator const Iterator = struct { - args: []const [:0]const u8, + args: [][:0]const u8, index: usize = 0, - @"=": ?[:0]const u8 = null, // stores value after '=' to return on next call + @"=": ?[:0]const u8 = null, - pub fn init(args: []const [:0]const u8) Iterator { + pub fn init(args: [][:0]const u8) @This() { return .{ .args = args }; } - pub fn next(self: *Iterator) ?[]const u8 { - if (self.@"=") |value| { - self.@"=" = null; - return value; + pub fn next(this: *@This()) ?[]const u8 { + if (this.@"=") |v| { + this.@"=" = null; + return v; } + if (this.index >= this.args.len) return null; - if (self.index >= self.args.len) return null; + const arg = this.args[this.index]; + this.index += 1; - const arg = self.args[self.index]; - self.index += 1; - - if (flagName(arg)) |_| if (mem.findScalar(u8, arg, '=')) |eq_pos| { - self.@"=" = arg[eq_pos + 1 .. :0]; - return arg[0..eq_pos]; + if (flagName(arg) != null) if (mem.indexOfScalar(u8, arg, '=')) |i| { + this.@"=" = arg[i + 1 .. :0]; + return arg[0..i]; }; - return arg; } - pub fn peek(self: *Iterator) ?[]const u8 { - const prev = .{ self.index, self.@"=" }; + pub fn peek(this: *@This()) ?[]const u8 { + const s = .{ this.index, this.@"=" }; defer { - self.index = prev[0]; - self.@"=" = prev[1]; + this.index = s[0]; + this.@"=" = s[1]; } - - return self.next(); + return this.next(); } - pub fn skip(self: *Iterator) ?void { - _ = self.next() orelse return null; + pub fn skip(this: *@This()) ?void { + _ = this.next() orelse return null; + } + + pub fn collect(this: *@This(), allocator: Allocator) ![]const []const u8 { + var list: std.ArrayList([]const u8) = .empty; + while (this.next()) |arg| try list.append(allocator, arg); + return list.toOwnedSlice(allocator); + } + + pub fn partitionRemaining(this: *@This()) void { + if (this.index < this.args.len) partition(this.args[this.index..]); } }; test "std.cli: Iterator" { - var iter1: Iterator = .{ .args = &.{ "--foo", "42", "--bar", "1" } }; - try testing.expectEqualStrings("--foo", iter1.next().?); - try testing.expectEqualStrings("42", iter1.next().?); - try testing.expectEqualStrings("--bar", iter1.next().?); - try testing.expectEqualStrings("1", iter1.next().?); - try testing.expectEqual(null, iter1.next()); + var args = [_][:0]const u8{ "--foo=42", "--bar", "1", "--foobar=💔", "🥀" }; + const expected: []const []const u8 = &.{ "--foo", "42", "--bar", "1", "--foobar", "💔", "🥀" }; - var iter2: Iterator = .{ .args = &.{ "--foo=42", "--bar=hello" } }; - try testing.expectEqualStrings("--foo", iter2.next().?); - try testing.expectEqualStrings("42", iter2.next().?); - try testing.expectEqualStrings("--bar", iter2.next().?); - try testing.expectEqualStrings("hello", iter2.next().?); - try testing.expectEqual(null, iter2.next()); + var it: Iterator = .init(&args); + + const actual = try it.collect(testing.allocator); + defer testing.allocator.free(actual); + + try testing.expectEqualDeep(expected, actual); } test "std.cli: partition" { - // Positionals appear BEFORE flags - partition reorders them - var args = [_][:0]const u8{ "file1.txt", "file2.txt", "--verbose", "true", "--output", "result.txt" }; - partition(&args); + var actual = [_][:0]const u8{ "output.mp4", "--resolution", "1920", "1080", "--fps", "60", "input.mp4" }; + const expected: []const []const u8 = &.{ "--resolution", "1920", "1080", "--fps", "60", "input.mp4", "output.mp4" }; - try testing.expectEqualStrings("--verbose", args[0]); - try testing.expectEqualStrings("true", args[1]); - try testing.expectEqualStrings("--output", args[2]); - try testing.expectEqualStrings("result.txt", args[3]); - try testing.expectEqualStrings("file1.txt", args[4]); - try testing.expectEqualStrings("file2.txt", args[5]); + partition(&actual); + try testing.expectEqualDeep(expected, &actual); } -// zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux -j8 -test "std.cli: zig build" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); +test "std.cli: simple" { + var arena: heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); - const Optimize = enum { Debug, ReleaseSafe, ReleaseFast, ReleaseSmall }; - const BuildOptions = struct { - optimize: Optimize = .Debug, - target: []const u8 = "native", - j: u32 = 1, + const allocator = arena.allocator(); + + const types = .{ + bool, + i32, + f64, + void, + ?bool, + ?i32, + ?f64, + enum { red, green, blue }, + ?enum { one, many }, + }; + + var arguments = [_][2][:0]const u8{ + .{ "", "true" }, + .{ "", "42" }, + .{ "", "3.14" }, + .{ "", "" }, + .{ "", "false" }, + .{ "", "-123" }, + .{ "", "2.718" }, + .{ "", "green" }, + .{ "", "one" }, + }; + const expected = .{ + true, + 42, + 3.14, + {}, + false, + @as(?i32, -123), + @as(?f64, 2.718), + .green, + .one, + }; + + inline for (types, &arguments, expected) |T, *a, e| + try testing.expectEqual(e, try parse(T, a, allocator)); +} + +test "std.cli: sequences" { + var arena: heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const allocator = arena.allocator(); + + const types = .{ + [3]i32, + @Vector(3, f64), + struct { i32, bool, f64 }, + []const []const u8, + }; + + var arguments = [_][4][:0]const u8{ + .{ "", "1", "2", "3" }, + .{ "", "1.5", "2.5", "3.5" }, + .{ "", "42", "true", "3.14" }, + .{ "", "hello", "🦎", "good" }, + }; + + const expected = .{ + [3]i32{ 1, 2, 3 }, + @Vector(3, f64){ 1.5, 2.5, 3.5 }, + .{ 42, true, 3.14 }, + @as([]const []const u8, &.{ "hello", "🦎", "good" }), + }; + + inline for (types, &arguments, expected) |T, *a, e| + try testing.expectEqualDeep(e, try parse(T, a, allocator)); +} + +test "std.cli: struct" { + var arena: heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const allocator = arena.allocator(); + + const Point = struct { x: i32 = 0, y: i32 = 0 }; + const Config = struct { name: []const u8, verbose: bool = false }; + const Color = struct { r: u8 = 0, g: u8 = 0, b: u8 = 0 }; + + const types = .{ Point, Config, Color }; + + var arguments = [_][5][:0]const u8{ + .{ "", "--x", "10", "--y", "20" }, + .{ "", "--verbose", "true", "--name", "alice" }, + .{ "", "--r", "255", "--g", "128" }, + }; + + const expected = .{ + Point{ .x = 10, .y = 20 }, + Config{ .name = "alice", .verbose = true }, + Color{ .r = 255, .g = 128 }, + }; + + inline for (types, &arguments, expected) |T, *a, e| + try testing.expectEqualDeep(e, try parse(T, a, allocator)); +} + +test "std.cli: union" { + var arena: heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const allocator = arena.allocator(); + + const Op = union(enum) { add: i32, sub: i32, nop: void }; + + const types = .{ Op, Op, Op }; + + var arguments = [_][3][:0]const u8{ + .{ "", "add", "5" }, + .{ "", "sub", "-3" }, + .{ "", "nop", "" }, + }; + + const expected = .{ + Op{ .add = 5 }, + Op{ .sub = -3 }, + Op{ .nop = {} }, + }; + + inline for (types, &arguments, expected) |T, *a, e| + try testing.expectEqualDeep(e, try parse(T, a, allocator)); +} + +test "std.cli: combined" { + var arena: heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const allocator = arena.allocator(); + + const EncodeOptions = struct { + input: []const u8, + output: []const u8, + format: enum { mp4, avi, mkv } = .mp4, + resolution: [2]u32, + quality: ?enum { low, medium, high } = null, verbose: bool = false, }; - const parsed = try parse(BuildOptions, .default, &.{ - "zig-build", "--optimize=ReleaseFast", "--target=x86_64-linux", "-j=8", - }, arena.allocator()); - - try testing.expectEqual(.ReleaseFast, parsed.optimize); - try testing.expectEqualStrings("x86_64-linux", parsed.target); - try testing.expectEqual(@as(u32, 8), parsed.j); - try testing.expectEqual(false, parsed.verbose); -} - -// git clone --depth 1 --branch main https://github.com/ziglang/zig -// git commit --message "fix: resolve memory leak" --amend true -// git log --oneline true --count 10 -test "std.cli: git" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - - const Command = union(enum) { - clone: struct { - depth: ?u32 = null, - branch: []const u8 = "master", - url: []const u8, - }, - commit: struct { - message: []const u8, - amend: bool = false, - signoff: bool = false, - }, - log: struct { - oneline: bool = false, - count: u32 = 20, + const GitCommand = union(enum) { + remote: union(enum) { + add: struct { name: []const u8, url: []const u8 }, + remove: []const u8, }, + status: void, }; - const clone = try parse(Command, .default, &.{ - "git", "clone", "--depth", "1", "--branch", "main", "--url", "https://github.com/ziglang/zig", - }, allocator); - try testing.expectEqual(@as(?u32, 1), clone.clone.depth); - try testing.expectEqualStrings("main", clone.clone.branch); - try testing.expectEqualStrings("https://github.com/ziglang/zig", clone.clone.url); - - const commit = try parse(Command, .default, &.{ - "git", "commit", "--message", "fix: resolve memory leak", "--amend", "true", - }, allocator); - try testing.expectEqualStrings("fix: resolve memory leak", commit.commit.message); - try testing.expectEqual(true, commit.commit.amend); - - const log = try parse(Command, .default, &.{ "git", "log", "--oneline", "true", "--count", "10" }, allocator); - try testing.expectEqual(true, log.log.oneline); - try testing.expectEqual(@as(u32, 10), log.log.count); -} - -// curl --request POST --header "Content-Type: application/json" --data '{"key":"value"}' https://api.example.com -test "std.cli: curl" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - const Method = enum { GET, POST, PUT, DELETE, PATCH }; - const CurlOptions = struct { - request: Method = .GET, - header: []const u8 = "", - data: []const u8 = "", - output: []const u8 = "-", - silent: bool = false, - url: []const u8, - }; - - const parsed = try parse(CurlOptions, .default, &.{ - "curl", "--request", "POST", - "--header", "Content-Type: application/json", "--data", - "{\"key\":\"value\"}", "--url", "https://api.example.com/endpoint", - }, arena.allocator()); - - try testing.expectEqual(.POST, parsed.request); - try testing.expectEqualStrings("Content-Type: application/json", parsed.header); - try testing.expectEqualStrings("{\"key\":\"value\"}", parsed.data); - try testing.expectEqualStrings("https://api.example.com/endpoint", parsed.url); -} - -// ffmpeg -i input.mp4 --codec h264 --bitrate 4500 --resolution 1920 1080 --fps 60 output.mp4 -test "std.cli: ffmpeg" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - const Codec = enum { h264, h265, vp9, av1 }; - const FfmpegOptions = struct { + const GrepArgs = struct { struct { - i: []const u8, - codec: Codec = .h264, - bitrate: u32 = 2500, - resolution: @Vector(2, u32) = .{ 1280, 720 }, - fps: u8 = 30, + @"ignore-case": ?void = null, + @"line-number": ?void = null, }, - []const u8, // output file + []const []const u8, }; - const opts, const output = try parse(FfmpegOptions, .default, &.{ - "ffmpeg", "-i", "input.mp4", "--codec", "h265", - "--bitrate", "4500", "--resolution", "1920", "1080", - "--fps", "60", "output.mp4", - }, arena.allocator()); + const types = .{ EncodeOptions, GitCommand, GitCommand, GitCommand, GrepArgs }; - try testing.expectEqualStrings("input.mp4", opts.i); - try testing.expectEqual(.h265, opts.codec); - try testing.expectEqual(@as(u32, 4500), opts.bitrate); - try testing.expectEqual(@Vector(2, u32){ 1920, 1080 }, opts.resolution); - try testing.expectEqual(@as(u8, 60), opts.fps); - try testing.expectEqualStrings("output.mp4", output); -} - -// cc -O2 --target x86_64-linux --output program main.c utils.c lib.c -test "std.cli: cc" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - const OptLevel = enum { O0, O1, O2, O3, Os, Oz }; - const CompilerOptions = struct { - struct { - O: OptLevel = .O0, - target: []const u8 = "native", - output: []const u8 = "a.out", - g: bool = false, - }, - []const []const u8, // input files + var arguments = [_][10][:0]const u8{ + .{ "", "--input", "video.mp4", "--output", "result.mkv", "--format", "mkv", "--resolution", "1920", "1080" }, + .{ "", "remote", "add", "--name", "origin", "--url", "git@github.com:user/repo", "", "", "" }, + .{ "", "remote", "remove", "upstream", "", "", "", "", "", "" }, + .{ "", "status", "", "", "", "", "", "", "", "" }, + .{ "", "--ignore-case", "pattern", "file1.txt", "file2.txt", "", "", "", "", "" }, }; - const opts, const files = try parse(CompilerOptions, .default, &.{ - "cc", "-O", "O2", "--target", "x86_64-linux", "--output", "program", "main.c", "utils.c", "lib.c", - }, arena.allocator()); - - try testing.expectEqual(.O2, opts.O); - try testing.expectEqualStrings("x86_64-linux", opts.target); - try testing.expectEqualStrings("program", opts.output); - try testing.expectEqual(@as(usize, 3), files.len); - try testing.expectEqualStrings("main.c", files[0]); - try testing.expectEqualStrings("utils.c", files[1]); - try testing.expectEqualStrings("lib.c", files[2]); -} - -// docker run --detach true --publish 8080 80 --env KEY=value --name myapp nginx:latest -test "std.cli: docker run" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - const DockerRun = struct { - struct { - detach: bool = false, - publish: @Vector(2, u16) = .{ 0, 0 }, - env: []const u8 = "", - name: []const u8 = "", - volume: []const u8 = "", - }, - []const u8, // image + const expected = .{ + EncodeOptions{ .input = "video.mp4", .output = "result.mkv", .format = .mkv, .resolution = .{ 1920, 1080 }, .quality = null }, + GitCommand{ .remote = .{ .add = .{ .name = "origin", .url = "git@github.com:user/repo" } } }, + GitCommand{ .remote = .{ .remove = "upstream" } }, + GitCommand{ .status = {} }, + GrepArgs{ .{ .@"ignore-case" = {} }, @as([]const []const u8, &.{ "pattern", "file1.txt", "file2.txt", "", "", "", "", "" }) }, }; - const opts, const image = try parse(DockerRun, .default, &.{ - "docker-run", "--detach", "true", "--publish", "8080", "80", "--env", "KEY=value", "--name", "myapp", "nginx:latest", - }, arena.allocator()); - - try testing.expectEqual(true, opts.detach); - try testing.expectEqual(@Vector(2, u16){ 8080, 80 }, opts.publish); - try testing.expectEqualStrings("KEY=value", opts.env); - try testing.expectEqualStrings("myapp", opts.name); - try testing.expectEqualStrings("nginx:latest", image); -} - -// tar --create true --gzip true --file archive.tar.gz src/ lib/ include/ -test "std.cli: tar" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - const TarOptions = struct { - struct { - create: bool = false, - extract: bool = false, - gzip: bool = false, - verbose: bool = false, - file: []const u8, - }, - []const []const u8, // paths - }; - - const opts, const paths = try parse(TarOptions, .default, &.{ - "tar", "--create", "true", "--gzip", "true", "--file", "archive.tar.gz", "src/", "lib/", "include/", - }, arena.allocator()); - - try testing.expectEqual(true, opts.create); - try testing.expectEqual(true, opts.gzip); - try testing.expectEqualStrings("archive.tar.gz", opts.file); - try testing.expectEqual(@as(usize, 3), paths.len); - try testing.expectEqualStrings("src/", paths[0]); - try testing.expectEqualStrings("lib/", paths[1]); - try testing.expectEqualStrings("include/", paths[2]); + inline for (types, &arguments, expected) |T, *a, e| + try testing.expectEqualDeep(e, try parse(T, a, allocator)); }