@stll/text-search 0.1.0 → 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.
- package/dist/index.d.ts +150 -0
- package/dist/index.js +464 -0
- package/package.json +15 -6
- package/src/classify.ts +223 -0
- package/src/merge.ts +34 -0
- package/src/text-search.ts +540 -0
- package/src/types.ts +114 -0
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
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": "
|
|
24
|
-
"module": "
|
|
23
|
+
"main": "dist/index.js",
|
|
24
|
+
"module": "dist/index.js",
|
|
25
|
+
"types": "dist/index.d.ts",
|
|
25
26
|
"exports": {
|
|
26
|
-
".":
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
27
32
|
},
|
|
28
33
|
"files": [
|
|
34
|
+
"src",
|
|
29
35
|
"dist"
|
|
30
36
|
],
|
|
31
37
|
"scripts": {
|
|
32
|
-
"build": "
|
|
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"
|