// @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; let previousRequire: unknown; let hadRequire = false; let php: InstanceType; let bridge: PhpWasmBridge; let adapter: PhpEngineAdapter; function request( pattern: string, subject: string, overrides: Partial = {}, ): 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 { 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("(?๐Ÿ˜€+)([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)(?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("(?a)(?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)", '', "'; 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", }), ); }); });