@thyn-ai/fuse-mojo-core 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +16 -0
- package/index.cjs +147 -0
- package/index.d.ts +87 -0
- package/index.mjs +7 -0
- package/package.json +48 -0
- package/src/engine.cjs +314 -0
- package/src/native.cjs +374 -0
- package/src/options.cjs +242 -0
- package/vendor/fuse.basic.cjs +1342 -0
package/NOTICE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
NOTICE
|
|
2
|
+
======
|
|
3
|
+
|
|
4
|
+
@thyn-ai/fuse-mojo-core includes a vendored copy of Fuse.js v7.1.0
|
|
5
|
+
(vendor/fuse.basic.cjs), used as the pure-JavaScript fallback backend on
|
|
6
|
+
platforms without a prebuilt native kernel, and as the reference oracle in
|
|
7
|
+
the differential test suite.
|
|
8
|
+
|
|
9
|
+
Fuse.js — Lightweight fuzzy-search
|
|
10
|
+
Copyright (c) Kiro Risk (http://kiro.me)
|
|
11
|
+
Licensed under the Apache License, Version 2.0
|
|
12
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
13
|
+
https://github.com/krisk/Fuse
|
|
14
|
+
|
|
15
|
+
The remainder of this package is Copyright (c) 2026 Algenta,
|
|
16
|
+
Apache License, Version 2.0.
|
package/index.cjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @thyn-ai/fuse-mojo-core — drop-in faster replacement for Fuse.js v7 fuzzy search.
|
|
5
|
+
*
|
|
6
|
+
* The heavy Bitap matching runs on a native Mojo kernel when its shared
|
|
7
|
+
* library is available (macOS arm64 / Linux x64 platform packages); on any
|
|
8
|
+
* load failure the package transparently falls back to the vendored Fuse.js
|
|
9
|
+
* implementation (Apache-2.0, see vendor/ and NOTICE), so results are
|
|
10
|
+
* identical on every platform.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
DEFAULT_CONFIG,
|
|
15
|
+
UnsupportedOptionError,
|
|
16
|
+
validateOptions,
|
|
17
|
+
createKeyStore,
|
|
18
|
+
} = require('./src/options.cjs')
|
|
19
|
+
const { buildEngine, nativeSearch } = require('./src/engine.cjs')
|
|
20
|
+
const {
|
|
21
|
+
NativeIndex,
|
|
22
|
+
NativeUnavailable,
|
|
23
|
+
nativeAvailable,
|
|
24
|
+
backendInfo,
|
|
25
|
+
} = require('./src/native.cjs')
|
|
26
|
+
const VendorFuse = require('./vendor/fuse.basic.cjs')
|
|
27
|
+
|
|
28
|
+
class FuseMojo {
|
|
29
|
+
/**
|
|
30
|
+
* @param {Array} docs string list or array of objects
|
|
31
|
+
* @param {object} options Fuse.js-compatible options (see README for the
|
|
32
|
+
* supported subset; unsupported options throw UnsupportedOptionError)
|
|
33
|
+
*/
|
|
34
|
+
constructor(docs, options = {}, index) {
|
|
35
|
+
if (index !== undefined) {
|
|
36
|
+
throw new UnsupportedOptionError('external index (Fuse.createIndex/parseIndex)')
|
|
37
|
+
}
|
|
38
|
+
this.options = { ...DEFAULT_CONFIG, ...options }
|
|
39
|
+
if (this.options.keys === undefined) {
|
|
40
|
+
this.options.keys = []
|
|
41
|
+
}
|
|
42
|
+
validateOptions(this.options)
|
|
43
|
+
this._keyStore = createKeyStore(this.options.keys)
|
|
44
|
+
this._fallback = null
|
|
45
|
+
this._engine = null
|
|
46
|
+
this._nativeIndex = null
|
|
47
|
+
this.setCollection(docs)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setCollection(docs, index) {
|
|
51
|
+
if (index !== undefined) {
|
|
52
|
+
throw new UnsupportedOptionError('external index (Fuse.createIndex/parseIndex)')
|
|
53
|
+
}
|
|
54
|
+
this._docs = docs
|
|
55
|
+
this._destroyNative()
|
|
56
|
+
this._fallback = null
|
|
57
|
+
this._engine = null
|
|
58
|
+
this._nativeIndex = null
|
|
59
|
+
try {
|
|
60
|
+
this._engine = buildEngine(docs, this._keyStore, this.options)
|
|
61
|
+
const nativeOptions = {
|
|
62
|
+
location: this.options.location,
|
|
63
|
+
distance: this.options.distance,
|
|
64
|
+
threshold: this.options.threshold,
|
|
65
|
+
minMatchCharLength: Math.max(this.options.minMatchCharLength, 0),
|
|
66
|
+
findAllMatches: this.options.findAllMatches,
|
|
67
|
+
ignoreLocation: this.options.ignoreLocation,
|
|
68
|
+
computeMatches: this.options.minMatchCharLength > 1 || this.options.includeMatches,
|
|
69
|
+
includeMatches: this.options.includeMatches,
|
|
70
|
+
}
|
|
71
|
+
this._nativeIndex = new NativeIndex(this._engine.chars, this._engine.offsets, nativeOptions)
|
|
72
|
+
this._backend = 'native'
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (!(err instanceof NativeUnavailable)) {
|
|
75
|
+
throw err
|
|
76
|
+
}
|
|
77
|
+
this._engine = null
|
|
78
|
+
this._nativeIndex = null
|
|
79
|
+
this._fallback = new VendorFuse(docs, this.options)
|
|
80
|
+
this._backend = 'fallback'
|
|
81
|
+
this._fallbackError = err.message
|
|
82
|
+
}
|
|
83
|
+
return this
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} pattern search pattern (extended-search syntax is not
|
|
88
|
+
* supported; non-string patterns throw)
|
|
89
|
+
* @param {{limit?: number}} opts
|
|
90
|
+
* @returns {Array<{item: any, refIndex: number, score?: number, matches?: Array}>}
|
|
91
|
+
*/
|
|
92
|
+
search(pattern, { limit = -1 } = {}) {
|
|
93
|
+
if (typeof pattern !== 'string') {
|
|
94
|
+
throw new UnsupportedOptionError(
|
|
95
|
+
`non-string query (${typeof pattern === 'object' && pattern !== null ? 'logical $and/$or query' : typeof pattern})`
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
if (this._backend === 'native') {
|
|
99
|
+
return nativeSearch(this._engine, this._nativeIndex, pattern, limit, this.options)
|
|
100
|
+
}
|
|
101
|
+
return this._fallback.search(pattern, { limit })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Which backend serves this instance: "native" or "fallback". */
|
|
105
|
+
get backend() {
|
|
106
|
+
return this._backend
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Free the native index handle (also freed automatically on GC). */
|
|
110
|
+
destroy() {
|
|
111
|
+
this._destroyNative()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_destroyNative() {
|
|
115
|
+
if (this._nativeIndex) {
|
|
116
|
+
this._nativeIndex.destroy()
|
|
117
|
+
this._nativeIndex = null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static createIndex() {
|
|
122
|
+
throw new UnsupportedOptionError('Fuse.createIndex')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static parseIndex() {
|
|
126
|
+
throw new UnsupportedOptionError('Fuse.parseIndex')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
static nativeAvailable() {
|
|
130
|
+
return nativeAvailable()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
static backendInfo() {
|
|
134
|
+
return backendInfo()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
FuseMojo.config = DEFAULT_CONFIG
|
|
139
|
+
FuseMojo.version = require('./package.json').version
|
|
140
|
+
|
|
141
|
+
module.exports = FuseMojo
|
|
142
|
+
module.exports.default = FuseMojo
|
|
143
|
+
module.exports.Fuse = FuseMojo
|
|
144
|
+
module.exports.FuseMojo = FuseMojo
|
|
145
|
+
module.exports.nativeAvailable = nativeAvailable
|
|
146
|
+
module.exports.backendInfo = backendInfo
|
|
147
|
+
module.exports.UnsupportedOptionError = UnsupportedOptionError
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Type definitions for the supported drop-in surface of @thyn-ai/fuse-mojo-core.
|
|
2
|
+
// Mirrors the Fuse.js v7 API shape for the supported option subset.
|
|
3
|
+
|
|
4
|
+
declare namespace FuseMojo {
|
|
5
|
+
interface FuseKeyObject {
|
|
6
|
+
name: string | string[]
|
|
7
|
+
weight?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface FuseOptions {
|
|
11
|
+
/** List of properties to search (dotted paths supported). */
|
|
12
|
+
keys?: Array<string | string[] | FuseKeyObject>
|
|
13
|
+
/** Give up threshold in [0, 1]; 0.0 requires a perfect match. Default 0.6. */
|
|
14
|
+
threshold?: number
|
|
15
|
+
/** Approximately where in the text the pattern is expected. Default 0. */
|
|
16
|
+
location?: number
|
|
17
|
+
/** How close the match must be to `location`. Default 100. */
|
|
18
|
+
distance?: number
|
|
19
|
+
/** Minimum matched-run length for a result to count. Default 1. */
|
|
20
|
+
minMatchCharLength?: number
|
|
21
|
+
/** Include the score in results. Default false. */
|
|
22
|
+
includeScore?: boolean
|
|
23
|
+
/** Include matched character index ranges in results. Default false. */
|
|
24
|
+
includeMatches?: boolean
|
|
25
|
+
/** Sort results by score. Default true. */
|
|
26
|
+
shouldSort?: boolean
|
|
27
|
+
/** Ignore `location`/`distance` when scoring. Default false. */
|
|
28
|
+
ignoreLocation?: boolean
|
|
29
|
+
/** Case-sensitive matching. Default false. */
|
|
30
|
+
isCaseSensitive?: boolean
|
|
31
|
+
/** Keep searching after a perfect match. Default false. */
|
|
32
|
+
findAllMatches?: boolean
|
|
33
|
+
/** Ignore the field-length norm in scoring. Default false. */
|
|
34
|
+
ignoreFieldNorm?: boolean
|
|
35
|
+
/** Field-length norm weight. Default 1. */
|
|
36
|
+
fieldNormWeight?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface FuseSearchOptions {
|
|
40
|
+
limit?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type FuseIndexRange = [number, number]
|
|
44
|
+
|
|
45
|
+
interface FuseResultMatch {
|
|
46
|
+
indices: FuseIndexRange[]
|
|
47
|
+
value: string
|
|
48
|
+
key?: string | string[]
|
|
49
|
+
refIndex?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface FuseResult<T> {
|
|
53
|
+
item: T
|
|
54
|
+
refIndex: number
|
|
55
|
+
score?: number
|
|
56
|
+
matches?: FuseResultMatch[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface BackendInfo {
|
|
60
|
+
native_available: boolean
|
|
61
|
+
native_source: string | null
|
|
62
|
+
abi_version_expected: number
|
|
63
|
+
abi_version_native: number | null
|
|
64
|
+
disabled_by_env: boolean
|
|
65
|
+
platform: string
|
|
66
|
+
arch: string
|
|
67
|
+
error: string | null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
declare class FuseMojo<T = any> {
|
|
72
|
+
constructor(list: ReadonlyArray<T>, options?: FuseMojo.FuseOptions)
|
|
73
|
+
options: FuseMojo.FuseOptions
|
|
74
|
+
search(pattern: string, opts?: FuseMojo.FuseSearchOptions): Array<FuseMojo.FuseResult<T>>
|
|
75
|
+
setCollection(docs: ReadonlyArray<T>): this
|
|
76
|
+
readonly backend: 'native' | 'fallback'
|
|
77
|
+
destroy(): void
|
|
78
|
+
|
|
79
|
+
static config: FuseMojo.FuseOptions
|
|
80
|
+
static version: string
|
|
81
|
+
static nativeAvailable(): boolean
|
|
82
|
+
static backendInfo(): FuseMojo.BackendInfo
|
|
83
|
+
static createIndex(): never
|
|
84
|
+
static parseIndex(): never
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export = FuseMojo
|
package/index.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import FuseMojo from './index.cjs'
|
|
2
|
+
|
|
3
|
+
export default FuseMojo
|
|
4
|
+
export const Fuse = FuseMojo
|
|
5
|
+
export const nativeAvailable = FuseMojo.nativeAvailable
|
|
6
|
+
export const backendInfo = FuseMojo.backendInfo
|
|
7
|
+
export const UnsupportedOptionError = FuseMojo.UnsupportedOptionError
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thyn-ai/fuse-mojo-core",
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "Drop-in faster replacement for Fuse.js, powered by a Mojo kernel (pure-JS fallback included).",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Algenta",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"fuse",
|
|
9
|
+
"fuzzy",
|
|
10
|
+
"search",
|
|
11
|
+
"bitap",
|
|
12
|
+
"mojo",
|
|
13
|
+
"fuse.js"
|
|
14
|
+
],
|
|
15
|
+
"type": "commonjs",
|
|
16
|
+
"main": "index.cjs",
|
|
17
|
+
"types": "index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./index.d.ts",
|
|
21
|
+
"import": "./index.mjs",
|
|
22
|
+
"require": "./index.cjs"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.cjs",
|
|
28
|
+
"index.mjs",
|
|
29
|
+
"index.d.ts",
|
|
30
|
+
"src/",
|
|
31
|
+
"vendor/",
|
|
32
|
+
"NOTICE"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"koffi": "^3.3.1"
|
|
39
|
+
},
|
|
40
|
+
"optionalDependencies": {
|
|
41
|
+
"@thyn-ai/fuse-mojo-darwin-arm64": "0.1.7",
|
|
42
|
+
"@thyn-ai/fuse-mojo-linux-x64": "0.1.7"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/thyn-ai/mojo-kernels"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/engine.cjs
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Index construction and search orchestration for the native backend.
|
|
5
|
+
*
|
|
6
|
+
* The engine extracts every searchable string from the document collection
|
|
7
|
+
* (replicating the reference's index records, including the reversed order of
|
|
8
|
+
* array-valued sub-records), lowercases them once at build time, and hands a
|
|
9
|
+
* flat UTF-16 buffer to the native kernel. Each search call then maps to one
|
|
10
|
+
* kernel call per pattern chunk and combines per-text results exactly like
|
|
11
|
+
* the reference's BitapSearch/Fuse pipeline.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { fuseGet, isBlank, createNormGetter, defaultSortFn } = require('./options.cjs')
|
|
15
|
+
|
|
16
|
+
const MAX_BITS = 32
|
|
17
|
+
const INT32_MAX = 0x7fffffff
|
|
18
|
+
|
|
19
|
+
class EngineBuildError extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message)
|
|
22
|
+
this.name = 'EngineBuildError'
|
|
23
|
+
this.code = 'FUSE_MOJO_ENGINE_BUILD'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Split a (lowered) pattern into <=32-code-unit chunks, mirroring the
|
|
29
|
+
* reference: full chunks of 32, with the final chunk of `remainder` units
|
|
30
|
+
* taken from the tail so it always has length 32 when possible.
|
|
31
|
+
*/
|
|
32
|
+
function chunkPattern(pattern) {
|
|
33
|
+
const len = pattern.length
|
|
34
|
+
const chunks = []
|
|
35
|
+
if (len > MAX_BITS) {
|
|
36
|
+
let i = 0
|
|
37
|
+
const remainder = len % MAX_BITS
|
|
38
|
+
const end = len - remainder
|
|
39
|
+
while (i < end) {
|
|
40
|
+
chunks.push({ text: pattern.slice(i, i + MAX_BITS), startIndex: i })
|
|
41
|
+
i += MAX_BITS
|
|
42
|
+
}
|
|
43
|
+
if (remainder) {
|
|
44
|
+
const startIndex = len - MAX_BITS
|
|
45
|
+
chunks.push({ text: pattern.slice(startIndex), startIndex })
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
chunks.push({ text: pattern, startIndex: 0 })
|
|
49
|
+
}
|
|
50
|
+
return chunks
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toU16(str) {
|
|
54
|
+
const out = new Uint16Array(str.length)
|
|
55
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
56
|
+
out[i] = str.charCodeAt(i)
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the searchable-text engine. Every entry corresponds to one string the
|
|
63
|
+
* reference would run Bitap on, in the reference's iteration order:
|
|
64
|
+
* string list: one entry per non-blank document
|
|
65
|
+
* object list: per document, per configured key, one entry per value
|
|
66
|
+
* (array values contribute one entry per element, in the
|
|
67
|
+
* reversed order produced by the reference's stack DFS)
|
|
68
|
+
*/
|
|
69
|
+
function buildEngine(docs, keyStore, options) {
|
|
70
|
+
const normOf = createNormGetter(options.fieldNormWeight, 3)
|
|
71
|
+
const nDocs = docs && typeof docs.length === 'number' ? docs.length : 0
|
|
72
|
+
const mode = typeof docs[0] === 'string' ? 'string' : 'object'
|
|
73
|
+
const entries = []
|
|
74
|
+
|
|
75
|
+
if (mode === 'string') {
|
|
76
|
+
for (let i = 0; i < nDocs; i += 1) {
|
|
77
|
+
const doc = docs[i]
|
|
78
|
+
if (doc === undefined || doc === null) {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (isBlank(doc)) {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
entries.push({ docIdx: i, keyIdx: -1, arrIdx: -1, value: doc, norm: normOf(doc) })
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
for (let i = 0; i < nDocs; i += 1) {
|
|
88
|
+
const doc = docs[i]
|
|
89
|
+
for (let k = 0; k < keyStore.length; k += 1) {
|
|
90
|
+
const key = keyStore[k]
|
|
91
|
+
const value = fuseGet(doc, key.path)
|
|
92
|
+
if (value === undefined || value === null) {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
// Reference _addObject: stack-based DFS pops later elements first,
|
|
97
|
+
// so sub-records come out reversed relative to document order.
|
|
98
|
+
const stack = [{ nestedArrIndex: -1, value }]
|
|
99
|
+
while (stack.length) {
|
|
100
|
+
const { nestedArrIndex, value: v } = stack.pop()
|
|
101
|
+
if (v === undefined || v === null) {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
if (typeof v === 'string' && !isBlank(v)) {
|
|
105
|
+
entries.push({ docIdx: i, keyIdx: k, arrIdx: nestedArrIndex, value: v, norm: normOf(v) })
|
|
106
|
+
} else if (Array.isArray(v)) {
|
|
107
|
+
for (let kk = 0; kk < v.length; kk += 1) {
|
|
108
|
+
stack.push({ nestedArrIndex: kk, value: v[kk] })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Non-string leaves are silently ignored, as in the reference.
|
|
112
|
+
}
|
|
113
|
+
} else if (typeof value === 'string' && !isBlank(value)) {
|
|
114
|
+
entries.push({ docIdx: i, keyIdx: k, arrIdx: -1, value, norm: normOf(value) })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Flatten the lowered texts into one UTF-16 buffer.
|
|
121
|
+
const loweredStrings = new Array(entries.length)
|
|
122
|
+
let total = 0
|
|
123
|
+
for (let e = 0; e < entries.length; e += 1) {
|
|
124
|
+
const s = options.isCaseSensitive ? entries[e].value : entries[e].value.toLowerCase()
|
|
125
|
+
loweredStrings[e] = s
|
|
126
|
+
total += s.length
|
|
127
|
+
}
|
|
128
|
+
if (total > INT32_MAX) {
|
|
129
|
+
throw new EngineBuildError(
|
|
130
|
+
`collection too large for the native kernel: ${total} UTF-16 code units (> 2^31)`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
const chars = new Uint16Array(total)
|
|
134
|
+
const offsets = new Int32Array(entries.length + 1)
|
|
135
|
+
let off = 0
|
|
136
|
+
for (let e = 0; e < entries.length; e += 1) {
|
|
137
|
+
const s = loweredStrings[e]
|
|
138
|
+
for (let k = 0; k < s.length; k += 1) {
|
|
139
|
+
chars[off + k] = s.charCodeAt(k)
|
|
140
|
+
}
|
|
141
|
+
off += s.length
|
|
142
|
+
offsets[e + 1] = off
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { mode, entries, loweredStrings, chars, offsets, docs, keyStore }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run a search on the native backend and format results identically to the
|
|
150
|
+
* reference `Fuse#search`.
|
|
151
|
+
*/
|
|
152
|
+
function nativeSearch(engine, nativeIndex, pattern, limit, options) {
|
|
153
|
+
const { entries, loweredStrings, docs, keyStore } = engine
|
|
154
|
+
const nEntries = entries.length
|
|
155
|
+
|
|
156
|
+
const loweredPattern = options.isCaseSensitive ? pattern : pattern.toLowerCase()
|
|
157
|
+
if (!loweredPattern.length) {
|
|
158
|
+
return []
|
|
159
|
+
}
|
|
160
|
+
const chunks = chunkPattern(loweredPattern)
|
|
161
|
+
const singleChunk = chunks.length === 1
|
|
162
|
+
const includeMatches = options.includeMatches
|
|
163
|
+
|
|
164
|
+
const totalScore = new Float64Array(nEntries)
|
|
165
|
+
const hasMatch = new Uint8Array(nEntries)
|
|
166
|
+
const indicesByEntry = includeMatches ? new Array(nEntries).fill(null) : null
|
|
167
|
+
|
|
168
|
+
for (const chunk of chunks) {
|
|
169
|
+
const chunkU16 = toU16(chunk.text)
|
|
170
|
+
const { totalPairs, scores, isMatch, idxOffsets } = nativeIndex.searchChunk(
|
|
171
|
+
chunkU16,
|
|
172
|
+
chunk.startIndex,
|
|
173
|
+
singleChunk
|
|
174
|
+
)
|
|
175
|
+
let pairs = null
|
|
176
|
+
if (includeMatches && totalPairs > 0) {
|
|
177
|
+
pairs = nativeIndex.copyIndices(totalPairs)
|
|
178
|
+
}
|
|
179
|
+
for (let e = 0; e < nEntries; e += 1) {
|
|
180
|
+
// The reference folds every chunk's (clamped) score into the average,
|
|
181
|
+
// even for chunks that did not match.
|
|
182
|
+
totalScore[e] += scores[e]
|
|
183
|
+
if (isMatch[e]) {
|
|
184
|
+
hasMatch[e] = 1
|
|
185
|
+
if (includeMatches) {
|
|
186
|
+
const from = idxOffsets[e]
|
|
187
|
+
const to = idxOffsets[e + 1]
|
|
188
|
+
if (to > from) {
|
|
189
|
+
let arr = indicesByEntry[e]
|
|
190
|
+
if (arr === null) {
|
|
191
|
+
arr = []
|
|
192
|
+
indicesByEntry[e] = arr
|
|
193
|
+
}
|
|
194
|
+
for (let p = from; p < to; p += 1) {
|
|
195
|
+
arr.push([pairs[p * 2], pairs[p * 2 + 1]])
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Whole-pattern exact-equality fast path. For single-chunk patterns the
|
|
204
|
+
// kernel already applied it; for multi-chunk patterns it is replicated here
|
|
205
|
+
// (the reference checks pattern === text before running any chunk).
|
|
206
|
+
if (!singleChunk) {
|
|
207
|
+
for (let e = 0; e < nEntries; e += 1) {
|
|
208
|
+
if (loweredStrings[e] === loweredPattern) {
|
|
209
|
+
hasMatch[e] = 1
|
|
210
|
+
totalScore[e] = 0
|
|
211
|
+
if (includeMatches) {
|
|
212
|
+
indicesByEntry[e] = [[0, loweredStrings[e].length - 1]]
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Assemble per-document results with reference match-record shapes.
|
|
219
|
+
const results = []
|
|
220
|
+
if (engine.mode === 'string') {
|
|
221
|
+
for (let e = 0; e < nEntries; e += 1) {
|
|
222
|
+
if (!hasMatch[e]) continue
|
|
223
|
+
const entry = entries[e]
|
|
224
|
+
results.push({
|
|
225
|
+
idx: entry.docIdx,
|
|
226
|
+
matches: [
|
|
227
|
+
{
|
|
228
|
+
score: totalScore[e] / chunks.length,
|
|
229
|
+
value: entry.value,
|
|
230
|
+
norm: entry.norm,
|
|
231
|
+
indices: includeMatches ? indicesByEntry[e] || [] : undefined,
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
let current = null
|
|
238
|
+
let currentDoc = -1
|
|
239
|
+
for (let e = 0; e < nEntries; e += 1) {
|
|
240
|
+
if (!hasMatch[e]) continue
|
|
241
|
+
const entry = entries[e]
|
|
242
|
+
const match = {
|
|
243
|
+
score: totalScore[e] / chunks.length,
|
|
244
|
+
key: keyStore[entry.keyIdx],
|
|
245
|
+
value: entry.value,
|
|
246
|
+
norm: entry.norm,
|
|
247
|
+
indices: includeMatches ? indicesByEntry[e] || [] : undefined,
|
|
248
|
+
}
|
|
249
|
+
if (entry.arrIdx > -1) {
|
|
250
|
+
match.idx = entry.arrIdx
|
|
251
|
+
}
|
|
252
|
+
if (entry.docIdx !== currentDoc) {
|
|
253
|
+
current = { idx: entry.docIdx, matches: [] }
|
|
254
|
+
results.push(current)
|
|
255
|
+
currentDoc = entry.docIdx
|
|
256
|
+
}
|
|
257
|
+
current.matches.push(match)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Practical scoring: product over matches of score^(weight * norm).
|
|
262
|
+
for (const result of results) {
|
|
263
|
+
let score = 1
|
|
264
|
+
for (const match of result.matches) {
|
|
265
|
+
const weight = match.key ? match.key.weight : null
|
|
266
|
+
score *= Math.pow(
|
|
267
|
+
match.score === 0 && weight ? Number.EPSILON : match.score,
|
|
268
|
+
(weight || 1) * (options.ignoreFieldNorm ? 1 : match.norm)
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
result.score = score
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (options.shouldSort) {
|
|
275
|
+
results.sort(defaultSortFn)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (typeof limit === 'number' && limit > -1) {
|
|
279
|
+
return formatResults(results.slice(0, limit), docs, options)
|
|
280
|
+
}
|
|
281
|
+
return formatResults(results, docs, options)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function formatResults(results, docs, options) {
|
|
285
|
+
const { includeMatches, includeScore } = options
|
|
286
|
+
return results.map((result) => {
|
|
287
|
+
const data = {
|
|
288
|
+
item: docs[result.idx],
|
|
289
|
+
refIndex: result.idx,
|
|
290
|
+
}
|
|
291
|
+
if (includeMatches) {
|
|
292
|
+
data.matches = []
|
|
293
|
+
for (const match of result.matches) {
|
|
294
|
+
if (!match.indices || !match.indices.length) {
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
const obj = { indices: match.indices, value: match.value }
|
|
298
|
+
if (match.key) {
|
|
299
|
+
obj.key = match.key.src
|
|
300
|
+
}
|
|
301
|
+
if (match.idx > -1) {
|
|
302
|
+
obj.refIndex = match.idx
|
|
303
|
+
}
|
|
304
|
+
data.matches.push(obj)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (includeScore) {
|
|
308
|
+
data.score = result.score
|
|
309
|
+
}
|
|
310
|
+
return data
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = { buildEngine, nativeSearch, chunkPattern, EngineBuildError }
|