feat: add multi-engine regex flavour support

This commit is contained in:
2026-07-27 11:43:51 +02:00
parent 7079cde15f
commit 7f3a91ad37
340 changed files with 643286 additions and 483 deletions

View File

@@ -0,0 +1,304 @@
/// <reference types="node" />
import { expect, test, type Page } from "@playwright/test";
import { readdir } from "node:fs/promises";
import path from "node:path";
interface FlavourCase {
readonly id:
"php" | "perl" | "ruby" | "cpp" | "go" | "rust" | "dotnet" | "scala";
readonly title: string;
readonly pattern: string;
readonly subject: string;
readonly expectedMatch: string;
readonly utf16Range: string;
readonly nativeRange: string;
readonly replacement: string;
readonly expectedOutput: string;
readonly capabilities: readonly string[];
readonly timeoutMs: number;
readonly expectedMatches?: number;
}
const FLAVOURS: readonly FlavourCase[] = [
{
id: "php",
title: "PHP preg / PCRE2",
pattern: "(?'word'[a-z]++)",
subject: "😀 abc",
expectedMatch: "abc",
utf16Range: "3…6",
nativeRange: "5…8 utf8-byte",
replacement: "<$1>",
expectedOutput: "😀 <abc>",
capabilities: [
"PHP preg (PCRE2)",
"PHP 8.5.8 preg / PCRE2 10.44",
"@php-wasm/web-8-5 3.1.46",
],
timeoutMs: 60_000,
},
{
id: "perl",
title: "Perl 5.28.1 via legacy WebPerl",
pattern: "(?<word>😀+)",
subject: "x😀😀 y😀",
expectedMatch: "😀😀",
utf16Range: "1…5",
nativeRange: "1…3 code-point",
replacement: "${word}-$1-$$",
expectedOutput: "x😀😀-😀😀-$ y😀-😀-$",
capabilities: [
"Perl regular expressions (legacy)",
"5.28.1",
"WebPerl 0.09-beta",
"legacy beta",
"classic worker",
],
timeoutMs: 60_000,
expectedMatches: 2,
},
{
id: "ruby",
title: "CRuby Regexp",
pattern: "(?<word>[A-Z&&[^AEIOU]]+)",
subject: "😀 BCD",
expectedMatch: "BCD",
utf16Range: "3…6",
nativeRange: "2…5 code-point",
replacement: "<\\k<word>>|\\&|\\`|\\'",
expectedOutput: "😀 <BCD>|BCD|😀 |",
capabilities: [
"CRuby Regexp via ruby.wasm",
"CRuby 4.0.0 Regexp",
"ruby.wasm 2.9.3-2.9.4",
"wasm32-wasi",
],
timeoutMs: 60_000,
},
{
id: "cpp",
title: "C++ libc++ std::regex",
pattern: "(\\w+)",
subject: "😀 one",
expectedMatch: "one",
utf16Range: "3…6",
nativeRange: "2…5 code-point",
replacement: "<$1>",
expectedOutput: "😀 <one>",
capabilities: [
"C++ std::wregex (Emscripten libc++)",
"Emscripten 6.0.4 libc++ std::wregex",
"32-bit wchar_t code-point bridge",
],
timeoutMs: 30_000,
},
{
id: "go",
title: "Go standard-library regexp",
pattern: "(?P<word>\\p{Greek}+)",
subject: "😀 λλ",
expectedMatch: "λλ",
utf16Range: "3…5",
nativeRange: "5…9 utf8-byte",
replacement: "<${word}>",
expectedOutput: "😀 <λλ>",
capabilities: ["Go standard-library regexp", "1.26.5", "go1.26.5 js/wasm"],
timeoutMs: 30_000,
},
{
id: "rust",
title: "Rust regex crate",
pattern: "(?P<word>\\p{Greek}+)",
subject: "😀 λλ",
expectedMatch: "λλ",
utf16Range: "3…5",
nativeRange: "5…9 utf8-byte",
replacement: "<${word}>",
expectedOutput: "😀 <λλ>",
capabilities: [
"Rust regex crate",
"1.13.1",
"rustc 1.97.1 wasm32-unknown-unknown",
],
timeoutMs: 30_000,
},
{
id: "dotnet",
title: ".NET System.Text.RegularExpressions",
pattern: "(?<word>\\p{L}+)",
subject: "😀 café",
expectedMatch: "café",
utf16Range: "3…7",
nativeRange: "3…7 utf16",
replacement: "<${word}>",
expectedOutput: "😀 <café>",
capabilities: [
"System.Text.RegularExpressions",
"10.0.10",
".NET browser WebAssembly",
"CultureInvariant",
],
timeoutMs: 60_000,
},
{
id: "scala",
title: "Scala/JVM compatibility profile",
pattern: "(?<word>[a-z]++)",
subject: "😀 abc",
expectedMatch: "abc",
utf16Range: "3…6",
nativeRange: "3…6 utf16",
replacement: "<$1>",
expectedOutput: "😀 <abc>",
capabilities: [
"Scala/JVM regex via TeaVM java.util.regex",
"TeaVM 0.15.0 compatibility profile",
"Scala/JVM pattern and replacement semantics",
"no Scala runtime",
],
timeoutMs: 30_000,
},
];
async function setEditor(page: Page, testId: string, value: string) {
await page.getByTestId(testId).locator(".cm-content").fill(value);
}
async function waitForReady(page: Page, timeout: number) {
await expect(page.locator(".run-status")).toHaveClass(/status-ready/u, {
timeout,
});
}
function observeRuntimeErrors(page: Page): string[] {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(`page: ${error.message}`));
page.on("console", (message) => {
const text = message.text();
if (
message.type() === "error" ||
/content security policy|\bCSP\b|refused to (?:execute|load)/iu.test(text)
) {
errors.push(`console ${message.type()}: ${text}`);
}
});
return errors;
}
for (const flavour of FLAVOURS) {
test(`${flavour.title} executes native syntax and replacement through its production worker`, async ({
page,
}) => {
test.setTimeout(flavour.timeoutMs + 60_000);
const runtimeErrors = observeRuntimeErrors(page);
const response = await page.goto("/deep/nested/regex/");
const csp = response?.headers()["content-security-policy"] ?? "";
expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'");
expect(csp).not.toContain("'unsafe-eval'");
await waitForReady(page, 15_000);
await page.getByLabel("Live").uncheck();
await page
.getByRole("combobox", { name: "Flavour" })
.selectOption(flavour.id);
await setEditor(page, "editor-regular-expression-pattern", flavour.pattern);
await setEditor(page, "editor-test-text", flavour.subject);
const run = page.getByRole("button", { name: "Run", exact: true });
await expect(run).toBeEnabled({ timeout: 15_000 });
await run.click();
await waitForReady(page, flavour.timeoutMs);
await expect(page.locator(".run-status")).toContainText(
`native offsets ${flavour.nativeRange.split(" ").at(-1)}`,
);
const matches = page.locator('.extraction-row[aria-level="1"]');
if (flavour.expectedMatches !== undefined) {
await expect(matches).toHaveCount(flavour.expectedMatches);
}
const match = matches.first();
await expect(match).toContainText(flavour.expectedMatch);
await expect(match).toContainText(`UTF-16 range: ${flavour.utf16Range}`);
await expect(match).toContainText(`native range: ${flavour.nativeRange}`);
await page
.getByRole("button", { name: "Capabilities", exact: true })
.click();
const capabilities = page.getByRole("dialog", { name: "Capabilities" });
for (const identity of flavour.capabilities) {
await expect(capabilities).toContainText(identity);
}
await capabilities
.getByRole("button", { name: "Close capabilities" })
.click();
await page.getByRole("button", { name: "Replace", exact: true }).click();
await setEditor(page, "editor-replacement-template", flavour.replacement);
await expect(run).toBeEnabled({ timeout: 15_000 });
await run.click();
await waitForReady(page, flavour.timeoutMs);
await expect(
page.getByTestId("editor-replacement-output").locator(".cm-content"),
).toHaveText(flavour.expectedOutput);
expect(runtimeErrors).toEqual([]);
});
}
test("serves every additional engine module and WebAssembly dependency with production MIME types", async ({
request,
}) => {
const fixedAssets = [
["engines/php/php-loader.mjs", "text/javascript"],
["engines/php/php.wasm", "application/wasm"],
["engines/perl/perl-runtime.worker.js", "text/javascript"],
["engines/perl/emperl.js", "text/javascript"],
["engines/perl/emperl.wasm", "application/wasm"],
["engines/ruby/ruby.wasm", "application/wasm"],
["engines/cpp/cpp-regex.mjs", "text/javascript"],
["engines/cpp/cpp-regex.wasm", "application/wasm"],
["engines/go/wasm_exec.mjs", "text/javascript"],
["engines/go/go-regex.wasm", "application/wasm"],
["engines/rust/rust-regex.mjs", "text/javascript"],
["engines/rust/rust-regex_bg.wasm", "application/wasm"],
["engines/dotnet/_framework/dotnet.js", "text/javascript"],
["engines/java/java-regex.mjs", "text/javascript"],
] as const;
for (const [asset, expectedMime] of fixedAssets) {
const response = await request.get(`./${asset}`);
expect(response.ok(), asset).toBe(true);
expect(response.headers()["content-type"], asset).toContain(expectedMime);
if (expectedMime === "application/wasm") {
expect((await response.body()).subarray(0, 4), asset).toEqual(
Buffer.from([0x00, 0x61, 0x73, 0x6d]),
);
}
}
const dotnetFramework = path.resolve(
"public",
"engines",
"dotnet",
"_framework",
);
const hashedDotnetDependencies = (await readdir(dotnetFramework))
.filter((name) => /\.[a-z0-9]{10}\.(?:js|wasm)$/u.test(name))
.sort();
expect(hashedDotnetDependencies.length).toBeGreaterThan(2);
expect(hashedDotnetDependencies.some((name) => name.endsWith(".js"))).toBe(
true,
);
expect(hashedDotnetDependencies.some((name) => name.endsWith(".wasm"))).toBe(
true,
);
for (const asset of hashedDotnetDependencies) {
const response = await request.get(`./engines/dotnet/_framework/${asset}`);
expect(response.ok(), asset).toBe(true);
expect(response.headers()["content-type"], asset).toContain(
asset.endsWith(".wasm") ? "application/wasm" : "text/javascript",
);
}
});

View File

@@ -0,0 +1,172 @@
// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import {
CppEngineAdapter,
CPP_ENGINE_VERSION,
CPP_FLAVOUR_VERSION,
} from "../../src/regex/execution/adapters/cpp/CppEngineAdapter";
import type { CppRegexModuleFactory } from "../../src/regex/execution/adapters/cpp/cpp-module";
import type {
RegexExecutionRequest,
RegexReplacementRequest,
} from "../../src/regex/model/match";
const pack = path.resolve("public/engines/cpp");
let adapter: CppEngineAdapter;
function request(
pattern: string,
subject: string,
overrides: Partial<RegexExecutionRequest> = {},
): RegexExecutionRequest {
return {
flavour: "cpp",
flavourVersion: CPP_FLAVOUR_VERSION,
pattern,
flags: ["g"],
options: { grammar: "ECMAScript" },
subject,
captureMetadata: [],
scanAll: false,
maximumMatches: 100,
maximumCaptureRows: 1_000,
...overrides,
};
}
function replacement(
pattern: string,
subject: string,
replacementText: string,
overrides: Partial<RegexReplacementRequest> = {},
): RegexReplacementRequest {
return {
...request(pattern, subject),
replacement: replacementText,
maximumOutputBytes: 1_024,
...overrides,
};
}
beforeAll(async () => {
const imported = (await import(
`${pathToFileURL(path.join(pack, "cpp-regex.mjs")).href}?conformance`
)) as { default: CppRegexModuleFactory };
const module = await imported.default({
locateFile: (name) => path.join(pack, name),
print: () => undefined,
printErr: () => undefined,
});
adapter = new CppEngineAdapter(module, "Node.js conformance");
await adapter.load();
});
describe("C++ Emscripten libc++ std::wregex conformance", () => {
it("loads the exact libc++ and compiler identity", async () => {
await expect(adapter.load()).resolves.toEqual(
expect.objectContaining({
flavour: "cpp",
engineName: "C++ std::wregex (Emscripten libc++)",
engineVersion: CPP_ENGINE_VERSION,
runtimeVersion: expect.stringContaining(
"32-bit wchar_t code-point bridge",
),
offsetUnit: "code-point",
}),
);
});
it("returns code-point-native ranges without losing editor UTF-16 offsets", async () => {
const result = await adapter.execute(request("(\\w+)", "😀 one two"));
expect(result.accepted).toBe(true);
expect(
result.matches.map(({ nativeRange, range, value }) => ({
nativeRange,
range,
value,
})),
).toEqual([
{
nativeRange: { start: 2, end: 5, unit: "code-point" },
range: { startUtf16: 3, endUtf16: 6 },
value: "one",
},
{
nativeRange: { start: 6, end: 9, unit: "code-point" },
range: { startUtf16: 7, endUtf16: 10 },
value: "two",
},
]);
});
it("uses native std::regex replacement and grammar selection", async () => {
const replaced = await adapter.replace(
replacement("(\\w+)", "one two", "<$1>"),
);
expect(replaced.execution.accepted).toBe(true);
expect(replaced.output).toBe("<one> <two>");
const basic = await adapter.execute(
request("\\(ab\\)", "xxabyy", {
options: { grammar: "basic" },
}),
);
expect(basic.matches[0]?.value).toBe("ab");
});
it("bounds capture amplification and preserves the untouched partial suffix", async () => {
const bounded = await adapter.replace(
replacement("(a+)", "a".repeat(256 * 1024), "$1".repeat(4_096), {
maximumOutputBytes: 5,
}),
);
expect(bounded.execution.accepted).toBe(true);
expect(bounded.output).toBe("aaaaa");
expect(bounded.outputBytes).toBe(5);
expect(bounded.outputTruncated).toBe(true);
const partial = await adapter.replace(
replacement("a", "aaa", "x", { maximumMatches: 1 }),
);
expect(partial.execution.matches).toHaveLength(1);
expect(partial.execution.truncated).toBe(true);
expect(partial.output).toBe("xaa");
expect(partial.outputTruncated).toBe(false);
});
it("reports syntax that libc++ std::regex does not accept", async () => {
const result = await adapter.execute(request("(?<name>a)", "a"));
expect(result.accepted).toBe(false);
expect(result.diagnostics[0]).toEqual(
expect.objectContaining({
source: "execution-engine",
severity: "error",
}),
);
});
it("ships a fixed bridge module without dynamic source evaluation", async () => {
const module = await readFile(path.join(pack, "cpp-regex.mjs"), "utf8");
expect(module).not.toContain("new Function");
expect(module).not.toMatch(/\beval\s*\(/u);
const metadata = JSON.parse(
await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
) as {
bridge: { dynamicExecutionDisabled: boolean };
toolchain: { version: string; commitSha1: string };
};
expect(metadata.bridge.dynamicExecutionDisabled).toBe(true);
expect(metadata.toolchain).toEqual(
expect.objectContaining({
version: "6.0.4",
commitSha1: "fe5be6afdff43ad58860d821fcc8572a23f92d19",
}),
);
});
});

View File

@@ -0,0 +1,260 @@
// @vitest-environment node
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
GO_ENGINE_VERSION,
GO_FLAVOUR_VERSION,
GoEngineAdapter,
} from "../../src/regex/execution/adapters/go/GoEngineAdapter";
import { GoWasmBridge } from "../../src/regex/execution/adapters/go/GoWasmBridge";
import type {
RegexExecutionRequest,
RegexReplacementRequest,
} from "../../src/regex/model/match";
const pack = path.resolve("public/engines/go");
let adapter: GoEngineAdapter;
function request(
pattern: string,
subject: string,
overrides: Partial<RegexExecutionRequest> = {},
): RegexExecutionRequest {
return {
flavour: "go",
flavourVersion: GO_FLAVOUR_VERSION,
pattern,
flags: ["g"],
subject,
captureMetadata: [],
scanAll: false,
maximumMatches: 100,
maximumCaptureRows: 1_000,
...overrides,
};
}
function replacement(
pattern: string,
subject: string,
replacementText: string,
overrides: Partial<RegexReplacementRequest> = {},
): RegexReplacementRequest {
return {
...request(pattern, subject),
replacement: replacementText,
maximumOutputBytes: 1_024,
...overrides,
};
}
beforeAll(async () => {
const wasm = await WebAssembly.compile(
await readFile(path.join(pack, "go-regex.wasm")),
);
const bridge = new GoWasmBridge(
pathToFileURL(path.join(pack, "wasm_exec.mjs")),
wasm,
);
adapter = new GoEngineAdapter(bridge, "Node.js conformance");
await adapter.load();
}, 30_000);
afterAll(() => {
adapter?.terminate();
});
describe("Go 1.26.5 standard-library regexp conformance", () => {
it("loads the exact runtime and bridge self-test identity", async () => {
await expect(adapter.load()).resolves.toEqual(
expect.objectContaining({
flavour: "go",
engineName: "Go standard-library regexp",
engineVersion: GO_ENGINE_VERSION,
runtimeVersion: expect.stringContaining("go1.26.5 js/wasm"),
offsetUnit: "utf8-byte",
}),
);
});
it("returns native UTF-8 byte offsets and native named captures", async () => {
const result = await adapter.execute(
request("(?P<emoji>😀+)([A-Z]+)?", "x😀😀AB 😀"),
);
expect(result.accepted).toBe(true);
expect(
result.matches.map(({ nativeRange, range }) => ({ nativeRange, range })),
).toEqual([
{
nativeRange: { start: 1, end: 11, unit: "utf8-byte" },
range: { startUtf16: 1, endUtf16: 7 },
},
{
nativeRange: { start: 12, end: 16, unit: "utf8-byte" },
range: { startUtf16: 8, endUtf16: 10 },
},
]);
expect(result.matches[0]?.captures).toEqual([
expect.objectContaining({
groupNumber: 1,
groupName: "emoji",
value: "😀😀",
nativeRange: { start: 1, end: 9, unit: "utf8-byte" },
}),
expect.objectContaining({ groupNumber: 2, value: "AB" }),
]);
expect(result.matches[1]?.captures[1]).toEqual({
groupNumber: 2,
status: "did-not-participate",
});
});
it("uses Go's native empty-match suppression and RE2 boundary", async () => {
const empty = await adapter.execute(request("a*", "baaab"));
expect(empty.matches.map(({ nativeRange }) => nativeRange)).toEqual([
{ start: 0, end: 0, unit: "utf8-byte" },
{ start: 1, end: 4, unit: "utf8-byte" },
{ start: 5, end: 5, unit: "utf8-byte" },
]);
const unsupported = await adapter.execute(request("(?<=a)b", "ab"));
expect(unsupported.accepted).toBe(false);
expect(unsupported.diagnostics[0]).toEqual(
expect.objectContaining({ code: "compile-error" }),
);
});
it("maps i, m, s and U to native Go inline flags", async () => {
const cases = [
["a", "A", ["i"]],
["^b$", "a\nb\nc", ["m"]],
["a.b", "a\nb", ["s"]],
["a+?", "aaa", ["U"]],
] as const;
for (const [pattern, subject, flags] of cases) {
const result = await adapter.execute(
request(pattern, subject, { flags: [...flags] }),
);
expect(result.matches, `${flags.join("")}: ${pattern}`).toHaveLength(1);
}
const ungreedy = await adapter.execute(
request("a+", "aaa", { flags: ["U"] }),
);
expect(ungreedy.matches[0]?.value).toBe("a");
});
it("uses native ExpandString replacement name and longest-name rules", async () => {
const result = await adapter.replace(
replacement("(?P<word>[a-z]+)-(\\d+)", "a-1 b-22", "${word}[$2]/$1/$$"),
);
expect(result.execution.accepted).toBe(true);
expect(result.output).toBe("a[1]/a/$ b[22]/b/$");
const longest = await adapter.replace(replacement("(a)", "a", "$1x|${1}x"));
expect(longest.output).toBe("|ax");
});
it("bounds capture amplification and preserves the untouched partial suffix", async () => {
const bounded = await adapter.replace(
replacement("(a+)", "a".repeat(256 * 1024), "$1".repeat(4_096), {
maximumOutputBytes: 5,
}),
);
expect(bounded.execution.accepted).toBe(true);
expect(bounded.output).toBe("aaaaa");
expect(bounded.outputBytes).toBe(5);
expect(bounded.outputTruncated).toBe(true);
const partial = await adapter.replace(
replacement("a", "aaa", "x", { maximumMatches: 1 }),
);
expect(partial.execution.matches).toHaveLength(1);
expect(partial.execution.truncated).toBe(true);
expect(partial.output).toBe("xaa");
expect(partial.outputTruncated).toBe(false);
});
it("enforces match/capture/output bounds without splitting UTF-8", async () => {
const limited = await adapter.execute(
request("a", "aaa", { maximumMatches: 1 }),
);
expect(limited.matches).toHaveLength(1);
expect(limited.truncated).toBe(true);
const rows = await adapter.execute(
request("(a)(b)", "ab", { maximumCaptureRows: 1 }),
);
expect(rows.matches).toHaveLength(0);
expect(rows.truncated).toBe(true);
const output = await adapter.replace(
replacement(".", "abc", "😀", { maximumOutputBytes: 5 }),
);
expect(output.output).toBe("😀");
expect(output.outputBytes).toBe(4);
expect(output.outputTruncated).toBe(true);
});
it("rejects lone UTF-16 surrogates before entering Go", async () => {
await expect(adapter.execute(request(".", "\ud800"))).rejects.toThrow(
/unpaired UTF-16 surrogate/u,
);
});
it("accepts control-heavy subjects within the decoded 16 MiB contract", async () => {
const subject = "\u0000".repeat(3 * 1024 * 1024);
const result = await adapter.execute(
request("^\\x00+$", subject, {
flags: [],
maximumMatches: 1,
maximumCaptureRows: 1,
}),
);
expect(result.accepted).toBe(true);
expect(result.matches[0]?.nativeRange).toEqual({
start: 0,
end: subject.length,
unit: "utf8-byte",
});
});
it("ships verified metadata, licence and checksums", async () => {
const metadata = JSON.parse(
await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
) as {
engineVersion: string;
source: { tag: string; commitSha1: string; license: string };
toolchain: { archiveSha256: string };
};
expect(metadata).toEqual(
expect.objectContaining({
engineVersion: "1.26.5",
source: expect.objectContaining({
tag: "go1.26.5",
commitSha1: "c19862e5f8415b4f24b189d065ed739517c548ba",
license: "BSD-3-Clause",
}),
toolchain: expect.objectContaining({
archiveSha256:
"5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053",
}),
}),
);
const checksums = await readFile(path.join(pack, "SHA256SUMS"), "utf8");
for (const line of checksums.trim().split("\n")) {
const [expected, name] = line.split(/ {2}/u);
const value = await readFile(path.join(pack, name ?? ""));
expect(createHash("sha256").update(value).digest("hex"), name).toBe(
expected,
);
}
expect(await readFile(path.join(pack, "LICENSE-Go.txt"), "utf8")).toContain(
"Redistribution and use",
);
});
});

View File

@@ -0,0 +1,356 @@
// @vitest-environment node
import { createRequire } from "node:module";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
PHP_ENGINE_VERSION,
PHP_FLAVOUR_VERSION,
PhpEngineAdapter,
} from "../../src/regex/execution/adapters/php/PhpEngineAdapter";
import {
PHP,
loadPHPRuntime,
type PhpLoaderModule,
} from "../../src/regex/execution/adapters/php/php-runtime";
import { PhpWasmBridge } from "../../src/regex/execution/adapters/php/PhpWasmBridge";
import type {
RegexExecutionRequest,
RegexReplacementRequest,
} from "../../src/regex/model/match";
const pack = path.resolve("public/engines/php");
const mutableGlobal = globalThis as unknown as Record<string, unknown>;
let previousRequire: unknown;
let hadRequire = false;
let php: InstanceType<typeof PHP>;
let bridge: PhpWasmBridge;
let adapter: PhpEngineAdapter;
function request(
pattern: string,
subject: string,
overrides: Partial<RegexExecutionRequest> = {},
): RegexExecutionRequest {
return {
flavour: "php",
flavourVersion: PHP_FLAVOUR_VERSION,
pattern,
flags: ["g", "u"],
subject,
captureMetadata: [],
scanAll: false,
maximumMatches: 100,
maximumCaptureRows: 1_000,
...overrides,
};
}
function replacement(
pattern: string,
subject: string,
replacementText: string,
overrides: Partial<RegexReplacementRequest> = {},
): RegexReplacementRequest {
return {
...request(pattern, subject),
replacement: replacementText,
maximumOutputBytes: 1_024,
...overrides,
};
}
beforeAll(async () => {
hadRequire = Object.hasOwn(mutableGlobal, "require");
previousRequire = mutableGlobal.require;
mutableGlobal.require = createRequire(import.meta.url);
const loader = (await import(
`${pathToFileURL(path.join(pack, "php-loader.mjs")).href}?conformance`
)) as PhpLoaderModule;
const wasmBinary = await readFile(path.join(pack, "php.wasm"));
const runtimeId = await loadPHPRuntime(loader, {
wasmBinary,
phpWasmAsyncMode: "asyncify",
debug: false,
print: () => undefined,
printErr: () => undefined,
});
php = new PHP(runtimeId);
bridge = new PhpWasmBridge(php);
adapter = new PhpEngineAdapter(bridge, "Node.js conformance");
await adapter.load();
}, 30_000);
afterAll(() => {
adapter?.terminate();
try {
php?.exit(0);
} catch {
// Emscripten may signal its normal exit as an exception.
}
if (hadRequire) mutableGlobal.require = previousRequire;
else delete mutableGlobal.require;
});
describe("PHP 8.5.8 preg / PCRE2 10.44 conformance", () => {
it("loads the exact Asyncify runtime and native self-test identity", async () => {
await expect(adapter.load()).resolves.toEqual(
expect.objectContaining({
flavour: "php",
engineName: "PHP preg (PCRE2)",
engineVersion: PHP_ENGINE_VERSION,
runtimeVersion: expect.stringContaining(
"@php-wasm/web-8-5 3.1.46 · Asyncify",
),
offsetUnit: "utf8-byte",
}),
);
});
it("returns PREG_OFFSET_CAPTURE byte ranges and named/optional captures", async () => {
const result = await adapter.execute(
request("(?<emoji>😀+)([A-Z]+)?", "x😀😀AB 😀"),
);
expect(result.accepted).toBe(true);
expect(
result.matches.map(({ nativeRange, range }) => ({ nativeRange, range })),
).toEqual([
{
nativeRange: { start: 1, end: 11, unit: "utf8-byte" },
range: { startUtf16: 1, endUtf16: 7 },
},
{
nativeRange: { start: 12, end: 16, unit: "utf8-byte" },
range: { startUtf16: 8, endUtf16: 10 },
},
]);
expect(result.matches[0]?.captures).toEqual([
expect.objectContaining({
groupNumber: 1,
groupName: "emoji",
value: "😀😀",
nativeRange: { start: 1, end: 9, unit: "utf8-byte" },
}),
expect.objectContaining({ groupNumber: 2, value: "AB" }),
]);
expect(result.matches[1]?.captures[1]).toEqual({
groupNumber: 2,
status: "did-not-participate",
});
});
it("reproduces preg_match_all empty-match retry and bounded search", async () => {
const global = await adapter.execute(request("(?:|a)", "a"));
expect(
global.matches.map(({ value, nativeRange }) => [value, nativeRange]),
).toEqual([
["", { start: 0, end: 0, unit: "utf8-byte" }],
["a", { start: 0, end: 1, unit: "utf8-byte" }],
["", { start: 1, end: 1, unit: "utf8-byte" }],
]);
const search = await adapter.execute(
request("a", "a a", { flags: ["u"], scanAll: false }),
);
expect(search.matches).toHaveLength(1);
});
it("passes PHP preg modifiers to PCRE2 without reinterpretation", async () => {
const cases = [
["a", "A", ["i", "u"], "A"],
["^b$", "a\nb\nc", ["m", "u"], "b"],
["a.b", "a\nb", ["s", "u"], "a\nb"],
["a # note\n b", "ab", ["x", "u"], "ab"],
["a+", "aaa", ["U", "u"], "a"],
] as const;
for (const [pattern, subject, flags, expected] of cases) {
const result = await adapter.execute(
request(pattern, subject, { flags: [...flags] }),
);
expect(result.matches[0]?.value, flags.join("")).toBe(expected);
}
const anchored = await adapter.execute(
request("b", "ab", { flags: ["A", "u"] }),
);
expect(anchored.matches).toHaveLength(0);
const dollarEndOnly = await adapter.execute(
request("a$", "a\n", { flags: ["D", "u"] }),
);
expect(dollarEndOnly.matches).toHaveLength(0);
const noAutoCapture = await adapter.execute(
request("(a)(?<letter>b)", "ab", { flags: ["n", "u"] }),
);
expect(noAutoCapture.matches[0]?.captures).toEqual([
expect.objectContaining({
groupNumber: 1,
groupName: "letter",
value: "b",
}),
]);
const duplicateNames = await adapter.execute(
request("(?<part>a)(?<part>b)", "ab", { flags: ["J", "u"] }),
);
expect(duplicateNames.matches[0]?.captures).toEqual([
expect.objectContaining({
groupNumber: 1,
groupName: "part",
value: "a",
}),
expect.objectContaining({
groupNumber: 2,
groupName: "part",
value: "b",
}),
]);
});
it("selects or escapes a delimiter without changing pattern semantics", async () => {
const result = await adapter.execute(
request("[~#%!@;:=,`/]+", "x~#%!@;:=,`/y"),
);
expect(result.matches[0]?.value).toBe("~#%!@;:=,`/");
const alreadyEscaped = await adapter.execute(request("\\~+", "~~~"));
expect(alreadyEscaped.matches[0]?.value).toBe("~~~");
});
it("matches PHP 8.5.8 preg_replace numbered and escape rules", async () => {
const global = await adapter.replace(
replacement("([a-z]+)-(\\d+)", "a-1 b-22", "$2:$1/${1}"),
);
expect(global.execution.accepted).toBe(true);
expect(global.output).toBe("1:a/a 22:b/b");
const first = await adapter.replace(
replacement("a", "a a", "x", { flags: ["u"] }),
);
expect(first.output).toBe("x a");
const inertPhp = await adapter.replace(
replacement(
"(x)",
'<?php "x"; ?>',
"'; throw new Exception('not source'); //$1",
),
);
expect(inertPhp.execution.accepted).toBe(true);
expect(inertPhp.output).toContain("not source");
const templateCases = [
["$2:$1/${1}", "b:a/a :a/a"],
["\\1", "a a"],
["\\\\1", "\\1 \\1"],
["\\$1", "$1 $1"],
["$99", " "],
["$123", "3 3"],
["${1x}", "${1x} ${1x}"],
] as const;
for (const [template, expected] of templateCases) {
const result = await adapter.replace(
replacement("(a)(b)?", "ab a", template),
);
expect(result.output, JSON.stringify(template)).toBe(expected);
}
});
it("enforces match/capture/output bounds on UTF-8 boundaries", async () => {
const limited = await adapter.execute(
request("a", "aaa", { maximumMatches: 1 }),
);
expect(limited.matches).toHaveLength(1);
expect(limited.truncated).toBe(true);
const rows = await adapter.execute(
request("(a)(b)", "ab", { maximumCaptureRows: 1 }),
);
expect(rows.matches).toHaveLength(0);
expect(rows.truncated).toBe(true);
const output = await adapter.replace(
replacement(".", "abc", "😀", { maximumOutputBytes: 5 }),
);
expect(output.output).toBe("😀");
expect(output.outputBytes).toBe(4);
expect(output.outputTruncated).toBe(true);
const partial = await adapter.replace(
replacement("a", "aa", "x", { maximumMatches: 1 }),
);
expect(partial.execution.truncated).toBe(true);
expect(partial.output).toBe("xa");
const expanded = await adapter.replace(
replacement("a", "a", "😀".repeat(32_768), {
maximumOutputBytes: 5,
}),
);
expect(expanded.output).toBe("😀");
expect(expanded.outputBytes).toBe(4);
expect(expanded.outputTruncated).toBe(true);
});
it("reports native compile failures and lossy byte-mode offsets", async () => {
const compile = await adapter.execute(request("(", ""));
expect(compile.accepted).toBe(false);
expect(compile.diagnostics[0]).toEqual(
expect.objectContaining({
code: "compile-error",
range: { startUtf16: 1, endUtf16: 1 },
}),
);
const byteMode = await adapter.execute(request(".", "é", { flags: ["g"] }));
expect(byteMode.accepted).toBe(false);
expect(byteMode.diagnostics[0]).toEqual(
expect.objectContaining({ code: "unaligned-utf8-offset" }),
);
});
it("rejects lone UTF-16 surrogates before entering PHP", async () => {
await expect(adapter.execute(request(".", "\ud800"))).rejects.toThrow(
/unpaired UTF-16 surrogate/u,
);
});
it("enforces UTF-16 pattern and replacement limits inside the PHP bridge", async () => {
const common = {
abi: 1 as const,
flags: "u",
options: {},
subject: "",
scanAll: false,
maximumMatches: 1,
maximumCaptureRows: 1,
};
await expect(
bridge.run({
...common,
operation: "execute",
pattern: "😀".repeat(32_769),
}),
).resolves.toEqual(
expect.objectContaining({
ok: false,
code: "pattern-limit",
}),
);
await expect(
bridge.run({
...common,
operation: "replace",
pattern: "",
replacement: "😀".repeat(32_769),
maximumOutputBytes: 1,
}),
).resolves.toEqual(
expect.objectContaining({
ok: false,
code: "replacement-limit",
}),
);
});
});

View File

@@ -6,6 +6,7 @@ import {
PythonEngineAdapter,
PYODIDE_ENGINE_VERSION,
PYTHON_ENGINE_VERSION,
PYTHON_FLAVOUR_VERSION,
} from "../../src/regex/execution/adapters/python/PythonEngineAdapter";
import type {
PyodideModule,
@@ -28,7 +29,7 @@ function request(
): RegexExecutionRequest {
return {
flavour: "python",
flavourVersion: PYTHON_ENGINE_VERSION,
flavourVersion: PYTHON_FLAVOUR_VERSION,
pattern: "a",
flags: ["g"],
subject: "a",
@@ -68,7 +69,7 @@ describe("CPython re 3.14.2 conformance through self-hosted Pyodide", () => {
expect.objectContaining({
flavour: "python",
engineName: "CPython re via Pyodide",
engineVersion: "CPython 3.14.2 re",
engineVersion: PYTHON_ENGINE_VERSION,
runtimeVersion: expect.stringContaining("Pyodide 314.0.3"),
offsetUnit: "code-point",
}),

View File

@@ -0,0 +1,344 @@
// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { File, PreopenDirectory } from "@bjorn3/browser_wasi_shim";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
RubyEngineAdapter,
RUBY_ENGINE_VERSION,
RUBY_FLAVOUR_VERSION,
} from "../../src/regex/execution/adapters/ruby/RubyEngineAdapter";
import { DefaultRubyVM } from "../../src/regex/execution/adapters/ruby/ruby-module";
import type {
RegexExecutionRequest,
RegexReplacementRequest,
} from "../../src/regex/model/match";
const runtimeFile = path.resolve(
process.cwd(),
"public",
"engines",
"ruby",
"ruby.wasm",
);
function request(
overrides: Partial<RegexExecutionRequest> = {},
): RegexExecutionRequest {
return {
flavour: "ruby",
flavourVersion: RUBY_FLAVOUR_VERSION,
pattern: "a",
flags: ["g"],
subject: "a",
captureMetadata: [],
scanAll: false,
maximumMatches: 100,
maximumCaptureRows: 1_000,
...overrides,
};
}
describe("CRuby 4.0.0 Regexp conformance through ruby.wasm", () => {
let adapter: RubyEngineAdapter;
beforeAll(async () => {
const module = await WebAssembly.compile(await readFile(runtimeFile));
const { vm, wasi } = await DefaultRubyVM(module, {
consolePrint: false,
});
const requestFile = new File([]);
const root = wasi.fds[3];
if (!(root instanceof PreopenDirectory)) {
throw new Error("Ruby conformance runtime has no writable root.");
}
root.dir.contents.set("regex-tools-request.bin", requestFile);
const outputFile = new File([]);
root.dir.contents.set("regex-tools-output.bin", outputFile);
adapter = new RubyEngineAdapter(
vm,
"Node conformance runtime",
(payload) => {
requestFile.data = payload;
},
() => outputFile.data,
);
await adapter.load();
}, 30_000);
afterAll(() => {
adapter?.terminate();
});
it("reports the exact CRuby, ruby.wasm, platform and offset identity", async () => {
await expect(adapter.load()).resolves.toEqual(
expect.objectContaining({
flavour: "ruby",
engineName: "CRuby Regexp via ruby.wasm",
engineVersion: RUBY_ENGINE_VERSION,
runtimeVersion: expect.stringMatching(
/ruby\.wasm 2\.9\.3-2\.9\.4 · wasm32-wasi/u,
),
offsetUnit: "code-point",
}),
);
});
it("uses native Ruby named groups, intersections and optional captures", async () => {
const result = await adapter.execute(
request({
pattern: "(?<word>[a-z&&[^aeiou]]+)(?:-(?<number>\\d+))?",
flags: ["g", "i"],
subject: "BCD-12 xyz",
}),
);
expect(result.accepted).toBe(true);
expect(result.matches.map((match) => match.value)).toEqual([
"BCD-12",
"xyz",
]);
expect(result.matches[0]?.captures).toEqual([
expect.objectContaining({
groupNumber: 1,
groupName: "word",
value: "BCD",
}),
expect.objectContaining({
groupNumber: 2,
groupName: "number",
value: "12",
}),
]);
expect(result.matches[1]?.captures).toEqual([
expect.objectContaining({
groupNumber: 1,
groupName: "word",
value: "xyz",
}),
{
groupNumber: 2,
groupName: "number",
status: "did-not-participate",
},
]);
});
it("normalizes astral and zero-length code-point offsets to UTF-16", async () => {
const result = await adapter.execute(
request({
pattern: "(?=.)",
subject: "😀a",
}),
);
expect(
result.matches.map((match) => ({
native: match.nativeRange,
editor: match.range,
value: match.value,
})),
).toEqual([
{
native: { start: 0, end: 0, unit: "code-point" },
editor: { startUtf16: 0, endUtf16: 0 },
value: "",
},
{
native: { start: 1, end: 1, unit: "code-point" },
editor: { startUtf16: 2, endUtf16: 2 },
value: "",
},
]);
});
it("implements Ruby g, i, m and x with scan-all normalization", async () => {
const combined = await adapter.execute(
request({
pattern: " a . b ",
flags: ["i", "m", "x"],
subject: "z A\nB q",
scanAll: true,
}),
);
expect(combined.accepted).toBe(true);
expect(combined.flags.effectiveFlags).toBe("gimx");
expect(combined.flags.internallyAddedGlobalFlag).toBe(true);
expect(combined.matches.map((match) => match.value)).toEqual(["A\nB"]);
});
it("uses native String#gsub/sub named, numbered and zero-length replacement", async () => {
const replacementRequest: RegexReplacementRequest = {
...request({
pattern: "(?<word>[a-z]+)-(?<number>\\d+)",
subject: "a-1 b-22",
}),
replacement: "\\k<word>[\\k<number>]",
maximumOutputBytes: 1_024,
};
const replaced = await adapter.replace(replacementRequest);
expect(replaced.execution.accepted).toBe(true);
expect(replaced.output).toBe("a[1] b[22]");
expect(replaced.outputTruncated).toBe(false);
const numbered = await adapter.replace({
...request({
pattern: "([a-z]+)-(\\d+)",
subject: "a-1 b-22",
}),
replacement: "\\1[\\2]",
maximumOutputBytes: 1_024,
});
expect(numbered.output).toBe("a[1] b[22]");
const firstOnly = await adapter.replace({
...request({
pattern: "(?<word>[a-z]+)",
flags: [],
subject: "a b",
}),
replacement: "<\\k<word>>",
maximumOutputBytes: 1_024,
});
expect(firstOnly.output).toBe("<a> b");
const zeroLength = await adapter.replace({
...request({
pattern: "(?=.)",
subject: "😀a",
}),
replacement: "|",
maximumOutputBytes: 1_024,
});
expect(zeroLength.output).toBe("|😀|a");
});
it("matches CRuby replacement grammar without materializing expanded output", async () => {
const base = request({
pattern: "(a)(b)?",
subject: "ab a",
});
const cases = [
["\\0", "ab a"],
["\\&", "ab a"],
["\\1", "a a"],
["\\2", "b "],
["\\9", " "],
["\\+", "b a"],
["\\\\", "\\ \\"],
["\\q", "\\q \\q"],
["\\`", " ab "],
["\\'", " a "],
["$1", "$1 $1"],
] as const;
for (const [template, expected] of cases) {
const result = await adapter.replace({
...base,
replacement: template,
maximumOutputBytes: 1_024,
});
expect(result.execution.accepted, template).toBe(true);
expect(result.output, template).toBe(expected);
expect(result.outputTruncated, template).toBe(false);
}
const namedDisablesNumeric = await adapter.replace({
...request({
pattern: "(?<word>a)",
subject: "a",
}),
replacement: "\\1",
maximumOutputBytes: 1_024,
});
expect(namedDisablesNumeric.output).toBe("");
});
it("stops expansion at the byte bound and replacement at result caps", async () => {
const boundedExpansion = await adapter.replace({
...request({
pattern: "(a+)",
subject: "a".repeat(4_096),
}),
replacement: "\\1".repeat(32_768),
maximumOutputBytes: 5,
});
expect(boundedExpansion.execution.accepted).toBe(true);
expect(boundedExpansion.output).toBe("aaaaa");
expect(boundedExpansion.outputBytes).toBe(5);
expect(boundedExpansion.outputTruncated).toBe(true);
const partial = await adapter.replace({
...request({
pattern: "a",
subject: "aaa",
maximumMatches: 1,
}),
replacement: "x",
maximumOutputBytes: 1_024,
});
expect(partial.execution.matches).toHaveLength(1);
expect(partial.execution.truncated).toBe(true);
expect(partial.output).toBe("xaa");
expect(partial.outputTruncated).toBe(false);
});
it("bounds UTF-8 output without splitting an astral scalar", async () => {
const result = await adapter.replace({
...request({ pattern: ".", subject: "abc" }),
replacement: "😀",
maximumOutputBytes: 5,
});
expect(result.execution.accepted).toBe(true);
expect(result.output).toBe("😀");
expect(result.outputBytes).toBe(4);
expect(result.outputTruncated).toBe(true);
expect(result.truncated).toBe(true);
});
it("reports native compile and replacement errors", async () => {
const compileError = await adapter.execute(request({ pattern: "(" }));
expect(compileError.accepted).toBe(false);
expect(compileError.diagnostics[0]?.code).toBe("compile-error");
const replacementError = await adapter.replace({
...request({ pattern: "(?<word>a)" }),
replacement: "\\k<missing>",
maximumOutputBytes: 1_024,
});
expect(replacementError.execution.accepted).toBe(false);
expect(replacementError.execution.diagnostics[0]?.code).toBe(
"replacement-error",
);
});
it("rejects lone UTF-16 surrogates before entering ruby.wasm", async () => {
await expect(
adapter.execute(request({ subject: `a${String.fromCharCode(0xd800)}` })),
).rejects.toThrow(/unpaired UTF-16 surrogate/u);
});
it("enforces authoritative match and capture-row collection caps", async () => {
const matchCap = await adapter.execute(
request({
pattern: "a",
subject: "aaa",
maximumMatches: 1,
}),
);
expect(matchCap.matches).toHaveLength(1);
expect(matchCap.truncated).toBe(true);
const rowCap = await adapter.execute(
request({
pattern: "(a)(b)",
subject: "ab",
maximumCaptureRows: 1,
}),
);
expect(rowCap.matches).toHaveLength(0);
expect(rowCap.truncated).toBe(true);
});
});

View File

@@ -0,0 +1,278 @@
// @vitest-environment node
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
RUST_ENGINE_VERSION,
RUST_FLAVOUR_VERSION,
RustEngineAdapter,
} from "../../src/regex/execution/adapters/rust/RustEngineAdapter";
import {
RustWasmBridge,
type RustRegexWasmModule,
} from "../../src/regex/execution/adapters/rust/RustWasmBridge";
import type {
RegexExecutionRequest,
RegexReplacementRequest,
} from "../../src/regex/model/match";
const pack = path.resolve("public/engines/rust");
let adapter: RustEngineAdapter;
function request(
pattern: string,
subject: string,
overrides: Partial<RegexExecutionRequest> = {},
): RegexExecutionRequest {
return {
flavour: "rust",
flavourVersion: RUST_FLAVOUR_VERSION,
pattern,
flags: ["g"],
subject,
captureMetadata: [],
scanAll: false,
maximumMatches: 100,
maximumCaptureRows: 1_000,
...overrides,
};
}
function replacement(
pattern: string,
subject: string,
replacementText: string,
overrides: Partial<RegexReplacementRequest> = {},
): RegexReplacementRequest {
return {
...request(pattern, subject),
replacement: replacementText,
maximumOutputBytes: 1_024,
...overrides,
};
}
beforeAll(async () => {
const imported = (await import(
`${pathToFileURL(path.join(pack, "rust-regex.mjs")).href}?conformance`
)) as RustRegexWasmModule;
imported.initSync({
module: await WebAssembly.compile(
await readFile(path.join(pack, "rust-regex_bg.wasm")),
),
});
adapter = new RustEngineAdapter(
new RustWasmBridge(imported),
"Node.js conformance",
);
await adapter.load();
});
afterAll(() => {
adapter?.terminate();
});
describe("Rust regex 1.13.1 WebAssembly conformance", () => {
it("loads the exact crate and compiler identity", async () => {
await expect(adapter.load()).resolves.toEqual(
expect.objectContaining({
flavour: "rust",
engineName: "Rust regex crate",
engineVersion: RUST_ENGINE_VERSION,
runtimeVersion: expect.stringContaining(
"rustc 1.97.1 wasm32-unknown-unknown",
),
offsetUnit: "utf8-byte",
}),
);
});
it("returns native UTF-8 byte offsets and named captures", async () => {
const result = await adapter.execute(
request("(?P<emoji>😀+)([A-Z]+)?", "x😀😀AB 😀"),
);
expect(result.accepted).toBe(true);
expect(
result.matches.map(({ nativeRange, range }) => ({ nativeRange, range })),
).toEqual([
{
nativeRange: { start: 1, end: 11, unit: "utf8-byte" },
range: { startUtf16: 1, endUtf16: 7 },
},
{
nativeRange: { start: 12, end: 16, unit: "utf8-byte" },
range: { startUtf16: 8, endUtf16: 10 },
},
]);
expect(result.matches[0]?.captures[0]).toEqual(
expect.objectContaining({
groupNumber: 1,
groupName: "emoji",
value: "😀😀",
nativeRange: { start: 1, end: 9, unit: "utf8-byte" },
}),
);
expect(result.matches[1]?.captures[1]).toEqual({
groupNumber: 2,
status: "did-not-participate",
});
});
it("uses Rust's native Unicode default and rejects unsupported constructs", async () => {
const unicode = await adapter.execute(
request("^\\w+$", "Grüße", { flags: [] }),
);
expect(unicode.matches).toHaveLength(1);
const unsupported = await adapter.execute(request("(?<=a)b", "ab"));
expect(unsupported.accepted).toBe(false);
expect(unsupported.diagnostics[0]).toEqual(
expect.objectContaining({ code: "compile-error" }),
);
});
it("maps i, m, s, U, u, x and R through RegexBuilder", async () => {
const cases = [
["a", "A", ["i"]],
["^b$", "a\nb\nc", ["m"]],
["a.b", "a\nb", ["s"]],
["a+?", "aaa", ["U"]],
["^ a + $", "aaa", ["x"]],
["^b$", "a\r\nb\r\nc", ["m", "R"]],
["\\p{Greek}+", "λ", ["u"]],
] as const;
for (const [pattern, subject, flags] of cases) {
const result = await adapter.execute(
request(pattern, subject, { flags: [...flags] }),
);
expect(result.matches, `${flags.join("")}: ${pattern}`).toHaveLength(1);
}
const ungreedy = await adapter.execute(
request("a+", "aaa", { flags: ["U"] }),
);
expect(ungreedy.matches[0]?.value).toBe("a");
});
it("uses native Captures::expand replacement syntax", async () => {
const result = await adapter.replace(
replacement(
"(?P<word>[a-z]+)-(\\d+)",
"a-1 b-22",
"${word}[$2]/$word/$$",
),
);
expect(result.execution.accepted).toBe(true);
expect(result.output).toBe("a[1]/a/$ b[22]/b/$");
const longest = await adapter.replace(replacement("(a)", "a", "$1x|${1}x"));
expect(longest.output).toBe("|ax");
});
it("bounds capture amplification and preserves the untouched partial suffix", async () => {
const bounded = await adapter.replace(
replacement("(a+)", "a".repeat(256 * 1024), "$1".repeat(4_096), {
maximumOutputBytes: 5,
}),
);
expect(bounded.execution.accepted).toBe(true);
expect(bounded.output).toBe("aaaaa");
expect(bounded.outputBytes).toBe(5);
expect(bounded.outputTruncated).toBe(true);
const partial = await adapter.replace(
replacement("a", "aaa", "x", { maximumMatches: 1 }),
);
expect(partial.execution.matches).toHaveLength(1);
expect(partial.execution.truncated).toBe(true);
expect(partial.output).toBe("xaa");
expect(partial.outputTruncated).toBe(false);
});
it("enforces match/capture/output bounds without splitting UTF-8", async () => {
const limited = await adapter.execute(
request("a", "aaa", { maximumMatches: 1 }),
);
expect(limited.matches).toHaveLength(1);
expect(limited.truncated).toBe(true);
const rows = await adapter.execute(
request("(a)(b)", "ab", { maximumCaptureRows: 1 }),
);
expect(rows.matches).toHaveLength(0);
expect(rows.truncated).toBe(true);
const output = await adapter.replace(
replacement(".", "abc", "😀", { maximumOutputBytes: 5 }),
);
expect(output.output).toBe("😀");
expect(output.outputBytes).toBe(4);
expect(output.outputTruncated).toBe(true);
});
it("rejects lone UTF-16 surrogates before entering Rust", async () => {
await expect(adapter.execute(request(".", "\ud800"))).rejects.toThrow(
/unpaired UTF-16 surrogate/u,
);
});
it("accepts control-heavy subjects within the decoded 16 MiB contract", async () => {
const subject = "\u0000".repeat(3 * 1024 * 1024);
const result = await adapter.execute(
request("^\\x00+$", subject, {
flags: [],
maximumMatches: 1,
maximumCaptureRows: 1,
}),
);
expect(result.accepted).toBe(true);
expect(result.matches[0]?.nativeRange).toEqual({
start: 0,
end: subject.length,
unit: "utf8-byte",
});
});
it("ships verified metadata, licences, Cargo.lock and checksums", async () => {
const metadata = JSON.parse(
await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
) as {
engineVersion: string;
source: { crateChecksumSha256: string; license: string };
toolchain: {
rustcCommitSha1: string;
wasmBindgenVersion: string;
};
};
expect(metadata).toEqual(
expect.objectContaining({
engineVersion: "1.13.1",
source: expect.objectContaining({
crateChecksumSha256:
"f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d",
license: "MIT OR Apache-2.0",
}),
toolchain: expect.objectContaining({
rustcCommitSha1: "8bab26f4f68e0e26f0bb7960be334d5b520ea452",
wasmBindgenVersion: "0.2.126",
}),
}),
);
const checksums = await readFile(path.join(pack, "SHA256SUMS"), "utf8");
for (const line of checksums.trim().split("\n")) {
const [expected, name] = line.split(/ {2}/u);
const value = await readFile(path.join(pack, name ?? ""));
expect(createHash("sha256").update(value).digest("hex"), name).toBe(
expected,
);
}
expect(
await readFile(path.join(pack, "LICENSE-MIT.txt"), "utf8"),
).toContain("Permission is hereby granted");
expect(
await readFile(path.join(pack, "LICENSE-Apache-2.0.txt"), "utf8"),
).toContain("Apache License");
});
});