feat: add multi-engine regex flavour support
This commit is contained in:
356
tests/conformance/php.conformance.test.ts
Normal file
356
tests/conformance/php.conformance.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user