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
↓ nina-4871502e.tar.gzatlas at this save
files changed2 files
src/commands.zig+62 −1src/output.zig+6 −0src/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 bysrc/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",