From 97c8e3e52e5453fe88c1cc99a39a050a6802652c Mon Sep 17 00:00:00 2001 From: pull <39814207+pull@users.noreply.github.com> Date: Sun, 31 May 2026 03:57:46 +0000 Subject: [PATCH] chore(deps): bump fuse.js from `7.3.0` to `7.4.0` --- assets/lib/fuse/fuse.js | 2261 +++++++++++++++++++++++++++++++++++++++ librarybot.yml | 2 +- 2 files changed, 2262 insertions(+), 1 deletion(-) create mode 100644 assets/lib/fuse/fuse.js diff --git a/assets/lib/fuse/fuse.js b/assets/lib/fuse/fuse.js new file mode 100644 index 000000000..d4bbc4059 --- /dev/null +++ b/assets/lib/fuse/fuse.js @@ -0,0 +1,2261 @@ +/** + * Fuse.js v7.4.0 - Lightweight fuzzy-search (http://fusejs.io) + * + * Copyright (c) 2026 Kiro Risk (http://kiro.me) + * All Rights Reserved. Apache Software License 2.0 + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +'use strict'; + +function isArray(value) { + return !Array.isArray ? getTag(value) === '[object Array]' : Array.isArray(value); +} +function baseToString(value) { + // Exit early for strings to avoid a performance hit in some environments. + if (typeof value == 'string') { + return value; + } + if (typeof value === 'bigint') { + return value.toString(); + } + const result = value + ''; + return result == '0' && 1 / value == -Infinity ? '-0' : result; +} +function toString(value) { + return value == null ? '' : baseToString(value); +} +function isString(value) { + return typeof value === 'string'; +} +function isNumber(value) { + return typeof value === 'number'; +} + +// Adapted from: https://github.com/lodash/lodash/blob/master/isBoolean.js +function isBoolean(value) { + return value === true || value === false || isObjectLike(value) && getTag(value) == '[object Boolean]'; +} +function isObject(value) { + return typeof value === 'object'; +} + +// Checks if `value` is object-like. +function isObjectLike(value) { + return isObject(value) && value !== null; +} +function isDefined(value) { + return value !== undefined && value !== null; +} +function isBlank(value) { + return !value.trim().length; +} + +// Gets the `toStringTag` of `value`. +// Adapted from: https://github.com/lodash/lodash/blob/master/.internal/getTag.js +function getTag(value) { + return value == null ? value === undefined ? '[object Undefined]' : '[object Null]' : Object.prototype.toString.call(value); +} + +const INCORRECT_INDEX_TYPE = "Incorrect 'index' type"; +const INVALID_DOC_INDEX = 'Invalid doc index: must be a non-negative integer within the bounds of the docs array'; +const LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY = key => `Invalid value for key ${key}`; +const PATTERN_LENGTH_TOO_LARGE = max => `Pattern length exceeds max of ${max}.`; +const MISSING_KEY_PROPERTY = name => `Missing ${name} property in key`; +const INVALID_KEY_WEIGHT_VALUE = key => `Property 'weight' in key '${key}' must be a positive integer`; +const FUSE_MATCH_TOKEN_SEARCH_UNSUPPORTED = `Fuse.match does not support useTokenSearch: token search requires ` + `corpus-level statistics (df, fieldCount) that a one-off string ` + `comparison does not have. Use new Fuse(...).search(...) instead.`; + +const hasOwn = Object.prototype.hasOwnProperty; +class KeyStore { + constructor(keys) { + this._keys = []; + this._keyMap = {}; + let totalWeight = 0; + keys.forEach(key => { + const obj = createKey(key); + this._keys.push(obj); + this._keyMap[obj.id] = obj; + totalWeight += obj.weight; + }); + + // Normalize weights so that their sum is equal to 1 + this._keys.forEach(key => { + key.weight /= totalWeight; + }); + } + get(keyId) { + return this._keyMap[keyId]; + } + keys() { + return this._keys; + } + toJSON() { + return JSON.stringify(this._keys); + } +} +function createKey(key) { + let path = null; + let id = null; + let src = null; + let weight = 1; + let getFn = null; + if (isString(key) || isArray(key)) { + src = key; + path = createKeyPath(key); + id = createKeyId(key); + } else { + if (!hasOwn.call(key, 'name')) { + throw new Error(MISSING_KEY_PROPERTY('name')); + } + const name = key.name; + src = name; + if (hasOwn.call(key, 'weight') && key.weight !== undefined) { + weight = key.weight; + if (weight <= 0) { + throw new Error(INVALID_KEY_WEIGHT_VALUE(createKeyId(name))); + } + } + path = createKeyPath(name); + id = createKeyId(name); + getFn = key.getFn ?? null; + } + return { + path: path, + id: id, + weight, + src: src, + getFn + }; +} +function createKeyPath(key) { + return isArray(key) ? key : key.split('.'); +} +function createKeyId(key) { + return isArray(key) ? key.join('.') : key; +} + +function get(obj, path) { + const list = []; + let arr = false; + const deepGet = (obj, path, index, arrayIndex) => { + if (!isDefined(obj)) { + return; + } + if (!path[index]) { + // If there's no path left, we've arrived at the object we care about. + list.push(arrayIndex !== undefined ? { + v: obj, + i: arrayIndex + } : obj); + } else { + const key = path[index]; + const value = obj[key]; + if (!isDefined(value)) { + return; + } + + // If we're at the last value in the path, and if it's a string/number/bool, + // add it to the list + if (index === path.length - 1 && (isString(value) || isNumber(value) || isBoolean(value) || typeof value === 'bigint')) { + list.push(arrayIndex !== undefined ? { + v: toString(value), + i: arrayIndex + } : toString(value)); + } else if (isArray(value)) { + arr = true; + // Search each item in the array. + for (let i = 0, len = value.length; i < len; i += 1) { + deepGet(value[i], path, index + 1, i); + } + } else if (path.length) { + // An object. Recurse further. + deepGet(value, path, index + 1, arrayIndex); + } + } + }; + + // Backwards compatibility (since path used to be a string) + deepGet(obj, isString(path) ? path.split('.') : path, 0); + return arr ? list : list[0]; +} + +const MatchOptions = { + includeMatches: false, + findAllMatches: false, + minMatchCharLength: 1 +}; +const BasicOptions = { + isCaseSensitive: false, + ignoreDiacritics: false, + includeScore: false, + keys: [], + shouldSort: true, + sortFn: (a, b) => a.score === b.score ? a.idx < b.idx ? -1 : 1 : a.score < b.score ? -1 : 1 +}; +const FuzzyOptions = { + location: 0, + threshold: 0.6, + distance: 100 +}; +const AdvancedOptions = { + useExtendedSearch: false, + useTokenSearch: false, + tokenize: undefined, + tokenMatch: 'any', + getFn: get, + ignoreLocation: false, + ignoreFieldNorm: false, + fieldNormWeight: 1 +}; +const Config = Object.freeze({ + ...BasicOptions, + ...MatchOptions, + ...FuzzyOptions, + ...AdvancedOptions +}); + +// Field-length norm: the shorter the field, the higher the weight. +// Set to 3 decimals to reduce index size. +function norm(weight = 1, mantissa = 3) { + const cache = new Map(); + const m = Math.pow(10, mantissa); + return { + get(value) { + // Count words by counting space transitions — avoids allocating a regex match array + let numTokens = 1; + let inSpace = false; + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) === 32) { + if (!inSpace) { + numTokens++; + inSpace = true; + } + } else { + inSpace = false; + } + } + if (cache.has(numTokens)) { + return cache.get(numTokens); + } + + // Default function is 1/sqrt(x), weight makes that variable + const n = Math.round(m / Math.pow(numTokens, 0.5 * weight)) / m; + cache.set(numTokens, n); + return n; + }, + clear() { + cache.clear(); + } + }; +} + +class FuseIndex { + constructor({ + getFn = Config.getFn, + fieldNormWeight = Config.fieldNormWeight + } = {}) { + this.norm = norm(fieldNormWeight, 3); + this.getFn = getFn; + this.isCreated = false; + this.docs = []; + this.keys = []; + this._keysMap = {}; + this.setIndexRecords(); + } + setSources(docs = []) { + this.docs = docs; + } + setIndexRecords(records = []) { + this.records = records; + } + setKeys(keys = []) { + this.keys = keys; + this._keysMap = {}; + keys.forEach((key, idx) => { + this._keysMap[key.id] = idx; + }); + } + create() { + if (this.isCreated || !this.docs.length) { + return; + } + this.isCreated = true; + const len = this.docs.length; + this.records = new Array(len); + let recordCount = 0; + + // List is Array + if (isString(this.docs[0])) { + for (let i = 0; i < len; i++) { + const record = this._createStringRecord(this.docs[i], i); + if (record) { + this.records[recordCount++] = record; + } + } + } else { + // List is Array + for (let i = 0; i < len; i++) { + this.records[recordCount++] = this._createObjectRecord(this.docs[i], i); + } + } + this.records.length = recordCount; + this.norm.clear(); + } + // Appends a record for `doc` at `docIndex` (the doc's position in the source + // array). Returns the appended record, or null when `doc` is a blank string + // (those are skipped at record creation; see `_createStringRecord`). Callers + // use the return value to gate downstream bookkeeping like the inverted + // index, which must not be touched when no record was produced. + add(doc, docIndex) { + if (!Number.isInteger(docIndex) || docIndex < 0) { + throw new Error(INVALID_DOC_INDEX); + } + if (isString(doc)) { + const record = this._createStringRecord(doc, docIndex); + if (record) { + this.records.push(record); + } + return record; + } + const record = this._createObjectRecord(doc, docIndex); + this.records.push(record); + return record; + } + // Removes the record for the doc at the specified source-array (docs) index. + // Blank string docs have no record; callers may pass such an index and the + // splice is a no-op, but subsequent records still need their .i decremented + // to track the docs array that the caller is splicing in parallel. + removeAt(idx) { + if (!Number.isInteger(idx) || idx < 0) { + throw new Error(INVALID_DOC_INDEX); + } + + // Find and remove the record at this doc-index, if one exists. Records are + // typically sorted by .i but the algorithm doesn't depend on it — parsed + // indexes via setIndexRecords may arrive in arbitrary order. + for (let i = 0, len = this.records.length; i < len; i += 1) { + if (this.records[i].i === idx) { + this.records.splice(i, 1); + break; + } + } + + // Decrement every record whose source-array index is now stale. + for (let i = 0, len = this.records.length; i < len; i += 1) { + if (this.records[i].i > idx) { + this.records[i].i -= 1; + } + } + } + // Removes records for the docs at the specified source-array indices, then + // shifts every surviving record's .i down by the count of removed indices + // strictly less than it (mirrors removeAndShiftInvertedIndex's shift math). + // Invalid entries (non-integer, negative) in `indices` are dropped silently + // — removeAll's natural use case is "caller passed a list of matched doc + // indices"; asymmetric throw-vs-no-op would be more surprising than a clean + // filter. + removeAll(indices) { + const toRemove = new Set(); + for (const v of indices) { + if (Number.isInteger(v) && v >= 0) { + toRemove.add(v); + } + } + if (toRemove.size === 0) { + return; + } + this.records = this.records.filter(r => !toRemove.has(r.i)); + const sorted = Array.from(toRemove).sort((a, b) => a - b); + for (const record of this.records) { + // shift = count of removed indices strictly less than record.i + let lo = 0; + let hi = sorted.length; + while (lo < hi) { + const mid = lo + hi >>> 1; + if (sorted[mid] < record.i) lo = mid + 1;else hi = mid; + } + record.i -= lo; + } + } + getValueForItemAtKeyId(item, keyId) { + return item[this._keysMap[keyId]]; + } + size() { + return this.records.length; + } + _createStringRecord(doc, docIndex) { + if (!isDefined(doc) || isBlank(doc)) { + return null; + } + return { + v: doc, + i: docIndex, + n: this.norm.get(doc) + }; + } + _createObjectRecord(doc, docIndex) { + const record = { + i: docIndex, + $: {} + }; + + // Iterate over every key (i.e, path), and fetch the value at that key + for (let keyIndex = 0, keyLen = this.keys.length; keyIndex < keyLen; keyIndex++) { + const key = this.keys[keyIndex]; + const value = key.getFn ? key.getFn(doc) : this.getFn(doc, key.path); + if (!isDefined(value)) { + continue; + } + if (isArray(value)) { + const subRecords = []; + for (let i = 0, len = value.length; i < len; i += 1) { + const item = value[i]; + if (!isDefined(item)) { + continue; + } + if (isString(item)) { + // Custom getFn returning plain string array (backward compat) + if (!isBlank(item)) { + const subRecord = { + v: item, + i: i, + n: this.norm.get(item) + }; + subRecords.push(subRecord); + } + } else if (isDefined(item.v)) { + // Default get() returns {v, i} objects with original array indices + const text = isString(item.v) ? item.v : toString(item.v); + if (!isBlank(text)) { + const subRecord = { + v: text, + i: item.i, + n: this.norm.get(text) + }; + subRecords.push(subRecord); + } + } + } + record.$[keyIndex] = subRecords; + } else if (isString(value) && !isBlank(value)) { + const subRecord = { + v: value, + n: this.norm.get(value) + }; + record.$[keyIndex] = subRecord; + } + } + return record; + } + toJSON() { + return { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + keys: this.keys.map(({ + getFn, + ...key + }) => key), + records: this.records + }; + } +} +function createIndex(keys, docs, { + getFn = Config.getFn, + fieldNormWeight = Config.fieldNormWeight +} = {}) { + const myIndex = new FuseIndex({ + getFn, + fieldNormWeight + }); + myIndex.setKeys(keys.map(createKey)); + myIndex.setSources(docs); + myIndex.create(); + return myIndex; +} +function parseIndex(data, { + getFn = Config.getFn, + fieldNormWeight = Config.fieldNormWeight +} = {}) { + const { + keys, + records + } = data; + const myIndex = new FuseIndex({ + getFn, + fieldNormWeight + }); + myIndex.setKeys(keys); + myIndex.setIndexRecords(records); + return myIndex; +} + +function convertMaskToIndices(matchmask = [], minMatchCharLength = Config.minMatchCharLength) { + const indices = []; + let start = -1; + let end = -1; + let i = 0; + for (let len = matchmask.length; i < len; i += 1) { + const match = matchmask[i]; + if (match && start === -1) { + start = i; + } else if (!match && start !== -1) { + end = i - 1; + if (end - start + 1 >= minMatchCharLength) { + indices.push([start, end]); + } + start = -1; + } + } + + // (i-1 - start) + 1 => i - start + if (matchmask[i - 1] && i - start >= minMatchCharLength) { + indices.push([start, i - 1]); + } + return indices; +} + +// Machine word size +const MAX_BITS = 32; + +function search(text, pattern, patternAlphabet, { + location = Config.location, + distance = Config.distance, + threshold = Config.threshold, + findAllMatches = Config.findAllMatches, + minMatchCharLength = Config.minMatchCharLength, + includeMatches = Config.includeMatches, + ignoreLocation = Config.ignoreLocation +} = {}) { + if (pattern.length > MAX_BITS) { + throw new Error(PATTERN_LENGTH_TOO_LARGE(MAX_BITS)); + } + const patternLen = pattern.length; + // Set starting location at beginning text and initialize the alphabet. + const textLen = text.length; + // Handle the case when location > text.length + const expectedLocation = Math.max(0, Math.min(location, textLen)); + // Highest score beyond which we give up. + let currentThreshold = threshold; + // Is there a nearby exact match? (speedup) + let bestLocation = expectedLocation; + + // Inlined score computation — avoids object allocation per call in hot loops. + // See ./computeScore.ts for the documented version of this formula. + const calcScore = (errors, currentLocation) => { + const accuracy = errors / patternLen; + if (ignoreLocation) return accuracy; + const proximity = Math.abs(expectedLocation - currentLocation); + if (!distance) return proximity ? 1.0 : accuracy; + return accuracy + proximity / distance; + }; + + // Performance: only computer matches when the minMatchCharLength > 1 + // OR if `includeMatches` is true. + const computeMatches = minMatchCharLength > 1 || includeMatches; + // A mask of the matches, used for building the indices + const matchMask = computeMatches ? Array(textLen) : []; + let index; + + // Get all exact matches, here for speed up + while ((index = text.indexOf(pattern, bestLocation)) > -1) { + const score = calcScore(0, index); + currentThreshold = Math.min(score, currentThreshold); + bestLocation = index + patternLen; + if (computeMatches) { + let i = 0; + while (i < patternLen) { + matchMask[index + i] = 1; + i += 1; + } + } + } + + // Reset the best location + bestLocation = -1; + let lastBitArr = []; + let finalScore = 1; + let bestErrors = 0; + let binMax = patternLen + textLen; + const mask = 1 << patternLen - 1; + for (let i = 0; i < patternLen; i += 1) { + // Scan for the best match; each iteration allows for one more error. + // Run a binary search to determine how far from the match location we can stray + // at this error level. + let binMin = 0; + let binMid = binMax; + while (binMin < binMid) { + const score = calcScore(i, expectedLocation + binMid); + if (score <= currentThreshold) { + binMin = binMid; + } else { + binMax = binMid; + } + binMid = Math.floor((binMax - binMin) / 2 + binMin); + } + + // Use the result from this iteration as the maximum for the next. + binMax = binMid; + let start = Math.max(1, expectedLocation - binMid + 1); + const finish = findAllMatches ? textLen : Math.min(expectedLocation + binMid, textLen) + patternLen; + + // Initialize the bit array + const bitArr = Array(finish + 2); + bitArr[finish + 1] = (1 << i) - 1; + for (let j = finish; j >= start; j -= 1) { + const currentLocation = j - 1; + const charMatch = patternAlphabet[text[currentLocation]]; + + // First pass: exact match + bitArr[j] = (bitArr[j + 1] << 1 | 1) & charMatch; + + // Subsequent passes: fuzzy match + if (i) { + bitArr[j] |= (lastBitArr[j + 1] | lastBitArr[j]) << 1 | 1 | lastBitArr[j + 1]; + } + if (bitArr[j] & mask) { + finalScore = calcScore(i, currentLocation); + + // This match will almost certainly be better than any existing match. + // But check anyway. + if (finalScore <= currentThreshold) { + // Indeed it is + currentThreshold = finalScore; + bestLocation = currentLocation; + bestErrors = i; + + // Already passed `loc`, downhill from here on in. + if (bestLocation <= expectedLocation) { + break; + } + + // When passing `bestLocation`, don't exceed our current distance from `expectedLocation`. + start = Math.max(1, 2 * expectedLocation - bestLocation); + } + } + } + + // No hope for a (better) match at greater error levels. + const score = calcScore(i + 1, expectedLocation); + if (score > currentThreshold) { + break; + } + lastBitArr = bitArr; + } + + // Fill matchMask across the matched window only. Bitap anchors a match at + // bestLocation (the start), spanning patternLen characters plus up to + // bestErrors extra characters when errors are text-side insertions. Marking + // alphabet positions in that window keeps the highlight indices honest about + // what actually matched, instead of every pattern-alphabet character the + // scan happened to visit. + if (computeMatches && bestLocation >= 0) { + const matchEnd = Math.min(textLen - 1, bestLocation + patternLen - 1 + bestErrors); + for (let k = bestLocation; k <= matchEnd; k += 1) { + if (patternAlphabet[text[k]]) { + matchMask[k] = 1; + } + } + } + const result = { + isMatch: bestLocation >= 0, + // Count exact matches (those with a score of 0) to be "almost" exact + score: Math.max(0.001, finalScore) + }; + if (computeMatches) { + const indices = convertMaskToIndices(matchMask, minMatchCharLength); + if (!indices.length) { + result.isMatch = false; + } else if (includeMatches) { + result.indices = indices; + } + } + return result; +} + +function createPatternAlphabet(pattern) { + const mask = {}; + for (let i = 0, len = pattern.length; i < len; i += 1) { + const char = pattern.charAt(i); + mask[char] = (mask[char] || 0) | 1 << len - i - 1; + } + return mask; +} + +function mergeIndices(indices) { + if (indices.length <= 1) return indices; + indices.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const merged = [indices[0]]; + for (let i = 1, len = indices.length; i < len; i += 1) { + const last = merged[merged.length - 1]; + const curr = indices[i]; + if (curr[0] <= last[1] + 1) { + last[1] = Math.max(last[1], curr[1]); + } else { + merged.push(curr); + } + } + return merged; +} + +// Characters that survive NFD normalization unchanged and need explicit mapping +const NON_DECOMPOSABLE_MAP = { + '\u0142': 'l', + // ł + '\u0141': 'L', + // Ł + '\u0111': 'd', + // đ + '\u0110': 'D', + // Đ + '\u00F8': 'o', + // ø + '\u00D8': 'O', + // Ø + '\u0127': 'h', + // ħ + '\u0126': 'H', + // Ħ + '\u0167': 't', + // ŧ + '\u0166': 'T', + // Ŧ + '\u0131': 'i', + // ı + '\u00DF': 'ss' // ß +}; +const NON_DECOMPOSABLE_RE = new RegExp('[' + Object.keys(NON_DECOMPOSABLE_MAP).join('') + ']', 'g'); +const stripDiacritics = typeof String.prototype.normalize === 'function' ? str => str.normalize('NFD').replace(/[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D3-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u09FE\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C04\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D00-\u0D03\u0D3B\u0D3C\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF7-\u1CF9\u1DC0-\u1DF9\u1DFB-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F]/g, '').replace(NON_DECOMPOSABLE_RE, ch => NON_DECOMPOSABLE_MAP[ch]) : str => str; + +class BitapSearch { + constructor(pattern, { + location = Config.location, + threshold = Config.threshold, + distance = Config.distance, + includeMatches = Config.includeMatches, + findAllMatches = Config.findAllMatches, + minMatchCharLength = Config.minMatchCharLength, + isCaseSensitive = Config.isCaseSensitive, + ignoreDiacritics = Config.ignoreDiacritics, + ignoreLocation = Config.ignoreLocation + } = {}) { + this.options = { + location, + threshold, + distance, + includeMatches, + findAllMatches, + minMatchCharLength, + isCaseSensitive, + ignoreDiacritics, + ignoreLocation + }; + pattern = isCaseSensitive ? pattern : pattern.toLowerCase(); + pattern = ignoreDiacritics ? stripDiacritics(pattern) : pattern; + this.pattern = pattern; + this.chunks = []; + if (!this.pattern.length) { + return; + } + const addChunk = (pattern, startIndex) => { + this.chunks.push({ + pattern, + alphabet: createPatternAlphabet(pattern), + startIndex + }); + }; + const len = this.pattern.length; + if (len > MAX_BITS) { + let i = 0; + const remainder = len % MAX_BITS; + const end = len - remainder; + while (i < end) { + addChunk(this.pattern.substr(i, MAX_BITS), i); + i += MAX_BITS; + } + if (remainder) { + const startIndex = len - MAX_BITS; + addChunk(this.pattern.substr(startIndex), startIndex); + } + } else { + addChunk(this.pattern, 0); + } + } + searchIn(text) { + const { + isCaseSensitive, + ignoreDiacritics, + includeMatches + } = this.options; + text = isCaseSensitive ? text : text.toLowerCase(); + text = ignoreDiacritics ? stripDiacritics(text) : text; + + // Exact match + if (this.pattern === text) { + const result = { + isMatch: true, + score: 0 + }; + if (includeMatches) { + result.indices = [[0, text.length - 1]]; + } + return result; + } + + // Otherwise, use Bitap algorithm + const { + location, + distance, + threshold, + findAllMatches, + minMatchCharLength, + ignoreLocation + } = this.options; + const allIndices = []; + let totalScore = 0; + let hasMatches = false; + this.chunks.forEach(({ + pattern, + alphabet, + startIndex + }) => { + const { + isMatch, + score, + indices + } = search(text, pattern, alphabet, { + location: location + startIndex, + distance, + threshold, + findAllMatches, + minMatchCharLength, + includeMatches, + ignoreLocation + }); + if (isMatch) { + hasMatches = true; + } + totalScore += score; + if (isMatch && indices) { + allIndices.push(...indices); + } + }); + const result = { + isMatch: hasMatches, + score: hasMatches ? totalScore / this.chunks.length : 1 + }; + if (hasMatches && includeMatches) { + result.indices = mergeIndices(allIndices); + } + return result; + } +} + +// ── Matcher interface ───────────────────────────────────────────── +// +// Each matcher is a lightweight object with a type tag and a search +// function. No class hierarchy needed — the search logic for most +// matchers is a one-liner. + +// ── Matcher definition ──────────────────────────────────────────── +// +// A definition pairs the detection regexes (used by parseQuery to +// recognize string-syntax operators like ^, =, !) with a factory +// that creates a Matcher instance. + +// Whether a matcher type can return multiple index ranges +const MULTI_MATCH_TYPES = new Set(['fuzzy', 'include']); + +// Whether a matcher type is an inverse match +function isInverse(type) { + return type.startsWith('inverse'); +} + +// ── Matcher definitions ─────────────────────────────────────────── +// +// Order matters — parseQuery tries each in sequence and uses the +// first match. FuzzyMatch (catch-all) must be last. + +// prettier-ignore +const matchers = [ +// =term — exact match +{ + type: 'exact', + multiRegex: /^="(.*)"$/, + singleRegex: /^=(.*)$/, + create: pattern => ({ + type: 'exact', + search(text) { + const isMatch = text === pattern; + return { + isMatch, + score: isMatch ? 0 : 1, + indices: [0, pattern.length - 1] + }; + } + }) +}, +// 'term — include (substring) match +{ + type: 'include', + multiRegex: /^'"(.*)"$/, + singleRegex: /^'(.*)$/, + create: pattern => ({ + type: 'include', + search(text) { + let location = 0; + let index; + const indices = []; + const patternLen = pattern.length; + while ((index = text.indexOf(pattern, location)) > -1) { + location = index + patternLen; + indices.push([index, location - 1]); + } + const isMatch = !!indices.length; + return { + isMatch, + score: isMatch ? 0 : 1, + indices + }; + } + }) +}, +// ^term — prefix match +{ + type: 'prefix-exact', + multiRegex: /^\^"(.*)"$/, + singleRegex: /^\^(.*)$/, + create: pattern => ({ + type: 'prefix-exact', + search(text) { + const isMatch = text.startsWith(pattern); + return { + isMatch, + score: isMatch ? 0 : 1, + indices: [0, pattern.length - 1] + }; + } + }) +}, +// !^term — inverse prefix match +{ + type: 'inverse-prefix-exact', + multiRegex: /^!\^"(.*)"$/, + singleRegex: /^!\^(.*)$/, + create: pattern => ({ + type: 'inverse-prefix-exact', + search(text) { + const isMatch = !text.startsWith(pattern); + return { + isMatch, + score: isMatch ? 0 : 1, + indices: [0, text.length - 1] + }; + } + }) +}, +// !term$ — inverse suffix match +{ + type: 'inverse-suffix-exact', + multiRegex: /^!"(.*)"\$$/, + singleRegex: /^!(.*)\$$/, + create: pattern => ({ + type: 'inverse-suffix-exact', + search(text) { + const isMatch = !text.endsWith(pattern); + return { + isMatch, + score: isMatch ? 0 : 1, + indices: [0, text.length - 1] + }; + } + }) +}, +// term$ — suffix match +{ + type: 'suffix-exact', + multiRegex: /^"(.*)"\$$/, + singleRegex: /^(.*)\$$/, + create: pattern => ({ + type: 'suffix-exact', + search(text) { + const isMatch = text.endsWith(pattern); + return { + isMatch, + score: isMatch ? 0 : 1, + indices: [text.length - pattern.length, text.length - 1] + }; + } + }) +}, +// !term — inverse exact (does not contain) +{ + type: 'inverse-exact', + multiRegex: /^!"(.*)"$/, + singleRegex: /^!(.*)$/, + create: pattern => ({ + type: 'inverse-exact', + search(text) { + const isMatch = text.indexOf(pattern) === -1; + return { + isMatch, + score: isMatch ? 0 : 1, + indices: [0, text.length - 1] + }; + } + }) +}, +// term — fuzzy match (catch-all, must be last) +{ + type: 'fuzzy', + multiRegex: /^"(.*)"$/, + singleRegex: /^(.*)$/, + create: (pattern, options = {}) => { + const bitap = new BitapSearch(pattern, { + location: options.location ?? Config.location, + threshold: options.threshold ?? Config.threshold, + distance: options.distance ?? Config.distance, + includeMatches: options.includeMatches ?? Config.includeMatches, + findAllMatches: options.findAllMatches ?? Config.findAllMatches, + minMatchCharLength: options.minMatchCharLength ?? Config.minMatchCharLength, + isCaseSensitive: options.isCaseSensitive ?? Config.isCaseSensitive, + ignoreDiacritics: options.ignoreDiacritics ?? Config.ignoreDiacritics, + ignoreLocation: options.ignoreLocation ?? Config.ignoreLocation + }); + return { + type: 'fuzzy', + search(text) { + return bitap.searchIn(text); + } + }; + } +}]; + +const matchersLen = matchers.length; +const ESCAPED_PIPE = '\u0000'; // placeholder for escaped \| +const OR_TOKEN = '|'; + +// Tokenize a query string into individual search terms. +// Respects multi-match quoted tokens like ="said "test"" or ^"hello world"$ +// where inner spaces and quotes are part of the token. +function tokenize(pattern) { + const tokens = []; + const len = pattern.length; + let i = 0; + while (i < len) { + // Skip spaces + while (i < len && pattern[i] === ' ') i++; + if (i >= len) break; + + // Scan past prefix characters (=, !, ^, ') to see if a quote follows + let j = i; + while (j < len && pattern[j] !== ' ' && pattern[j] !== '"') j++; + if (j < len && pattern[j] === '"') { + // Multi-match token: prefix + "content" (possibly with inner quotes) + // Find the closing " that ends this token: + // it must be followed by optional $, then space or end-of-string + j++; // skip opening quote + while (j < len) { + if (pattern[j] === '"') { + // Check if this is the closing quote + const next = j + 1; + if (next >= len || pattern[next] === ' ') { + j++; // include closing quote + break; + } + if (pattern[next] === '$' && (next + 1 >= len || pattern[next + 1] === ' ')) { + j += 2; // include "$ + break; + } + } + j++; + } + tokens.push(pattern.substring(i, j)); + i = j; + } else { + // Regular (unquoted) token: read until space or end + while (j < len && pattern[j] !== ' ') j++; + tokens.push(pattern.substring(i, j)); + i = j; + } + } + return tokens; +} +function getMatch(pattern, exp) { + const matches = pattern.match(exp); + return matches ? matches[1] : null; +} + +// Return a 2D array representation of the query, for simpler parsing. +// Example: +// "^core go$ | rb$ | py$ xy$" => [["^core", "go$"], ["rb$"], ["py$", "xy$"]] +function parseQuery(pattern, options = {}) { + // Replace escaped \| with placeholder before splitting on | + const escaped = pattern.replace(/\\\|/g, ESCAPED_PIPE); + return escaped.split(OR_TOKEN).map(item => { + // Restore escaped pipes in each OR group + const restored = item.replace(/\u0000/g, '|'); + const query = tokenize(restored.trim()).filter(item => item && !!item.trim()); + const results = []; + for (let i = 0, len = query.length; i < len; i += 1) { + const queryItem = query[i]; + + // 1. Handle multiple query match (i.e, ones that are quoted, like `"hello world"`) + let found = false; + let idx = -1; + while (!found && ++idx < matchersLen) { + const def = matchers[idx]; + const token = getMatch(queryItem, def.multiRegex); + if (token) { + results.push(def.create(token, options)); + found = true; + } + } + if (found) { + continue; + } + + // 2. Handle single query matches (i.e, ones that are *not* quoted) + idx = -1; + while (++idx < matchersLen) { + const def = matchers[idx]; + const token = getMatch(queryItem, def.singleRegex); + if (token) { + results.push(def.create(token, options)); + break; + } + } + } + return results; + }); +} + +class ExtendedSearch { + constructor(pattern, { + isCaseSensitive = Config.isCaseSensitive, + ignoreDiacritics = Config.ignoreDiacritics, + includeMatches = Config.includeMatches, + minMatchCharLength = Config.minMatchCharLength, + ignoreLocation = Config.ignoreLocation, + findAllMatches = Config.findAllMatches, + location = Config.location, + threshold = Config.threshold, + distance = Config.distance + } = {}) { + this.query = null; + this.options = { + isCaseSensitive, + ignoreDiacritics, + includeMatches, + minMatchCharLength, + findAllMatches, + ignoreLocation, + location, + threshold, + distance + }; + pattern = isCaseSensitive ? pattern : pattern.toLowerCase(); + pattern = ignoreDiacritics ? stripDiacritics(pattern) : pattern; + this.pattern = pattern; + this.query = parseQuery(this.pattern, this.options); + } + static condition(_, options) { + return options.useExtendedSearch; + } + + // Note: searchIn operates on a single text value and sets hasInverse on the + // result when inverse patterns are involved. _searchObjectList uses this to + // switch from "ANY key" to "ALL keys" aggregation. See #712. + searchIn(text) { + const query = this.query; + if (!query) { + return { + isMatch: false, + score: 1 + }; + } + const { + includeMatches, + isCaseSensitive, + ignoreDiacritics + } = this.options; + text = isCaseSensitive ? text : text.toLowerCase(); + text = ignoreDiacritics ? stripDiacritics(text) : text; + let numMatches = 0; + const allIndices = []; + let totalScore = 0; + let hasInverse = false; + + // ORs + for (let i = 0, qLen = query.length; i < qLen; i += 1) { + const searchers = query[i]; + + // Reset indices + allIndices.length = 0; + numMatches = 0; + hasInverse = false; + + // ANDs + for (let j = 0, pLen = searchers.length; j < pLen; j += 1) { + const matcher = searchers[j]; + const { + isMatch, + indices, + score + } = matcher.search(text); + if (isMatch) { + numMatches += 1; + totalScore += score; + if (isInverse(matcher.type)) { + hasInverse = true; + } + if (includeMatches) { + if (MULTI_MATCH_TYPES.has(matcher.type)) { + allIndices.push(...indices); + } else { + allIndices.push(indices); + } + } + } else { + totalScore = 0; + numMatches = 0; + allIndices.length = 0; + hasInverse = false; + break; + } + } + + // OR condition, so if TRUE, return + if (numMatches) { + const result = { + isMatch: true, + score: totalScore / numMatches + }; + if (hasInverse) { + result.hasInverse = true; + } + if (includeMatches) { + result.indices = mergeIndices(allIndices); + } + return result; + } + } + + // Nothing was matched + return { + isMatch: false, + score: 1 + }; + } +} + +const registeredSearchers = []; +function register(...args) { + registeredSearchers.push(...args); +} +function createSearcher(pattern, options) { + for (let i = 0, len = registeredSearchers.length; i < len; i += 1) { + const searcherClass = registeredSearchers[i]; + if (searcherClass.condition(pattern, options)) { + return new searcherClass(pattern, options); + } + } + return new BitapSearch(pattern, options); +} + +const LogicalOperator = { + AND: '$and', + OR: '$or' +}; +const KeyType = { + PATH: '$path', + PATTERN: '$val' +}; +const isExpression = query => !!(query[LogicalOperator.AND] || query[LogicalOperator.OR]); +const isPath = query => !!query[KeyType.PATH]; +const isLeaf = query => !isArray(query) && isObject(query) && !isExpression(query); +const convertToExplicit = query => ({ + [LogicalOperator.AND]: Object.keys(query).map(key => ({ + [key]: query[key] + })) +}); + +// When `auto` is `true`, the parse function will infer and initialize and add +// the appropriate `Searcher` instance +function parse(query, options, { + auto = true +} = {}) { + const next = query => { + // Keyless string entry: search across all keys + if (isString(query)) { + const obj = { + keyId: null, + pattern: query + }; + if (auto) { + obj.searcher = createSearcher(query, options); + } + return obj; + } + const keys = Object.keys(query); + const isQueryPath = isPath(query); + if (!isQueryPath && keys.length > 1 && !isExpression(query)) { + return next(convertToExplicit(query)); + } + if (isLeaf(query)) { + const key = isQueryPath ? query[KeyType.PATH] : keys[0]; + const pattern = isQueryPath ? query[KeyType.PATTERN] : query[key]; + if (!isString(pattern)) { + throw new Error(LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY(key)); + } + const obj = { + keyId: createKeyId(key), + pattern + }; + if (auto) { + obj.searcher = createSearcher(pattern, options); + } + return obj; + } + const node = { + children: [], + operator: keys[0] + }; + keys.forEach(key => { + const value = query[key]; + if (isArray(value)) { + value.forEach(item => { + node.children.push(next(item)); + }); + } + }); + return node; + }; + if (!isExpression(query)) { + query = convertToExplicit(query); + } + return next(query); +} + +function computeScoreSingle(matches, { + ignoreFieldNorm = Config.ignoreFieldNorm +}) { + let totalScore = 1; + matches.forEach(({ + key, + norm, + score + }) => { + const weight = key ? key.weight : null; + totalScore *= Math.pow(score === 0 && weight ? Number.EPSILON : score, (weight || 1) * (ignoreFieldNorm ? 1 : norm)); + }); + return totalScore; +} +function computeScore(results, { + ignoreFieldNorm = Config.ignoreFieldNorm +}) { + results.forEach(result => { + result.score = computeScoreSingle(result.matches, { + ignoreFieldNorm + }); + }); +} + +// Max-heap by score: keeps the worst (highest) score at the top +// so we can efficiently evict it when a better result arrives. +class MaxHeap { + constructor(limit) { + this.limit = limit; + this.heap = []; + } + get size() { + return this.heap.length; + } + shouldInsert(score) { + return this.size < this.limit || score < this.heap[0].score; + } + insert(item) { + if (this.size < this.limit) { + this.heap.push(item); + this._bubbleUp(this.size - 1); + } else if (item.score < this.heap[0].score) { + this.heap[0] = item; + this._sinkDown(0); + } + } + extractSorted(sortFn) { + return this.heap.sort(sortFn); + } + _bubbleUp(i) { + const heap = this.heap; + while (i > 0) { + const parent = i - 1 >> 1; + if (heap[i].score <= heap[parent].score) break; + const tmp = heap[i]; + heap[i] = heap[parent]; + heap[parent] = tmp; + i = parent; + } + } + _sinkDown(i) { + const heap = this.heap; + const len = heap.length; + let largest = i; + do { + i = largest; + const left = 2 * i + 1; + const right = 2 * i + 2; + if (left < len && heap[left].score > heap[largest].score) { + largest = left; + } + if (right < len && heap[right].score > heap[largest].score) { + largest = right; + } + if (largest !== i) { + const tmp = heap[i]; + heap[i] = heap[largest]; + heap[largest] = tmp; + } + } while (largest !== i); + } +} + +function formatMatches(result) { + const matches = []; + result.matches.forEach(match => { + if (!isDefined(match.indices) || !match.indices.length) { + return; + } + const obj = { + indices: match.indices, + value: match.value + }; + if (match.key) { + // `key.id` is the canonical dotted-string identity (array paths joined + // with '.'); `key.src` is the raw user input and can be a string[]. + obj.key = match.key.id; + } + if (match.idx > -1) { + obj.refIndex = match.idx; + } + matches.push(obj); + }); + return matches; +} + +function format(results, docs, { + includeMatches = Config.includeMatches, + includeScore = Config.includeScore +} = {}) { + return results.map(result => { + const { + idx + } = result; + const data = { + item: docs[idx], + refIndex: idx + }; + if (includeMatches) data.matches = formatMatches(result); + if (includeScore) data.score = result.score; + return data; + }); +} + +// Includes \p{M} (Mark) so combining marks stay attached to their base +// letter — without it, scripts like Devanagari and NFD-normalized Latin +// shatter (e.g. 'हिन्दी' → ['ह','न','द'], 'café'.normalize('NFD') → ['cafe']). +const DEFAULT_TOKEN = /[\p{L}\p{M}\p{N}_]+/gu; +const warned = new WeakSet(); +function warnNonGlobal(regex) { + if (!warned.has(regex)) { + warned.add(regex); + console.warn(`[Fuse] tokenize regex ${regex} lacks the global flag; only the ` + `first match per text will be returned. Add the 'g' flag.`); + } +} +function resolveTokenize(tokenize) { + if (typeof tokenize === 'function') { + let validated = false; + return text => { + const result = tokenize(text); + if (!validated) { + validated = true; + if (!Array.isArray(result) || result.some(t => typeof t !== 'string')) { + throw new Error(`[Fuse] tokenize function must return string[]; received ${Array.isArray(result) ? 'array containing non-strings' : typeof result}.`); + } + } + return result; + }; + } + if (tokenize instanceof RegExp) { + if (!tokenize.global) warnNonGlobal(tokenize); + return text => text.match(tokenize) || []; + } + return text => text.match(DEFAULT_TOKEN) || []; +} +function createAnalyzer({ + isCaseSensitive = false, + ignoreDiacritics = false, + tokenize +} = {}) { + const tokenizeFn = resolveTokenize(tokenize); + return { + tokenize(text) { + if (!isCaseSensitive) { + text = text.toLowerCase(); + } + if (ignoreDiacritics) { + text = stripDiacritics(text); + } + return tokenizeFn(text); + } + }; +} + +// `tokenMatch: 'all'` packs per-term coverage into a bitmask. JS bitwise ops +// are 32-bit *signed*, so bit 31 is the sign bit — only bits 0..30 are safe. +// Queries with more than this many terms fall back to a Set (no bit limit). +const MAX_MASK_TERMS = 31; +class TokenSearch { + // `tokenMatch: 'all'` (AND) coverage. When true, searchIn reports which + // query terms matched each text so the core loop can require record-level + // coverage of every term. Bitmask is the ≤31-term fast path; Set is the + // ≥32-term fallback (JS bitwise ops are 32-bit signed). + + static condition(_, options) { + return options.useTokenSearch; + } + constructor(pattern, options) { + this.options = options; + this.analyzer = createAnalyzer({ + isCaseSensitive: options.isCaseSensitive, + ignoreDiacritics: options.ignoreDiacritics, + tokenize: options.tokenize + }); + const queryTerms = this.analyzer.tokenize(pattern); + const invertedIndex = options._invertedIndex; + const { + df, + fieldCount + } = invertedIndex; + this.termSearchers = []; + this.idfWeights = []; + for (const term of queryTerms) { + this.termSearchers.push(new BitapSearch(term, { + location: options.location, + threshold: options.threshold, + distance: options.distance, + includeMatches: options.includeMatches, + findAllMatches: options.findAllMatches, + minMatchCharLength: options.minMatchCharLength, + isCaseSensitive: options.isCaseSensitive, + ignoreDiacritics: options.ignoreDiacritics, + ignoreLocation: true + })); + const docFreq = df.get(term) || 0; + const idf = Math.log(1 + (fieldCount - docFreq + 0.5) / (docFreq + 0.5)); + this.idfWeights.push(idf); + } + this.combineAll = options.tokenMatch === 'all'; + this.numTerms = this.termSearchers.length; + this.useMask = this.numTerms <= MAX_MASK_TERMS; + } + searchIn(text) { + if (!this.termSearchers.length) { + return { + isMatch: false, + score: 1 + }; + } + const allIndices = []; + let weightedScore = 0; + let maxPossibleScore = 0; + let matchedCount = 0; + + // `tokenMatch: 'all'` coverage for this text (untouched in the default + // 'any' path, so it allocates nothing there). + let matchedMask = 0; + const matchedTerms = this.combineAll && !this.useMask ? new Set() : null; + for (let i = 0; i < this.termSearchers.length; i++) { + const result = this.termSearchers[i].searchIn(text); + const idf = this.idfWeights[i]; + maxPossibleScore += idf; + if (result.isMatch) { + matchedCount++; + weightedScore += idf * (1 - result.score); + if (result.indices) { + allIndices.push(...result.indices); + } + if (this.combineAll) { + if (this.useMask) { + matchedMask |= 1 << i; + } else { + matchedTerms.add(i); + } + } + } + } + if (matchedCount === 0) { + return { + isMatch: false, + score: 1 + }; + } + const normalized = maxPossibleScore > 0 ? 1 - weightedScore / maxPossibleScore : 0; + const searchResult = { + isMatch: true, + score: Math.max(0.001, normalized) + }; + if (this.options.includeMatches && allIndices.length) { + searchResult.indices = mergeIndices(allIndices); + } + + // Report term coverage so the core loop can enforce record-level AND. + if (this.combineAll) { + if (this.useMask) { + searchResult.matchedMask = matchedMask; + } else { + searchResult.matchedTerms = matchedTerms; + } + searchResult.termCount = this.numTerms; + } + return searchResult; + } +} + +// Stats-only inverted index for token search (per Plan 008 Direction B). +// +// The query path consumes only `df` and `fieldCount` (IDF weighting). The +// per-doc maps exist solely to keep `df` and `fieldCount` correct under +// `add` / `remove` / `removeAt`: +// +// docFieldCount[doc] = # distinct fields the doc contributed; subtracted +// from `fieldCount` on remove. +// docTermFieldHits[doc] = Map; each entry decrements `df[term]` by +// that count on remove. +// +// `df` is incremented once per (doc, term, field) at index time. Removing a +// doc decrements `df` by the same count, mirroring the increment exactly. + +function addField(index, text, docIdx, analyzer) { + const tokens = analyzer.tokenize(text); + if (!tokens.length) return; + index.fieldCount++; + index.docFieldCount.set(docIdx, (index.docFieldCount.get(docIdx) || 0) + 1); + + // We count each (doc, term, field) once — repeated occurrences within the + // same field don't multiply df. + const distinctTerms = new Set(tokens); + let perDocTerms = index.docTermFieldHits.get(docIdx); + if (!perDocTerms) { + perDocTerms = new Map(); + index.docTermFieldHits.set(docIdx, perDocTerms); + } + for (const term of distinctTerms) { + perDocTerms.set(term, (perDocTerms.get(term) || 0) + 1); + index.df.set(term, (index.df.get(term) || 0) + 1); + } +} +function ingestRecord(index, record, keyCount, analyzer) { + const { + i: docIdx, + v, + $: fields + } = record; + if (v !== undefined) { + addField(index, v, docIdx, analyzer); + return; + } + if (!fields) return; + for (let keyIdx = 0; keyIdx < keyCount; keyIdx++) { + const value = fields[keyIdx]; + if (!value) continue; + if (Array.isArray(value)) { + for (const sub of value) addField(index, sub.v, docIdx, analyzer); + } else { + addField(index, value.v, docIdx, analyzer); + } + } +} +function buildInvertedIndex(records, keyCount, analyzer) { + const index = { + fieldCount: 0, + df: new Map(), + docFieldCount: new Map(), + docTermFieldHits: new Map() + }; + for (const record of records) { + ingestRecord(index, record, keyCount, analyzer); + } + return index; +} +function addToInvertedIndex(index, record, keyCount, analyzer) { + ingestRecord(index, record, keyCount, analyzer); +} +function removeFromInvertedIndex(index, docIdx) { + const fieldCount = index.docFieldCount.get(docIdx); + if (fieldCount === undefined) return; + index.fieldCount -= fieldCount; + index.docFieldCount.delete(docIdx); + const perDocTerms = index.docTermFieldHits.get(docIdx); + if (!perDocTerms) return; + for (const [term, hits] of perDocTerms) { + const next = (index.df.get(term) || 0) - hits; + if (next <= 0) { + index.df.delete(term); + } else { + index.df.set(term, next); + } + } + index.docTermFieldHits.delete(docIdx); +} + +// Removes the given docIdx entries and renumbers the remaining per-doc maps +// so they stay in sync with FuseIndex's contiguous renumbering on remove. +function removeAndShiftInvertedIndex(index, removedIndices) { + if (removedIndices.length === 0) return; + + // De-dup and sort so the shift computation is O(log k) per lookup. + const sorted = Array.from(new Set(removedIndices)).sort((a, b) => a - b); + for (const idx of sorted) { + removeFromInvertedIndex(index, idx); + } + + // For any surviving oldIdx, its new idx is oldIdx minus the number of + // removed indices strictly less than oldIdx. + const shift = oldIdx => { + let lo = 0; + let hi = sorted.length; + while (lo < hi) { + const mid = lo + hi >>> 1; + if (sorted[mid] < oldIdx) lo = mid + 1;else hi = mid; + } + return oldIdx - lo; + }; + const firstRemoved = sorted[0]; + const shiftedDocFieldCount = new Map(); + for (const [oldKey, count] of index.docFieldCount) { + shiftedDocFieldCount.set(oldKey > firstRemoved ? shift(oldKey) : oldKey, count); + } + index.docFieldCount = shiftedDocFieldCount; + const shiftedDocTermFieldHits = new Map(); + for (const [oldKey, terms] of index.docTermFieldHits) { + shiftedDocTermFieldHits.set(oldKey > firstRemoved ? shift(oldKey) : oldKey, terms); + } + index.docTermFieldHits = shiftedDocTermFieldHits; +} + +class Fuse { + // Statics are assigned in entry.ts + + constructor(docs, options, index) { + this.options = { + ...Config, + ...options + }; + if (this.options.useExtendedSearch && false) ; + if (this.options.useTokenSearch && false) ; + this._keyStore = new KeyStore(this.options.keys); + this._docs = docs; + this._myIndex = null; + this._invertedIndex = null; + this.setCollection(docs, index); + this._lastQuery = null; + this._lastSearcher = null; + } + _getSearcher(query) { + if (this._lastQuery === query) { + return this._lastSearcher; + } + const opts = this._invertedIndex ? { + ...this.options, + _invertedIndex: this._invertedIndex + } : this.options; + const searcher = createSearcher(query, opts); + this._lastQuery = query; + this._lastSearcher = searcher; + return searcher; + } + setCollection(docs, index) { + this._docs = docs; + if (index && !(index instanceof FuseIndex)) { + throw new Error(INCORRECT_INDEX_TYPE); + } + this._myIndex = index || createIndex(this.options.keys, this._docs, { + getFn: this.options.getFn, + fieldNormWeight: this.options.fieldNormWeight + }); + if (this.options.useTokenSearch) { + const analyzer = createAnalyzer({ + isCaseSensitive: this.options.isCaseSensitive, + ignoreDiacritics: this.options.ignoreDiacritics, + tokenize: this.options.tokenize + }); + this._invertedIndex = buildInvertedIndex(this._myIndex.records, this._myIndex.keys.length, analyzer); + } + this._invalidateSearcherCache(); + } + add(doc) { + if (!isDefined(doc)) { + return; + } + this._docs.push(doc); + const record = this._myIndex.add(doc, this._docs.length - 1); + + // Skip inverted-index bookkeeping when no record was appended (blank + // strings produce null). The previous code read `records[records.length-1]` + // unconditionally, which would re-ingest the previous doc on `add("")`. + if (this._invertedIndex && record) { + const analyzer = createAnalyzer({ + isCaseSensitive: this.options.isCaseSensitive, + ignoreDiacritics: this.options.ignoreDiacritics, + tokenize: this.options.tokenize + }); + addToInvertedIndex(this._invertedIndex, record, this._myIndex.keys.length, analyzer); + } + this._invalidateSearcherCache(); + } + remove(predicate = () => false) { + const results = []; + const indicesToRemove = []; + for (let i = 0, len = this._docs.length; i < len; i += 1) { + if (predicate(this._docs[i], i)) { + results.push(this._docs[i]); + indicesToRemove.push(i); + } + } + if (indicesToRemove.length) { + if (this._invertedIndex) { + removeAndShiftInvertedIndex(this._invertedIndex, indicesToRemove); + } + + // Filter docs in a single pass instead of reverse-splicing + const toRemove = new Set(indicesToRemove); + this._docs = this._docs.filter((_, i) => !toRemove.has(i)); + this._myIndex.removeAll(indicesToRemove); + this._invalidateSearcherCache(); + } + return results; + } + removeAt(idx) { + // Validate before any mutation. The previous code spliced `_docs` first + // and let FuseIndex.removeAt throw afterward — partial-state on invalid + // input. Atomic now. + if (!Number.isInteger(idx) || idx < 0 || idx >= this._docs.length) { + throw new Error(INVALID_DOC_INDEX); + } + if (this._invertedIndex) { + removeAndShiftInvertedIndex(this._invertedIndex, [idx]); + } + const doc = this._docs.splice(idx, 1)[0]; + this._myIndex.removeAt(idx); + this._invalidateSearcherCache(); + return doc; + } + _invalidateSearcherCache() { + this._lastQuery = null; + this._lastSearcher = null; + } + getIndex() { + return this._myIndex; + } + search(query, options) { + const { + limit = -1 + } = options || {}; + const { + includeMatches, + includeScore, + shouldSort, + sortFn, + ignoreFieldNorm + } = this.options; + + // Empty string query returns all docs (useful for search UIs) + if (isString(query) && !query.trim()) { + let docs = this._docs.map((item, idx) => ({ + item, + refIndex: idx + })); + if (isNumber(limit) && limit > -1) { + docs = docs.slice(0, limit); + } + return docs; + } + const useHeap = isNumber(limit) && limit > 0 && isString(query); + let results; + if (useHeap) { + const heap = new MaxHeap(limit); + if (isString(this._docs[0])) { + this._searchStringList(query, { + heap, + ignoreFieldNorm + }); + } else { + this._searchObjectList(query, { + heap, + ignoreFieldNorm + }); + } + results = heap.extractSorted(sortFn); + } else { + results = isString(query) ? isString(this._docs[0]) ? this._searchStringList(query) : this._searchObjectList(query) : this._searchLogical(query); + computeScore(results, { + ignoreFieldNorm + }); + if (shouldSort) { + results.sort(sortFn); + } + if (isNumber(limit) && limit > -1) { + results = results.slice(0, limit); + } + } + return format(results, this._docs, { + includeMatches, + includeScore + }); + } + _searchStringList(query, { + heap, + ignoreFieldNorm + } = {}) { + const searcher = this._getSearcher(query); + const requireAllTokens = this.options.useTokenSearch && this.options.tokenMatch === 'all'; + const { + records + } = this._myIndex; + const results = heap ? null : []; + + // Iterate over every string in the index + records.forEach(({ + v: text, + i: idx, + n: norm + }) => { + if (!isDefined(text)) { + return; + } + const searchResult = searcher.searchIn(text); + if (searchResult.isMatch) { + const match = { + score: searchResult.score, + value: text, + norm: norm, + indices: searchResult.indices + }; + if (requireAllTokens) { + match.matchedMask = searchResult.matchedMask; + match.matchedTerms = searchResult.matchedTerms; + match.termCount = searchResult.termCount; + } + const matches = [match]; + + // Record-level AND gate (token search `tokenMatch: 'all'`), applied + // before heap insertion so `limit` returns the same top-N as unlimited. + if (!requireAllTokens || this._coversAllTokens(matches)) { + const result = { + item: text, + idx, + matches + }; + if (heap) { + result.score = computeScoreSingle(result.matches, { + ignoreFieldNorm + }); + if (heap.shouldInsert(result.score)) { + heap.insert(result); + } + } else { + results.push(result); + } + } + } + }); + return results; + } + _searchLogical(query) { + const expression = parse(query, this.options); + const evaluate = (node, item, idx) => { + if (!('children' in node)) { + const { + keyId, + searcher + } = node; + let matches; + if (keyId === null) { + // Keyless entry: search across all keys + matches = []; + this._myIndex.keys.forEach((key, keyIndex) => { + matches.push(...this._findMatches({ + key, + value: item[keyIndex], + searcher: searcher + })); + }); + } else { + matches = this._findMatches({ + key: this._keyStore.get(keyId), + value: this._myIndex.getValueForItemAtKeyId(item, keyId), + searcher: searcher + }); + } + if (matches && matches.length) { + return [{ + idx, + item, + matches + }]; + } + return []; + } + const { + children, + operator + } = node; + const res = []; + for (let i = 0, len = children.length; i < len; i += 1) { + const child = children[i]; + const result = evaluate(child, item, idx); + if (result.length) { + res.push(...result); + } else if (operator === LogicalOperator.AND) { + return []; + } + } + return res; + }; + const records = this._myIndex.records; + const resultMap = new Map(); + const results = []; + records.forEach(({ + $: item, + i: idx + }) => { + if (isDefined(item)) { + const expResults = evaluate(expression, item, idx); + if (expResults.length) { + // Dedupe when adding + if (!resultMap.has(idx)) { + resultMap.set(idx, { + idx, + item, + matches: [] + }); + results.push(resultMap.get(idx)); + } + expResults.forEach(({ + matches + }) => { + resultMap.get(idx).matches.push(...matches); + }); + } + } + }); + return results; + } + + // When a search involves inverse patterns (e.g. !Syrup), the aggregation + // across keys switches from "ANY key matches" to "ALL keys must match." + // This is signaled by hasInverse on the SearchResult from ExtendedSearch. + // + // For mixed patterns like "^hello !Syrup", a key failure is ambiguous — + // it could be the positive or inverse term that failed. In that case we + // conservatively exclude the item, which is strictly better than the old + // behavior of including it. See: https://github.com/krisk/Fuse/issues/712 + _searchObjectList(query, { + heap, + ignoreFieldNorm + } = {}) { + const searcher = this._getSearcher(query); + const requireAllTokens = this.options.useTokenSearch && this.options.tokenMatch === 'all'; + const { + keys, + records + } = this._myIndex; + const results = heap ? null : []; + + // List is Array + records.forEach(({ + $: item, + i: idx + }) => { + if (!isDefined(item)) { + return; + } + const matches = []; + let anyKeyFailed = false; + let hasInverse = false; + + // Iterate over every key (i.e, path), and fetch the value at that key + keys.forEach((key, keyIndex) => { + const keyMatches = this._findMatches({ + key, + value: item[keyIndex], + searcher + }); + if (keyMatches.length) { + matches.push(...keyMatches); + if (keyMatches[0].hasInverse) { + hasInverse = true; + } + } else { + anyKeyFailed = true; + } + }); + + // If the search involves inverse patterns, ALL keys must match + if (hasInverse && anyKeyFailed) { + return; + } + + // Record-level AND gate (token search `tokenMatch: 'all'`): every query + // term must be covered across the record's field/array-element matches. + // Applied before heap insertion so `limit` returns the same top-N. + if (matches.length && (!requireAllTokens || this._coversAllTokens(matches))) { + const result = { + idx, + item, + matches + }; + if (heap) { + result.score = computeScoreSingle(result.matches, { + ignoreFieldNorm + }); + if (heap.shouldInsert(result.score)) { + heap.insert(result); + } + } else { + results.push(result); + } + } + }); + return results; + } + _findMatches({ + key, + value, + searcher + }) { + if (!isDefined(value)) { + return []; + } + const matches = []; + if (isArray(value)) { + value.forEach(({ + v: text, + i: idx, + n: norm + }) => { + if (!isDefined(text)) { + return; + } + const searchResult = searcher.searchIn(text); + if (searchResult.isMatch) { + const match = { + score: searchResult.score, + key, + value: text, + idx, + norm, + indices: searchResult.indices, + hasInverse: searchResult.hasInverse + }; + // Carry token-search AND coverage only when present, so the default + // (non-token / 'any') MatchScore keeps its original object shape. + if (searchResult.termCount !== undefined) { + match.matchedMask = searchResult.matchedMask; + match.matchedTerms = searchResult.matchedTerms; + match.termCount = searchResult.termCount; + } + matches.push(match); + } + }); + } else { + const { + v: text, + n: norm + } = value; + const searchResult = searcher.searchIn(text); + if (searchResult.isMatch) { + const match = { + score: searchResult.score, + key, + value: text, + norm, + indices: searchResult.indices, + hasInverse: searchResult.hasInverse + }; + if (searchResult.termCount !== undefined) { + match.matchedMask = searchResult.matchedMask; + match.matchedTerms = searchResult.matchedTerms; + match.termCount = searchResult.termCount; + } + matches.push(match); + } + } + return matches; + } + + // Record-level AND gate for token search (`tokenMatch: 'all'`). Returns true + // unless the matched terms across ALL of a record's field/array-element + // matches fail to cover every query term. `termCount` is only set by + // TokenSearch in 'all' mode, so non-token / 'any' searches always pass. + _coversAllTokens(matches) { + const termCount = matches.length ? matches[0].termCount : undefined; + if (termCount === undefined) { + return true; + } + if (termCount <= MAX_MASK_TERMS) { + let coverage = 0; + for (let i = 0; i < matches.length; i++) { + coverage |= matches[i].matchedMask || 0; + } + return coverage === 2 ** termCount - 1; + } + const coverage = new Set(); + for (let i = 0; i < matches.length; i++) { + const terms = matches[i].matchedTerms; + if (terms) { + for (const t of terms) { + coverage.add(t); + } + } + } + return coverage.size === termCount; + } +} + +Fuse.version = '7.4.0'; +Fuse.createIndex = createIndex; +Fuse.parseIndex = parseIndex; +Fuse.config = Config; +Fuse.match = function (pattern, text, options) { + // Token search needs corpus statistics (df, fieldCount) that a one-off + // string comparison can't provide. Reject it here so the contract is the + // same in the full and basic builds — without this guard, the full build + // crashes with an opaque TypeError and the basic build silently falls back + // to fuzzy matching. + if (options && options.useTokenSearch) { + throw new Error(FUSE_MATCH_TOKEN_SEARCH_UNSUPPORTED); + } + const searcher = createSearcher(pattern, { + ...Config, + ...options + }); + return searcher.searchIn(text); +}; +{ + Fuse.parseQuery = parse; +} +{ + register(ExtendedSearch); +} +{ + register(TokenSearch); +} +Fuse.use = function (...plugins) { + plugins.forEach(plugin => register(plugin)); +}; + +// Re-export public types + +module.exports = Fuse; diff --git a/librarybot.yml b/librarybot.yml index d495c3433..e02f70c0a 100644 --- a/librarybot.yml +++ b/librarybot.yml @@ -60,7 +60,7 @@ libraries: - from: css/all.min.css to: fontawesome-free/all.min.css - npm: fuse.js - version: 7.3.0 + version: 7.4.0 local: items: - from: dist/fuse.cjs