| const maxDistance = 3; |
| |
| function editDistance(a, b) { |
| // https://en.wikipedia.org/wiki/Damerau–Levenshtein_distance |
| // Calculating optimal string alignment distance, no substring is edited more than once. |
| // (Simple implementation.) |
| |
| // Quick early exit, return worst case. |
| if (Math.abs(a.length - b.length) > maxDistance) return Math.max(a.length, b.length); |
| |
| // distance between prefix substrings of a and b |
| const d = []; |
| |
| // pure deletions turn a into empty string |
| for (let i = 0; i <= a.length; i++) { |
| d[i] = [i]; |
| } |
| // pure insertions turn empty string into b |
| for (let j = 0; j <= b.length; j++) { |
| d[0][j] = j; |
| } |
| |
| // fill matrix |
| for (let j = 1; j <= b.length; j++) { |
| for (let i = 1; i <= a.length; i++) { |
| let cost = 1; |
| if (a[i - 1] === b[j - 1]) { |
| cost = 0; |
| } else { |
| cost = 1; |
| } |
| d[i][j] = Math.min( |
| d[i - 1][j] + 1, // deletion |
| d[i][j - 1] + 1, // insertion |
| d[i - 1][j - 1] + cost // substitution |
| ); |
| // transposition |
| if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { |
| d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1); |
| } |
| } |
| } |
| |
| return d[a.length][b.length]; |
| } |
| |
| /** |
| * Find close matches, restricted to same number of edits. |
| * |
| * @param {string} word |
| * @param {string[]} candidates |
| * @returns {string} |
| */ |
| |
| function suggestSimilar(word, candidates) { |
| if (!candidates || candidates.length === 0) return ''; |
| // remove possible duplicates |
| candidates = Array.from(new Set(candidates)); |
| |
| const searchingOptions = word.startsWith('--'); |
| if (searchingOptions) { |
| word = word.slice(2); |
| candidates = candidates.map(candidate => candidate.slice(2)); |
| } |
| |
| let similar = []; |
| let bestDistance = maxDistance; |
| const minSimilarity = 0.4; |
| candidates.forEach((candidate) => { |
| if (candidate.length <= 1) return; // no one character guesses |
| |
| const distance = editDistance(word, candidate); |
| const length = Math.max(word.length, candidate.length); |
| const similarity = (length - distance) / length; |
| if (similarity > minSimilarity) { |
| if (distance < bestDistance) { |
| // better edit distance, throw away previous worse matches |
| bestDistance = distance; |
| similar = [candidate]; |
| } else if (distance === bestDistance) { |
| similar.push(candidate); |
| } |
| } |
| }); |
| |
| similar.sort((a, b) => a.localeCompare(b)); |
| if (searchingOptions) { |
| similar = similar.map(candidate => `--${candidate}`); |
| } |
| |
| if (similar.length > 1) { |
| return `\n(Did you mean one of ${similar.join(', ')}?)`; |
| } |
| if (similar.length === 1) { |
| return `\n(Did you mean ${similar[0]}?)`; |
| } |
| return ''; |
| } |
| |
| exports.suggestSimilar = suggestSimilar; |