Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

368 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { RE2JS } from 're2js'
/**
* Linear-time matching for caller-supplied regex patterns.
*
* The built-in engine backtracks, so a pattern chosen by a caller can take
* exponential time on input the same caller controls — `a*a*b` against a 10k
* run of `a` measured 213s on JSC and 132s on V8. Anywhere that runs on a
* shared event loop, that is a denial of service against every other tenant.
*
* Screening the pattern instead does not work. `safe-regex2` documents itself
* as having false negatives and passes `(a|a)*b`; rejecting quantified groups
* on top of it still passes `a*a*b`. Every syntactic rule only excludes the
* shapes someone thought to enumerate, so the engine has to change instead.
*
* RE2 has no backtracking and matches in time linear in the input. Two costs
* follow, and this module works to keep both off the caller:
*
* - **Syntax.** RE2 implements neither lookaround nor backreferences, and
* spells some escapes differently. `translateToRe2` bridges the mechanical
* differences and `compileLookaroundSplit` recovers the lookaround *split*
* idioms; genuine gaps return `null` so each caller decides how to degrade.
* - **Throughput.** Measured 0.5270ms per megabyte depending on the pattern,
* against roughly 0.04ms/MB for the built-in engine. Callers matching a
* pattern with no metacharacter should take `literalRegex` instead.
*/
export interface LinearRegexOptions {
ignoreCase?: boolean
}
export interface LinearRegex {
/** Whether the pattern matches anywhere in `text`. */
test(text: string): boolean
/** Index of the first match in `text`, or -1. */
find(text: string): number
/**
* Split `text` around every match.
*
* Follows `String.prototype.split` except that a trailing empty segment is
* omitted — RE2 drops it, and every caller here discards empties anyway.
*/
split(text: string): string[]
}
const METACHARACTERS = /[.*+?^${}()|[\]\\]/
/** Escape every regex metacharacter so `input` matches only itself. */
function escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** True when `pattern` has no metacharacter, so both engines behave identically. */
export function isPlainText(pattern: string): boolean {
return !METACHARACTERS.test(pattern)
}
/**
* ECMAScript's `\s` as an RE2 character-class body.
*
* RE2's `\s` is ASCII-only (`[\t\n\f\r ]`), while ECMAScript's also covers
* `\v`, NBSP, U+1680, U+2000U+200A, U+2028, U+2029, U+202F, U+205F, U+3000
* and U+FEFF. Left untranslated, a `\s`-based document splitter silently stops
* splitting on the non-breaking spaces that PDF, DOCX and HTML extraction put
* everywhere — producing different chunks, and so different embeddings, for a
* document that has not changed. Verified equivalent to ECMAScript `\s` across
* a full sweep of the BMP.
*/
const JS_WHITESPACE_BODY = '\\t\\n\\v\\f\\r\\x{2028}\\x{2029}\\x{feff}\\p{Zs}'
/**
* Rewrite the mechanical ECMAScript-vs-RE2 spelling differences.
*
* Two substitutions, both verified equivalent:
* - `\s`/`\S` → the Unicode set above, so whitespace splitting is unchanged.
* - `\uXXXX` → `\x{XXXX}`, RE2's spelling. Untranslated, RE2 rejects the
* pattern outright, which turns the ordinary way of writing a non-ASCII
* delimiter (`•`) into a hard failure.
*
* Scans with escape and character-class state so `\\s` (a literal backslash
* followed by `s`) is left alone and `[\s\d]` splices the set body rather than
* nesting a class. `\S` *inside* a class is left as RE2's ASCII form: negation
* within a set cannot be spliced, and `[^\S\n]` is rare enough not to justify
* a full class parser.
*/
function translateToRe2(pattern: string): string {
let out = ''
let inClass = false
for (let i = 0; i < pattern.length; i++) {
const char = pattern[i]
if (char === '\\') {
const next = pattern[i + 1]
if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(pattern.slice(i + 2, i + 6))) {
out += `\\x{${pattern.slice(i + 2, i + 6)}}`
i += 5
continue
}
if (next === 's') {
out += inClass ? JS_WHITESPACE_BODY : `[${JS_WHITESPACE_BODY}]`
i += 1
continue
}
if (next === 'S' && !inClass) {
out += `[^${JS_WHITESPACE_BODY}]`
i += 1
continue
}
out += next === undefined ? char : char + next
i += 1
continue
}
if (inClass) {
if (char === ']') inClass = false
} else if (char === '[') {
inClass = true
}
out += char
}
return out
}
/** Index of the `)` closing the group opened at `open`, or -1. */
function closingParen(pattern: string, open: number): number {
let depth = 0
let inClass = false
for (let i = open; i < pattern.length; i++) {
const char = pattern[i]
if (char === '\\') {
i += 1
continue
}
if (inClass) {
if (char === ']') inClass = false
continue
}
if (char === '[') inClass = true
else if (char === '(') depth += 1
else if (char === ')') {
depth -= 1
if (depth === 0) return i
}
}
return -1
}
interface SplitShape {
behind: string
middle: string
ahead: string
}
/**
* True when `pattern` has a `|` outside any group or character class.
*
* A split shape cannot be decomposed when its middle alternates at the top
* level: `(?<=\.)\s+|\n\n` means `((?<=\.)\s+)|(\n\n)`, so the assertion binds
* to the first branch only. Rebuilding it as `(?:\.)(\s+|\n\n)` would require
* the period before *either* branch — a silently different pattern. No grouping
* fixes that, so such patterns are rejected rather than reshaped.
*/
function hasTopLevelAlternation(pattern: string): boolean {
let depth = 0
let inClass = false
for (let i = 0; i < pattern.length; i++) {
const char = pattern[i]
if (char === '\\') {
i += 1
continue
}
if (inClass) {
if (char === ']') inClass = false
continue
}
if (char === '[') inClass = true
else if (char === '(') depth += 1
else if (char === ')') depth -= 1
else if (char === '|' && depth === 0) return true
}
return false
}
/**
* Decompose a split pattern into `(?<=behind) middle (?=ahead)`, with either
* assertion optional. Returns `null` when neither is present (the caller should
* compile normally) or when the shape is anything else.
*/
function parseSplitShape(pattern: string): SplitShape | null {
let rest = pattern
let behind = ''
if (rest.startsWith('(?<=')) {
const close = closingParen(rest, 0)
if (close === -1) return null
behind = rest.slice(4, close)
rest = rest.slice(close + 1)
}
let ahead = ''
let depth = 0
let inClass = false
for (let i = 0; i < rest.length; i++) {
const char = rest[i]
if (char === '\\') {
i += 1
continue
}
if (inClass) {
if (char === ']') inClass = false
continue
}
if (char === '[') {
inClass = true
continue
}
if (char === ')') depth -= 1
if (char !== '(') continue
depth += 1
if (depth !== 1 || !rest.startsWith('(?=', i)) continue
const close = closingParen(rest, i)
if (close !== rest.length - 1) continue
ahead = rest.slice(i + 3, close)
rest = rest.slice(0, i)
break
}
if (!behind && !ahead) return null
if (hasTopLevelAlternation(rest)) return null
return { behind, middle: rest, ahead }
}
/**
* Compile a lookaround *split* pattern onto RE2, which has no lookaround.
*
* Splitting never needs the assertion itself — only the span the delimiter
* consumes. `(?<=X)Y(?=Z)` becomes `(?:X)(Y)(?:Z)`, and the span of group 1 is
* exactly what `String.prototype.split` would remove. That covers every
* combination in one rule: `(?=Z)` alone splits before a delimiter and keeps
* it, `(?<=X)` alone splits after one, and `(?<=[.!?])\s+(?=[A-Z])` — the
* sentence splitter — consumes the whitespace between them.
*
* Returns `null` for negative lookaround, backreferences, a middle that
* alternates at the top level, or any body RE2 cannot represent.
*
* Two details make the reconstruction faithful rather than approximate. The
* middle is captured by *name*, so a capturing group inside `behind` cannot
* shift the index out from under it. And each iteration resumes the search at
* the end of the previous delimiter rather than at the end of the whole match,
* so the assertion text is not consumed — without that, every boundary whose
* lookahead text doubles as the next boundary's lookbehind would be swallowed
* (`(?<=\w)\s+(?=[A-Z])` over `A B C D` would split only half the gaps).
*
* Known divergence: a delimiter that self-overlaps — `(?<=aa)` over `aaaaa`,
* or a single-character middle whose matches abut as in `(?<=\w).(?=\w)` —
* yields fewer boundaries than the built-in engine. Matching every position
* would mean restarting the scan one character past each match start, which is
* quadratic on a multi-megabyte document and forfeits the linear guarantee
* this module exists for. Delimiters that do not self-overlap — punctuation,
* tags, whitespace between tokens — are exact, and
* `linear-regex.differential.test.ts` pins that.
*/
export function compileLookaroundSplit(
pattern: string,
options: LinearRegexOptions = {}
): LinearRegex | null {
const shape = parseSplitShape(pattern)
if (!shape) return null
const behind = shape.behind ? `(?:${shape.behind})` : ''
const ahead = shape.ahead ? `(?:${shape.ahead})` : ''
const source = translateToRe2(`${behind}(?P<mid>${shape.middle})${ahead}`)
let compiled: ReturnType<typeof RE2JS.compile>
try {
compiled = RE2JS.compile(source, options.ignoreCase ? RE2JS.CASE_INSENSITIVE : 0)
} catch {
return null
}
/** Span of the delimiter itself — what `String.prototype.split` removes. */
const delimiterAt = (text: string, from: number): { start: number; end: number } | null => {
const matcher = compiled.matcher(text)
if (!matcher.find(from)) return null
return { start: matcher.start('mid'), end: matcher.end('mid') }
}
return {
test: (text) => compiled.matcher(text).find(),
find: (text) => delimiterAt(text, 0)?.start ?? -1,
split: (text) => {
const segments: string[] = []
let cursor = 0
let searchFrom = 0
while (searchFrom <= text.length) {
const span = delimiterAt(text, searchFrom)
if (!span) break
// Always advance, so a zero-width delimiter cannot spin.
searchFrom = span.end > span.start ? span.end : span.start + 1
// Skip a boundary behind the cursor, or one that would only emit an
// empty leading or trailing segment.
if (span.start < cursor || span.start >= text.length) continue
if (span.start === cursor && span.end === cursor) continue
segments.push(text.slice(cursor, span.start))
cursor = span.end
}
segments.push(text.slice(cursor))
return segments
},
}
}
/**
* Match `pattern` as an escaped literal on the built-in engine.
*
* Safe because an escaped literal cannot backtrack, and far quicker than RE2 —
* worth taking whenever the pattern has no metacharacter to interpret, or as a
* degradation path when RE2 rejects the syntax.
*/
export function literalRegex(pattern: string, options: LinearRegexOptions = {}): LinearRegex {
const source = escapeRegExp(pattern)
const caseFlag = options.ignoreCase ? 'i' : ''
// Non-global, so `exec`/`test` keep no `lastIndex` between calls and one
// instance is reusable — callers scan line-by-line, and recompiling per line
// would dominate the cost.
const scanner = new RegExp(source, caseFlag)
return {
test: (text) => scanner.test(text),
find: (text) => {
const match = scanner.exec(text)
return match ? match.index : -1
},
// Built lazily: no caller splits on a literal, so the second compile is
// only paid if one ever does.
split: (text) => text.split(new RegExp(source, `g${caseFlag}`)),
}
}
/**
* Compile `pattern` into a matcher that cannot backtrack.
*
* Returns `null` when RE2 cannot represent the pattern — invalid syntax, or
* constructs RE2 does not implement (lookaround, backreferences, repeat counts
* above 1000). Callers must handle `null` explicitly rather than silently
* falling back to the built-in engine, which would reintroduce the exposure
* this exists to remove.
*/
export function compileLinearRegex(
pattern: string,
options: LinearRegexOptions = {}
): LinearRegex | null {
try {
const compiled = RE2JS.compile(
translateToRe2(pattern),
options.ignoreCase ? RE2JS.CASE_INSENSITIVE : 0
)
return {
test: (text) => compiled.matcher(text).find(),
find: (text) => {
const matcher = compiled.matcher(text)
return matcher.find() ? matcher.start() : -1
},
split: (text) => compiled.split(text),
}
} catch {
return null
}
}