From f667c140abc008dd04f0c565c8e3d4a859dd71a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sat, 26 Sep 2026 23:28:48 +0200 Subject: [PATCH 1/7] cli: scale the "did you mean" distance with the input's length A flat limit of two edits was too loose for short words and too strict for long ones: `--dump md` suggested `pdf`, and a bare word within two edits of `run` or `mcp` was rejected as a mistyped command instead of being fetched, while `--insecure-disable-tls-verification` got no suggestion at all. `closest` now allows about one edit per three characters, as rustc does, not counting a leading `--`, and swapping two adjacent characters counts as one edit so `--dmup` still finds `--dump`. `--log-level` and `--log-format` now suggest the closest value too. --- src/Config.zig | 17 +++++++++----- src/agent/SlashCommand.zig | 4 ++-- src/cli.zig | 8 +++---- src/string.zig | 45 +++++++++++++++++++++++++++----------- 4 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 095976a72..4f8fc0cb3 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -86,8 +86,7 @@ fn logLevelValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?l } target.* = std.meta.stringToEnum(log.Level, str) orelse { - log.fatal(.app, "invalid option choice", .{ .arg = "--log-level", .value = str }); - return error.InvalidArgument; + return invalidChoice("--log-level", str, comptime tagNames(log.Level) ++ &[_][]const u8{"error"}); }; log.opts.level = target.*.?; } @@ -99,11 +98,19 @@ fn mcpLogDefaults() void { log.opts.format = .logfmt; } +fn invalidChoice(arg: []const u8, value: []const u8, choices: []const []const u8) error{InvalidArgument} { + if (string.closest(value, choices)) |near| { + log.fatal(.app, "invalid option choice", .{ .arg = arg, .value = log.red(value), .did_you_mean = log.green(near) }); + } else { + log.fatal(.app, "invalid option choice", .{ .arg = arg, .value = log.red(value) }); + } + return error.InvalidArgument; +} + fn logFormatValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?log.Format) !void { const str = args.next() orelse return error.MissingArgument; const format = std.meta.stringToEnum(log.Format, str) orelse { - log.fatal(.app, "invalid option choice", .{ .arg = "--log-format", .value = str }); - return error.InvalidArgument; + return invalidChoice("--log-format", str, tagNames(log.Format)); }; target.* = format; log.opts.format = format; @@ -323,7 +330,7 @@ fn dumpValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?DumpF if (peek_args.next()) |next_arg| { const mode = std.meta.stringToEnum(DumpFormat, next_arg) orelse { // Anything else is the positional url, unless it is a misspelt format. - if (string.closest(next_arg, tagNames(DumpFormat), 2)) |near| { + if (string.closest(next_arg, tagNames(DumpFormat))) |near| { log.fatal(.app, "invalid option choice", .{ .arg = "--dump", .value = log.red(next_arg), .did_you_mean = log.green(near) }); return error.InvalidArgument; } diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig index 76f3b59cb..13c1f19f1 100644 --- a/src/agent/SlashCommand.zig +++ b/src/agent/SlashCommand.zig @@ -103,7 +103,7 @@ const meta_names = blk: { /// Every slash-invocable name: browser tools, LLM triggers, meta commands. pub const all_names = browser_tools.names ++ llm_names ++ meta_names; -/// Closest command name within two edits, or null — for "did you mean?" on typos. +/// Closest command name, or null — for "did you mean?" on typos. pub fn closestCommand(name: []const u8) ?[]const u8 { - return string.closest(name, &all_names, 2); + return string.closest(name, &all_names); } diff --git a/src/cli.zig b/src/cli.zig index 8eda8588a..7bb869ea8 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -442,7 +442,7 @@ pub fn Builder(comptime commands: anytype) type { } // A bare word close to a command name is a typo, not a fetch url. - if (std.mem.startsWith(u8, cmd_str, "--") == false and string.closest(cmd_str, command_names, 2) != null) { + if (std.mem.startsWith(u8, cmd_str, "--") == false and string.closest(cmd_str, command_names) != null) { return unknownCommand(cmd_str); } @@ -474,7 +474,7 @@ pub fn Builder(comptime commands: anytype) type { fn unknownCommand(name: []const u8) error{UnknownCommand} { const arg = log.red(name); - if (string.closest(name, command_names, 2)) |near| { + if (string.closest(name, command_names)) |near| { log.fatal(.app, "unknown command", .{ .arg = arg, .did_you_mean = log.green(near) }); } else { log.fatal(.app, "unknown command", .{ .arg = arg }); @@ -690,7 +690,7 @@ pub fn Builder(comptime commands: anytype) type { const str = args.next() orelse return error.MissingArgument; const v = std.meta.stringToEnum(E, str) orelse { const value = log.red(str); - if (string.closest(str, tagNames(E), 2)) |near| { + if (string.closest(str, tagNames(E))) |near| { log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = value, .did_you_mean = log.green(near) }); } else { log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = value }); @@ -819,7 +819,7 @@ pub fn Builder(comptime commands: anytype) type { if (std.mem.startsWith(u8, option_name, "--")) { const names = comptime optionNames(options) ++ &[_][]const u8{"--help"}; const arg = log.red(option_name); - if (string.closest(option_name, names, 2)) |near| { + if (string.closest(option_name, names)) |near| { log.fatal(.app, "unknown argument", .{ .mode = command.name, .arg = arg, .did_you_mean = log.green(near) }); } else { log.fatal(.app, "unknown argument", .{ .mode = command.name, .arg = arg }); diff --git a/src/string.zig b/src/string.zig index de8515a93..9fa507f62 100644 --- a/src/string.zig +++ b/src/string.zig @@ -417,11 +417,12 @@ pub fn isOneOf(needle: []const u8, haystack: []const []const u8) bool { } else false; } -/// Case-insensitive. Inputs over 64 bytes return `maxInt`; that fits the -/// longest CLI flag. +/// Case-insensitive, and swapping two adjacent characters counts as one +/// edit. Inputs over 64 bytes return `maxInt`; that fits the longest CLI flag. fn editDistance(a: []const u8, b: []const u8) usize { const max = 64; if (a.len > max or b.len > max) return std.math.maxInt(usize); + var prev2: [max + 1]u8 = undefined; var prev: [max + 1]u8 = undefined; var cur: [max + 1]u8 = undefined; for (0..b.len + 1) |j| prev[j] = @intCast(j); @@ -429,16 +430,24 @@ fn editDistance(a: []const u8, b: []const u8) usize { const la = std.ascii.toLower(ca); cur[0] = @intCast(i); for (b, 1..) |cb, j| { - const cost: u8 = if (la == std.ascii.toLower(cb)) 0 else 1; + const lb = std.ascii.toLower(cb); + const cost: u8 = if (la == lb) 0 else 1; cur[j] = @min(@min(prev[j] + 1, cur[j - 1] + 1), prev[j - 1] + cost); + if (i > 1 and j > 1 and la == std.ascii.toLower(b[j - 2]) and std.ascii.toLower(a[i - 2]) == lb) { + cur[j] = @min(cur[j], prev2[j - 2] + 1); + } } + prev2 = prev; prev = cur; } return prev[b.len]; } -/// Earlier candidates win ties. -pub fn closest(name: []const u8, candidates: []const []const u8, max_dist: usize) ?[]const u8 { +/// The candidate nearest to `name`, within about one edit per three characters +/// (rustc's rule). A leading `--` doesn't count toward the length, since every +/// flag shares it. Earlier candidates win ties. +pub fn closest(name: []const u8, candidates: []const []const u8) ?[]const u8 { + const max_dist = @max(std.mem.trimStart(u8, name, "-").len, 3) / 3; var best: ?[]const u8 = null; var best_dist: usize = std.math.maxInt(usize); for (candidates) |cand| { @@ -553,6 +562,8 @@ test "editDistance" { try testing.expectEqual(@as(usize, 1), editDistance("wait-m", "wait-ms")); try testing.expectEqual(@as(usize, 1), editDistance("wait_ms", "wait-ms")); try testing.expectEqual(@as(usize, 3), editDistance("kitten", "sitting")); + try testing.expectEqual(@as(usize, 1), editDistance("dmup", "dump")); + try testing.expectEqual(@as(usize, 1), editDistance("ab", "ba")); try testing.expectEqual(@as(usize, 3), editDistance("", "abc")); try testing.expectEqual(@as(usize, 3), editDistance("abc", "")); @@ -562,15 +573,23 @@ test "editDistance" { } test "closest" { - const names = [_][]const u8{ "--dump", "--wait-ms", "--wait-until" }; - try testing.expectEqual("--wait-ms", closest("--wait-mss", &names, 2)); - try testing.expectEqual("--dump", closest("--dmup", &names, 2)); - try testing.expectEqual(null, closest("--totally-wrong", &names, 2)); - try testing.expectEqual(null, closest("--wait-mss", &names, 0)); - try testing.expectEqual(null, closest("--dump", &.{}, 2)); + const names = [_][]const u8{ "--dump", "--wait-ms", "--wait-until", "--insecure-disable-tls-host-verification" }; + try testing.expectEqual("--wait-ms", closest("--wait-mss", &names)); + try testing.expectEqual("--dump", closest("--dmup", &names)); + try testing.expectEqual(null, closest("--totally-wrong", &names)); + try testing.expectEqual(null, closest("--dump", &.{})); + try testing.expectEqual("--insecure-disable-tls-host-verification", closest("--insecure-disable-tls-verification", &names)); - const tie = [_][]const u8{ "ab", "ac" }; - try testing.expectEqual("ab", closest("a", &tie, 1)); + const formats = [_][]const u8{ "html", "markdown", "pdf", "png" }; + try testing.expectEqual(null, closest("md", &formats)); + try testing.expectEqual("pdf", closest("pdg", &formats)); + + const commands = [_][]const u8{ "fetch", "mcp", "run" }; + try testing.expectEqual("run", closest("fun", &commands)); + try testing.expectEqual(null, closest("ab", &commands)); + + const tie = [_][]const u8{ "abcd", "abce" }; + try testing.expectEqual("abcd", closest("abc", &tie)); } test "latin1ToUtf8" { From 497f0363e5009c835818292fce223dbf38f68eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sat, 26 Sep 2026 23:33:30 +0200 Subject: [PATCH 2/7] cli: share the "invalid option choice" error `cli.invalidChoice` logs a bad value with its closest match, and the generic enum path, --dump, --log-level, --log-format and now --log-filter all use it. editDistance lowercases its inputs once, and closest strips exactly a leading `--`, as its doc says. --- src/Config.zig | 25 +++++-------------------- src/cli.zig | 20 +++++++++++--------- src/string.zig | 20 ++++++++++++-------- 3 files changed, 28 insertions(+), 37 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 4f8fc0cb3..e1e83e1c6 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -69,8 +69,7 @@ fn logFilterValidator(allocator: Allocator, args: *std.process.Args.Iterator, li } const v = std.meta.stringToEnum(log.Scope, name) orelse { - log.fatal(.app, "invalid option choice", .{ .arg = "--log-filter", .value = part }); - return error.InvalidOption; + return cli.invalidChoice("--log-filter", name, comptime tagNames(log.Scope) ++ &[_][]const u8{"all"}); }; try list.append(allocator, .{ .scope = v, .enable = enable }); @@ -85,9 +84,7 @@ fn logLevelValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?l return; } - target.* = std.meta.stringToEnum(log.Level, str) orelse { - return invalidChoice("--log-level", str, comptime tagNames(log.Level) ++ &[_][]const u8{"error"}); - }; + target.* = std.meta.stringToEnum(log.Level, str) orelse return cli.invalidChoice("--log-level", str, tagNames(log.Level)); log.opts.level = target.*.?; } @@ -98,20 +95,9 @@ fn mcpLogDefaults() void { log.opts.format = .logfmt; } -fn invalidChoice(arg: []const u8, value: []const u8, choices: []const []const u8) error{InvalidArgument} { - if (string.closest(value, choices)) |near| { - log.fatal(.app, "invalid option choice", .{ .arg = arg, .value = log.red(value), .did_you_mean = log.green(near) }); - } else { - log.fatal(.app, "invalid option choice", .{ .arg = arg, .value = log.red(value) }); - } - return error.InvalidArgument; -} - fn logFormatValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?log.Format) !void { const str = args.next() orelse return error.MissingArgument; - const format = std.meta.stringToEnum(log.Format, str) orelse { - return invalidChoice("--log-format", str, tagNames(log.Format)); - }; + const format = std.meta.stringToEnum(log.Format, str) orelse return cli.invalidChoice("--log-format", str, tagNames(log.Format)); target.* = format; log.opts.format = format; } @@ -330,9 +316,8 @@ fn dumpValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?DumpF if (peek_args.next()) |next_arg| { const mode = std.meta.stringToEnum(DumpFormat, next_arg) orelse { // Anything else is the positional url, unless it is a misspelt format. - if (string.closest(next_arg, tagNames(DumpFormat))) |near| { - log.fatal(.app, "invalid option choice", .{ .arg = "--dump", .value = log.red(next_arg), .did_you_mean = log.green(near) }); - return error.InvalidArgument; + if (string.closest(next_arg, tagNames(DumpFormat)) != null) { + return cli.invalidChoice("--dump", next_arg, tagNames(DumpFormat)); } target.* = .html; return; diff --git a/src/cli.zig b/src/cli.zig index 7bb869ea8..06dbe5cf2 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -200,6 +200,16 @@ pub fn tagNames(comptime E: type) []const []const u8 { }; } +/// Logs a bad value for `arg` with the closest of `choices`, if any. +pub fn invalidChoice(arg: []const u8, value: []const u8, choices: []const []const u8) error{InvalidArgument} { + if (string.closest(value, choices)) |near| { + log.fatal(.app, "invalid option choice", .{ .arg = arg, .value = log.red(value), .did_you_mean = log.green(near) }); + } else { + log.fatal(.app, "invalid option choice", .{ .arg = arg, .value = log.red(value) }); + } + return error.InvalidArgument; +} + pub fn Builder(comptime commands: anytype) type { return struct { const Self = @This(); @@ -688,15 +698,7 @@ pub fn Builder(comptime commands: anytype) type { }; const str = args.next() orelse return error.MissingArgument; - const v = std.meta.stringToEnum(E, str) orelse { - const value = log.red(str); - if (string.closest(str, tagNames(E))) |near| { - log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = value, .did_you_mean = log.green(near) }); - } else { - log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = value }); - } - return error.InvalidArgument; - }; + const v = std.meta.stringToEnum(E, str) orelse return invalidChoice(kebab_cased, str, tagNames(E)); if (is_multiple) { try target.append(allocator, v); diff --git a/src/string.zig b/src/string.zig index 9fa507f62..4931eca35 100644 --- a/src/string.zig +++ b/src/string.zig @@ -422,18 +422,21 @@ pub fn isOneOf(needle: []const u8, haystack: []const []const u8) bool { fn editDistance(a: []const u8, b: []const u8) usize { const max = 64; if (a.len > max or b.len > max) return std.math.maxInt(usize); + var a_buf: [max]u8 = undefined; + var b_buf: [max]u8 = undefined; + const la = std.ascii.lowerString(&a_buf, a); + const lb = std.ascii.lowerString(&b_buf, b); + var prev2: [max + 1]u8 = undefined; var prev: [max + 1]u8 = undefined; var cur: [max + 1]u8 = undefined; - for (0..b.len + 1) |j| prev[j] = @intCast(j); - for (a, 1..) |ca, i| { - const la = std.ascii.toLower(ca); + for (0..lb.len + 1) |j| prev[j] = @intCast(j); + for (la, 1..) |ca, i| { cur[0] = @intCast(i); - for (b, 1..) |cb, j| { - const lb = std.ascii.toLower(cb); - const cost: u8 = if (la == lb) 0 else 1; + for (lb, 1..) |cb, j| { + const cost: u8 = if (ca == cb) 0 else 1; cur[j] = @min(@min(prev[j] + 1, cur[j - 1] + 1), prev[j - 1] + cost); - if (i > 1 and j > 1 and la == std.ascii.toLower(b[j - 2]) and std.ascii.toLower(a[i - 2]) == lb) { + if (i > 1 and j > 1 and ca == lb[j - 2] and la[i - 2] == cb) { cur[j] = @min(cur[j], prev2[j - 2] + 1); } } @@ -447,7 +450,8 @@ fn editDistance(a: []const u8, b: []const u8) usize { /// (rustc's rule). A leading `--` doesn't count toward the length, since every /// flag shares it. Earlier candidates win ties. pub fn closest(name: []const u8, candidates: []const []const u8) ?[]const u8 { - const max_dist = @max(std.mem.trimStart(u8, name, "-").len, 3) / 3; + const typed = if (std.mem.startsWith(u8, name, "--")) name[2..] else name; + const max_dist = @max(typed.len, 3) / 3; var best: ?[]const u8 = null; var best_dist: usize = std.math.maxInt(usize); for (candidates) |cand| { From 4f4829ccf86ecbcfae34cf83c936a0e86d244f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sat, 26 Sep 2026 23:37:00 +0200 Subject: [PATCH 3/7] string: don't count the candidates' shared prefix in closest's limit closest stripped a leading `--`, a CLI convention in a helper the agent's slash commands share too. It now leaves out whatever prefix every candidate starts with: `--` for flags, nothing for commands and values. --- src/string.zig | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/string.zig b/src/string.zig index 4931eca35..3963ddca4 100644 --- a/src/string.zig +++ b/src/string.zig @@ -447,11 +447,16 @@ fn editDistance(a: []const u8, b: []const u8) usize { } /// The candidate nearest to `name`, within about one edit per three characters -/// (rustc's rule). A leading `--` doesn't count toward the length, since every -/// flag shares it. Earlier candidates win ties. +/// (rustc's rule). A prefix every candidate shares, like a flag's `--`, doesn't +/// count toward the length. Earlier candidates win ties. pub fn closest(name: []const u8, candidates: []const []const u8) ?[]const u8 { - const typed = if (std.mem.startsWith(u8, name, "--")) name[2..] else name; - const max_dist = @max(typed.len, 3) / 3; + if (candidates.len == 0) return null; + var shared = candidates[0]; + for (candidates[1..]) |cand| { + shared = shared[0 .. std.mem.indexOfDiff(u8, shared, cand) orelse shared.len]; + } + const typed = if (std.mem.startsWith(u8, name, shared)) name.len - shared.len else name.len; + const max_dist = @max(typed, 3) / 3; var best: ?[]const u8 = null; var best_dist: usize = std.math.maxInt(usize); for (candidates) |cand| { @@ -580,6 +585,7 @@ test "closest" { const names = [_][]const u8{ "--dump", "--wait-ms", "--wait-until", "--insecure-disable-tls-host-verification" }; try testing.expectEqual("--wait-ms", closest("--wait-mss", &names)); try testing.expectEqual("--dump", closest("--dmup", &names)); + try testing.expectEqual(null, closest("--dmpx", &names)); try testing.expectEqual(null, closest("--totally-wrong", &names)); try testing.expectEqual(null, closest("--dump", &.{})); try testing.expectEqual("--insecure-disable-tls-host-verification", closest("--insecure-disable-tls-verification", &names)); @@ -588,6 +594,7 @@ test "closest" { try testing.expectEqual(null, closest("md", &formats)); try testing.expectEqual("pdf", closest("pdg", &formats)); + const commands = [_][]const u8{ "fetch", "mcp", "run" }; try testing.expectEqual("run", closest("fun", &commands)); try testing.expectEqual(null, closest("ab", &commands)); From 1e93f038f59ac6d4443d307460d4d5a5538db2a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sat, 26 Sep 2026 23:39:09 +0200 Subject: [PATCH 4/7] cli: drop a comment restating invalidChoice --- src/cli.zig | 1 - src/string.zig | 1 - 2 files changed, 2 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 06dbe5cf2..734cb97d9 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -200,7 +200,6 @@ pub fn tagNames(comptime E: type) []const []const u8 { }; } -/// Logs a bad value for `arg` with the closest of `choices`, if any. pub fn invalidChoice(arg: []const u8, value: []const u8, choices: []const []const u8) error{InvalidArgument} { if (string.closest(value, choices)) |near| { log.fatal(.app, "invalid option choice", .{ .arg = arg, .value = log.red(value), .did_you_mean = log.green(near) }); diff --git a/src/string.zig b/src/string.zig index 3963ddca4..32a953647 100644 --- a/src/string.zig +++ b/src/string.zig @@ -594,7 +594,6 @@ test "closest" { try testing.expectEqual(null, closest("md", &formats)); try testing.expectEqual("pdf", closest("pdg", &formats)); - const commands = [_][]const u8{ "fetch", "mcp", "run" }; try testing.expectEqual("run", closest("fun", &commands)); try testing.expectEqual(null, closest("ab", &commands)); From cafe31649a931c5f49eae2f620214f0f9867f847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 27 Sep 2026 13:21:52 +0200 Subject: [PATCH 5/7] cli: read a url-like --dump argument as the url, keep --log-filter's sign `fetch --dump markdown.com` was rejected as a misspelt `markdown`: the length-scaled limit allows 4 edits for 12 characters. No format has a `.`, `/` or `:`, so an argument with one is always the url. `--log-filter -cdpp` suggested `cdp`; invalidChoice now takes the stripped prefix and puts it back on the value and the suggestion. --- src/Config.zig | 31 ++++++++++++++++++++++++++----- src/cli.zig | 13 +++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index e1e83e1c6..264bcfa88 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -69,7 +69,7 @@ fn logFilterValidator(allocator: Allocator, args: *std.process.Args.Iterator, li } const v = std.meta.stringToEnum(log.Scope, name) orelse { - return cli.invalidChoice("--log-filter", name, comptime tagNames(log.Scope) ++ &[_][]const u8{"all"}); + return cli.invalidChoice("--log-filter", part[0 .. part.len - name.len], name, comptime tagNames(log.Scope) ++ &[_][]const u8{"all"}); }; try list.append(allocator, .{ .scope = v, .enable = enable }); @@ -84,7 +84,7 @@ fn logLevelValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?l return; } - target.* = std.meta.stringToEnum(log.Level, str) orelse return cli.invalidChoice("--log-level", str, tagNames(log.Level)); + target.* = std.meta.stringToEnum(log.Level, str) orelse return cli.invalidChoice("--log-level", "", str, tagNames(log.Level)); log.opts.level = target.*.?; } @@ -97,7 +97,7 @@ fn mcpLogDefaults() void { fn logFormatValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?log.Format) !void { const str = args.next() orelse return error.MissingArgument; - const format = std.meta.stringToEnum(log.Format, str) orelse return cli.invalidChoice("--log-format", str, tagNames(log.Format)); + const format = std.meta.stringToEnum(log.Format, str) orelse return cli.invalidChoice("--log-format", "", str, tagNames(log.Format)); target.* = format; log.opts.format = format; } @@ -316,8 +316,10 @@ fn dumpValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?DumpF if (peek_args.next()) |next_arg| { const mode = std.meta.stringToEnum(DumpFormat, next_arg) orelse { // Anything else is the positional url, unless it is a misspelt format. - if (string.closest(next_arg, tagNames(DumpFormat)) != null) { - return cli.invalidChoice("--dump", next_arg, tagNames(DumpFormat)); + // No format has a `.`, `/` or `:`, so `markdown.com` is a url. + const url_like = std.mem.indexOfAny(u8, next_arg, ".:/") != null; + if (!url_like and string.closest(next_arg, tagNames(DumpFormat)) != null) { + return cli.invalidChoice("--dump", "", next_arg, tagNames(DumpFormat)); } target.* = .html; return; @@ -1360,6 +1362,25 @@ test "Config: parseArgs --http-session-timeout" { } } +test "Config: parseArgs --dump tells a url from a misspelt format" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + { + const argv = [_][*:0]const u8{ "lightpanda", "fetch", "--dump", "markdown.com" }; + const proc_args: std.process.Args = .{ .vector = &argv }; + const config = try parseArgs(arena.allocator(), proc_args); + try std.testing.expectEqual(.html, config.mode.fetch.dump); + try std.testing.expectEqualStrings("markdown.com", config.mode.fetch.url.items[0]); + } + { + log.expectLog(&.{.app}); + const argv = [_][*:0]const u8{ "lightpanda", "fetch", "--dump", "markdwon", "https://example.com" }; + const proc_args: std.process.Args = .{ .vector = &argv }; + try std.testing.expectError(error.InvalidArgument, parseArgs(arena.allocator(), proc_args)); + } +} + test "Config: validateUserAgent" { try validateUserAgent("Lightpanda/1.0"); try std.testing.expectError(error.Reserved, validateUserAgent("mozilla/1.0")); diff --git a/src/cli.zig b/src/cli.zig index 734cb97d9..bcf0afe9f 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -200,11 +200,16 @@ pub fn tagNames(comptime E: type) []const []const u8 { }; } -pub fn invalidChoice(arg: []const u8, value: []const u8, choices: []const []const u8) error{InvalidArgument} { +/// `prefix` was stripped from `value` before matching, like `--log-filter`'s sign. +pub fn invalidChoice(arg: []const u8, prefix: []const u8, value: []const u8, choices: []const []const u8) error{InvalidArgument} { + var value_buf: [128]u8 = undefined; + const typed = std.fmt.bufPrint(&value_buf, "{s}{s}", .{ prefix, value }) catch value; if (string.closest(value, choices)) |near| { - log.fatal(.app, "invalid option choice", .{ .arg = arg, .value = log.red(value), .did_you_mean = log.green(near) }); + var near_buf: [128]u8 = undefined; + const suggestion = std.fmt.bufPrint(&near_buf, "{s}{s}", .{ prefix, near }) catch near; + log.fatal(.app, "invalid option choice", .{ .arg = arg, .value = log.red(typed), .did_you_mean = log.green(suggestion) }); } else { - log.fatal(.app, "invalid option choice", .{ .arg = arg, .value = log.red(value) }); + log.fatal(.app, "invalid option choice", .{ .arg = arg, .value = log.red(typed) }); } return error.InvalidArgument; } @@ -697,7 +702,7 @@ pub fn Builder(comptime commands: anytype) type { }; const str = args.next() orelse return error.MissingArgument; - const v = std.meta.stringToEnum(E, str) orelse return invalidChoice(kebab_cased, str, tagNames(E)); + const v = std.meta.stringToEnum(E, str) orelse return invalidChoice(kebab_cased, "", str, tagNames(E)); if (is_multiple) { try target.append(allocator, v); From 298f1b1f0cf9f78d11cbcb52f4eb4007485c989e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 27 Sep 2026 13:35:04 +0200 Subject: [PATCH 6/7] main: drop InvalidOption, which parseArgs no longer returns --log-filter reports an unknown scope through invalidChoice now. --- src/main.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index a669571de..9e2b3531f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -67,7 +67,6 @@ fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args) // Already logged where they were found. error.UnknownCommand, error.UnknownOption, - error.InvalidOption, error.InvalidArgument, error.MissingArgument, error.TooManyPositionalArguments, From 1188554515d56f39e57795758ea3aba8c76ffb39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 28 Sep 2026 09:22:29 +0200 Subject: [PATCH 7/7] cli: read a url-like bare word as the url, not a misspelt command `lightpanda version.io` was rejected as a misspelt `version`, 3 edits within the length-scaled limit. Like curl, it fetches now: the command check shares --dump's isUrlLike. --- src/Config.zig | 22 +++++++++++++++++++--- src/cli.zig | 8 +++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 264bcfa88..e6b16d37e 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -316,9 +316,7 @@ fn dumpValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?DumpF if (peek_args.next()) |next_arg| { const mode = std.meta.stringToEnum(DumpFormat, next_arg) orelse { // Anything else is the positional url, unless it is a misspelt format. - // No format has a `.`, `/` or `:`, so `markdown.com` is a url. - const url_like = std.mem.indexOfAny(u8, next_arg, ".:/") != null; - if (!url_like and string.closest(next_arg, tagNames(DumpFormat)) != null) { + if (!cli.isUrlLike(next_arg) and string.closest(next_arg, tagNames(DumpFormat)) != null) { return cli.invalidChoice("--dump", "", next_arg, tagNames(DumpFormat)); } target.* = .html; @@ -1381,6 +1379,24 @@ test "Config: parseArgs --dump tells a url from a misspelt format" { } } +test "Config: parseArgs tells a url from a misspelt command" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + { + const argv = [_][*:0]const u8{ "lightpanda", "version.io" }; + const proc_args: std.process.Args = .{ .vector = &argv }; + const config = try parseArgs(arena.allocator(), proc_args); + try std.testing.expectEqualStrings("version.io", config.mode.fetch.url.items[0]); + } + { + log.expectLog(&.{.app}); + const argv = [_][*:0]const u8{ "lightpanda", "versoin" }; + const proc_args: std.process.Args = .{ .vector = &argv }; + try std.testing.expectError(error.UnknownCommand, parseArgs(arena.allocator(), proc_args)); + } +} + test "Config: validateUserAgent" { try validateUserAgent("Lightpanda/1.0"); try std.testing.expectError(error.Reserved, validateUserAgent("mozilla/1.0")); diff --git a/src/cli.zig b/src/cli.zig index bcf0afe9f..881644427 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -200,6 +200,12 @@ pub fn tagNames(comptime E: type) []const []const u8 { }; } +/// No command or choice has a `.`, `/` or `:`, so `markdown.com` is a url +/// however close it is to `markdown`. +pub fn isUrlLike(arg: []const u8) bool { + return std.mem.indexOfAny(u8, arg, ".:/") != null; +} + /// `prefix` was stripped from `value` before matching, like `--log-filter`'s sign. pub fn invalidChoice(arg: []const u8, prefix: []const u8, value: []const u8, choices: []const []const u8) error{InvalidArgument} { var value_buf: [128]u8 = undefined; @@ -456,7 +462,7 @@ pub fn Builder(comptime commands: anytype) type { } // A bare word close to a command name is a typo, not a fetch url. - if (std.mem.startsWith(u8, cmd_str, "--") == false and string.closest(cmd_str, command_names) != null) { + if (std.mem.startsWith(u8, cmd_str, "--") == false and !isUrlLike(cmd_str) and string.closest(cmd_str, command_names) != null) { return unknownCommand(cmd_str); }