Files
2024-02-15 17:01:50 +00:00

258 lines
7.5 KiB
TypeScript

// Port of the fzf v1 algorithm to typescript
// https://github.com/junegunn/fzf/blob/f81feb1e69e5cb75797d50817752ddfe4933cd68/src/algo/algo.go#L615
// The MIT License (MIT)
// Copyright (c) 2013-2020 Junegunn Choi
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
interface Result {
readonly end: number;
readonly matches: ReadonlyArray<[number, number]>;
readonly score: number;
readonly start: number;
}
// https://github.com/junegunn/fzf/blob/f81feb1e69e5cb75797d50817752ddfe4933cd68/src/algo/algo.go#L107
const scoreMatch = 16;
const scoreGapStart = -3;
const scoreGapExtention = -1;
const bonusBoundary = scoreMatch / 2;
const bonusNonWord = scoreMatch / 2;
const bonusCamel123 = bonusBoundary + scoreGapExtention;
const bonusConsecutive = -(scoreGapStart + scoreGapExtention);
const bonusFirstCharMultiplier = 2;
enum CharTypes {
CHAR_LOWER,
CHAR_UPPER,
CHAR_NUMBER,
CHAR_NON_WORD,
}
const CharCodes = {
lowerA: 'a'.charCodeAt(0),
lowerZ: 'z'.charCodeAt(0),
upperA: 'A'.charCodeAt(0),
upperZ: 'Z'.charCodeAt(0),
zero: '0'.charCodeAt(0),
nine: '9'.charCodeAt(0),
};
function getCharClass(c: number): CharTypes {
if (c >= CharCodes.lowerA && c <= CharCodes.lowerZ) {
return CharTypes.CHAR_LOWER;
}
if (c >= CharCodes.upperA && c <= CharCodes.upperZ) {
return CharTypes.CHAR_UPPER;
}
if (c >= CharCodes.zero && c <= CharCodes.nine) {
return CharTypes.CHAR_NUMBER;
}
return CharTypes.CHAR_NON_WORD;
}
// Algo functions make two assumptions
// 1. "pattern" is given in lowercase if "caseSensitive" is false
// 2. "pattern" is already normalized if "normalize" is true
// https://github.com/junegunn/fzf/blob/f81feb1e69e5cb75797d50817752ddfe4933cd68/src/algo/algo.go#L244
export function fzf(text: string, pattern: string, caseSensitive: boolean): Result {
if (pattern.length === 0) {
return {end: 0, score: 0, start: 0, matches: []};
}
let pidx = 0;
let sidx = -1;
let eidx = -1;
const textLength = text.length;
const patternLength = pattern.length;
for (let index = 0; index < textLength; index++) {
let char = text[index];
// This is considerably faster than blindly applying strings.ToLower to the whole string
if (!caseSensitive) {
const cc = char.charCodeAt(0);
if (cc >= 65 && cc <= 90) {
char = String.fromCharCode(cc + 32);
}
}
const patternchar = pattern[pidx];
if (char === patternchar) {
pidx++;
if (sidx < 0) {
sidx = index;
}
if (pidx === patternLength) {
eidx = index + 1;
break;
}
}
}
if (eidx === -1) {
return {end: -1, score: 0, start: -1, matches: []};
}
pidx--;
for (let index = eidx - 1; index >= sidx; index--) {
let char = text[index];
// This is considerably faster than blindly applying strings.ToLower to the whole string
if (!caseSensitive) {
const cc = char.charCodeAt(0);
if (cc >= 65 && cc <= 90) {
char = String.fromCharCode(cc + 32);
}
}
const patternchar = pattern[pidx];
if (char === patternchar) {
pidx--;
if (pidx < 0) {
sidx = index;
break;
}
}
}
const [score, matches] = calculateScore(text, pattern, sidx, eidx, caseSensitive);
// Fzf will return matches per each character, we will try to merge these together
return {
start: sidx,
end: eidx,
score,
matches,
};
}
function bonusForCharClass(prevClass: CharTypes, currentClass: CharTypes): number {
if (prevClass === CharTypes.CHAR_NON_WORD && currentClass !== CharTypes.CHAR_NON_WORD) {
// Word boundary
return bonusBoundary;
}
if (
(prevClass === CharTypes.CHAR_LOWER && currentClass === CharTypes.CHAR_UPPER) ||
(prevClass !== CharTypes.CHAR_NUMBER && currentClass === CharTypes.CHAR_NUMBER)
) {
// camelCase letter123
return bonusCamel123;
}
if (currentClass === CharTypes.CHAR_NON_WORD) {
return bonusNonWord;
}
return 0;
}
// Implement the same sorting criteria as V2
function calculateScore(
text: string,
pattern: string,
sidx: number,
eidx: number,
caseSensitive: boolean
): [number, ReadonlyArray<[number, number]>] {
let pidx = 0;
let score = 0;
let inGap: boolean = false;
let firstBonus = 0;
let consecutive = 0;
let prevCharClass = CharTypes.CHAR_NON_WORD;
const pos: number[] = new Array(pattern.length);
if (sidx > 0) {
prevCharClass = getCharClass(text.charCodeAt(sidx - 1));
}
for (let idx = sidx; idx < eidx; idx++) {
let char = text[idx];
if (!caseSensitive) {
const cc = char.charCodeAt(0);
if (cc >= 65 && cc <= 90) {
char = String.fromCharCode(cc + 32);
}
}
const patternchar = pattern[pidx];
const currentCharClass = getCharClass(char.charCodeAt(0));
if (char === patternchar) {
pos[pidx] = idx;
score += scoreMatch;
let bonus = bonusForCharClass(prevCharClass, currentCharClass);
if (consecutive === 0) {
firstBonus = bonus;
} else {
// Break consecutive chunk
if (bonus === bonusBoundary) {
firstBonus = bonus;
}
bonus = Math.max(bonus, firstBonus, bonusConsecutive);
}
if (pidx === 0) {
score += bonus * bonusFirstCharMultiplier;
} else {
score += bonus;
}
inGap = false;
consecutive++;
pidx++;
} else {
if (inGap) {
score += scoreGapExtention;
} else {
score += scoreGapStart;
}
inGap = true;
consecutive = 0;
firstBonus = 0;
}
prevCharClass = currentCharClass;
}
if (!pos.length) {
throw new Error('Calculate score should not be called for a result with no matches');
}
// pos is where the text char matched a pattern char. If we have consecutive matches,
// we want to update/extend our current range, otherwise we want to add a new range.
// Init range to first match, at this point we should have at least 1
const matches = [[pos[0], pos[0] + 1]] as [number, number][];
// iterate over all positions and check for overlaps from current and end of last
// range. Positions are already sorted by match index, we can just check the last range.
for (let i = 1; i < pos.length; i++) {
const lastrange = matches[matches.length - 1];
// if last range ends where new range stars, we can extend it
if (lastrange[1] === pos[i]) {
lastrange[1] = pos[i] + 1;
} else {
// otherwise we add a new range
matches.push([pos[i], pos[i] + 1]);
}
}
return [score, matches];
}