🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@stll/text-search

Package Overview
Dependencies
Maintainers
1
Versions
22
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@stll/text-search - npm Package Compare versions

Comparing version
0.1.3
to
1.0.0-rc.1
+18
-12
package.json
{
"name": "@stll/text-search",
"version": "0.1.3",
"description": "Multi-engine text search orchestrator. Routes patterns to optimal engines: Aho-Corasick, RegexSet, or FuzzySearch.",
"version": "1.0.0-rc.1",
"description": "Multi-engine text search orchestrator for Node.js and Bun. Routes literals, regex, and fuzzy patterns to the right engine automatically.",
"keywords": [

@@ -22,4 +22,4 @@ "aho-corasick",

},
"packageManager": "bun@1.3.12",
"files": [
"src",
"dist"

@@ -39,23 +39,29 @@ ],

"publishConfig": {
"access": "restricted"
"access": "public"
},
"scripts": {
"build": "tsdown",
"build:js": "tsdown",
"prepublishOnly": "bun run build",
"test": "bun test",
"test:runtime:bun": "bun scripts/runtime-smoke.mjs",
"test:runtime:node": "node scripts/runtime-smoke.mjs",
"lint": "oxlint .",
"format": "oxfmt ."
"format": "oxfmt .",
"version:check": "node scripts/version-sync.mjs check",
"version:sync": "node scripts/version-sync.mjs sync"
},
"dependencies": {
"@stll/aho-corasick": "^0.1.4",
"@stll/fuzzy-search": "^0.1.1",
"@stll/regex-set": "^0.1.1"
"@stll/aho-corasick": "^1.0.0-rc.1",
"@stll/fuzzy-search": "^1.0.0-rc.1",
"@stll/regex-set": "^1.0.0-rc.3"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/node": "^25.5.2",
"bun-types": "^1.3.10",
"oxfmt": "^0.42.0",
"oxlint": "^1.57.0",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"tsdown": "^0.21.6",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vite": "^8.0.8"
},

@@ -62,0 +68,0 @@ "engines": {

+81
-106

@@ -7,8 +7,7 @@ <p align="center">

Multi-engine text search orchestrator for
Node.js and Bun. Routes patterns to the optimal
engine automatically: Aho-Corasick for literals,
RegexSet for regex, FuzzySearch for approximate
matching, with auto-optimization for large
alternations.
Multi-engine text search orchestrator for Node.js
and Bun. It classifies each pattern once, routes
literals to Aho-Corasick, regex to RegexSet, fuzzy
entries to FuzzySearch, and merges the results into
one stable match stream.

@@ -23,2 +22,4 @@ Part of the

Node.js / Bun:
```bash

@@ -30,81 +31,75 @@ npm install @stll/text-search

Requires `@stll/regex-set`, `@stll/aho-corasick`,
and `@stll/fuzzy-search` as peer dependencies
(installed automatically).
`@stll/text-search` ships the engine packages as
regular dependencies. You do not install them
separately unless you want to use the lower-level
engine APIs directly.
Browser / WebAssembly:
```bash
npm install @stll/text-search-wasm
# or
bun add @stll/text-search-wasm
```
## Vite
`@stll/text-search-wasm` depends on the browser
variants of the Stella engines. Import the bundled
Vite plugin so those WASM loaders stay out of
pre-bundling and keep their relative asset paths.
```ts
import stllTextSearchWasm from "@stll/text-search-wasm/vite";
export default {
plugins: [stllTextSearchWasm()],
};
```
## Usage
```typescript
```ts
import { TextSearch } from "@stll/text-search";
const ts = new TextSearch([
// Regex patterns → RegexSet (DFA)
const search = new TextSearch([
"Confidential",
"Attorney-Client Privilege",
/\b\d{2}\.\d{2}\.\d{4}\b/,
/\b[\w.+-]+@[\w-]+\.[\w]+\b/,
// Pure literals → Aho-Corasick (SIMD)
"Confidential",
"Attorney-Client Privilege",
// Fuzzy patterns → FuzzySearch (Levenshtein)
{ pattern: "Novák", distance: 1, name: "person" },
// Large alternation → auto-isolated RegexSet
`(?:${titles.join("|")})\\s+[A-Z][a-z]+`,
// Named patterns
{ pattern: /\+?\d{9,12}/, name: "phone" },
{ pattern: "s.r.o.", literal: true, name: "company-type" },
]);
ts.findIter("Ing. Jan Novak, born 15.03.1990");
// [
// { pattern: 5, text: "Ing. Jan Novak", ... },
// { pattern: 4, text: "Novak", distance: 1, ... },
// { pattern: 0, text: "15.03.1990", ... },
// ]
const matches = search.findIter(
"Ing. Jan Novak, s.r.o., born 15.03.1990.",
);
```
## Engine routing
## Routing model
Patterns are classified and routed to the optimal
engine at construction time:
Patterns are classified once at construction time.
| Engine | Condition | Performance |
| ------------------- | ------------------------ | ---------------------- |
| Aho-Corasick | Pure literal strings | SIMD-accelerated |
| RegexSet (shared) | Normal regex patterns | Single-pass DFA |
| RegexSet (isolated) | >50 alternation branches | Prevents DFA explosion |
| FuzzySearch | `distance` field present | Levenshtein/Damerau |
| Engine | Used for |
| --- | --- |
| Aho-Corasick | Pure literals and explicit `literal: true` entries |
| RegexSet | Standard regex patterns |
| FuzzySearch | Entries with a `distance` field |
Large alternation patterns (e.g., 80+ title
prefixes) are automatically isolated into their
own RegexSet instance, preventing DFA state
explosion when combined with other patterns.
Large alternation-heavy regexes are isolated into
their own RegexSet instance so they do not poison
the shared DFA for simpler patterns.
```typescript
// Without text-search: 73ms (DFA state explosion)
new RegexSet([hugePattern, simplePattern]);
// With text-search: 0.4ms (auto-split)
new TextSearch([hugePattern, simplePattern]);
```
## Options
```typescript
```ts
new TextSearch(patterns, {
// Unicode word boundaries (default: true)
unicodeBoundaries: true,
// Only match whole words (default: false)
wholeWords: false,
// Max alternation branches before auto-split
// (default: 50)
maxAlternations: 50,
// Fuzzy matching options
fuzzyMetric: "levenshtein", // or "damerau-levenshtein"
fuzzyMetric: "levenshtein",
normalizeDiacritics: false,
caseInsensitive: false,
overlapStrategy: "longest",
allLiteral: false,
});

@@ -115,53 +110,36 @@ ```

| Method | Returns | Description |
| -------------------------------- | ---------- | ----------------------------- |
| `findIter(text)` | `Match[]` | All non-overlapping matches |
| `isMatch(text)` | `boolean` | Any pattern matches? |
| `whichMatch(text)` | `number[]` | Which pattern indices matched |
| `replaceAll(text, replacements)` | `string` | Replace matches |
| `length` | `number` | Number of patterns |
| Method | Returns | Description |
| --- | --- | --- |
| `findIter(text)` | `Match[]` | Find matches in input text |
| `isMatch(text)` | `boolean` | Fast yes/no check |
| `whichMatch(text)` | `number[]` | Pattern indices that matched |
| `replaceAll(text, replacements)` | `string` | Replace matched ranges |
| `length` | `number` | Number of configured patterns |
## Pattern entry types
```typescript
// Simple string (literal → AC, regex → RegexSet)
"foo"
// RegExp object → RegexSet
/\btest\b/i
// Named pattern
```ts
"literal"
/\bregex\b/i
{ pattern: "\\d+", name: "number" }
// Fuzzy pattern → FuzzySearch
{ pattern: "Novák", distance: 1 }
{ pattern: "Smith", distance: "auto", name: "person" }
{ pattern: "Novák", distance: 1, name: "person" }
{ pattern: "s.r.o.", literal: true, wholeWords: true }
```
## Match type
## Match shape
```typescript
```ts
type Match = {
pattern: number; // original pattern index
start: number; // UTF-16 offset
end: number; // exclusive
text: string; // matched substring
name?: string; // pattern name (if provided)
pattern: number;
start: number;
end: number;
text: string;
name?: string;
distance?: number;
};
```
Same `Match` shape as `@stll/regex-set`,
`@stll/aho-corasick`, and `@stll/fuzzy-search`.
The `Match` shape is aligned with the other Stella
text-search packages.
## How it works
1. **Classify**: detect literals, count alternation
branches, identify fuzzy patterns
2. **Route**: literals → AC, fuzzy → FuzzySearch,
large alternations → isolated RegexSet,
normal regex → shared RegexSet
3. **Search**: each engine scans the text
4. **Merge**: combine results, sort by position,
select non-overlapping (longest match at ties)
## Development

@@ -179,8 +157,5 @@

- [@stll/regex-set](https://github.com/stella/regex-set) —
NAPI-RS bindings to Rust regex-automata
- [@stll/aho-corasick](https://github.com/stella/aho-corasick) —
NAPI-RS bindings to Rust aho-corasick
- [@stll/fuzzy-search](https://github.com/stella/fuzzy-search) —
NAPI-RS Levenshtein/Damerau-Levenshtein matcher
- [@stll/aho-corasick](https://github.com/stella/aho-corasick)
- [@stll/regex-set](https://github.com/stella/regex-set)
- [@stll/fuzzy-search](https://github.com/stella/fuzzy-search)

@@ -187,0 +162,0 @@ ## License

import type { PatternEntry } from "./types";
/**
* Normalized pattern with metadata for routing.
*/
export type ClassifiedPattern = {
/** Original index in the input array. */
originalIndex: number;
/** The regex-compatible pattern string. */
pattern: string | RegExp;
/** Optional name. */
name?: string;
/**
* Number of top-level alternation branches.
* Used to detect large alternations that should
* be isolated into their own RegexSet instance.
*/
alternationCount: number;
/**
* True if the pattern is a pure literal string
* (no regex metacharacters). These can be routed
* to Aho-Corasick for SIMD-accelerated matching.
*/
isLiteral: boolean;
/**
* Fuzzy distance if this is a fuzzy pattern.
* Routes to @stll/fuzzy-search.
*/
fuzzyDistance?: number | "auto";
/**
* Per-pattern AC options. When set, this literal
* is grouped with others that have the same
* options into a separate AC engine instance.
*/
acOptions?: {
caseInsensitive?: boolean;
wholeWords?: boolean;
};
};
/**
* Check if a string is a pure literal (no regex
* metacharacters). Pure literals are routed to
* Aho-Corasick instead of the regex DFA.
*/
export function isLiteralPattern(pattern: string): boolean {
// All standard regex metacharacters cause a
// pattern to be classified as regex (→ RegexSet).
// To force literal AC routing for patterns with
// dots/parens (e.g., "s.r.o.", "č.p."), use the
// explicit { literal: true } PatternEntry flag.
for (let i = 0; i < pattern.length; i++) {
const ch = pattern.charAt(i);
if (
ch === "\\" ||
ch === "." ||
ch === "^" ||
ch === "$" ||
ch === "*" ||
ch === "+" ||
ch === "?" ||
ch === "{" ||
ch === "}" ||
ch === "(" ||
ch === ")" ||
ch === "[" ||
ch === "]" ||
ch === "|"
) {
return false;
}
}
return pattern.length > 0;
}
/**
* Count the maximum alternation branches at any
* depth in a regex string. Used to detect patterns
* with large alternations (even nested inside
* groups) that should be isolated into their own
* RegexSet to prevent DFA state explosion.
*
* "a|b|c" → 3
* "(a|b)|c" → 2 (max of top=2, depth1=2)
* "(?:Ing\\.|Mgr\\.|Dr\\.)" → 3 (depth 1)
*/
export function countAlternations(pattern: string): number {
let depth = 0;
let inClass = false;
let i = 0;
// Track max alternation count seen at any depth.
// Each time we enter a group, start a fresh count.
// When we leave, update the global max.
let max = 1;
let currentCount = 1; // count for current group
const stack: number[] = []; // saved counts
while (i < pattern.length) {
const ch = pattern[i];
if (ch === "\\" && i + 1 < pattern.length) {
i += 2;
continue;
}
if (ch === "[") inClass = true;
if (ch === "]") inClass = false;
if (!inClass) {
if (ch === "(") {
stack.push(currentCount);
currentCount = 1;
depth++;
}
if (ch === ")") {
if (currentCount > max) max = currentCount;
currentCount = stack.pop() ?? 1;
depth--;
}
if (ch === "|") {
currentCount++;
}
}
i++;
}
// Check top-level count too
if (currentCount > max) max = currentCount;
return max;
}
/**
* Classify and normalize pattern entries.
*/
export function classifyPatterns(entries: PatternEntry[], allLiteral = false): ClassifiedPattern[] {
return entries.map((entry, i) => {
if (typeof entry === "string") {
return {
originalIndex: i,
pattern: entry,
alternationCount: allLiteral ? 0 : countAlternations(entry),
isLiteral: allLiteral || isLiteralPattern(entry),
};
}
if (entry instanceof RegExp) {
return {
originalIndex: i,
pattern: entry,
alternationCount: countAlternations(entry.source),
isLiteral: false, // RegExp is never literal
};
}
// Fuzzy pattern: has `distance` field
if ("distance" in entry) {
const result: ClassifiedPattern = {
originalIndex: i,
pattern: entry.pattern,
alternationCount: 0,
isLiteral: false,
fuzzyDistance: entry.distance,
};
if (entry.name !== undefined) result.name = entry.name;
return result;
}
// Explicit literal: skip metachar detection
if ("literal" in entry && entry.literal) {
const hasPerPatternOpts = "caseInsensitive" in entry || "wholeWords" in entry;
const result: ClassifiedPattern = {
originalIndex: i,
pattern: entry.pattern,
alternationCount: 0,
isLiteral: true,
};
if (entry.name !== undefined) result.name = entry.name;
if (hasPerPatternOpts) {
const opts: NonNullable<ClassifiedPattern["acOptions"]> = {};
if (entry.caseInsensitive !== undefined) opts.caseInsensitive = entry.caseInsensitive;
if (entry.wholeWords !== undefined) opts.wholeWords = entry.wholeWords;
result.acOptions = opts;
}
return result;
}
const pat = entry.pattern;
const source = pat instanceof RegExp ? pat.source : pat;
const result: ClassifiedPattern = {
originalIndex: i,
pattern: pat,
alternationCount: allLiteral ? 0 : countAlternations(source),
isLiteral: typeof pat === "string" && (allLiteral || isLiteralPattern(pat)),
};
if (entry.name !== undefined) result.name = entry.name;
return result;
});
}
/**
* Late-bound engine registry.
*
* Native and WASM entry points call initEngines()
* with their respective implementations before any
* TextSearch instance is created.
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
type Constructor = new (...args: any[]) => any;
type Engines = {
AhoCorasick: Constructor;
FuzzySearch: Constructor;
RegexSet: Constructor;
};
let engines: Engines | undefined;
export const initEngines = (e: Engines): void => {
engines = e;
};
export const getEngines = (): Engines => {
if (!engines) {
throw new Error(
"Engines not initialized. Import from " +
"@stll/text-search or @stll/text-search-wasm, " +
"not from internal modules.",
);
}
return engines;
};
/* Native entry point — loads @stll engines
* for Node.js/Bun and re-exports the public API. */
import { AhoCorasick } from "@stll/aho-corasick";
import { FuzzySearch } from "@stll/fuzzy-search";
import { RegexSet } from "@stll/regex-set";
import { initEngines } from "./engines";
initEngines({ AhoCorasick, FuzzySearch, RegexSet });
export { TextSearch } from "./text-search";
export type { Match, PatternEntry, TextSearchOptions } from "./types";
import type { Match } from "./types";
/**
* Merge matches from multiple engines, sort by
* position, and select non-overlapping (longest
* first at ties). Same algorithm as regex-set's
* internal select_non_overlapping.
*/
export function mergeAndSelect(matches: Match[]): Match[] {
if (matches.length <= 1) return matches;
// Sort: start ascending, longest first at ties
matches.sort((a, b) => {
if (a.start !== b.start) {
return a.start - b.start;
}
return b.end - b.start - (a.end - a.start);
});
// Greedily select non-overlapping
const selected: Match[] = [];
let lastEnd = 0;
for (const m of matches) {
if (m.start >= lastEnd) {
selected.push(m);
lastEnd = m.end;
}
}
return selected;
}
import type { ClassifiedPattern } from "./classify";
import { classifyPatterns } from "./classify";
import { getEngines } from "./engines";
import { mergeAndSelect } from "./merge";
import type { Match, PatternEntry, TextSearchOptions } from "./types";
/** Common engine interface for dispatch. */
type Engine = {
isMatch: (haystack: string) => boolean;
findIter: (haystack: string) => Match[];
};
/**
* An engine instance with pattern index mapping.
*/
type RegexSlot = {
type: "regex";
rs: Engine;
indexMap: number[];
nameMap: (string | undefined)[];
};
type AcSlot = {
type: "ac";
ac: Engine;
indexMap: number[];
nameMap: (string | undefined)[];
};
type FuzzySlot = {
type: "fuzzy";
fs: Engine;
indexMap: number[];
nameMap: (string | undefined)[];
};
type EngineSlot = RegexSlot | AcSlot | FuzzySlot;
/**
* Multi-engine text search orchestrator.
*
* Routes patterns to the optimal engine
* configuration:
* - Large alternation patterns get their own
* RegexSet instance (prevents DFA state explosion)
* - Normal patterns share a single RegexSet
* (single-pass multi-pattern DFA)
*
* Merges results from all engines into a unified
* non-overlapping Match[] sorted by position.
*/
export class TextSearch {
private engines: EngineSlot[] = [];
private patternCount: number;
private overlapAll: boolean;
/**
* True when there's exactly one engine and all
* patterns map to identity indices (0→0, 1→1, ...).
* Enables zero-overhead findIter: return raw engine
* output without remapping or object allocation.
*/
private zeroOverhead: boolean = false;
constructor(patterns: PatternEntry[], options?: TextSearchOptions) {
this.patternCount = patterns.length;
this.overlapAll = options?.overlapStrategy === "all";
const maxAlt = options?.maxAlternations ?? 50;
const classified = classifyPatterns(patterns, options?.allLiteral ?? false);
// Four buckets:
// 1. Fuzzy patterns → FuzzySearch (Levenshtein)
// 2. Pure literals → Aho-Corasick (SIMD)
// 3. Normal regex → shared RegexSet (DFA)
// 4. Large alternations → isolated RegexSet
const fuzzy: ClassifiedPattern[] = [];
const literals: ClassifiedPattern[] = [];
const shared: ClassifiedPattern[] = [];
const isolated: ClassifiedPattern[] = [];
for (const cp of classified) {
if (cp.fuzzyDistance !== undefined) {
fuzzy.push(cp);
} else if (cp.isLiteral) {
literals.push(cp);
} else if (cp.alternationCount > maxAlt) {
isolated.push(cp);
} else {
shared.push(cp);
}
}
const rsOptions = {
unicodeBoundaries: options?.unicodeBoundaries ?? true,
wholeWords: options?.wholeWords ?? false,
caseInsensitive: options?.caseInsensitive ?? false,
};
// Build fuzzy engine
if (fuzzy.length > 0) {
const fuzzyOpts: Parameters<typeof buildFuzzyEngine>[1] = {
unicodeBoundaries: rsOptions.unicodeBoundaries,
wholeWords: rsOptions.wholeWords,
};
if (options?.fuzzyMetric !== undefined) fuzzyOpts.metric = options.fuzzyMetric;
if (options?.normalizeDiacritics !== undefined)
fuzzyOpts.normalizeDiacritics = options.normalizeDiacritics;
if (options?.caseInsensitive !== undefined)
fuzzyOpts.caseInsensitive = options.caseInsensitive;
this.engines.push(buildFuzzyEngine(fuzzy, fuzzyOpts));
}
// Build AC engine(s) for pure literals.
// Group by per-pattern AC options so patterns
// with different caseInsensitive/wholeWords
// settings get separate AC instances.
if (literals.length > 0) {
const groups = new Map<string, ClassifiedPattern[]>();
for (const cp of literals) {
const ci = cp.acOptions?.caseInsensitive ?? rsOptions.caseInsensitive;
const ww = cp.acOptions?.wholeWords ?? rsOptions.wholeWords;
const key = `${ci ? 1 : 0}:${ww ? 1 : 0}`;
const group = groups.get(key);
if (group) {
group.push(cp);
} else {
groups.set(key, [cp]);
}
}
for (const [key, group] of groups) {
const [ci, ww] = key.split(":");
this.engines.push(
buildAcEngine(group, {
...rsOptions,
caseInsensitive: ci === "1",
wholeWords: ww === "1",
}),
);
}
}
// Adaptive regex grouping: try combining shared
// patterns, measure actual search time on a
// probe string. If combined is slower than
// individual, fall back to isolation.
if (shared.length > 1) {
const combined = buildRegexEngine(shared, rsOptions);
// Probe: 1KB of mixed content
const probe = (
"Hello World 123 test@example.com " +
"2025-01-01 +420 123 456 789 " +
"Ing. Jan Novák, s.r.o. Praha 1 "
).repeat(10);
const t0 = performance.now();
combined.rs.findIter(probe);
const combinedMs = performance.now() - t0;
// Individual baseline (sum of isolated scans)
let individualMs = 0;
const individualEngines: RegexSlot[] = [];
for (const cp of shared) {
const eng = buildRegexEngine([cp], rsOptions);
const t1 = performance.now();
eng.rs.findIter(probe);
individualMs += performance.now() - t1;
individualEngines.push(eng);
}
if (combinedMs > individualMs * 1.5) {
// Combined is >1.5x slower — isolate
for (const eng of individualEngines) {
this.engines.push(eng);
}
} else {
this.engines.push(combined);
}
} else if (shared.length === 1) {
this.engines.push(buildRegexEngine(shared, rsOptions));
}
for (const cp of isolated) {
this.engines.push(buildRegexEngine([cp], rsOptions));
}
// Zero-overhead fast path: when all patterns
// land in a single engine, the indexMap is
// identity (0→0, 1→1, ...) and no names need
// attaching. findIter can return raw engine
// output without any JS-side remapping.
if (this.engines.length === 1) {
const engine = this.engines[0];
if (engine === undefined) {
throw new Error("Expected single engine after length check");
}
const hasNames = engine.nameMap.some((n) => n !== undefined);
if (!hasNames) {
this.zeroOverhead = true;
}
}
}
/** Number of patterns. */
get length(): number {
return this.patternCount;
}
/** Returns true if any pattern matches. */
isMatch(haystack: string): boolean {
for (const engine of this.engines) {
if (engineIsMatch(engine, haystack)) {
return true;
}
}
return false;
}
/**
* Find matches in text.
*
* With `overlapStrategy: "longest"` (default):
* returns non-overlapping matches, longest wins.
*
* With `overlapStrategy: "all"`: returns all
* matches including overlaps, sorted by position.
*/
findIter(haystack: string): Match[] {
// Fast path: single engine, identity indexMap,
// no names → return raw engine output directly.
// Zero JS overhead: no remapping, no allocation.
if (this.zeroOverhead) {
const engine = this.engines[0];
if (engine === undefined) {
throw new Error("Zero-overhead path requires a single engine");
}
return engineFindIter(engine, haystack);
}
// Single engine but needs name remapping
if (this.engines.length === 1) {
const engine = this.engines[0];
if (engine === undefined) {
throw new Error("Expected single engine after length check");
}
return remapMatches(engineFindIter(engine, haystack), engine);
}
// Multi-engine: collect from all, remap in-place
const all: Match[] = [];
for (const engine of this.engines) {
const matches = engineFindIter(engine, haystack);
// In-place remapping avoids .map() allocation
for (const m of remapMatches(matches, engine)) {
all.push(m);
}
}
if (this.overlapAll) {
return all.sort((a, b) => a.start - b.start);
}
return mergeAndSelect(all);
}
/** Which pattern indices matched (not where). */
whichMatch(haystack: string): number[] {
const seen = new Set<number>();
for (const engine of this.engines) {
// AC doesn't have whichMatch — use findIter
const matches = engineFindIter(engine, haystack);
for (const m of matches) {
const idx = engine.indexMap[m.pattern];
if (idx === undefined) {
throw new Error(`Missing indexMap entry for pattern ${m.pattern}`);
}
seen.add(idx);
}
}
return [...seen];
}
/**
* Replace all non-overlapping matches.
* replacements[i] replaces pattern i.
*/
replaceAll(haystack: string, replacements: string[]): string {
if (replacements.length !== this.patternCount) {
throw new Error(
`Expected ${this.patternCount} ` + `replacements, got ${replacements.length}`,
);
}
// Always use non-overlapping matches for
// replacement, even if overlapStrategy is "all".
const all: Match[] = [];
for (const engine of this.engines) {
const matches = engineFindIter(engine, haystack);
for (const m of remapMatches(matches, engine)) {
all.push(m);
}
}
const matches = mergeAndSelect(all);
let result = "";
let last = 0;
for (const m of matches) {
result += haystack.slice(last, m.start);
const replacement = replacements[m.pattern];
if (replacement === undefined) {
throw new Error(`Missing replacement for pattern ${m.pattern}`);
}
result += replacement;
last = m.end;
}
result += haystack.slice(last);
return result;
}
}
/**
* Build a RegexSet engine from classified patterns.
*/
function buildRegexEngine(
patterns: ClassifiedPattern[],
options: {
unicodeBoundaries: boolean;
wholeWords: boolean;
caseInsensitive: boolean;
},
): RegexSlot {
const rsPatterns: (
| string
| RegExp
| {
pattern: string | RegExp;
name?: string;
}
)[] = [];
const indexMap: number[] = [];
const nameMap: (string | undefined)[] = [];
for (const cp of patterns) {
if (cp.name !== undefined) {
rsPatterns.push({
pattern: cp.pattern,
name: cp.name,
});
} else {
rsPatterns.push(cp.pattern);
}
indexMap.push(cp.originalIndex);
nameMap.push(cp.name);
}
const { RegexSet } = getEngines();
const rs = new RegexSet(rsPatterns, options);
return { type: "regex", rs, indexMap, nameMap };
}
/**
* Build an Aho-Corasick engine from literal patterns.
*/
function buildAcEngine(
patterns: ClassifiedPattern[],
options: {
unicodeBoundaries: boolean;
wholeWords: boolean;
caseInsensitive: boolean;
},
): AcSlot {
const literals: string[] = [];
const indexMap: number[] = [];
const nameMap: (string | undefined)[] = [];
for (const cp of patterns) {
literals.push(cp.pattern as string);
indexMap.push(cp.originalIndex);
nameMap.push(cp.name);
}
const { AhoCorasick } = getEngines();
const ac = new AhoCorasick(literals, {
wholeWords: options.wholeWords,
unicodeBoundaries: options.unicodeBoundaries,
caseInsensitive: options.caseInsensitive,
});
return { type: "ac", ac, indexMap, nameMap };
}
/**
* Build a FuzzySearch engine from fuzzy patterns.
*/
function buildFuzzyEngine(
patterns: ClassifiedPattern[],
options: {
unicodeBoundaries: boolean;
wholeWords: boolean;
metric?: "levenshtein" | "damerau-levenshtein";
normalizeDiacritics?: boolean;
caseInsensitive?: boolean;
},
): FuzzySlot {
const fsPatterns: {
pattern: string;
distance?: number | "auto";
name?: string;
}[] = [];
const indexMap: number[] = [];
const nameMap: (string | undefined)[] = [];
for (const cp of patterns) {
const entry: (typeof fsPatterns)[number] = {
pattern: cp.pattern as string,
};
if (cp.fuzzyDistance !== undefined) entry.distance = cp.fuzzyDistance;
if (cp.name !== undefined) entry.name = cp.name;
fsPatterns.push(entry);
indexMap.push(cp.originalIndex);
nameMap.push(cp.name);
}
const fsOptions: Record<string, unknown> = {
unicodeBoundaries: options.unicodeBoundaries,
wholeWords: options.wholeWords,
};
if (options.metric !== undefined) fsOptions.metric = options.metric;
if (options.normalizeDiacritics !== undefined)
fsOptions.normalizeDiacritics = options.normalizeDiacritics;
if (options.caseInsensitive !== undefined) fsOptions.caseInsensitive = options.caseInsensitive;
const { FuzzySearch } = getEngines();
const fs = new FuzzySearch(fsPatterns, fsOptions);
return { type: "fuzzy", fs, indexMap, nameMap };
}
/**
* Dispatch isMatch to the correct engine.
*/
function engineIsMatch(engine: EngineSlot, haystack: string): boolean {
switch (engine.type) {
case "ac":
return engine.ac.isMatch(haystack);
case "fuzzy":
return engine.fs.isMatch(haystack);
case "regex":
return engine.rs.isMatch(haystack);
}
}
/**
* Dispatch findIter to the correct engine.
*/
function engineFindIter(engine: EngineSlot, haystack: string): Match[] {
switch (engine.type) {
case "ac":
return engine.ac.findIter(haystack);
case "fuzzy":
return engine.fs.findIter(haystack);
case "regex":
return engine.rs.findIter(haystack);
}
}
/**
* Remap engine-local match indices to original
* input indices and add names.
*/
function remapMatches(matches: Match[], engine: EngineSlot): Match[] {
return matches.map((m) => {
const originalIdx = engine.indexMap[m.pattern];
if (originalIdx === undefined) {
throw new Error(`Missing indexMap entry for pattern ${m.pattern}`);
}
const name = engine.nameMap[m.pattern];
const result: Match = {
pattern: originalIdx,
start: m.start,
end: m.end,
text: m.text,
};
if (name !== undefined) {
result.name = name;
}
// Preserve edit distance from fuzzy matches
if ("distance" in m && m.distance !== undefined) {
result.distance = m.distance as number;
}
return result;
});
}
/**
* A single match result. Same shape as
* @stll/regex-set and @stll/aho-corasick.
*/
export type Match = {
/** Index of the pattern that matched. */
pattern: number;
/** Start UTF-16 code unit offset. */
start: number;
/** End offset (exclusive). */
end: number;
/** The matched text. */
text: string;
/** Pattern name (if provided). */
name?: string;
/** Edit distance (fuzzy matches only). */
distance?: number;
};
/** A pattern entry for TextSearch. */
export type PatternEntry =
| string
| RegExp
| {
pattern: string | RegExp;
name?: string;
}
| {
pattern: string;
name?: string;
/** Fuzzy matching distance. Routes to
* @stll/fuzzy-search instead of regex. */
distance: number | "auto";
}
| {
pattern: string;
name?: string;
/** Force literal matching via Aho-Corasick.
* Skips regex metacharacter detection so
* patterns like "č.p." or "s.r.o." are
* matched literally, not as regex. */
literal: true;
/** Per-pattern case-insensitive for AC.
* Overrides the global option for this
* pattern only. */
caseInsensitive?: boolean;
/** Per-pattern whole-word matching for AC. */
wholeWords?: boolean;
};
/** Options for TextSearch. */
export type TextSearchOptions = {
/**
* Use Unicode word boundaries.
* @default true
*/
unicodeBoundaries?: boolean;
/**
* Only match whole words.
* @default false
*/
wholeWords?: boolean;
/**
* Max alternation branches before auto-splitting
* into a separate engine instance. Prevents DFA
* state explosion when large-alternation patterns
* are combined with other patterns.
* @default 50
*/
maxAlternations?: number;
/**
* Fuzzy matching metric.
* @default "levenshtein"
*/
fuzzyMetric?: "levenshtein" | "damerau-levenshtein";
/**
* Normalize diacritics for fuzzy matching.
* @default false
*/
normalizeDiacritics?: boolean;
/**
* Case-insensitive matching for AC literals
* and fuzzy patterns.
* @default false
*/
caseInsensitive?: boolean;
/**
* How to handle overlapping matches from
* different engines or patterns.
*
* - "longest": keep longest non-overlapping match
* at each position (default).
* - "all": return all matches including overlaps.
* Useful when the caller applies its own dedup.
*
* @default "longest"
*/
overlapStrategy?: "longest" | "all";
/**
* Treat ALL string patterns as literals (route
* to AC, skip metacharacter detection). Useful
* for deny-list patterns where "s.r.o." means
* the literal string, not a regex with wildcards.
* @default false
*/
allLiteral?: boolean;
};
/* Browser/WASM entry point — loads @stll/-wasm
* engines and re-exports the public API. */
import { AhoCorasick } from "@stll/aho-corasick-wasm";
import { FuzzySearch } from "@stll/fuzzy-search-wasm";
import { RegexSet } from "@stll/regex-set-wasm";
import { initEngines } from "./engines";
initEngines({ AhoCorasick, FuzzySearch, RegexSet });
export { TextSearch } from "./text-search";
export type { Match, PatternEntry, TextSearchOptions } from "./types";