@stll/text-search 0.1.1 → 0.2.0

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.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * A single match result. Same shape as
3
+ * @stll/regex-set and @stll/aho-corasick.
4
+ */
5
+ type Match = {
6
+ /** Index of the pattern that matched. */
7
+ pattern: number;
8
+ /** Start UTF-16 code unit offset. */
9
+ start: number;
10
+ /** End offset (exclusive). */
11
+ end: number;
12
+ /** The matched text. */
13
+ text: string;
14
+ /** Pattern name (if provided). */
15
+ name?: string;
16
+ /** Edit distance (fuzzy matches only). */
17
+ distance?: number;
18
+ };
19
+ /** A pattern entry for TextSearch. */
20
+ type PatternEntry = string | RegExp | {
21
+ pattern: string | RegExp;
22
+ name?: string;
23
+ } | {
24
+ pattern: string;
25
+ name?: string;
26
+ /** Fuzzy matching distance. Routes to
27
+ * @stll/fuzzy-search instead of regex. */
28
+ distance: number | "auto";
29
+ } | {
30
+ pattern: string;
31
+ name?: string;
32
+ /** Force literal matching via Aho-Corasick.
33
+ * Skips regex metacharacter detection so
34
+ * patterns like "č.p." or "s.r.o." are
35
+ * matched literally, not as regex. */
36
+ literal: true;
37
+ /** Per-pattern case-insensitive for AC.
38
+ * Overrides the global option for this
39
+ * pattern only. */
40
+ caseInsensitive?: boolean;
41
+ /** Per-pattern whole-word matching for AC. */
42
+ wholeWords?: boolean;
43
+ };
44
+ /** Options for TextSearch. */
45
+ type TextSearchOptions = {
46
+ /**
47
+ * Use Unicode word boundaries.
48
+ * @default true
49
+ */
50
+ unicodeBoundaries?: boolean;
51
+ /**
52
+ * Only match whole words.
53
+ * @default false
54
+ */
55
+ wholeWords?: boolean;
56
+ /**
57
+ * Max alternation branches before auto-splitting
58
+ * into a separate engine instance. Prevents DFA
59
+ * state explosion when large-alternation patterns
60
+ * are combined with other patterns.
61
+ * @default 50
62
+ */
63
+ maxAlternations?: number;
64
+ /**
65
+ * Fuzzy matching metric.
66
+ * @default "levenshtein"
67
+ */
68
+ fuzzyMetric?: "levenshtein" | "damerau-levenshtein";
69
+ /**
70
+ * Normalize diacritics for fuzzy matching.
71
+ * @default false
72
+ */
73
+ normalizeDiacritics?: boolean;
74
+ /**
75
+ * Case-insensitive matching for AC literals
76
+ * and fuzzy patterns.
77
+ * @default false
78
+ */
79
+ caseInsensitive?: boolean;
80
+ /**
81
+ * How to handle overlapping matches from
82
+ * different engines or patterns.
83
+ *
84
+ * - "longest": keep longest non-overlapping match
85
+ * at each position (default).
86
+ * - "all": return all matches including overlaps.
87
+ * Useful when the caller applies its own dedup.
88
+ *
89
+ * @default "longest"
90
+ */
91
+ overlapStrategy?: "longest" | "all";
92
+ /**
93
+ * Treat ALL string patterns as literals (route
94
+ * to AC, skip metacharacter detection). Useful
95
+ * for deny-list patterns where "s.r.o." means
96
+ * the literal string, not a regex with wildcards.
97
+ * @default false
98
+ */
99
+ allLiteral?: boolean;
100
+ };
101
+
102
+ /**
103
+ * Multi-engine text search orchestrator.
104
+ *
105
+ * Routes patterns to the optimal engine
106
+ * configuration:
107
+ * - Large alternation patterns get their own
108
+ * RegexSet instance (prevents DFA state explosion)
109
+ * - Normal patterns share a single RegexSet
110
+ * (single-pass multi-pattern DFA)
111
+ *
112
+ * Merges results from all engines into a unified
113
+ * non-overlapping Match[] sorted by position.
114
+ */
115
+ declare class TextSearch {
116
+ private engines;
117
+ private patternCount;
118
+ private overlapAll;
119
+ /**
120
+ * True when there's exactly one engine and all
121
+ * patterns map to identity indices (0→0, 1→1, ...).
122
+ * Enables zero-overhead findIter: return raw engine
123
+ * output without remapping or object allocation.
124
+ */
125
+ private zeroOverhead;
126
+ constructor(patterns: PatternEntry[], options?: TextSearchOptions);
127
+ /** Number of patterns. */
128
+ get length(): number;
129
+ /** Returns true if any pattern matches. */
130
+ isMatch(haystack: string): boolean;
131
+ /**
132
+ * Find matches in text.
133
+ *
134
+ * With `overlapStrategy: "longest"` (default):
135
+ * returns non-overlapping matches, longest wins.
136
+ *
137
+ * With `overlapStrategy: "all"`: returns all
138
+ * matches including overlaps, sorted by position.
139
+ */
140
+ findIter(haystack: string): Match[];
141
+ /** Which pattern indices matched (not where). */
142
+ whichMatch(haystack: string): number[];
143
+ /**
144
+ * Replace all non-overlapping matches.
145
+ * replacements[i] replaces pattern i.
146
+ */
147
+ replaceAll(haystack: string, replacements: string[]): string;
148
+ }
149
+
150
+ export { type Match, type PatternEntry, TextSearch, type TextSearchOptions };
package/dist/index.js ADDED
@@ -0,0 +1,464 @@
1
+ // src/text-search.ts
2
+ import { AhoCorasick } from "@stll/aho-corasick";
3
+ import { FuzzySearch } from "@stll/fuzzy-search";
4
+ import { RegexSet } from "@stll/regex-set";
5
+
6
+ // src/classify.ts
7
+ function isLiteralPattern(pattern) {
8
+ for (let i = 0; i < pattern.length; i++) {
9
+ const ch = pattern[i];
10
+ if (ch === "\\" || ch === "." || ch === "^" || ch === "$" || ch === "*" || ch === "+" || ch === "?" || ch === "{" || ch === "}" || ch === "(" || ch === ")" || ch === "[" || ch === "]" || ch === "|") {
11
+ return false;
12
+ }
13
+ }
14
+ return pattern.length > 0;
15
+ }
16
+ function countAlternations(pattern) {
17
+ let depth = 0;
18
+ let inClass = false;
19
+ let i = 0;
20
+ let max = 1;
21
+ let currentCount = 1;
22
+ const stack = [];
23
+ while (i < pattern.length) {
24
+ const ch = pattern[i];
25
+ if (ch === "\\" && i + 1 < pattern.length) {
26
+ i += 2;
27
+ continue;
28
+ }
29
+ if (ch === "[") inClass = true;
30
+ if (ch === "]") inClass = false;
31
+ if (!inClass) {
32
+ if (ch === "(") {
33
+ stack.push(currentCount);
34
+ currentCount = 1;
35
+ depth++;
36
+ }
37
+ if (ch === ")") {
38
+ if (currentCount > max) max = currentCount;
39
+ currentCount = stack.pop() ?? 1;
40
+ depth--;
41
+ }
42
+ if (ch === "|") {
43
+ currentCount++;
44
+ }
45
+ }
46
+ i++;
47
+ }
48
+ if (currentCount > max) max = currentCount;
49
+ return max;
50
+ }
51
+ function classifyPatterns(entries, allLiteral = false) {
52
+ return entries.map((entry, i) => {
53
+ if (typeof entry === "string") {
54
+ return {
55
+ originalIndex: i,
56
+ pattern: entry,
57
+ alternationCount: allLiteral ? 0 : countAlternations(entry),
58
+ isLiteral: allLiteral || isLiteralPattern(entry)
59
+ };
60
+ }
61
+ if (entry instanceof RegExp) {
62
+ return {
63
+ originalIndex: i,
64
+ pattern: entry,
65
+ alternationCount: countAlternations(
66
+ entry.source
67
+ ),
68
+ isLiteral: false
69
+ // RegExp is never literal
70
+ };
71
+ }
72
+ if ("distance" in entry) {
73
+ const result2 = {
74
+ originalIndex: i,
75
+ pattern: entry.pattern,
76
+ alternationCount: 0,
77
+ isLiteral: false,
78
+ fuzzyDistance: entry.distance
79
+ };
80
+ if (entry.name !== void 0) result2.name = entry.name;
81
+ return result2;
82
+ }
83
+ if ("literal" in entry && entry.literal) {
84
+ const hasPerPatternOpts = "caseInsensitive" in entry || "wholeWords" in entry;
85
+ const result2 = {
86
+ originalIndex: i,
87
+ pattern: entry.pattern,
88
+ alternationCount: 0,
89
+ isLiteral: true
90
+ };
91
+ if (entry.name !== void 0) result2.name = entry.name;
92
+ if (hasPerPatternOpts) {
93
+ const opts = {};
94
+ if (entry.caseInsensitive !== void 0)
95
+ opts.caseInsensitive = entry.caseInsensitive;
96
+ if (entry.wholeWords !== void 0)
97
+ opts.wholeWords = entry.wholeWords;
98
+ result2.acOptions = opts;
99
+ }
100
+ return result2;
101
+ }
102
+ const pat = entry.pattern;
103
+ const source = pat instanceof RegExp ? pat.source : pat;
104
+ const result = {
105
+ originalIndex: i,
106
+ pattern: pat,
107
+ alternationCount: allLiteral ? 0 : countAlternations(source),
108
+ isLiteral: typeof pat === "string" && (allLiteral || isLiteralPattern(pat))
109
+ };
110
+ if (entry.name !== void 0) result.name = entry.name;
111
+ return result;
112
+ });
113
+ }
114
+
115
+ // src/merge.ts
116
+ function mergeAndSelect(matches) {
117
+ if (matches.length <= 1) return matches;
118
+ matches.sort((a, b) => {
119
+ if (a.start !== b.start) {
120
+ return a.start - b.start;
121
+ }
122
+ return b.end - b.start - (a.end - a.start);
123
+ });
124
+ const selected = [];
125
+ let lastEnd = 0;
126
+ for (const m of matches) {
127
+ if (m.start >= lastEnd) {
128
+ selected.push(m);
129
+ lastEnd = m.end;
130
+ }
131
+ }
132
+ return selected;
133
+ }
134
+
135
+ // src/text-search.ts
136
+ var TextSearch = class {
137
+ engines = [];
138
+ patternCount;
139
+ overlapAll;
140
+ /**
141
+ * True when there's exactly one engine and all
142
+ * patterns map to identity indices (0→0, 1→1, ...).
143
+ * Enables zero-overhead findIter: return raw engine
144
+ * output without remapping or object allocation.
145
+ */
146
+ zeroOverhead = false;
147
+ constructor(patterns, options) {
148
+ this.patternCount = patterns.length;
149
+ this.overlapAll = options?.overlapStrategy === "all";
150
+ const maxAlt = options?.maxAlternations ?? 50;
151
+ const classified = classifyPatterns(
152
+ patterns,
153
+ options?.allLiteral ?? false
154
+ );
155
+ const fuzzy = [];
156
+ const literals = [];
157
+ const shared = [];
158
+ const isolated = [];
159
+ for (const cp of classified) {
160
+ if (cp.fuzzyDistance !== void 0) {
161
+ fuzzy.push(cp);
162
+ } else if (cp.isLiteral) {
163
+ literals.push(cp);
164
+ } else if (cp.alternationCount > maxAlt) {
165
+ isolated.push(cp);
166
+ } else {
167
+ shared.push(cp);
168
+ }
169
+ }
170
+ const rsOptions = {
171
+ unicodeBoundaries: options?.unicodeBoundaries ?? true,
172
+ wholeWords: options?.wholeWords ?? false,
173
+ caseInsensitive: options?.caseInsensitive ?? false
174
+ };
175
+ if (fuzzy.length > 0) {
176
+ const fuzzyOpts = {
177
+ unicodeBoundaries: rsOptions.unicodeBoundaries,
178
+ wholeWords: rsOptions.wholeWords
179
+ };
180
+ if (options?.fuzzyMetric !== void 0)
181
+ fuzzyOpts.metric = options.fuzzyMetric;
182
+ if (options?.normalizeDiacritics !== void 0)
183
+ fuzzyOpts.normalizeDiacritics = options.normalizeDiacritics;
184
+ if (options?.caseInsensitive !== void 0)
185
+ fuzzyOpts.caseInsensitive = options.caseInsensitive;
186
+ this.engines.push(
187
+ buildFuzzyEngine(fuzzy, fuzzyOpts)
188
+ );
189
+ }
190
+ if (literals.length > 0) {
191
+ const groups = /* @__PURE__ */ new Map();
192
+ for (const cp of literals) {
193
+ const ci = cp.acOptions?.caseInsensitive ?? rsOptions.caseInsensitive;
194
+ const ww = cp.acOptions?.wholeWords ?? rsOptions.wholeWords;
195
+ const key = `${ci ? 1 : 0}:${ww ? 1 : 0}`;
196
+ const group = groups.get(key);
197
+ if (group) {
198
+ group.push(cp);
199
+ } else {
200
+ groups.set(key, [cp]);
201
+ }
202
+ }
203
+ for (const [key, group] of groups) {
204
+ const [ci, ww] = key.split(":");
205
+ this.engines.push(
206
+ buildAcEngine(group, {
207
+ ...rsOptions,
208
+ caseInsensitive: ci === "1",
209
+ wholeWords: ww === "1"
210
+ })
211
+ );
212
+ }
213
+ }
214
+ if (shared.length > 1) {
215
+ const combined = buildRegexEngine(
216
+ shared,
217
+ rsOptions
218
+ );
219
+ const probe = "Hello World 123 test@example.com 2025-01-01 +420 123 456 789 Ing. Jan Nov\xE1k, s.r.o. Praha 1 ".repeat(10);
220
+ const t0 = performance.now();
221
+ combined.rs.findIter(probe);
222
+ const combinedMs = performance.now() - t0;
223
+ let individualMs = 0;
224
+ const individualEngines = [];
225
+ for (const cp of shared) {
226
+ const eng = buildRegexEngine(
227
+ [cp],
228
+ rsOptions
229
+ );
230
+ const t1 = performance.now();
231
+ eng.rs.findIter(probe);
232
+ individualMs += performance.now() - t1;
233
+ individualEngines.push(eng);
234
+ }
235
+ if (combinedMs > individualMs * 1.5) {
236
+ for (const eng of individualEngines) {
237
+ this.engines.push(eng);
238
+ }
239
+ } else {
240
+ this.engines.push(combined);
241
+ }
242
+ } else if (shared.length === 1) {
243
+ this.engines.push(
244
+ buildRegexEngine(shared, rsOptions)
245
+ );
246
+ }
247
+ for (const cp of isolated) {
248
+ this.engines.push(
249
+ buildRegexEngine([cp], rsOptions)
250
+ );
251
+ }
252
+ if (this.engines.length === 1) {
253
+ const engine = this.engines[0];
254
+ const hasNames = engine.nameMap.some(
255
+ (n) => n !== void 0
256
+ );
257
+ if (!hasNames) {
258
+ this.zeroOverhead = true;
259
+ }
260
+ }
261
+ }
262
+ /** Number of patterns. */
263
+ get length() {
264
+ return this.patternCount;
265
+ }
266
+ /** Returns true if any pattern matches. */
267
+ isMatch(haystack) {
268
+ for (const engine of this.engines) {
269
+ if (engineIsMatch(engine, haystack)) {
270
+ return true;
271
+ }
272
+ }
273
+ return false;
274
+ }
275
+ /**
276
+ * Find matches in text.
277
+ *
278
+ * With `overlapStrategy: "longest"` (default):
279
+ * returns non-overlapping matches, longest wins.
280
+ *
281
+ * With `overlapStrategy: "all"`: returns all
282
+ * matches including overlaps, sorted by position.
283
+ */
284
+ findIter(haystack) {
285
+ if (this.zeroOverhead) {
286
+ return engineFindIter(
287
+ this.engines[0],
288
+ haystack
289
+ );
290
+ }
291
+ if (this.engines.length === 1) {
292
+ return remapMatches(
293
+ engineFindIter(this.engines[0], haystack),
294
+ this.engines[0]
295
+ );
296
+ }
297
+ const all = [];
298
+ for (const engine of this.engines) {
299
+ const matches = engineFindIter(
300
+ engine,
301
+ haystack
302
+ );
303
+ for (const m of remapMatches(matches, engine)) {
304
+ all.push(m);
305
+ }
306
+ }
307
+ if (this.overlapAll) {
308
+ return all.sort(
309
+ (a, b) => a.start - b.start
310
+ );
311
+ }
312
+ return mergeAndSelect(all);
313
+ }
314
+ /** Which pattern indices matched (not where). */
315
+ whichMatch(haystack) {
316
+ const seen = /* @__PURE__ */ new Set();
317
+ for (const engine of this.engines) {
318
+ const matches = engineFindIter(
319
+ engine,
320
+ haystack
321
+ );
322
+ for (const m of matches) {
323
+ seen.add(engine.indexMap[m.pattern]);
324
+ }
325
+ }
326
+ return [...seen];
327
+ }
328
+ /**
329
+ * Replace all non-overlapping matches.
330
+ * replacements[i] replaces pattern i.
331
+ */
332
+ replaceAll(haystack, replacements) {
333
+ if (replacements.length !== this.patternCount) {
334
+ throw new Error(
335
+ `Expected ${this.patternCount} replacements, got ${replacements.length}`
336
+ );
337
+ }
338
+ const all = [];
339
+ for (const engine of this.engines) {
340
+ const matches2 = engineFindIter(
341
+ engine,
342
+ haystack
343
+ );
344
+ for (const m of remapMatches(matches2, engine)) {
345
+ all.push(m);
346
+ }
347
+ }
348
+ const matches = mergeAndSelect(all);
349
+ let result = "";
350
+ let last = 0;
351
+ for (const m of matches) {
352
+ result += haystack.slice(last, m.start);
353
+ result += replacements[m.pattern];
354
+ last = m.end;
355
+ }
356
+ result += haystack.slice(last);
357
+ return result;
358
+ }
359
+ };
360
+ function buildRegexEngine(patterns, options) {
361
+ const rsPatterns = [];
362
+ const indexMap = [];
363
+ const nameMap = [];
364
+ for (const cp of patterns) {
365
+ if (cp.name !== void 0) {
366
+ rsPatterns.push({
367
+ pattern: cp.pattern,
368
+ name: cp.name
369
+ });
370
+ } else {
371
+ rsPatterns.push(cp.pattern);
372
+ }
373
+ indexMap.push(cp.originalIndex);
374
+ nameMap.push(cp.name);
375
+ }
376
+ const rs = new RegexSet(rsPatterns, options);
377
+ return { type: "regex", rs, indexMap, nameMap };
378
+ }
379
+ function buildAcEngine(patterns, options) {
380
+ const literals = [];
381
+ const indexMap = [];
382
+ const nameMap = [];
383
+ for (const cp of patterns) {
384
+ literals.push(cp.pattern);
385
+ indexMap.push(cp.originalIndex);
386
+ nameMap.push(cp.name);
387
+ }
388
+ const ac = new AhoCorasick(literals, {
389
+ wholeWords: options.wholeWords,
390
+ unicodeBoundaries: options.unicodeBoundaries,
391
+ caseInsensitive: options.caseInsensitive
392
+ });
393
+ return { type: "ac", ac, indexMap, nameMap };
394
+ }
395
+ function buildFuzzyEngine(patterns, options) {
396
+ const fsPatterns = [];
397
+ const indexMap = [];
398
+ const nameMap = [];
399
+ for (const cp of patterns) {
400
+ const entry = {
401
+ pattern: cp.pattern
402
+ };
403
+ if (cp.fuzzyDistance !== void 0)
404
+ entry.distance = cp.fuzzyDistance;
405
+ if (cp.name !== void 0) entry.name = cp.name;
406
+ fsPatterns.push(entry);
407
+ indexMap.push(cp.originalIndex);
408
+ nameMap.push(cp.name);
409
+ }
410
+ const fsOptions = {
411
+ unicodeBoundaries: options.unicodeBoundaries,
412
+ wholeWords: options.wholeWords
413
+ };
414
+ if (options.metric !== void 0)
415
+ fsOptions.metric = options.metric;
416
+ if (options.normalizeDiacritics !== void 0)
417
+ fsOptions.normalizeDiacritics = options.normalizeDiacritics;
418
+ if (options.caseInsensitive !== void 0)
419
+ fsOptions.caseInsensitive = options.caseInsensitive;
420
+ const fs = new FuzzySearch(fsPatterns, fsOptions);
421
+ return { type: "fuzzy", fs, indexMap, nameMap };
422
+ }
423
+ function engineIsMatch(engine, haystack) {
424
+ switch (engine.type) {
425
+ case "ac":
426
+ return engine.ac.isMatch(haystack);
427
+ case "fuzzy":
428
+ return engine.fs.isMatch(haystack);
429
+ case "regex":
430
+ return engine.rs.isMatch(haystack);
431
+ }
432
+ }
433
+ function engineFindIter(engine, haystack) {
434
+ switch (engine.type) {
435
+ case "ac":
436
+ return engine.ac.findIter(haystack);
437
+ case "fuzzy":
438
+ return engine.fs.findIter(haystack);
439
+ case "regex":
440
+ return engine.rs.findIter(haystack);
441
+ }
442
+ }
443
+ function remapMatches(matches, engine) {
444
+ return matches.map((m) => {
445
+ const originalIdx = engine.indexMap[m.pattern];
446
+ const name = engine.nameMap[m.pattern];
447
+ const result = {
448
+ pattern: originalIdx,
449
+ start: m.start,
450
+ end: m.end,
451
+ text: m.text
452
+ };
453
+ if (name !== void 0) {
454
+ result.name = name;
455
+ }
456
+ if ("distance" in m && m.distance !== void 0) {
457
+ result.distance = m.distance;
458
+ }
459
+ return result;
460
+ });
461
+ }
462
+ export {
463
+ TextSearch
464
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/text-search",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Multi-engine text search orchestrator. Routes patterns to optimal engines: Aho-Corasick, RegexSet, or FuzzySearch.",
5
5
  "keywords": [
6
6
  "text-search",
@@ -20,16 +20,23 @@
20
20
  "url": "https://github.com/stella/text-search"
21
21
  },
22
22
  "type": "module",
23
- "main": "src/index.ts",
24
- "module": "src/index.ts",
23
+ "main": "dist/index.js",
24
+ "module": "dist/index.js",
25
+ "types": "dist/index.d.ts",
25
26
  "exports": {
26
- ".": "./src/index.ts"
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js",
30
+ "default": "./dist/index.js"
31
+ }
27
32
  },
28
33
  "files": [
29
- "src"
34
+ "src",
35
+ "dist"
30
36
  ],
31
37
  "scripts": {
32
- "build": "bun build src/index.ts --outdir dist --target node",
38
+ "build": "tsup",
39
+ "prepublishOnly": "bun run build",
33
40
  "test": "bun test",
34
41
  "lint": "oxlint .",
35
42
  "format": "oxfmt ."
@@ -43,7 +50,9 @@
43
50
  "@types/node": "^22.0.0",
44
51
  "bun-types": "^1.3.10",
45
52
  "oxfmt": "^0.40.0",
46
- "oxlint": "^1.55.0"
53
+ "oxlint": "^1.55.0",
54
+ "tsup": "^8.5.1",
55
+ "typescript": "^5.9.3"
47
56
  },
48
57
  "engines": {
49
58
  "node": ">= 18"
package/src/classify.ts CHANGED
@@ -167,14 +167,15 @@ export function classifyPatterns(
167
167
 
168
168
  // Fuzzy pattern: has `distance` field
169
169
  if ("distance" in entry) {
170
- return {
170
+ const result: ClassifiedPattern = {
171
171
  originalIndex: i,
172
172
  pattern: entry.pattern,
173
- name: entry.name,
174
173
  alternationCount: 0,
175
174
  isLiteral: false,
176
175
  fuzzyDistance: entry.distance,
177
176
  };
177
+ if (entry.name !== undefined) result.name = entry.name;
178
+ return result;
178
179
  }
179
180
 
180
181
  // Explicit literal: skip metachar detection
@@ -182,30 +183,33 @@ export function classifyPatterns(
182
183
  const hasPerPatternOpts =
183
184
  "caseInsensitive" in entry ||
184
185
  "wholeWords" in entry;
185
- return {
186
+ const result: ClassifiedPattern = {
186
187
  originalIndex: i,
187
188
  pattern: entry.pattern,
188
- name: entry.name,
189
189
  alternationCount: 0,
190
190
  isLiteral: true,
191
- acOptions: hasPerPatternOpts
192
- ? {
193
- caseInsensitive:
194
- entry.caseInsensitive,
195
- wholeWords: entry.wholeWords,
196
- }
197
- : undefined,
198
191
  };
192
+ if (entry.name !== undefined) result.name = entry.name;
193
+ if (hasPerPatternOpts) {
194
+ const opts: NonNullable<
195
+ ClassifiedPattern["acOptions"]
196
+ > = {};
197
+ if (entry.caseInsensitive !== undefined)
198
+ opts.caseInsensitive = entry.caseInsensitive;
199
+ if (entry.wholeWords !== undefined)
200
+ opts.wholeWords = entry.wholeWords;
201
+ result.acOptions = opts;
202
+ }
203
+ return result;
199
204
  }
200
205
 
201
206
  const pat = entry.pattern;
202
207
  const source =
203
208
  pat instanceof RegExp ? pat.source : pat;
204
209
 
205
- return {
210
+ const result: ClassifiedPattern = {
206
211
  originalIndex: i,
207
212
  pattern: pat,
208
- name: entry.name,
209
213
  alternationCount: allLiteral
210
214
  ? 0
211
215
  : countAlternations(source),
@@ -213,5 +217,7 @@ export function classifyPatterns(
213
217
  typeof pat === "string" &&
214
218
  (allLiteral || isLiteralPattern(pat)),
215
219
  };
220
+ if (entry.name !== undefined) result.name = entry.name;
221
+ return result;
216
222
  });
217
223
  }
@@ -107,17 +107,23 @@ export class TextSearch {
107
107
 
108
108
  // Build fuzzy engine
109
109
  if (fuzzy.length > 0) {
110
+ const fuzzyOpts: Parameters<
111
+ typeof buildFuzzyEngine
112
+ >[1] = {
113
+ unicodeBoundaries:
114
+ rsOptions.unicodeBoundaries,
115
+ wholeWords: rsOptions.wholeWords,
116
+ };
117
+ if (options?.fuzzyMetric !== undefined)
118
+ fuzzyOpts.metric = options.fuzzyMetric;
119
+ if (options?.normalizeDiacritics !== undefined)
120
+ fuzzyOpts.normalizeDiacritics =
121
+ options.normalizeDiacritics;
122
+ if (options?.caseInsensitive !== undefined)
123
+ fuzzyOpts.caseInsensitive =
124
+ options.caseInsensitive;
110
125
  this.engines.push(
111
- buildFuzzyEngine(fuzzy, {
112
- unicodeBoundaries:
113
- rsOptions.unicodeBoundaries,
114
- wholeWords: rsOptions.wholeWords,
115
- metric: options?.fuzzyMetric,
116
- normalizeDiacritics:
117
- options?.normalizeDiacritics,
118
- caseInsensitive:
119
- options?.caseInsensitive,
120
- }),
126
+ buildFuzzyEngine(fuzzy, fuzzyOpts),
121
127
  );
122
128
  }
123
129
 
@@ -440,23 +446,32 @@ function buildFuzzyEngine(
440
446
  const nameMap: (string | undefined)[] = [];
441
447
 
442
448
  for (const cp of patterns) {
443
- fsPatterns.push({
449
+ const entry: (typeof fsPatterns)[number] = {
444
450
  pattern: cp.pattern as string,
445
- distance: cp.fuzzyDistance,
446
- name: cp.name,
447
- });
451
+ };
452
+ if (cp.fuzzyDistance !== undefined)
453
+ entry.distance = cp.fuzzyDistance;
454
+ if (cp.name !== undefined) entry.name = cp.name;
455
+ fsPatterns.push(entry);
448
456
  indexMap.push(cp.originalIndex);
449
457
  nameMap.push(cp.name);
450
458
  }
451
459
 
452
- const fs = new FuzzySearch(fsPatterns, {
460
+ const fsOptions: ConstructorParameters<
461
+ typeof FuzzySearch
462
+ >[1] = {
453
463
  unicodeBoundaries: options.unicodeBoundaries,
454
464
  wholeWords: options.wholeWords,
455
- metric: options.metric,
456
- normalizeDiacritics:
457
- options.normalizeDiacritics,
458
- caseInsensitive: options.caseInsensitive,
459
- });
465
+ };
466
+ if (options.metric !== undefined)
467
+ fsOptions.metric = options.metric;
468
+ if (options.normalizeDiacritics !== undefined)
469
+ fsOptions.normalizeDiacritics =
470
+ options.normalizeDiacritics;
471
+ if (options.caseInsensitive !== undefined)
472
+ fsOptions.caseInsensitive =
473
+ options.caseInsensitive;
474
+ const fs = new FuzzySearch(fsPatterns, fsOptions);
460
475
 
461
476
  return { type: "fuzzy", fs, indexMap, nameMap };
462
477
  }