feat: publish Regex Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user