fzf.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. // Port of the fzf v1 algorithm
  2. // https://github.com/junegunn/fzf/blob/f81feb1e69e5cb75797d50817752ddfe4933cd68/src/algo/algo.go#L615
  3. interface Result {
  4. readonly end: number;
  5. readonly matches: ReadonlyArray<[number, number]>;
  6. readonly score: number;
  7. readonly start: number;
  8. }
  9. // https://github.com/junegunn/fzf/blob/f81feb1e69e5cb75797d50817752ddfe4933cd68/src/algo/algo.go#L107
  10. const scoreMatch = 16;
  11. const scoreGapStart = -3;
  12. const scoreGapExtention = -1;
  13. const bonusBoundary = scoreMatch / 2;
  14. const bonusNonWord = scoreMatch / 2;
  15. const bonusCamel123 = bonusBoundary + scoreGapExtention;
  16. const bonusConsecutive = -(scoreGapStart + scoreGapExtention);
  17. const bonusFirstCharMultiplier = 2;
  18. enum CharTypes {
  19. charLower,
  20. charUpper,
  21. charNumber,
  22. charNonWord,
  23. }
  24. const CharCodes = {
  25. lowerA: 'a'.charCodeAt(0),
  26. lowerZ: 'z'.charCodeAt(0),
  27. upperA: 'A'.charCodeAt(0),
  28. upperZ: 'Z'.charCodeAt(0),
  29. zero: '0'.charCodeAt(0),
  30. nine: '9'.charCodeAt(0),
  31. };
  32. function getCharClass(c: number): CharTypes {
  33. if (c >= CharCodes.lowerA && c <= CharCodes.lowerZ) {
  34. return CharTypes.charLower;
  35. }
  36. if (c >= CharCodes.upperA && c <= CharCodes.upperZ) {
  37. return CharTypes.charUpper;
  38. }
  39. if (c >= CharCodes.zero && c <= CharCodes.nine) {
  40. return CharTypes.charNumber;
  41. }
  42. return CharTypes.charNonWord;
  43. }
  44. // Algo functions make two assumptions
  45. // 1. "pattern" is given in lowercase if "caseSensitive" is false
  46. // 2. "pattern" is already normalized if "normalize" is true
  47. // https://github.com/junegunn/fzf/blob/f81feb1e69e5cb75797d50817752ddfe4933cd68/src/algo/algo.go#L244
  48. export function fzf(text: string, pattern: string, caseSensitive: boolean): Result {
  49. if (pattern.length === 0) {
  50. return {end: 0, score: 0, start: 0, matches: []};
  51. }
  52. let pidx = 0;
  53. let sidx = -1;
  54. let eidx = -1;
  55. const textLength = text.length;
  56. const patternLength = pattern.length;
  57. for (let index = 0; index < textLength; index++) {
  58. let char = text[index];
  59. // This is considerably faster than blindly applying strings.ToLower to the whole string
  60. if (!caseSensitive) {
  61. const cc = char.charCodeAt(0);
  62. if (cc >= 65 && cc <= 90) {
  63. char = String.fromCharCode(cc + 32);
  64. }
  65. }
  66. const patternchar = pattern[pidx];
  67. if (char === patternchar) {
  68. pidx++;
  69. if (sidx < 0) {
  70. sidx = index;
  71. }
  72. if (pidx === patternLength) {
  73. eidx = index + 1;
  74. break;
  75. }
  76. }
  77. }
  78. if (eidx === -1) {
  79. return {end: -1, score: 0, start: -1, matches: []};
  80. }
  81. pidx--;
  82. for (let index = eidx - 1; index >= sidx; index--) {
  83. let char = text[index];
  84. // This is considerably faster than blindly applying strings.ToLower to the whole string
  85. if (!caseSensitive) {
  86. const cc = char.charCodeAt(0);
  87. if (cc >= 65 && cc <= 90) {
  88. char = String.fromCharCode(cc + 32);
  89. }
  90. }
  91. const patternchar = pattern[pidx];
  92. if (char === patternchar) {
  93. pidx--;
  94. if (pidx < 0) {
  95. sidx = index;
  96. break;
  97. }
  98. }
  99. }
  100. const [score, matches] = calculateScore(text, pattern, sidx, eidx, caseSensitive);
  101. return {
  102. start: sidx,
  103. end: eidx,
  104. score,
  105. matches,
  106. };
  107. }
  108. function bonusForCharClass(prevClass: CharTypes, currentClass: CharTypes): number {
  109. if (prevClass === CharTypes.charNonWord && currentClass !== CharTypes.charNonWord) {
  110. // Word boundary
  111. return bonusBoundary;
  112. }
  113. if (
  114. (prevClass === CharTypes.charLower && currentClass === CharTypes.charUpper) ||
  115. (prevClass !== CharTypes.charNumber && currentClass === CharTypes.charNumber)
  116. ) {
  117. // camelCase letter123
  118. return bonusCamel123;
  119. }
  120. if (currentClass === CharTypes.charNonWord) {
  121. return bonusNonWord;
  122. }
  123. return 0;
  124. }
  125. // Implement the same sorting criteria as V2
  126. function calculateScore(
  127. text: string,
  128. pattern: string,
  129. sidx: number,
  130. eidx: number,
  131. caseSensitive: boolean
  132. ): [number, ReadonlyArray<[number, number]>] {
  133. let pidx = 0;
  134. let score = 0;
  135. let inGap: boolean = false;
  136. let firstBonus = 0;
  137. let consecutive = 0;
  138. let prevCharClass = CharTypes.charNonWord;
  139. const pos: number[] = new Array(pattern.length);
  140. if (sidx > 0) {
  141. prevCharClass = getCharClass(text.charCodeAt(sidx - 1));
  142. }
  143. for (let idx = sidx; idx < eidx; idx++) {
  144. let char = text[idx];
  145. if (!caseSensitive) {
  146. const cc = char.charCodeAt(0);
  147. if (cc >= 65 && cc <= 90) {
  148. char = String.fromCharCode(cc + 32);
  149. }
  150. }
  151. const patternchar = pattern[pidx];
  152. const currentCharClass = getCharClass(char.charCodeAt(0));
  153. if (char === patternchar) {
  154. pos[pidx] = idx;
  155. score += scoreMatch;
  156. let bonus = bonusForCharClass(prevCharClass, currentCharClass);
  157. if (consecutive === 0) {
  158. firstBonus = bonus;
  159. } else {
  160. // Break consecutive chunk
  161. if (bonus === bonusBoundary) {
  162. firstBonus = bonus;
  163. }
  164. bonus = Math.max(bonus, firstBonus, bonusConsecutive);
  165. }
  166. if (pidx === 0) {
  167. score += bonus * bonusFirstCharMultiplier;
  168. } else {
  169. score += bonus;
  170. }
  171. inGap = false;
  172. consecutive++;
  173. pidx++;
  174. } else {
  175. if (inGap) {
  176. score += scoreGapExtention;
  177. } else {
  178. score += scoreGapStart;
  179. }
  180. inGap = true;
  181. consecutive = 0;
  182. firstBonus = 0;
  183. }
  184. prevCharClass = currentCharClass;
  185. }
  186. if (!pos.length) {
  187. throw new Error('Calculate score should not be called for a result with no matches');
  188. }
  189. // pos is where the text char matched a pattern char. If we have consecutive matches,
  190. // we want to update/extend our current range, otherwise we want to add a new range.
  191. // Init range to first match, at this point we should have at least 1
  192. const matches = [[pos[0], pos[0] + 1]] as [number, number][];
  193. // iterate over all positions and check for overlaps from current and end of last
  194. // range. Positions are already sorted by match index, we can just check the last range.
  195. for (let i = 1; i < pos.length; i++) {
  196. const lastrange = matches[matches.length - 1];
  197. // if last range ends where new range stars, we can extend it
  198. if (lastrange[1] === pos[i]) {
  199. lastrange[1] = pos[i] + 1;
  200. } else {
  201. // otherwise we add a new range
  202. matches.push([pos[i], pos[i] + 1]);
  203. }
  204. }
  205. return [score, matches];
  206. }