UNPKG

@leeoniya/ufuzzy

Version:

A tiny, efficient fuzzy matcher that doesn't suck

1,042 lines (820 loc) 26.9 kB
/** * Copyright (c) 2025, Leon Sorokin * All rights reserved. (MIT Licensed) * * uFuzzy.js (μFuzzy) * A tiny, efficient fuzzy matcher that doesn't suck * https://github.com/leeoniya/uFuzzy (v1.0.18) */ var uFuzzy = (function () { 'use strict'; const cmp = (a, b) => a > b ? 1 : a < b ? -1 : 0; const inf = Infinity; // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping const escapeRegExp = str => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // meh, magic tmp placeholder, must be tolerant to toLocaleLowerCase(), interSplit, and intraSplit const EXACT_HERE = 'eexxaacctt'; const PUNCT_RE = /\p{P}/gu; const LATIN_UPPER = 'A-Z'; const LATIN_LOWER = 'a-z'; const COLLATE_ARGS = ['en', { numeric: true, sensitivity: 'base' }]; const swapAlpha = (str, upper, lower) => str.replace(LATIN_UPPER, upper).replace(LATIN_LOWER, lower); const OPTS = { // whether regexps use a /u unicode flag unicode: false, alpha: null, // term segmentation & punct/whitespace merging interSplit: "[^A-Za-z\\d']+", intraSplit: "[a-z][A-Z]", // inter bounds that will be used to increase lft2/rgt2 info counters interBound: "[^A-Za-z\\d]", // intra bounds that will be used to increase lft1/rgt1 info counters intraBound: "[A-Za-z]\\d|\\d[A-Za-z]|[a-z][A-Z]", // inter-bounds mode // 2 = strict (will only match 'man' on whitepace and punct boundaries: Mega Man, Mega_Man, mega.man) // 1 = loose (plus allowance for alpha-num and case-change boundaries: MegaMan, 0007man) // 0 = any (will match 'man' as any substring: megamaniac) interLft: 0, interRgt: 0, // allowance between terms interChars: '.', interIns: inf, // allowance between chars in terms intraChars: "[a-z\\d']", // internally case-insensitive intraIns: null, intraContr: "'[a-z]{1,2}\\b", // multi-insert or single-error mode intraMode: 0, // single-error bounds for errors within terms, default requires exact first char intraSlice: [1, inf], // single-error tolerance toggles intraSub: null, intraTrn: null, intraDel: null, // can post-filter matches that are too far apart in distance or length // (since intraIns is between each char, it can accum to nonsense matches) intraFilt: (term, match, index) => true, // should this also accept WIP info? toUpper: str => str.toLocaleUpperCase(), toLower: str => str.toLocaleLowerCase(), compare: null, // final sorting fn sort: (info, haystack, needle, compare = cmp) => { let { idx, chars, terms, interLft2, interLft1, // interRgt2, // interRgt1, start, intraIns, interIns, cases, } = info; return idx.map((v, i) => i).sort((ia, ib) => ( // most contig chars matched chars[ib] - chars[ia] || // least char intra-fuzz (most contiguous) intraIns[ia] - intraIns[ib] || // most prefix bounds, boosted by full term matches ( (terms[ib] + interLft2[ib] + 0.5 * interLft1[ib]) - (terms[ia] + interLft2[ia] + 0.5 * interLft1[ia]) ) || // highest density of match (least span) // span[ia] - span[ib] || // highest density of match (least term inter-fuzz) interIns[ia] - interIns[ib] || // earliest start of match start[ia] - start[ib] || // case match cases[ib] - cases[ia] || // alphabetic compare(haystack[idx[ia]], haystack[idx[ib]]) )); }, }; const lazyRepeat = (chars, limit) => ( limit == 0 ? '' : limit == 1 ? chars + '??' : limit == inf ? chars + '*?' : chars + `{0,${limit}}?` ); const mode2Tpl = '(?:\\b|_)'; function uFuzzy(opts) { opts = Object.assign({}, OPTS, opts); let { unicode, interLft, interRgt, intraMode, intraSlice, intraIns, intraSub, intraTrn, intraDel, intraContr, intraSplit: _intraSplit, interSplit: _interSplit, intraBound: _intraBound, interBound: _interBound, intraChars, toUpper, toLower, compare, } = opts; intraIns ??= intraMode; intraSub ??= intraMode; intraTrn ??= intraMode; intraDel ??= intraMode; compare ??= typeof Intl == "undefined" ? cmp : new Intl.Collator(...COLLATE_ARGS).compare; let alpha = opts.letters ?? opts.alpha; if (alpha != null) { let upper = toUpper(alpha); let lower = toLower(alpha); _interSplit = swapAlpha(_interSplit, upper, lower); _intraSplit = swapAlpha(_intraSplit, upper, lower); _interBound = swapAlpha(_interBound, upper, lower); _intraBound = swapAlpha(_intraBound, upper, lower); intraChars = swapAlpha(intraChars, upper, lower); intraContr = swapAlpha(intraContr, upper, lower); } let uFlag = unicode ? 'u' : ''; const quotedAny = '".+?"'; const EXACTS_RE = new RegExp(quotedAny, 'gi' + uFlag); const NEGS_RE = new RegExp(`(?:\\s+|^)-(?:${intraChars}+|${quotedAny})`, 'gi' + uFlag); let { intraRules } = opts; if (intraRules == null) { intraRules = p => { // default is exact term matches only let _intraSlice = OPTS.intraSlice, // requires first char _intraIns = 0, _intraSub = 0, _intraTrn = 0, _intraDel = 0; // only-digits strings should match exactly, else special rules for short strings if (/[^\d]/.test(p)) { let plen = p.length; // prevent junk matches by requiring stricter rules for short terms if (plen <= 4) { if (plen >= 3) { // one swap in non-first char when 3-4 chars _intraTrn = Math.min(intraTrn, 1); // or one insertion when 4 chars if (plen == 4) _intraIns = Math.min(intraIns, 1); } // else exact match when 1-2 chars } // use supplied opts else { _intraSlice = intraSlice; _intraIns = intraIns, _intraSub = intraSub, _intraTrn = intraTrn, _intraDel = intraDel; } } return { intraSlice: _intraSlice, intraIns: _intraIns, intraSub: _intraSub, intraTrn: _intraTrn, intraDel: _intraDel, }; }; } let withIntraSplit = !!_intraSplit; let intraSplit = new RegExp(_intraSplit, 'g' + uFlag); let interSplit = new RegExp(_interSplit, 'g' + uFlag); let trimRe = new RegExp('^' + _interSplit + '|' + _interSplit + '$', 'g' + uFlag); let contrsRe = new RegExp(intraContr, 'gi' + uFlag); const split = (needle, keepCase = false) => { let exacts = []; needle = needle.replace(EXACTS_RE, m => { exacts.push(m); return EXACT_HERE; }); needle = needle.replace(trimRe, ''); if (!keepCase) needle = toLower(needle); if (withIntraSplit) needle = needle.replace(intraSplit, m => m[0] + ' ' + m[1]); let j = 0; return needle.split(interSplit).filter(t => t != '').map(v => v === EXACT_HERE ? exacts[j++] : v); }; const NUM_OR_ALPHA_RE = /[^\d]+|\d+/g; const prepQuery = (needle, capt = 0, interOR = false) => { // split on punct, whitespace, num-alpha, and upper-lower boundaries let parts = split(needle); if (parts.length == 0) return []; // split out any detected contractions for each term that become required suffixes let contrs = Array(parts.length).fill(''); parts = parts.map((p, pi) => p.replace(contrsRe, m => { contrs[pi] = m; return ''; })); // array of regexp tpls for each term let reTpl; // allows single mutations within each term if (intraMode == 1) { reTpl = parts.map((p, pi) => { if (p[0] === '"') return escapeRegExp(p.slice(1, -1)); let reTpl = ''; // split into numeric and alpha parts, so numbers are only matched as following punct or alpha boundaries, without swaps or insertions for (let m of p.matchAll(NUM_OR_ALPHA_RE)) { let p = m[0]; let { intraSlice, intraIns, intraSub, intraTrn, intraDel, } = intraRules(p); if (intraIns + intraSub + intraTrn + intraDel == 0) reTpl += p + contrs[pi]; else { let [lftIdx, rgtIdx] = intraSlice; let lftChar = p.slice(0, lftIdx); // prefix let rgtChar = p.slice(rgtIdx); // suffix let chars = p.slice(lftIdx, rgtIdx); // neg lookahead to prefer matching 'Test' instead of 'tTest' in ManifestTest or fittest // but skip when search term contains leading repetition (aardvark, aaa) if (intraIns == 1 && lftChar.length == 1 && lftChar != chars[0]) lftChar += '(?!' + lftChar + ')'; let numChars = chars.length; let variants = [p]; // variants with single char substitutions if (intraSub) { for (let i = 0; i < numChars; i++) variants.push(lftChar + chars.slice(0, i) + intraChars + chars.slice(i + 1) + rgtChar); } // variants with single transpositions if (intraTrn) { for (let i = 0; i < numChars - 1; i++) { if (chars[i] != chars[i+1]) variants.push(lftChar + chars.slice(0, i) + chars[i+1] + chars[i] + chars.slice(i + 2) + rgtChar); } } // variants with single char omissions if (intraDel) { for (let i = 0; i < numChars; i++) variants.push(lftChar + chars.slice(0, i + 1) + '?' + chars.slice(i + 1) + rgtChar); } // variants with single char insertions if (intraIns) { let intraInsTpl = lazyRepeat(intraChars, 1); for (let i = 0; i < numChars; i++) variants.push(lftChar + chars.slice(0, i) + intraInsTpl + chars.slice(i) + rgtChar); } reTpl += '(?:' + variants.join('|') + ')' + contrs[pi]; } } // console.log(reTpl); return reTpl; }); } else { let intraInsTpl = lazyRepeat(intraChars, intraIns); // capture at char level if (capt == 2 && intraIns > 0) { // sadly, we also have to capture the inter-term junk via parenth-wrapping .*? // to accum other capture groups' indices for \b boosting during scoring intraInsTpl = ')(' + intraInsTpl + ')('; } reTpl = parts.map((p, pi) => p[0] === '"' ? escapeRegExp(p.slice(1, -1)) : p.split('').map((c, i, chars) => { // neg lookahead to prefer matching 'Test' instead of 'tTest' in ManifestTest or fittest // but skip when search term contains leading repetition (aardvark, aaa) if (intraIns == 1 && i == 0 && chars.length > 1 && c != chars[i+1]) c += '(?!' + c + ')'; return c; }).join(intraInsTpl) + contrs[pi]); } // console.log(reTpl); // this only helps to reduce initial matches early when they can be detected // TODO: might want a mode 3 that excludes _ let preTpl = interLft == 2 ? mode2Tpl : ''; let sufTpl = interRgt == 2 ? mode2Tpl : ''; let interCharsTpl = sufTpl + lazyRepeat(opts.interChars, opts.interIns) + preTpl; // capture at word level if (capt > 0) { if (interOR) { // this is basically for doing .matchAll() occurence counting and highlighting without needing permuted ooo needles reTpl = preTpl + '(' + reTpl.join(')' + sufTpl + '|' + preTpl + '(') + ')' + sufTpl; } else { // sadly, we also have to capture the inter-term junk via parenth-wrapping .*? // to accum other capture groups' indices for \b boosting during scoring reTpl = '(' + reTpl.join(')(' + interCharsTpl + ')(') + ')'; reTpl = '(.??' + preTpl + ')' + reTpl + '(' + sufTpl + '.*)'; // nit: trailing capture here assumes interIns = Inf } } else { reTpl = reTpl.join(interCharsTpl); reTpl = preTpl + reTpl + sufTpl; } // console.log(reTpl); return [new RegExp(reTpl, 'i' + uFlag), parts, contrs]; }; const filter = (haystack, needle, idxs) => { let [query] = prepQuery(needle); if (query == null) return null; let out = []; if (idxs != null) { for (let i = 0; i < idxs.length; i++) { let idx = idxs[i]; query.test(haystack[idx]) && out.push(idx); } } else { for (let i = 0; i < haystack.length; i++) query.test(haystack[i]) && out.push(i); } return out; }; let withIntraBound = !!_intraBound; let interBound = new RegExp(_interBound, uFlag); let intraBound = new RegExp(_intraBound, uFlag); const info = (idxs, haystack, needle) => { let [query, parts, contrs] = prepQuery(needle, 1); let partsCased = split(needle, true); let [queryR] = prepQuery(needle, 2); let partsLen = parts.length; let _terms = Array(partsLen); let _termsCased = Array(partsLen); for (let j = 0; j < partsLen; j++) { let part = parts[j]; let partCased = partsCased[j]; let term = part[0] == '"' ? part.slice(1, -1) : part + contrs[j]; let termCased = partCased[0] == '"' ? partCased.slice(1, -1) : partCased + contrs[j]; _terms[j] = term; _termsCased[j] = termCased; } let len = idxs.length; let field = Array(len).fill(0); let info = { // idx in haystack idx: Array(len), // start of match start: field.slice(), // length of match // span: field.slice(), // contiguous chars matched chars: field.slice(), // case matched in term (via term.includes(match)) cases: field.slice(), // contiguous (no fuzz) and bounded terms (intra=0, lft2/1, rgt2/1) // excludes terms that are contiguous but have < 2 bounds (substrings) terms: field.slice(), // cumulative length of unmatched chars (fuzz) within span interIns: field.slice(), // between terms intraIns: field.slice(), // within terms // interLft/interRgt counters interLft2: field.slice(), interRgt2: field.slice(), interLft1: field.slice(), interRgt1: field.slice(), ranges: Array(len), }; // might discard idxs based on bounds checks let mayDiscard = interLft == 1 || interRgt == 1; let ii = 0; for (let i = 0; i < idxs.length; i++) { let mhstr = haystack[idxs[i]]; // the matched parts are [full, junk, term, junk, term, junk] let m = mhstr.match(query); // leading junk let start = m.index + m[1].length; let idxAcc = start; // let span = m[0].length; let disc = false; let lft2 = 0; let lft1 = 0; let rgt2 = 0; let rgt1 = 0; let chars = 0; let terms = 0; let cases = 0; let inter = 0; let intra = 0; let refine = []; for (let j = 0, k = 2; j < partsLen; j++, k+=2) { let group = toLower(m[k]); let term = _terms[j]; let termCased = _termsCased[j]; let termLen = term.length; let groupLen = group.length; let fullMatch = group == term; if (m[k] == termCased) cases++; // this won't handle the case when an exact match exists across the boundary of the current group and the next junk // e.g. blob,ob when searching for 'bob' but finding the earlier `blob` (with extra insertion) if (!fullMatch && m[k+1].length >= termLen) { // probe for exact match in inter junk (TODO: maybe even in this matched part?) let idxOf = toLower(m[k+1]).indexOf(term); if (idxOf > -1) { refine.push(idxAcc, groupLen, idxOf, termLen); idxAcc += refineMatch(m, k, idxOf, termLen); group = term; groupLen = termLen; fullMatch = true; if (j == 0) start = idxAcc; } } if (mayDiscard || fullMatch) { // does group's left and/or right land on \b let lftCharIdx = idxAcc - 1; let rgtCharIdx = idxAcc + groupLen; let isPre = false; let isSuf = false; // prefix info if (lftCharIdx == -1 || interBound.test(mhstr[lftCharIdx])) { fullMatch && lft2++; isPre = true; } else { if (interLft == 2) { disc = true; break; } if (withIntraBound && intraBound.test(mhstr[lftCharIdx] + mhstr[lftCharIdx + 1])) { fullMatch && lft1++; isPre = true; } else { if (interLft == 1) { // regexps are eager, so try to improve the match by probing forward inter junk for exact match at a boundary let junk = m[k+1]; let junkIdx = idxAcc + groupLen; if (junk.length >= termLen) { let idxOf = 0; let found = false; let re = new RegExp(term, 'ig' + uFlag); let m2; while (m2 = re.exec(junk)) { idxOf = m2.index; let charIdx = junkIdx + idxOf; let lftCharIdx = charIdx - 1; if (lftCharIdx == -1 || interBound.test(mhstr[lftCharIdx])) { lft2++; found = true; break; } else if (intraBound.test(mhstr[lftCharIdx] + mhstr[charIdx])) { lft1++; found = true; break; } } if (found) { isPre = true; // identical to exact term refinement pass above refine.push(idxAcc, groupLen, idxOf, termLen); idxAcc += refineMatch(m, k, idxOf, termLen); group = term; groupLen = termLen; fullMatch = true; if (j == 0) start = idxAcc; } } if (!isPre) { disc = true; break; } } } } // suffix info if (rgtCharIdx == mhstr.length || interBound.test(mhstr[rgtCharIdx])) { fullMatch && rgt2++; isSuf = true; } else { if (interRgt == 2) { disc = true; break; } if (withIntraBound && intraBound.test(mhstr[rgtCharIdx - 1] + mhstr[rgtCharIdx])) { fullMatch && rgt1++; isSuf = true; } else { if (interRgt == 1) { disc = true; break; } } } if (fullMatch) { chars += termLen; if (isPre && isSuf) terms++; } } if (groupLen > termLen) intra += groupLen - termLen; // intraFuzz if (j > 0) inter += m[k-1].length; // interFuzz // TODO: group here is lowercased, which is okay for length cmp, but not more case-sensitive filts if (!opts.intraFilt(term, group, idxAcc)) { disc = true; break; } if (j < partsLen - 1) idxAcc += groupLen + m[k+1].length; } if (!disc) { info.idx[ii] = idxs[i]; info.interLft2[ii] = lft2; info.interLft1[ii] = lft1; info.interRgt2[ii] = rgt2; info.interRgt1[ii] = rgt1; info.chars[ii] = chars; info.terms[ii] = terms; info.cases[ii] = cases; info.interIns[ii] = inter; info.intraIns[ii] = intra; info.start[ii] = start; // info.span[ii] = span; // ranges let m = mhstr.match(queryR); let idxAcc = m.index + m[1].length; let refLen = refine.length; let ri = refLen > 0 ? 0 : Infinity; let lastRi = refLen - 4; for (let i = 2; i < m.length;) { let len = m[i].length; if (ri <= lastRi && refine[ri] == idxAcc) { let groupLen = refine[ri+1]; let idxOf = refine[ri+2]; let termLen = refine[ri+3]; // advance to end of original (full) group match that includes intra-junk let j = i; let v = ''; for (let _len = 0; _len < groupLen; j++) { v += m[j]; _len += m[j].length; } m.splice(i, j - i, v); idxAcc += refineMatch(m, i, idxOf, termLen); ri += 4; } else { idxAcc += len; i++; } } idxAcc = m.index + m[1].length; let ranges = info.ranges[ii] = []; let from = idxAcc; let to = idxAcc; for (let i = 2; i < m.length; i++) { let len = m[i].length; idxAcc += len; if (i % 2 == 0) to = idxAcc; else if (len > 0) { ranges.push(from, to); from = to = idxAcc; } } if (to > from) ranges.push(from, to); ii++; } } // trim arrays if (ii < idxs.length) { for (let k in info) info[k] = info[k].slice(0, ii); } return info; }; const refineMatch = (m, k, idxInNext, termLen) => { // shift the current group into the prior junk let prepend = m[k] + m[k+1].slice(0, idxInNext); m[k-1] += prepend; m[k] = m[k+1].slice(idxInNext, idxInNext + termLen); m[k+1] = m[k+1].slice(idxInNext + termLen); return prepend.length; }; const OOO_TERMS_LIMIT = 5; // returns [idxs, info, order] const _search = (haystack, needle, outOfOrder, infoThresh = 1e3, preFiltered) => { outOfOrder = !outOfOrder ? 0 : outOfOrder === true ? OOO_TERMS_LIMIT : outOfOrder; let needles = null; let matches = null; let negs = []; needle = needle.replace(NEGS_RE, m => { let neg = m.trim().slice(1); neg = neg[0] === '"' ? escapeRegExp(neg.slice(1,-1)) : neg.replace(PUNCT_RE, ''); if (neg != '') negs.push(neg); return ''; }); let terms = split(needle); let negsRe; if (negs.length > 0) { negsRe = new RegExp(negs.join('|'), 'i' + uFlag); if (terms.length == 0) { let idxs = []; for (let i = 0; i < haystack.length; i++) { if (!negsRe.test(haystack[i])) idxs.push(i); } return [idxs, null, null]; } } else { // abort search (needle is empty after pre-processing, e.g. no alpha-numeric chars) if (terms.length == 0) return [null, null, null]; } // console.log(negs); // console.log(needle); if (outOfOrder > 0) { // since uFuzzy is an AND-based search, we can iteratively pre-reduce the haystack by searching // for each term in isolation before running permutations on what's left. // this is a major perf win. e.g. searching "test man ger pp a" goes from 570ms -> 14ms let terms = split(needle); if (terms.length > 1) { // longest -> shortest let terms2 = terms.slice().sort((a, b) => b.length - a.length); for (let ti = 0; ti < terms2.length; ti++) { // no haystack item contained all terms if (preFiltered?.length == 0) return [[], null, null]; preFiltered = filter(haystack, terms2[ti], preFiltered); } // avoid combinatorial explosion by limiting outOfOrder to 5 terms (120 max searches) // fall back to just filter() otherwise if (terms.length > outOfOrder) return [preFiltered, null, null]; needles = permute(terms).map(perm => perm.join(' ')); // filtered matches for each needle excluding same matches for prior needles matches = []; // keeps track of already-matched idxs to skip in follow-up permutations let matchedIdxs = new Set(); for (let ni = 0; ni < needles.length; ni++) { if (matchedIdxs.size < preFiltered.length) { // filter further for this needle, exclude already-matched let preFiltered2 = preFiltered.filter(idx => !matchedIdxs.has(idx)); let matched = filter(haystack, needles[ni], preFiltered2); for (let j = 0; j < matched.length; j++) matchedIdxs.add(matched[j]); matches.push(matched); } else matches.push([]); } } } // interOR // console.log(prepQuery(needle, 1, null, true)); // non-ooo or ooo w/single term if (needles == null) { needles = [needle]; matches = [preFiltered?.length > 0 ? preFiltered : filter(haystack, needle)]; } let retInfo = null; let retOrder = null; if (negs.length > 0) matches = matches.map(idxs => idxs.filter(idx => !negsRe.test(haystack[idx]))); let matchCount = matches.reduce((acc, idxs) => acc + idxs.length, 0); // rank, sort, concat if (matchCount <= infoThresh) { retInfo = {}; retOrder = []; for (let ni = 0; ni < matches.length; ni++) { let idxs = matches[ni]; if (idxs == null || idxs.length == 0) continue; let needle = needles[ni]; let _info = info(idxs, haystack, needle); let order = opts.sort(_info, haystack, needle, compare); // offset idxs for concat'ing infos if (ni > 0) { for (let i = 0; i < order.length; i++) order[i] += retOrder.length; } for (let k in _info) retInfo[k] = (retInfo[k] ?? []).concat(_info[k]); retOrder = retOrder.concat(order); } } return [ [].concat(...matches), retInfo, retOrder, ]; }; return { search: (...args) => { let out = _search(...args); return out; }, split, filter, info, sort: opts.sort, }; } const latinize = (() => { let accents = { A: 'ÁÀÃÂÄĄ', a: 'áàãâäą', E: 'ÉÈÊËĖ', e: 'éèêëę', I: 'ÍÌÎÏĮ', i: 'íìîïį', O: 'ÓÒÔÕÖ', o: 'óòôõö', U: 'ÚÙÛÜŪŲ', u: 'úùûüūų', C: 'ÇČĆ', c: 'çčć', L: 'Ł', l: 'ł', N: 'ÑŃ', n: 'ñń', S: 'ŠŚ', s: 'šś', Z: 'ŻŹ', z: 'żź' }; let accentsMap = new Map(); let accentsTpl = ''; for (let r in accents) { accents[r].split('').forEach(a => { accentsTpl += a; accentsMap.set(a, r); }); } let accentsRe = new RegExp(`[${accentsTpl}]`, 'g'); let replacer = m => accentsMap.get(m); return strings => { if (typeof strings == 'string') return strings.replace(accentsRe, replacer); let out = Array(strings.length); for (let i = 0; i < strings.length; i++) out[i] = strings[i].replace(accentsRe, replacer); return out; }; })(); // https://stackoverflow.com/questions/9960908/permutations-in-javascript/37580979#37580979 function permute(arr) { arr = arr.slice(); let length = arr.length, result = [arr.slice()], c = new Array(length).fill(0), i = 1, k, p; while (i < length) { if (c[i] < i) { k = i % 2 && c[i]; p = arr[i]; arr[i] = arr[k]; arr[k] = p; ++c[i]; i = 1; result.push(arr.slice()); } else { c[i] = 0; ++i; } } return result; } const _mark = (part, matched) => matched ? `<mark>${part}</mark>` : part; const _append = (acc, part) => acc + part; function highlight(str, ranges, mark = _mark, accum = '', append = _append) { accum = append(accum, mark(str.substring(0, ranges[0]), false)) ?? accum; for (let i = 0; i < ranges.length; i+=2) { let fr = ranges[i]; let to = ranges[i+1]; accum = append(accum, mark(str.substring(fr, to), true)) ?? accum; if (i < ranges.length - 3) accum = append(accum, mark(str.substring(ranges[i+1], ranges[i+2]), false)) ?? accum; } accum = append(accum, mark(str.substring(ranges[ranges.length - 1]), false)) ?? accum; return accum; } uFuzzy.latinize = latinize; uFuzzy.permute = arr => { let idxs = permute([...Array(arr.length).keys()]).sort((a,b) => { for (let i = 0; i < a.length; i++) { if (a[i] != b[i]) return a[i] - b[i]; } return 0; }); return idxs.map(pi => pi.map(i => arr[i])); }; uFuzzy.highlight = highlight; return uFuzzy; })();