suggestSimilar.js 2.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. const maxDistance = 3;
  2. function editDistance(a, b) {
  3. // https://en.wikipedia.org/wiki/Damerau–Levenshtein_distance
  4. // Calculating optimal string alignment distance, no substring is edited more than once.
  5. // (Simple implementation.)
  6. // Quick early exit, return worst case.
  7. if (Math.abs(a.length - b.length) > maxDistance) return Math.max(a.length, b.length);
  8. // distance between prefix substrings of a and b
  9. const d = [];
  10. // pure deletions turn a into empty string
  11. for (let i = 0; i <= a.length; i++) {
  12. d[i] = [i];
  13. }
  14. // pure insertions turn empty string into b
  15. for (let j = 0; j <= b.length; j++) {
  16. d[0][j] = j;
  17. }
  18. // fill matrix
  19. for (let j = 1; j <= b.length; j++) {
  20. for (let i = 1; i <= a.length; i++) {
  21. let cost = 1;
  22. if (a[i - 1] === b[j - 1]) {
  23. cost = 0;
  24. } else {
  25. cost = 1;
  26. }
  27. d[i][j] = Math.min(
  28. d[i - 1][j] + 1, // deletion
  29. d[i][j - 1] + 1, // insertion
  30. d[i - 1][j - 1] + cost // substitution
  31. );
  32. // transposition
  33. if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
  34. d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
  35. }
  36. }
  37. }
  38. return d[a.length][b.length];
  39. }
  40. /**
  41. * Find close matches, restricted to same number of edits.
  42. *
  43. * @param {string} word
  44. * @param {string[]} candidates
  45. * @returns {string}
  46. */
  47. function suggestSimilar(word, candidates) {
  48. if (!candidates || candidates.length === 0) return '';
  49. // remove possible duplicates
  50. candidates = Array.from(new Set(candidates));
  51. const searchingOptions = word.startsWith('--');
  52. if (searchingOptions) {
  53. word = word.slice(2);
  54. candidates = candidates.map(candidate => candidate.slice(2));
  55. }
  56. let similar = [];
  57. let bestDistance = maxDistance;
  58. const minSimilarity = 0.4;
  59. candidates.forEach((candidate) => {
  60. if (candidate.length <= 1) return; // no one character guesses
  61. const distance = editDistance(word, candidate);
  62. const length = Math.max(word.length, candidate.length);
  63. const similarity = (length - distance) / length;
  64. if (similarity > minSimilarity) {
  65. if (distance < bestDistance) {
  66. // better edit distance, throw away previous worse matches
  67. bestDistance = distance;
  68. similar = [candidate];
  69. } else if (distance === bestDistance) {
  70. similar.push(candidate);
  71. }
  72. }
  73. });
  74. similar.sort((a, b) => a.localeCompare(b));
  75. if (searchingOptions) {
  76. similar = similar.map(candidate => `--${candidate}`);
  77. }
  78. if (similar.length > 1) {
  79. return `\n(Did you mean one of ${similar.join(', ')}?)`;
  80. }
  81. if (similar.length === 1) {
  82. return `\n(Did you mean ${similar[0]}?)`;
  83. }
  84. return '';
  85. }
  86. exports.suggestSimilar = suggestSimilar;