magdalena

interactive shell navigation and history
Log | Files | Refs | README | LICENSE

cli.zig (14779B)


      1 const std = @import("std");
      2 const io = @import("io.zig");
      3 
      4 pub const Action = enum {
      5     recent_dirs,
      6     recent_files,
      7     favorites,
      8     search,
      9     goto_dir,
     10     goto_file,
     11     look_file,
     12     look_dir,
     13     grep,
     14     cleanup,
     15     log_dir,
     16     log_file,
     17     help,
     18 };
     19 
     20 pub const Args = struct {
     21     action: Action,
     22     query: ?[]const u8 = null,
     23     log_path: ?[]const u8 = null,
     24     file_type: ?[]const u8 = null,
     25     file_action: ?[]const u8 = null,
     26     depth: ?usize = null,
     27 
     28     _cla: CommandLineArgs,
     29 
     30     pub fn deinit(self: Args) void {
     31         self._cla.deinit();
     32     }
     33 };
     34 
     35 // Minimal command-line parser. Replaces zul.CommandLineArgs, whose `parse`
     36 // still calls the removed `std.process.argsWithAllocator`; the parsing
     37 // semantics (flag/value/tail handling) are preserved exactly.
     38 pub const CommandLineArgs = struct {
     39     _arena: *std.heap.ArenaAllocator,
     40     _lookup: std.StringHashMapUnmanaged([]const u8),
     41 
     42     exe: []const u8,
     43     tail: []const [:0]const u8,
     44     list: []const [:0]const u8,
     45 
     46     pub fn parse(parent: std.mem.Allocator, source: std.process.Args) !CommandLineArgs {
     47         const arena = try parent.create(std.heap.ArenaAllocator);
     48         errdefer parent.destroy(arena);
     49 
     50         arena.* = std.heap.ArenaAllocator.init(parent);
     51         errdefer arena.deinit();
     52 
     53         const items = try source.toSlice(arena.allocator());
     54         return finishParse(arena, items);
     55     }
     56 
     57     // Parses an already-collected argv. Split out from `parse` so tests can
     58     // feed a literal slice without constructing a `std.process.Args`.
     59     fn finishParse(arena: *std.heap.ArenaAllocator, items: []const [:0]const u8) !CommandLineArgs {
     60         const allocator = arena.allocator();
     61 
     62         var lookup: std.StringHashMapUnmanaged([]const u8) = .{};
     63 
     64         if (items.len == 0) {
     65             return .{ .exe = "", .tail = &.{}, .list = &.{}, ._arena = arena, ._lookup = lookup };
     66         }
     67 
     68         const exe = items[0];
     69 
     70         var i: usize = 1;
     71         var tail_start: usize = 1;
     72 
     73         while (i < items.len) {
     74             const arg = items[i];
     75             if (arg.len == 1 or arg[0] != '-') {
     76                 // can't be a valid parameter, so it must be the start of our tail
     77                 break;
     78             }
     79 
     80             if (arg[1] == '-') {
     81                 const kv = KeyValue.from(arg[2..], items, &i);
     82                 try lookup.put(allocator, kv.key, kv.value);
     83             } else {
     84                 const kv = KeyValue.from(arg[1..], items, &i);
     85                 const key = kv.key;
     86 
     87                 // -xvf file.tar.gz parses into x=>"", v=>"", f=>"file.tar.gz"
     88                 for (0..key.len - 1) |j| {
     89                     try lookup.put(allocator, key[j .. j + 1], "");
     90                 }
     91                 try lookup.put(allocator, key[key.len - 1 ..], kv.value);
     92             }
     93             tail_start = i;
     94         }
     95 
     96         return .{
     97             .exe = exe,
     98             .tail = items[tail_start..],
     99             .list = items,
    100             ._arena = arena,
    101             ._lookup = lookup,
    102         };
    103     }
    104 
    105     pub fn deinit(self: CommandLineArgs) void {
    106         const arena = self._arena;
    107         const allocator = arena.child_allocator;
    108         arena.deinit();
    109         allocator.destroy(arena);
    110     }
    111 
    112     pub fn contains(self: *const CommandLineArgs, name: []const u8) bool {
    113         return self._lookup.contains(name);
    114     }
    115 
    116     pub fn get(self: *const CommandLineArgs, name: []const u8) ?[]const u8 {
    117         return self._lookup.get(name);
    118     }
    119 };
    120 
    121 const KeyValue = struct {
    122     key: []const u8,
    123     value: []const u8,
    124 
    125     fn from(key: []const u8, items: []const [:0]const u8, i: *usize) KeyValue {
    126         const item_index = i.*;
    127         if (std.mem.indexOfScalarPos(u8, key, 0, '=')) |pos| {
    128             // this parameter is in the form of --key=value, or -k=value
    129             i.* = item_index + 1;
    130             return .{ .key = key[0..pos], .value = key[pos + 1 ..] };
    131         }
    132 
    133         if (item_index == items.len - 1 or items[item_index + 1][0] == '-') {
    134             // key is at the end of the arguments OR the next argument starts
    135             // with a '-'. This means this key has no value.
    136             i.* = item_index + 1;
    137             return .{ .key = key, .value = "" };
    138         }
    139 
    140         // skip the current key, and the next arg (which is our value)
    141         i.* = item_index + 2;
    142         return .{ .key = key, .value = items[item_index + 1] };
    143     }
    144 };
    145 
    146 pub fn parseArgs(allocator: std.mem.Allocator, source: std.process.Args) !Args {
    147     const cla = try CommandLineArgs.parse(allocator, source);
    148     errdefer cla.deinit();
    149     return resolveArgs(cla);
    150 }
    151 
    152 // Maps a parsed command line onto an `Args`. Split from `parseArgs` so tests
    153 // can drive it from a `CommandLineArgs` built off a literal slice. On success
    154 // the returned `Args` owns `cla` (freed via `Args.deinit`).
    155 fn resolveArgs(cla: CommandLineArgs) !Args {
    156     var res = Args{
    157         .action = .help,
    158         ._cla = cla,
    159     };
    160 
    161     if (cla.contains("help") or cla.contains("-h") or cla.contains("--help")) {
    162         res.action = .help;
    163         return res;
    164     }
    165 
    166     if (cla.contains("clean") or cla.contains("c")) {
    167         res.action = .cleanup;
    168         return res;
    169     }
    170 
    171     if (cla.get("depth")) |d| {
    172         res.depth = try std.fmt.parseInt(usize, d, 10);
    173     } else if (cla.get("d")) |d| {
    174         res.depth = try std.fmt.parseInt(usize, d, 10);
    175     }
    176 
    177     if (res.depth == null) {
    178         for (cla.tail, 0..) |arg, i| {
    179             if (std.mem.startsWith(u8, arg, "--depth=")) {
    180                 res.depth = try std.fmt.parseInt(usize, arg["--depth=".len..], 10);
    181             } else if (std.mem.eql(u8, arg, "--depth") or std.mem.eql(u8, arg, "-d")) {
    182                 if (i + 1 < cla.tail.len) {
    183                     res.depth = try std.fmt.parseInt(usize, cla.tail[i + 1], 10);
    184                 }
    185             }
    186             if (res.depth != null) break;
    187         }
    188     }
    189 
    190     if (cla.tail.len == 0) {
    191         return res;
    192     }
    193 
    194     const cmd_str = cla.tail[0];
    195     if (std.mem.eql(u8, cmd_str, "recent-dirs")) {
    196         res.action = .recent_dirs;
    197     } else if (std.mem.eql(u8, cmd_str, "recent-files")) {
    198         res.action = .recent_files;
    199     } else if (std.mem.eql(u8, cmd_str, "favorites")) {
    200         res.action = .favorites;
    201     } else if (std.mem.eql(u8, cmd_str, "search")) {
    202         res.action = .search;
    203     } else if (std.mem.eql(u8, cmd_str, "goto-dir")) {
    204         res.action = .goto_dir;
    205     } else if (std.mem.eql(u8, cmd_str, "goto-file")) {
    206         res.action = .goto_file;
    207     } else if (std.mem.eql(u8, cmd_str, "look-file")) {
    208         res.action = .look_file;
    209     } else if (std.mem.eql(u8, cmd_str, "look-dir")) {
    210         res.action = .look_dir;
    211     } else if (std.mem.eql(u8, cmd_str, "grep")) {
    212         res.action = .grep;
    213     } else if (std.mem.eql(u8, cmd_str, "log-dir")) {
    214         res.action = .log_dir;
    215     } else if (std.mem.eql(u8, cmd_str, "log-file")) {
    216         res.action = .log_file;
    217     } else {
    218         res.action = .help;
    219         return res;
    220     }
    221 
    222     if (cla.tail.len > 1) {
    223         const arg = cla.tail[1];
    224         if (res.action == .search) {
    225             res.query = arg;
    226         } else if (res.action == .log_dir) {
    227             res.log_path = arg;
    228         } else if (res.action == .log_file) {
    229             res.log_path = arg;
    230             if (cla.tail.len > 2) res.file_type = cla.tail[2];
    231             if (cla.tail.len > 3) res.file_action = cla.tail[3];
    232         }
    233     }
    234 
    235     return res;
    236 }
    237 
    238 pub fn printUsage() !void {
    239     var buf: [4096]u8 = undefined;
    240     var writer = io.getStdout(&buf);
    241     try writer.print(
    242         \\Usage:
    243         \\  magdalena recent-dirs
    244         \\  magdalena recent-files
    245         \\  magdalena favorites
    246         \\  magdalena goto-dir
    247         \\  magdalena goto-file
    248         \\  magdalena log-dir <path>
    249         \\  magdalena log-file <path> [type] [action]
    250         \\  magdalena search <query>
    251         \\  magdalena look-file [--depth <n>]
    252         \\  magdalena look-dir [--depth <n>]
    253         \\  magdalena grep
    254         \\
    255         \\Options:
    256         \\  -c, --clean             Perform cleanup
    257         \\  -h, --help              Show this help
    258         \\
    259     , .{});
    260     try writer.end();
    261 }
    262 
    263 const testing = std.testing;
    264 
    265 // Builds a CommandLineArgs straight from a literal argv, bypassing
    266 // std.process.Args. Mirrors `parse`'s ownership shape exactly (errdefer +
    267 // error-union return). Caller must `defer cla.deinit()`.
    268 fn testCla(items: []const [:0]const u8) !CommandLineArgs {
    269     const arena = try testing.allocator.create(std.heap.ArenaAllocator);
    270     errdefer testing.allocator.destroy(arena);
    271     arena.* = std.heap.ArenaAllocator.init(testing.allocator);
    272     errdefer arena.deinit();
    273     return CommandLineArgs.finishParse(arena, items);
    274 }
    275 
    276 // Builds an Args from a literal argv, mirroring `parseArgs`. Caller must
    277 // `defer args.deinit()`.
    278 fn testArgs(items: []const [:0]const u8) !Args {
    279     const cla = try testCla(items);
    280     errdefer cla.deinit();
    281     return resolveArgs(cla);
    282 }
    283 
    284 test "CommandLineArgs: empty argv" {
    285     var cla = try testCla(&.{});
    286     defer cla.deinit();
    287     try testing.expectEqualStrings("", cla.exe);
    288     try testing.expectEqual(@as(usize, 0), cla.tail.len);
    289 }
    290 
    291 test "CommandLineArgs: exe only" {
    292     var cla = try testCla(&[_][:0]const u8{"magdalena"});
    293     defer cla.deinit();
    294     try testing.expectEqualStrings("magdalena", cla.exe);
    295     try testing.expectEqual(@as(usize, 0), cla.tail.len);
    296 }
    297 
    298 test "CommandLineArgs: flags with and without values" {
    299     var cla = try testCla(&[_][:0]const u8{ "bin", "--level", "info", "--silent", "-p", "5432", "-x" });
    300     defer cla.deinit();
    301     try testing.expectEqualStrings("bin", cla.exe);
    302     try testing.expectEqual(@as(usize, 0), cla.tail.len);
    303     try testing.expectEqualStrings("info", cla.get("level").?);
    304     try testing.expect(cla.contains("silent"));
    305     try testing.expectEqualStrings("", cla.get("silent").?);
    306     try testing.expectEqualStrings("5432", cla.get("p").?);
    307     try testing.expect(cla.contains("x"));
    308 }
    309 
    310 test "CommandLineArgs: key=value form" {
    311     var cla = try testCla(&[_][:0]const u8{ "bin", "--level=error", "-p=6669" });
    312     defer cla.deinit();
    313     try testing.expectEqualStrings("error", cla.get("level").?);
    314     try testing.expectEqualStrings("6669", cla.get("p").?);
    315 }
    316 
    317 test "CommandLineArgs: bundled single-char flags" {
    318     var cla = try testCla(&[_][:0]const u8{ "bin", "-xvf", "file.tar.gz" });
    319     defer cla.deinit();
    320     try testing.expect(cla.contains("x"));
    321     try testing.expectEqualStrings("", cla.get("x").?);
    322     try testing.expect(cla.contains("v"));
    323     try testing.expectEqualStrings("file.tar.gz", cla.get("f").?);
    324 }
    325 
    326 test "CommandLineArgs: tail begins at first positional" {
    327     // `-l` is followed by `--k`, which starts with '-', so `-l` takes no value;
    328     // `--k` consumes the following `x`; the tail then starts at the first
    329     // non-flag token.
    330     var cla = try testCla(&[_][:0]const u8{ "bin", "-l", "--k", "x", "ts", "-p=6669", "hello" });
    331     defer cla.deinit();
    332     try testing.expect(cla.contains("l"));
    333     try testing.expectEqualStrings("x", cla.get("k").?);
    334     try testing.expectEqual(@as(usize, 3), cla.tail.len);
    335     try testing.expectEqualStrings("ts", cla.tail[0]);
    336     try testing.expectEqualStrings("-p=6669", cla.tail[1]);
    337     try testing.expectEqualStrings("hello", cla.tail[2]);
    338 }
    339 
    340 test "resolveArgs: no args is help" {
    341     var args = try testArgs(&[_][:0]const u8{"magdalena"});
    342     defer args.deinit();
    343     try testing.expectEqual(Action.help, args.action);
    344 }
    345 
    346 test "resolveArgs: --help and -h" {
    347     for ([_][:0]const u8{ "--help", "-h", "help" }) |flag| {
    348         var args = try testArgs(&[_][:0]const u8{ "magdalena", flag });
    349         defer args.deinit();
    350         try testing.expectEqual(Action.help, args.action);
    351     }
    352 }
    353 
    354 test "resolveArgs: unknown command falls back to help" {
    355     var args = try testArgs(&[_][:0]const u8{ "magdalena", "bogus" });
    356     defer args.deinit();
    357     try testing.expectEqual(Action.help, args.action);
    358 }
    359 
    360 test "resolveArgs: cleanup is triggered by -c and --clean flags" {
    361     // Cleanup is a flag, not a subcommand: a bare `clean` positional is not
    362     // recognized and falls through to help.
    363     for ([_][:0]const u8{ "-c", "--clean" }) |flag| {
    364         var args = try testArgs(&[_][:0]const u8{ "magdalena", flag });
    365         defer args.deinit();
    366         try testing.expectEqual(Action.cleanup, args.action);
    367     }
    368 
    369     var bare = try testArgs(&[_][:0]const u8{ "magdalena", "clean" });
    370     defer bare.deinit();
    371     try testing.expectEqual(Action.help, bare.action);
    372 }
    373 
    374 test "resolveArgs: simple subcommands" {
    375     const cases = [_]struct { cmd: [:0]const u8, action: Action }{
    376         .{ .cmd = "recent-dirs", .action = .recent_dirs },
    377         .{ .cmd = "recent-files", .action = .recent_files },
    378         .{ .cmd = "favorites", .action = .favorites },
    379         .{ .cmd = "goto-dir", .action = .goto_dir },
    380         .{ .cmd = "goto-file", .action = .goto_file },
    381         .{ .cmd = "grep", .action = .grep },
    382     };
    383     for (cases) |c| {
    384         var args = try testArgs(&[_][:0]const u8{ "magdalena", c.cmd });
    385         defer args.deinit();
    386         try testing.expectEqual(c.action, args.action);
    387     }
    388 }
    389 
    390 test "resolveArgs: search carries query" {
    391     var args = try testArgs(&[_][:0]const u8{ "magdalena", "search", "needle" });
    392     defer args.deinit();
    393     try testing.expectEqual(Action.search, args.action);
    394     try testing.expectEqualStrings("needle", args.query.?);
    395 }
    396 
    397 test "resolveArgs: log-file carries path, type and action" {
    398     var args = try testArgs(&[_][:0]const u8{ "magdalena", "log-file", "/tmp/x.zig", "zig", "open" });
    399     defer args.deinit();
    400     try testing.expectEqual(Action.log_file, args.action);
    401     try testing.expectEqualStrings("/tmp/x.zig", args.log_path.?);
    402     try testing.expectEqualStrings("zig", args.file_type.?);
    403     try testing.expectEqualStrings("open", args.file_action.?);
    404 }
    405 
    406 test "resolveArgs: depth before command via --depth, --depth= and -d" {
    407     const cases = [_][:0]const u8{ "--depth", "--depth=4", "-d" };
    408     inline for (cases) |form| {
    409         const items = if (std.mem.eql(u8, form, "--depth=4"))
    410             &[_][:0]const u8{ "magdalena", "--depth=4", "look-dir" }
    411         else
    412             &[_][:0]const u8{ "magdalena", form, "4", "look-dir" };
    413         var args = try testArgs(items);
    414         defer args.deinit();
    415         try testing.expectEqual(Action.look_dir, args.action);
    416         try testing.expectEqual(@as(usize, 4), args.depth.?);
    417     }
    418 }
    419 
    420 test "resolveArgs: depth after command via tail scan" {
    421     var args = try testArgs(&[_][:0]const u8{ "magdalena", "look-file", "--depth", "7" });
    422     defer args.deinit();
    423     try testing.expectEqual(Action.look_file, args.action);
    424     try testing.expectEqual(@as(usize, 7), args.depth.?);
    425 }