feat: publish Regex Tools 0.1.0

This commit is contained in:
2026-07-24 18:04:21 +02:00
commit 873b9b218d
136 changed files with 24212 additions and 0 deletions

View File

@@ -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.
}
}