557 lines
15 KiB
TypeScript
557 lines
15 KiB
TypeScript
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<string> {
|
|
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<RegexEngineInfo> {
|
|
return engineInfo();
|
|
}
|
|
|
|
async execute(request: RegexExecutionRequest): Promise<RegexExecutionResult> {
|
|
return executeEcmaScript(request);
|
|
}
|
|
|
|
async replace(
|
|
request: RegexReplacementRequest,
|
|
): Promise<RegexReplacementResult> {
|
|
return replaceEcmaScript(request);
|
|
}
|
|
|
|
terminate(): void {
|
|
// Lifecycle termination is performed by killing the containing worker.
|
|
}
|
|
}
|