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 }