UNPKG

fuzzbunny

Version:

Fast fuzzy string matching with scoring and matched ranges

354 lines (309 loc) 11.5 kB
const SCORE_START_STR = 1000; const SCORE_PREFIX = 200; const SCORE_CONTIGUOUS = 300; /** * @param {number} idx - index of the match * @param {number} len - length of the match * @param {boolean} isPrefix - was it a prefix of a word * @returns {number} - score of the match, higher is better */ function _getMatchScore(idx, len, isPrefix) { let score = 0; // increase score exponentially per letter matched so that contiguous matches are ranked higher // i.e '[abc]' ranks higher than '[ab]ott [c]hemicals' score += SCORE_CONTIGUOUS * len * len; if (idx === 0) { // matching at the start of string gets a ranking bonus score += SCORE_START_STR; } else if (isPrefix) { // closer to the start, the higher it ranks score += SCORE_PREFIX - idx; } return score; } // Ascii codes: <w_space>!"#$%&'()*+,-./0123456789:;<=>?@ // ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ const CODE_A = `a`.charCodeAt(0); const CODE_Z = `z`.charCodeAt(0); const CODE_0 = `0`.charCodeAt(0); const CODE_9 = `9`.charCodeAt(0); const CODE_EXCL_MARK = `!`.charCodeAt(0); const CODE_SLASH = `/`.charCodeAt(0); const CODE_COLON = `:`.charCodeAt(0); const CODE_AT = `@`.charCodeAt(0); const CODE_SQ_BKT = `[`.charCodeAt(0); const CODE_CARET = `\``.charCodeAt(0); const CODE_CURLY_BKT = `{`.charCodeAt(0); const CODE_TILDE = `~`.charCodeAt(0); const CODE_START_UNICODE = 127; /** * @param {number} charCode * @returns {boolean} */ function _isCodeAlphaNum(charCode) { // 0 - 126 charCodes are ascii, 127 onwards are unicode code points // str will be lowercased so we only check for lowercased codes return ( (charCode >= CODE_A && charCode <= CODE_Z) || (charCode >= CODE_0 && charCode <= CODE_9) || charCode >= CODE_START_UNICODE ); } /** * @param {number} charCode * @returns {boolean} */ function _isCodePunctuation(charCode) { // rather than create a uint8 typed array as a lookup table // uglifyjs inlines this function in prod builds. JIT should inline too. // we're calling it charCode rather than 'code' because of an uglifyjs bug // see: https://github.com/mishoo/UglifyJS2/issues/2842 return ( (charCode >= CODE_EXCL_MARK && charCode <= CODE_SLASH) || (charCode >= CODE_COLON && charCode <= CODE_AT) || (charCode >= CODE_SQ_BKT && charCode <= CODE_CARET) || (charCode >= CODE_CURLY_BKT && charCode <= CODE_TILDE) ); } /** * A skip index marks word and punctuation boundaries * We use this to skip around the targetStr and quickly find prefix matches * @param {string} targetStr * @returns {number[]} */ function _getTargetSkips(targetStr) { const targetSkips = []; let wasAlphaNum = false; for (let i = 0, len = targetStr.length; i < len; ++i) { const code = targetStr.charCodeAt(i); const isAlphaNum = _isCodeAlphaNum(code); if (isAlphaNum && !wasAlphaNum) { targetSkips.push(i); } else if (_isCodePunctuation(code)) { targetSkips.push(i); } wasAlphaNum = isAlphaNum; } // We push the length as the last skip so when matching // every range aligns between skip[i] and skip[i + 1] // and we don't have to do extraneous overflow checks targetSkips.push(targetStr.length); // NOTE: these can possibly be cached on the items for a faster search next time return targetSkips; } /** * performs a prefix match e.g 'usam' matches '[u]nited [s]tates of [am]erica * @param {number} skipIdx - skip index where to start search from * @param {string} searchStr - lowercased search string * @param {string} targetStr - lowercased target string * @param {number[]} targetSkips - skip boundary indices * @returns {number[] | null} * - the [idx, len, ...] ranges where the match occured * - null if no match found */ function _fuzzyPrefixMatch(skipIdx, searchStr, targetStr, targetSkips) { let searchIdx = 0; const searchLen = searchStr.length; /** @type {number[]} */ const ranges = []; for (let skipLen = targetSkips.length - 1; skipIdx < skipLen; ++skipIdx) { const startIdx = targetSkips[skipIdx]; const endIdx = targetSkips[skipIdx + 1]; let targetIdx = startIdx; let matchLen = 0; while (targetIdx < endIdx && searchIdx < searchLen) { const targetChar = targetStr[targetIdx]; const searchChar = searchStr[searchIdx]; if (targetChar === searchChar) { ++targetIdx; ++searchIdx; ++matchLen; continue; } // spaces shouldn't break matching if (targetChar === ` `) { ++targetIdx; continue; } if (searchChar === ` `) { ++searchIdx; continue; } break; } if (matchLen) { // make contiguous ranges if possible const rangesLen = ranges.length; if (rangesLen >= 2 && ranges[rangesLen - 2] + ranges[rangesLen - 1] === startIdx) { ranges[rangesLen - 1] += matchLen; } else { ranges.push(startIdx, matchLen); } } if (searchIdx === searchLen) { // search is fully matched, return ranges return ranges; } } return null; } /** * Returns the string parts for highlighting from the matched ranges * @example ('my example', [3, 2]) would return ['my ', 'ex', 'ample'] * @param {string} targetStr - the string that was matched * @param {number[]} ranges - [idx1, len1, idx2, len2] matched ranges * @returns {string[]} - ['no match', 'match', 'no match', 'match'] */ function highlightsFromRanges(targetStr, ranges) { const highlights = []; let lastIndex = 0; let rangesIdx = 0; for (; rangesIdx < ranges.length; rangesIdx += 2) { const startIndex = ranges[rangesIdx]; const endIndex = startIndex + ranges[rangesIdx + 1]; highlights.push(targetStr.slice(lastIndex, startIndex)); highlights.push(targetStr.slice(startIndex, endIndex)); lastIndex = endIndex; } if (lastIndex < targetStr.length) { highlights.push(targetStr.slice(lastIndex)); } return highlights; } /** * fuzzyMatchSanitized is called by fuzzyMatch, it's a slightly lower level call * If perf is of importance and you want to avoid lowercase + trim + highlighting on every item * Use this and only call highlightsFromRanges for only the items that are displayed * @param {string} targetStr - lowercased trimmed target string to search on * @param {string} searchStr - lowercased trimmed search string * @returns {{score: number, ranges: number[]} | null} - null if no match */ function fuzzyMatchSanitized(targetStr, searchStr) { if (!targetStr) { return null; } // empty search string is technically a match of nothing if (!searchStr) { return { score: 0, ranges: [], }; } // if user enters a quoted search then only perform substring match // e.g "la matches [{La}s Vegas] but not [Los Angeles] // NOTE: ending quote is optional so user can get incremental matching as they type. const isQuotedSearchStr = searchStr.startsWith(`"`); if (isQuotedSearchStr) { searchStr = searchStr.slice(1, searchStr.endsWith(`"`) ? -1 : searchStr.length); } // try substring search first // js engine uses boyer moore algo which is very fast O(m/n) const matchIdx = targetStr.indexOf(searchStr); const searchLen = searchStr.length; if (matchIdx >= 0) { const isWordPrefix = matchIdx > 0 && !_isCodeAlphaNum(targetStr.charCodeAt(matchIdx - 1)); return { score: _getMatchScore(matchIdx, searchLen, isWordPrefix), ranges: [matchIdx, searchLen], }; } // if we didn't match a single character as a substr, we won't fuzzy match it either, exit early. // if quoted search, exit after substring search as well, since user doesn't want fuzzy search. if (searchLen === 1 || isQuotedSearchStr) { return null; } // fall back to fuzzy matching which matches word prefixes or punctuations // because we've precomputed targetSkips, its O(m+n) for avg case // the skip array helps us make faster alignments, rather than letter by letter const targetSkips = _getTargetSkips(targetStr); for (let skipIdx = 0, skipLen = targetSkips.length - 1; skipIdx < skipLen; ++skipIdx) { if (targetStr[targetSkips[skipIdx]] === searchStr[0]) { // possible alignment, perform prefix match const ranges = _fuzzyPrefixMatch(skipIdx, searchStr, targetStr, targetSkips); if (ranges) { let score = 0; for (let i = 0, len = ranges.length; i < len; i += 2) { score += _getMatchScore(ranges[i], ranges[i + 1], true /*isWordPrefix*/); } return {score, ranges}; } } } return null; } /** * Fuzzy match and return the score, highlights, and lowercased matchStr (for sort) * @param {string} targetStr - target to search on / haystack string * @param {string} searchStr - search filter / needle string * @returns {{score: number, highlights: string[]} | null} - null if no match */ function fuzzyMatch(targetStr, searchStr) { targetStr = targetStr || ``; searchStr = (searchStr || ``).trim().toLowerCase(); const targetSanitizedStr = targetStr.toLowerCase(); const match = fuzzyMatchSanitized(targetSanitizedStr, searchStr); if (match) { return { score: match.score, highlights: highlightsFromRanges(targetStr, match.ranges), }; } return null; } /** * @template Item * @typedef {{item: Item, score: number, highlights: {[K in keyof Item]?: string[]}}} FuzzyFilterResult */ /** * Searches an array of items on props and returns filtered + sorted array with scores and highlights * @template Item * @param {Item[]} items * @param {string} searchStr * @param {{fields: (keyof Item)[]}} options * @returns {FuzzyFilterResult<Item>[]} */ function fuzzyFilter(items, searchStr, options) { /** @type {FuzzyFilterResult<Item>[]} */ const results = []; const searchStrLowerCased = (searchStr || ``).trim().toLowerCase(); const fields = options ? options.fields : null; if (!fields || !Array.isArray(fields) || fields.length == 0) { throw new Error(`invalid fields, did you forget to pass {fields: [...]} as options param?`); } for (const item of items) { /** @type {FuzzyFilterResult<Item> | null} */ let result = null; for (const field of fields) { const value = item[field]; if (typeof value === `string` && value) { const valueStrLowerCased = value.toLowerCase(); const match = fuzzyMatchSanitized(valueStrLowerCased, searchStrLowerCased); if (match) { result = result || {item, score: 0, highlights: {}}; result.score = Math.max(match.score, result.score); result.highlights[field] = highlightsFromRanges(value, match.ranges); } } } if (result) { results.push(result); } } // sort if searchStr is not empty, otherwise preserve original order, since its a pass through if (searchStrLowerCased) { results.sort((a, b) => { // sort by score, then alphabetically by each field let diff = b.score - a.score; for (let i = 0, len = fields.length; diff === 0 && i < len; ++i) { const field = fields[i]; const valA = a.item[field]; const valB = b.item[field]; // @ts-ignore string comparison diff = (valA || ``).localeCompare(valB); } return diff; }); } return results; } module.exports = {fuzzyFilter, fuzzyMatch, fuzzyMatchSanitized, highlightsFromRanges};