@stll/text-search
Advanced tools
+18
-12
| { | ||
| "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 |
-200
| 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; | ||
| }; |
-13
| /* 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"; |
-32
| 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; | ||
| }); | ||
| } |
-114
| /** | ||
| * 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; | ||
| }; |
-13
| /* 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"; |
55754
-32.09%7
16.67%6
-53.85%449
-64.34%160
-13.51%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated