suggestSimilar.js 2.7 KB

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