4871502e9c71

add 'did you mean?' typo suggestion to install: when a package name has 0 search results, closestPackage() prefix-searches for candidates and ranks by case-insensitive Levenshtein (editDistance); if the best is typo-close (threshold max(2,len/3)) it prints ':: <q> not found — did you mean <pkg>?' and confirms before installing, else the plain not-found error. Verified on kyoshi: firefux->firefox, ripgrp->ripgrep, zzzzqqq->not found. build+tests green incl editDistance test

time
2026-06-05 12:58 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
nina finishing brief
parent
f7a6d8142581
2 files src/commands.zig+62 −1src/output.zig+6 −0
src/commands.zig+62 −1
@@ -558,9 +558,14 @@558558         errors.error_info.setSuggestion("nina search {s}", .{pkg_name});559559         return error.PackageNotFound;560560     };561-    defer ctx.gpa.free(pkgs);562561 563562     if (pkgs.len == 0) {563+        // No matches — likely a typo. Suggest the closest real package and offer it.564+        if (closestPackage(ctx, pkg_name)) |sugg| {565+            output.didYouMean(pkg_name, sugg.pname);566+            if (output.confirm("")) try selectAndInstall(ctx, sugg);567+            return;568+        }564569         errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});565570         errors.error_info.setSuggestion("nina search {s}", .{pkg_name});566571         return error.PackageNotFound;@@ -576,6 +581,62 @@576581         }577582     }578583     try selectAndInstall(ctx, found);584+}585+586+// Find the real package whose name is closest to a not-found query (typo help).587+// Typos usually keep the leading characters, so gather candidates with a prefix588+// search and rank by edit distance; return the best only if it's typo-close.589+fn closestPackage(ctx: Ctx, query: []const u8) ?types.NixPackage {590+    if (query.len < 2) return null;591+    var candidates = api.searchPackages(ctx.gpa, ctx.io, query[0..@min(query.len, 4)]) catch &.{};592+    if (candidates.len == 0 and query.len > 2) {593+        candidates = api.searchPackages(ctx.gpa, ctx.io, query[0..2]) catch &.{};594+    }595+    if (candidates.len == 0) return null;596+597+    var best: ?types.NixPackage = null;598+    var best_dist: usize = std.math.maxInt(usize);599+    for (candidates) |p| {600+        const d = editDistance(query, p.pname);601+        if (d > 0 and d < best_dist) {602+            best_dist = d;603+            best = p;604+        }605+    }606+    // Only suggest a genuinely close name, scaled to query length.607+    const threshold = @max(@as(usize, 2), query.len / 3);608+    return if (best_dist <= threshold) best else null;609+}610+611+// Case-insensitive Levenshtein distance. Names longer than the row buffer are612+// treated as far away (never suggested).613+fn editDistance(a: []const u8, b: []const u8) usize {614+    const n = b.len;615+    if (a.len == 0) return n;616+    if (n == 0) return a.len;617+    if (n + 1 > 128) return std.math.maxInt(usize);618+    var prev: [128]usize = undefined;619+    var curr: [128]usize = undefined;620+    var j: usize = 0;621+    while (j <= n) : (j += 1) prev[j] = j;622+    var i: usize = 1;623+    while (i <= a.len) : (i += 1) {624+        curr[0] = i;625+        j = 1;626+        while (j <= n) : (j += 1) {627+            const cost: usize = if (std.ascii.toLower(a[i - 1]) == std.ascii.toLower(b[j - 1])) 0 else 1;628+            curr[j] = @min(prev[j] + 1, @min(curr[j - 1] + 1, prev[j - 1] + cost));629+        }630+        @memcpy(prev[0 .. n + 1], curr[0 .. n + 1]);631+    }632+    return prev[n];633+}634+635+test "editDistance is case-insensitive Levenshtein" {636+    try std.testing.expectEqual(@as(usize, 1), editDistance("firefux", "firefox"));637+    try std.testing.expectEqual(@as(usize, 0), editDistance("git", "GIT"));638+    try std.testing.expectEqual(@as(usize, 2), editDistance("fierfox", "firefox"));639+    try std.testing.expectEqual(@as(usize, 7), editDistance("", "firefox"));579640 }580641 581642 // Prompt for the install method (profile / system / try) and dispatch. Shared by

view file

src/output.zig+6 −0
@@ -511,6 +511,12 @@511511 512512 // --- Install / Remove / Try ---513513 514+// Typo help for install: the query matched nothing, so suggest the closest real515+// package name (pink) and let the caller confirm.516+pub fn didYouMean(query: []const u8, suggestion: []const u8) void {517+    p("{s}{s}'{s}' not found{s}  —  did you mean {s}{s}{s}?\n", .{ hdr(), c(DIM), query, c(RESET), c(PINK), suggestion, c(RESET) });518+}519+514520 pub fn installStart(pkg: []const u8, path: types.InstallPath) void {515521     const path_str = switch (path) {516522         .profile => "profile",

view file