/** * Shared Levenshtein edit-distance helper for CLI "did you mean …?" * suggestions. Lives in cli/lib so `dench crm`, `dench apps`, and any * other hand-rolled dispatcher can suggest the closest known verb * instead of dead-ending on `Unknown subcommand: entires`. */ export function levenshteinDistance(a: string, b: string): number { if (a === b) return 0; if (a.length === 0) return b.length; if (b.length === 0) return a.length; let prev = Array.from({ length: b.length + 1 }, (_, i) => i); let curr = new Array(b.length + 1); for (let i = 1; i <= a.length; i += 1) { curr[0] = i; for (let j = 1; j <= b.length; j += 1) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); } [prev, curr] = [curr, prev]; } return prev[b.length]; } /** * Return the candidate closest to `input` (case-insensitive), or null * when nothing is plausibly a typo. Exact case-insensitive matches win * outright; otherwise the best candidate must be within * max(2, floor(len/2)) edits — same threshold the CRM field * suggester has used in production. */ export function closestMatch( input: string, candidates: readonly string[], ): string | null { const lower = input.toLowerCase(); const caseMatch = candidates.find( (candidate) => candidate.toLowerCase() === lower, ); if (caseMatch) return caseMatch; let best: string | null = null; let bestDistance = Number.POSITIVE_INFINITY; for (const candidate of candidates) { const distance = levenshteinDistance(lower, candidate.toLowerCase()); if (distance < bestDistance) { bestDistance = distance; best = candidate; } } const threshold = Math.max(2, Math.floor(input.length / 2)); return best !== null && bestDistance <= threshold ? best : null; }