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] 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" {