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.
This commit is contained in:
Adrià Arrufat committed 2026-09-27 13:26:28 +02:00
1 parent 6cd05967ed
commit f667c140ab
4 files changed
+50 -24

No files matched your search

+12 -5
View File
@@ -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;
}
+2 -2
View File
@@ -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);
}
+4 -4
View File
@@ -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 });
+32 -13
View File
@@ -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" {