mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
258 lines
7.5 KiB
TypeScript
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];
|
|
}
|