diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig index e84d855db..20db5aceb 100644 --- a/src/agent/SlashCommand.zig +++ b/src/agent/SlashCommand.zig @@ -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); } diff --git a/src/cli.zig b/src/cli.zig index aa788a93d..84987f593 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -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)); +} diff --git a/src/log.zig b/src/log.zig index 8afe305cc..fc51ac66e 100644 --- a/src/log.zig +++ b/src/log.zig @@ -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; diff --git a/src/string.zig b/src/string.zig index 97287537c..b5c77148c 100644 --- a/src/string.zig +++ b/src/string.zig @@ -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" },