-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathfts_fuzzy_match.js
280 lines (246 loc) · 8.2 KB
/
fts_fuzzy_match.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
// LICENSE
//
// This software is dual-licensed to the public domain and under the following
// license: you are granted a perpetual, irrevocable license to copy, modify,
// publish, and distribute this file as you see fit.
//
// VERSION
// 0.1.0 (2016-03-28) Initial release
//
// AUTHOR
// Forrest Smith
//
// CONTRIBUTORS
// J�rgen Tjern� - async helper
// Anurag Awasthi - updated to 0.2.0
const SEQUENTIAL_BONUS = 15; // bonus for adjacent matches
const SEPARATOR_BONUS = 30; // bonus if match occurs after a separator
const CAMEL_BONUS = 30; // bonus if match is uppercase and prev is lower
const FIRST_LETTER_BONUS = 15; // bonus if the first letter is matched
const LEADING_LETTER_PENALTY = -5; // penalty applied for every letter in str before the first match
const MAX_LEADING_LETTER_PENALTY = -15; // maximum penalty for leading letters
const UNMATCHED_LETTER_PENALTY = -1;
/**
* Returns true if each character in pattern is found sequentially within str
* @param {*} pattern string
* @param {*} str string
*/
function fuzzyMatchSimple(pattern, str) {
let patternIdx = 0;
let strIdx = 0;
const patternLength = pattern.length;
const strLength = str.length;
while (patternIdx != patternLength && strIdx != strLength) {
const patternChar = pattern.charAt(patternIdx).toLowerCase();
const strChar = str.charAt(strIdx).toLowerCase();
if (patternChar == strChar) ++patternIdx;
++strIdx;
}
return patternLength != 0 && strLength != 0 && patternIdx == patternLength
? true
: false;
}
/**
* Does a fuzzy search to find pattern inside a string.
* @param {*} pattern string pattern to search for
* @param {*} str string string which is being searched
* @returns [boolean, number] a boolean which tells if pattern was
* found or not and a search score
*/
module.exports.fuzzyMatch = function(pattern, str) {
const recursionCount = 0;
const recursionLimit = 10;
const matches = [];
const maxMatches = 256;
return fuzzyMatchRecursive(
pattern,
str,
0 /* patternCurIndex */,
0 /* strCurrIndex */,
null /* srcMatces */,
matches,
maxMatches,
0 /* nextMatch */,
recursionCount,
recursionLimit
);
}
function fuzzyMatchRecursive(
pattern,
str,
patternCurIndex,
strCurrIndex,
srcMatces,
matches,
maxMatches,
nextMatch,
recursionCount,
recursionLimit
) {
let outScore = 0;
// Return if recursion limit is reached.
if (++recursionCount >= recursionLimit) {
return [false, outScore];
}
// Return if we reached ends of strings.
if (patternCurIndex === pattern.length || strCurrIndex === str.length) {
return [false, outScore];
}
// Recursion params
let recursiveMatch = false;
let bestRecursiveMatches = [];
let bestRecursiveScore = 0;
// Loop through pattern and str looking for a match.
let firstMatch = true;
while (patternCurIndex < pattern.length && strCurrIndex < str.length) {
// Match found.
if (
pattern[patternCurIndex].toLowerCase() === str[strCurrIndex].toLowerCase()
) {
if (nextMatch >= maxMatches) {
return [false, outScore];
}
if (firstMatch && srcMatces) {
matches = [...srcMatces];
firstMatch = false;
}
const recursiveMatches = [];
const [matched, recursiveScore] = fuzzyMatchRecursive(
pattern,
str,
patternCurIndex,
strCurrIndex + 1,
matches,
recursiveMatches,
maxMatches,
nextMatch,
recursionCount,
recursionLimit
);
if (matched) {
// Pick best recursive score.
if (!recursiveMatch || recursiveScore > bestRecursiveScore) {
bestRecursiveMatches = [...recursiveMatches];
bestRecursiveScore = recursiveScore;
}
recursiveMatch = true;
}
matches[nextMatch++] = strCurrIndex;
++patternCurIndex;
}
++strCurrIndex;
}
const matched = patternCurIndex === pattern.length;
if (matched) {
outScore = 100;
// Apply leading letter penalty
let penalty = LEADING_LETTER_PENALTY * matches[0];
penalty =
penalty < MAX_LEADING_LETTER_PENALTY
? MAX_LEADING_LETTER_PENALTY
: penalty;
outScore += penalty;
//Apply unmatched penalty
const unmatched = str.length - nextMatch;
outScore += UNMATCHED_LETTER_PENALTY * unmatched;
// Apply ordering bonuses
for (let i = 0; i < nextMatch; i++) {
const currIdx = matches[i];
if (i > 0) {
const prevIdx = matches[i - 1];
if (currIdx == prevIdx + 1) {
outScore += SEQUENTIAL_BONUS;
}
}
// Check for bonuses based on neighbor character value.
if (currIdx > 0) {
// Camel case
const neighbor = str[currIdx - 1];
const curr = str[currIdx];
if (
neighbor === neighbor.toLowerCase() &&
curr === curr.toUpperCase()
) {
outScore += CAMEL_BONUS;
}
const isNeighbourSeparator = neighbor == "_" || neighbor == " ";
if (isNeighbourSeparator) {
outScore += SEPARATOR_BONUS;
}
} else {
// First letter
outScore += FIRST_LETTER_BONUS;
}
}
// Return best result
if (recursiveMatch && (!matched || bestRecursiveScore > outScore)) {
// Recursive score is better than "this"
matches = [...bestRecursiveMatches];
outScore = bestRecursiveScore;
return [true, outScore];
} else if (matched) {
// "this" score is better than recursive
return [true, outScore];
} else {
return [false, outScore];
}
}
return [false, outScore];
}
/**
* Strictly optional utility to help make using fts_fuzzy_match easier for large data sets
* Uses setTimeout to process matches before a maximum amount of time before sleeping
*
* To use:
* const asyncMatcher = new ftsFuzzyMatchAsync(fuzzyMatch, "fts", "ForrestTheWoods",
* function(results) { console.log(results); });
* asyncMatcher.start();
*
* @param {*} matchFn function Matching function - fuzzyMatchSimple or fuzzyMatch.
* @param {*} pattern string Pattern to search for.
* @param {*} dataSet array Array of string in which pattern is searched.
* @param {*} onComplete function Callback function which is called after search is complete.
*/
function ftsFuzzyMatchAsync(matchFn, pattern, dataSet, onComplete) {
const ITEMS_PER_CHECK = 1000; // performance.now can be very slow depending on platform
const results = [];
const max_ms_per_frame = 1000.0 / 30.0; // 30FPS
let dataIndex = 0;
let resumeTimeout = null;
// Perform matches for at most max_ms
function step() {
clearTimeout(resumeTimeout);
resumeTimeout = null;
var stopTime = performance.now() + max_ms_per_frame;
for (; dataIndex < dataSet.length; ++dataIndex) {
if (dataIndex % ITEMS_PER_CHECK == 0) {
if (performance.now() > stopTime) {
resumeTimeout = setTimeout(step, 1);
return;
}
}
var str = dataSet[dataIndex];
var result = matchFn(pattern, str);
// A little gross because fuzzy_match_simple and fuzzy_match return different things
if (matchFn == fuzzyMatchSimple && result == true) results.push(str);
else if (matchFn == fuzzyMatch && result[0] == true) results.push(result);
}
onComplete(results);
return null;
}
// Abort current process
this.cancel = function() {
if (resumeTimeout !== null) clearTimeout(resumeTimeout);
};
// Must be called to start matching.
// I tried to make asyncMatcher auto-start via "var resumeTimeout = step();"
// However setTimout behaving in an unexpected fashion as onComplete insisted on triggering twice.
this.start = function() {
step();
};
// Process full list. Blocks script execution until complete
this.flush = function() {
max_ms_per_frame = Infinity;
step();
};
}