import type { RegexEngineAdapter } from "../../RegexEngineAdapter"; import type { CaptureResult, EcmaScriptExecutionFlags, RegexExecutionRequest, RegexExecutionResult, RegexMatchResult, RegexReplacementRequest, RegexReplacementResult, } from "../../../model/match"; import type { RegexEngineInfo } from "../../../model/flavour"; import type { RegexDiagnostic } from "../../../model/diagnostics"; import { DEFAULT_REGEX_LIMITS } from "../../request-limits"; const FLAG_ORDER = "dgimsuvy"; const CAPTURE_VALUE_PREVIEW_UTF16 = 64 * 1024; const TOTAL_VALUE_PREVIEW_UTF16 = 8 * 1024 * 1024; class ValuePreviewBudget { #remaining = TOTAL_VALUE_PREVIEW_UTF16; truncated = false; take(value: string): { readonly value: string; readonly truncated: boolean } { const maximum = Math.min(CAPTURE_VALUE_PREVIEW_UTF16, this.#remaining); const preview = value.slice(0, maximum); this.#remaining -= preview.length; const truncated = preview.length !== value.length; this.truncated ||= truncated; return { value: preview, truncated }; } } function hasIndicesSupport(): boolean { try { return new RegExp("", "d").hasIndices; } catch { return false; } } function runtimeIdentity(): string { return typeof navigator === "undefined" ? "Unknown ECMAScript runtime" : navigator.userAgent; } function engineInfo(): RegexEngineInfo { const identity = runtimeIdentity(); return { flavour: "ecmascript", adapterVersion: "0.1.0", engineName: "Native ECMAScript RegExp", engineVersion: identity, runtimeVersion: identity, offsetUnit: "utf16", capabilities: { compilation: true, matching: true, replacement: true, namedCaptures: true, captureHistory: false, actualTrace: false, benchmark: false, }, }; } function normalizedFlags( userFlags: readonly string[], scanAll: boolean, ): EcmaScriptExecutionFlags { const unique = new Set(userFlags); const user = FLAG_ORDER.split("") .filter((flag) => unique.has(flag)) .join(""); const addIndices = hasIndicesSupport() && !unique.has("d"); const addGlobal = scanAll && !unique.has("g") && !unique.has("y"); if (addIndices) unique.add("d"); if (addGlobal) unique.add("g"); return { userFlags: user, effectiveFlags: FLAG_ORDER.split("") .filter((flag) => unique.has(flag)) .join(""), internallyAddedIndicesFlag: addIndices, internallyAddedGlobalFlag: addGlobal, }; } function advanceStringIndex( subject: string, index: number, unicode: boolean, ): number { if (!unicode) return index + 1; const first = subject.charCodeAt(index); if (first < 0xd800 || first > 0xdbff || index + 1 >= subject.length) { return index + 1; } const second = subject.charCodeAt(index + 1); return second >= 0xdc00 && second <= 0xdfff ? index + 2 : index + 1; } function captureResult( match: RegExpExecArray, groupNumber: number, name: string | undefined, previews: ValuePreviewBudget, ): CaptureResult { const value = match[groupNumber]; const indices = match.indices?.[groupNumber]; if (value === undefined) { return { groupNumber, ...(name ? { groupName: name } : {}), status: "did-not-participate", }; } const preview = previews.take(value); if (!indices) { return { groupNumber, ...(name ? { groupName: name } : {}), value: preview.value, status: preview.truncated ? "truncated" : value.length === 0 ? "matched-empty" : "unavailable", }; } const [start, end] = indices; return { groupNumber, ...(name ? { groupName: name } : {}), value: preview.value, status: preview.truncated ? "truncated" : value.length === 0 ? "matched-empty" : "participated", range: { startUtf16: start, endUtf16: end }, nativeRange: { start, end, unit: "utf16" }, }; } function matchResult( match: RegExpExecArray, matchNumber: number, request: RegexExecutionRequest, previews: ValuePreviewBudget, ): RegexMatchResult { const indices = match.indices?.[0]; const start = indices?.[0] ?? match.index; const end = indices?.[1] ?? match.index + match[0].length; const names = new Map( request.captureMetadata.map((capture) => [capture.number, capture.name]), ); const preview = previews.take(match[0]); const captures: CaptureResult[] = []; for (let groupNumber = 1; groupNumber < match.length; groupNumber += 1) { captures.push( captureResult(match, groupNumber, names.get(groupNumber), previews), ); } return { matchNumber, value: preview.value, valueStatus: preview.truncated ? "truncated" : "complete", range: { startUtf16: start, endUtf16: end }, nativeRange: { start, end, unit: "utf16" }, captures, }; } function compileDiagnostic(error: unknown): RegexDiagnostic { return { id: "ecmascript-engine-compile-error", source: "execution-engine", severity: "error", code: "compile-error", message: error instanceof Error ? error.message : "The native ECMAScript engine rejected the pattern.", flavour: "ecmascript", provenance: "reported", }; } function emptyExecution( flags: EcmaScriptExecutionFlags, start: number, error: unknown, ): RegexExecutionResult { return { accepted: false, engine: engineInfo(), flags, matches: [], diagnostics: [compileDiagnostic(error)], elapsedMs: performance.now() - start, truncated: false, }; } function executeEcmaScriptInternal( request: RegexExecutionRequest, onMatch?: (match: RegExpExecArray) => void, ): RegexExecutionResult { const start = performance.now(); const flags = normalizedFlags(request.flags, request.scanAll); let expression: RegExp; try { expression = new RegExp(request.pattern, flags.effectiveFlags); } catch (error) { return emptyExecution(flags, start, error); } const iterate = flags.effectiveFlags.includes("g") || request.scanAll; const unicode = flags.effectiveFlags.includes("u") || flags.effectiveFlags.includes("v"); const matches: RegexMatchResult[] = []; const previews = new ValuePreviewBudget(); let captureRows = 0; let truncated = false; expression.lastIndex = 0; while (true) { const match = expression.exec(request.subject); if (!match) break; const rowsForMatch = Math.max(1, match.length - 1); if ( matches.length >= request.maximumMatches || captureRows + rowsForMatch > request.maximumCaptureRows ) { truncated = true; break; } matches.push(matchResult(match, matches.length + 1, request, previews)); onMatch?.(match); captureRows += rowsForMatch; if (!iterate) break; if (match[0].length === 0) { expression.lastIndex = advanceStringIndex( request.subject, expression.lastIndex, unicode, ); if (expression.lastIndex > request.subject.length) break; } } const diagnostics: RegexDiagnostic[] = []; if (!hasIndicesSupport()) { diagnostics.push({ id: "ecmascript-indices-unavailable", source: "execution-engine", severity: "warning", code: "capture-indices-unavailable", message: "This browser does not support the ECMAScript indices flag; exact capture ranges are unavailable.", flavour: "ecmascript", provenance: "reported", }); } if (truncated) { diagnostics.push({ id: "ecmascript-results-truncated", source: "execution-engine", severity: "warning", code: "result-limit", message: `Results reached the configured limit (${request.maximumMatches} matches or ${request.maximumCaptureRows} capture rows).`, flavour: "ecmascript", provenance: "derived", }); } if (previews.truncated) { diagnostics.push({ id: "ecmascript-value-previews-truncated", source: "execution-engine", severity: "warning", code: "result-value-preview-limit", message: `Displayed result values were clipped to ${CAPTURE_VALUE_PREVIEW_UTF16.toLocaleString()} UTF-16 units each and ${TOTAL_VALUE_PREVIEW_UTF16.toLocaleString()} units overall. Exact engine ranges are retained.`, flavour: "ecmascript", provenance: "derived", }); } return { accepted: true, engine: engineInfo(), flags, matches, diagnostics, elapsedMs: performance.now() - start, truncated, }; } export function executeEcmaScript( request: RegexExecutionRequest, ): RegexExecutionResult { return executeEcmaScriptInternal(request); } function utf8Prefix( value: string, maximumBytes: number, ): { readonly value: string; readonly bytes: number; readonly complete: boolean; } { let bytes = 0; let index = 0; while (index < value.length) { const first = value.charCodeAt(index); let width: number; let units = 1; if (first <= 0x7f) { width = 1; } else if (first <= 0x7ff) { width = 2; } else if ( first >= 0xd800 && first <= 0xdbff && index + 1 < value.length && value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff ) { width = 4; units = 2; } else { width = 3; } if (bytes + width > maximumBytes) break; bytes += width; index += units; } return { value: value.slice(0, index), bytes, complete: index === value.length, }; } class BoundedUtf8Output { readonly #maximumBytes: number; readonly #chunks: string[] = []; #bytes = 0; #truncated = false; constructor(maximumBytes: number) { this.#maximumBytes = Math.max(0, Math.floor(maximumBytes)); } append(value: string): boolean { if (this.#truncated || value.length === 0) return !this.#truncated; const prefix = utf8Prefix(value, this.#maximumBytes - this.#bytes); if (prefix.value.length > 0) this.#chunks.push(prefix.value); this.#bytes += prefix.bytes; this.#truncated = !prefix.complete; return !this.#truncated; } finish(): { readonly value: string; readonly bytes: number; readonly truncated: boolean; } { return { value: this.#chunks.join(""), bytes: this.#bytes, truncated: this.#truncated, }; } } function* substitutionChunks( replacement: string, match: RegExpExecArray, subject: string, ): Generator { const captureCount = match.length - 1; const matchEnd = match.index + match[0].length; let cursor = 0; while (cursor < replacement.length) { const dollar = replacement.indexOf("$", cursor); if (dollar < 0) { yield replacement.slice(cursor); return; } if (dollar > cursor) yield replacement.slice(cursor, dollar); const next = replacement[dollar + 1]; if (next === undefined) { yield "$"; return; } if (next === "$") { yield "$"; cursor = dollar + 2; continue; } if (next === "&") { yield match[0]; cursor = dollar + 2; continue; } if (next === "`") { yield subject.slice(0, match.index); cursor = dollar + 2; continue; } if (next === "'") { yield subject.slice(matchEnd); cursor = dollar + 2; continue; } if (next === "<" && match.groups !== undefined) { const close = replacement.indexOf(">", dollar + 2); if (close >= 0) { const name = replacement.slice(dollar + 2, close); yield match.groups[name] ?? ""; cursor = close + 1; continue; } } if (/[0-9]/u.test(next)) { const first = Number.parseInt(next, 10); const following = replacement[dollar + 2]; const hasSecondDigit = following !== undefined && /[0-9]/u.test(following); const second = hasSecondDigit ? Number.parseInt(`${next}${following}`, 10) : 0; if (hasSecondDigit && second > 0 && second <= captureCount) { yield match[second] ?? ""; cursor = dollar + 3; continue; } if (first > 0 && first <= captureCount) { yield match[first] ?? ""; cursor = dollar + 2; continue; } } yield "$"; cursor = dollar + 1; } } function appendOutputLimitDiagnostic( execution: RegexExecutionResult, truncated: boolean, maximumOutputBytes: number, ): RegexExecutionResult { if (!truncated) return execution; return { ...execution, diagnostics: [ ...execution.diagnostics, { id: "ecmascript-output-truncated", source: "execution-engine", severity: "warning", code: "replacement-output-limit", message: `Replacement preview was truncated at ${maximumOutputBytes} UTF-8 bytes.`, flavour: "ecmascript", provenance: "derived", }, ], }; } function boundedReplacement(request: RegexReplacementRequest): { readonly execution: RegexExecutionResult; readonly value: string; readonly bytes: number; readonly truncated: boolean; } { const output = new BoundedUtf8Output(request.maximumOutputBytes); let nextSourcePosition = 0; let outputStopped = false; const execution = executeEcmaScriptInternal(request, (match) => { if (outputStopped) return; const matchEnd = match.index + match[0].length; if ( !output.append(request.subject.slice(nextSourcePosition, match.index)) ) { outputStopped = true; return; } for (const chunk of substitutionChunks( request.replacement, match, request.subject, )) { if (!output.append(chunk)) { outputStopped = true; return; } } nextSourcePosition = matchEnd; }); if (!outputStopped) { output.append(request.subject.slice(nextSourcePosition)); } return { execution, ...output.finish() }; } export function replaceEcmaScript( request: RegexReplacementRequest, ): RegexReplacementResult { if ( request.replacement.length > DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16 ) { throw new RangeError( `Replacement template exceeds the ${DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16.toLocaleString()} UTF-16 unit limit.`, ); } const output = boundedReplacement(request); const execution = appendOutputLimitDiagnostic( output.execution, output.truncated, request.maximumOutputBytes, ); return { execution, output: output.value, outputBytes: output.bytes, outputTruncated: output.truncated, truncated: output.truncated || execution.truncated, }; } export class EcmaScriptEngineAdapter implements RegexEngineAdapter { readonly flavour = "ecmascript" as const; async load(): Promise { return engineInfo(); } async execute(request: RegexExecutionRequest): Promise { return executeEcmaScript(request); } async replace( request: RegexReplacementRequest, ): Promise { return replaceEcmaScript(request); } terminate(): void { // Lifecycle termination is performed by killing the containing worker. } }