cli: suggest the closest flag or command on a typo

An unknown --flag now logs the nearest accepted spelling within two
edits as did_you_mean, painted green next to the red typo in the pretty
log format; logfmt stays plain. A bare first argument within two edits
of a command name is rejected with the same hint instead of being
fetched as a url.

The Levenshtein helper moves from SlashCommand into string.zig so the
agent REPL and the CLI share it, with the table widened to fit the
longest flag name.
This commit is contained in:
Adrià Arrufat committed 2026-09-08 16:50:46 +02:00
1 parent 5d732eb8f1
commit 88d133a676
4 files changed
+177 -30

No files matched your search

+2 -27
View File
@@ -23,6 +23,7 @@
const std = @import("std");
const lp = @import("lightpanda");
const string = @import("../string.zig");
const Command = lp.Command;
const Config = lp.Config;
@@ -108,31 +109,5 @@ pub const all_names: [browser_tools.names.len + meta_commands.len + llm_values.l
/// Closest command name within two edits, or null — for "did you mean?" on typos.
pub fn closestCommand(name: []const u8) ?[]const u8 {
var best: ?[]const u8 = null;
var best_dist: usize = std.math.maxInt(usize);
for (all_names) |cand| {
const dist = editDistance(name, cand);
if (dist < best_dist) {
best_dist = dist;
best = cand;
}
}
return if (best_dist <= 2) best else null;
}
/// Case-insensitive Levenshtein distance. Returns `maxInt` for inputs longer
/// than the table (no slash command is that long).
fn editDistance(a: []const u8, b: []const u8) usize {
const max = 32;
if (a.len >= max or b.len >= max) return std.math.maxInt(usize);
var dp: [max][max]usize = undefined;
for (0..a.len + 1) |i| dp[i][0] = i;
for (0..b.len + 1) |j| dp[0][j] = j;
for (a, 1..) |ca, i| {
for (b, 1..) |cb, j| {
const cost: usize = if (std.ascii.toLower(ca) == std.ascii.toLower(cb)) 0 else 1;
dp[i][j] = @min(@min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost);
}
}
return dp[a.len][b.len];
return string.closest(name, &all_names, 2);
}
+74 -3
View File
@@ -20,6 +20,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const lp = @import("lightpanda");
const log = lp.log;
const string = @import("string.zig");
/// Comptime CLI builder that generates a tagged union parser from a
/// declarative command recipe. Each command becomes a union variant whose
@@ -41,6 +42,10 @@ const log = lp.log;
/// - Legacy fallback: if the first argument starts with `--` and matches a
/// known fetch/serve flag, the parser sniffs the command from it and
/// re-parses argv. Only exists for backwards compatibility.
/// - An unknown `--flag` returns `error.UnknownOption`, and a bare first
/// argument within two edits of a command name returns
/// `error.UnknownCommand` instead of being fetched as a url. The fatal
/// log line names the closest match as `did_you_mean`.
///
/// ## Command descriptor fields
///
@@ -206,6 +211,14 @@ pub fn Builder(comptime commands: anytype) type {
break :blk @Enum(Tag, .exhaustive, &names, &std.simd.iota(Tag, len));
};
const command_names: []const []const u8 = blk: {
var names: []const []const u8 = &.{};
for (std.meta.fieldNames(Enum)) |name| {
names = names ++ &[_][]const u8{name};
}
break :blk names;
};
/// Creates an array of `StructField` out of given options.
fn optionsToStructFields(comptime options: anytype) [options.len]std.builtin.Type.StructField {
var fields: [options.len]std.builtin.Type.StructField = undefined;
@@ -421,8 +434,12 @@ pub fn Builder(comptime commands: anytype) type {
return .{ exec_name, @unionInit(Union, "help", .help) };
}
log.fatal(.app, "unknown command", .{ .arg = command_name });
return error.UnknownCommand;
return unknownCommand(command_name);
}
// 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) {
return unknownCommand(cmd_str);
}
// Last resort, try sniffing.
@@ -451,6 +468,16 @@ pub fn Builder(comptime commands: anytype) type {
unreachable;
}
fn unknownCommand(name: []const u8) error{UnknownCommand} {
const arg = log.red(name);
if (string.closest(name, command_names, 2)) |near| {
log.fatal(.app, "unknown command", .{ .arg = arg, .did_you_mean = log.green(near) });
} else {
log.fatal(.app, "unknown command", .{ .arg = arg });
}
return error.UnknownCommand;
}
/// Try to sniff the command out of given option.
/// Only exists for legacy reasons; hence hardcoded.
fn sniffCommand(cmd_str: []const u8) error{UnknownCommand}!Enum {
@@ -509,6 +536,25 @@ pub fn Builder(comptime commands: anytype) type {
return output;
}
/// Short aliases are left out: a one-letter candidate sits within two
/// edits of nearly any typo.
fn optionNames(comptime options: anytype) []const []const u8 {
return comptime blk: {
// toKebabCase walks every byte of every name.
@setEvalBranchQuota(50_000);
var names: []const []const u8 = &.{};
for (options) |option| {
names = names ++ &[_][]const u8{"--" ++ toKebabCase(option.name)};
if (@hasField(@TypeOf(option), "variants")) {
for (option.variants) |variant| {
names = names ++ &[_][]const u8{"--" ++ toKebabCase(variant.name)};
}
}
}
break :blk names;
};
}
fn parseValue(
allocator: Allocator,
args: *std.process.Args.Iterator,
@@ -760,7 +806,13 @@ pub fn Builder(comptime commands: anytype) type {
// Encountered an option we don't know of.
if (std.mem.startsWith(u8, option_name, "--")) {
log.fatal(.app, "unknown argument", .{ .mode = command.name, .arg = option_name });
const names = comptime optionNames(options) ++ &[_][]const u8{"--help"};
const arg = log.red(option_name);
if (string.closest(option_name, names, 2)) |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 });
}
return error.UnknownOption;
}
@@ -834,3 +886,22 @@ pub fn Builder(comptime commands: anytype) type {
}
};
}
test "cli: optionNames" {
const options = .{
.{ .name = "dump", .type = bool },
.{
.name = "wait_script",
.type = ?[]const u8,
.variants = .{
.{ .name = "wait_script_file" },
},
},
};
const Cli = Builder(.{
.{ .name = "fetch", .options = options },
});
const expected = [_][]const u8{ "--dump", "--wait-script", "--wait-script-file" };
try std.testing.expectEqualDeep(&expected, Cli.optionNames(options));
}
+39
View File
@@ -346,6 +346,30 @@ pub const KV = struct {
}
};
/// A string the pretty format paints; logfmt writes it plainly.
pub const Colored = struct {
code: []const u8,
text: []const u8,
pub fn logFmt(self: Colored, key: []const u8, writer: LogFormatWriter) !void {
return writer.write(key, self.text);
}
pub fn format(self: Colored, writer: *std.Io.Writer) !void {
try writer.writeAll(self.code);
try writer.writeAll(self.text);
return writer.writeAll("\x1b[0m");
}
};
pub fn red(text: []const u8) Colored {
return .{ .code = "\x1b[0;31m", .text = text };
}
pub fn green(text: []const u8) Colored {
return .{ .code = "\x1b[0;32m", .text = text };
}
const Value = union(enum) {
null,
string: []const u8,
@@ -577,6 +601,21 @@ fn timestamp(comptime clock: std.Io.Clock) u64 {
}
const testing = @import("testing.zig");
test "log: colored" {
opts.format = .logfmt;
defer opts.format = .pretty;
var aw = std.Io.Writer.Allocating.init(testing.allocator);
defer aw.deinit();
try logTo(.app, .err, "test", .{ .arg = red("--wait mss") }, &aw.writer);
try testing.expectEqual("$time=1739795092929 $scope=app $level=error $msg=\"test\" arg=\"--wait mss\"\n", aw.written());
aw.clearRetainingCapacity();
try writeValue(.pretty, green("--wait-ms"), &aw.writer);
try testing.expectEqual("\x1b[0;32m--wait-ms\x1b[0m", aw.written());
}
test "log: data" {
opts.format = .logfmt;
defer opts.format = .pretty;
+62
View File
@@ -425,6 +425,40 @@ 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.
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 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);
cur[0] = @intCast(i);
for (b, 1..) |cb, j| {
const cost: u8 = if (la == std.ascii.toLower(cb)) 0 else 1;
cur[j] = @min(@min(prev[j] + 1, cur[j - 1] + 1), prev[j - 1] + cost);
}
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 {
var best: ?[]const u8 = null;
var best_dist: usize = std.math.maxInt(usize);
for (candidates) |cand| {
const dist = editDistance(name, cand);
if (dist < best_dist) {
best_dist = dist;
best = cand;
}
}
return if (best_dist <= max_dist) best else null;
}
/// Largest prefix of `bytes` whose length is at most `max_bytes` and
/// ends on a UTF-8 codepoint boundary. Invalid sequences count as one
/// byte each so the function never loops.
@@ -519,6 +553,34 @@ test "truncateUtf8" {
try testing.expectEqual("\xFFx", truncateUtf8("\xFFx", 2));
}
test "editDistance" {
try testing.expectEqual(@as(usize, 0), editDistance("", ""));
try testing.expectEqual(@as(usize, 0), editDistance("wait-ms", "wait-ms"));
try testing.expectEqual(@as(usize, 0), editDistance("Wait-MS", "wait-ms"));
try testing.expectEqual(@as(usize, 1), editDistance("wait-mss", "wait-ms"));
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, 3), editDistance("", "abc"));
try testing.expectEqual(@as(usize, 3), editDistance("abc", ""));
const long = "x" ** 64;
try testing.expectEqual(@as(usize, 0), editDistance(long, long));
try testing.expectEqual(std.math.maxInt(usize), editDistance(long ++ "x", long));
}
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 tie = [_][]const u8{ "ab", "ac" };
try testing.expectEqual("ab", closest("a", &tie, 1));
}
test "latin1ToUtf8" {
const cases = [_]struct { in: []const u8, out: []const u8 }{
.{ .in = "caf\xE9.txt", .out = "café.txt" },