349 lines
10 KiB
TypeScript
349 lines
10 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
Pcre2EngineAdapter,
|
|
PCRE2_ENGINE_VERSION,
|
|
} from "../../src/regex/execution/adapters/pcre2/Pcre2EngineAdapter";
|
|
import { Pcre2TraceAdapter } from "../../src/regex/execution/adapters/pcre2/Pcre2TraceAdapter";
|
|
import type { Pcre2ModuleFactory } from "../../src/regex/execution/adapters/pcre2/pcre2-module";
|
|
import type {
|
|
RegexExecutionRequest,
|
|
RegexReplacementRequest,
|
|
} from "../../src/regex/model/match";
|
|
|
|
const pack = path.resolve("public/engines/pcre2");
|
|
let adapter: Pcre2EngineAdapter;
|
|
let traceAdapter: Pcre2TraceAdapter;
|
|
|
|
function request(
|
|
pattern: string,
|
|
subject: string,
|
|
overrides: Partial<RegexExecutionRequest> = {},
|
|
): RegexExecutionRequest {
|
|
return {
|
|
flavour: "pcre2",
|
|
flavourVersion: "PCRE2 10.47 8-bit WebAssembly",
|
|
pattern,
|
|
flags: ["g"],
|
|
options: {
|
|
matchLimit: 1_000_000,
|
|
depthLimit: 1_000,
|
|
heapLimitKib: 32_768,
|
|
},
|
|
subject,
|
|
captureMetadata: [],
|
|
scanAll: false,
|
|
maximumMatches: 100,
|
|
maximumCaptureRows: 1_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const imported = (await import(
|
|
`${pathToFileURL(path.join(pack, "pcre2.mjs")).href}?conformance`
|
|
)) as { default: Pcre2ModuleFactory };
|
|
const module = await imported.default({
|
|
locateFile: (name) => path.join(pack, name),
|
|
print: () => undefined,
|
|
printErr: () => undefined,
|
|
});
|
|
adapter = new Pcre2EngineAdapter(module, "Node.js WebAssembly conformance");
|
|
traceAdapter = new Pcre2TraceAdapter(
|
|
module,
|
|
"Node.js WebAssembly trace conformance",
|
|
);
|
|
await adapter.load();
|
|
await traceAdapter.load();
|
|
});
|
|
|
|
describe("pinned PCRE2 10.47 WebAssembly conformance", () => {
|
|
it("loads the exact production engine identity", async () => {
|
|
await expect(adapter.load()).resolves.toEqual(
|
|
expect.objectContaining({
|
|
flavour: "pcre2",
|
|
engineName: "PCRE2 WebAssembly",
|
|
engineVersion: PCRE2_ENGINE_VERSION,
|
|
offsetUnit: "utf8-byte",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "branch-reset groups",
|
|
pattern: "(?|(a)|(b))-(\\d+)",
|
|
subject: "b-42",
|
|
match: "b-42",
|
|
captures: ["b", "42"],
|
|
},
|
|
{
|
|
name: "named recursion",
|
|
pattern: "(?<parentheses>\\((?:[^()]|(?&parentheses))*\\))",
|
|
subject: "x (a(b)c) y",
|
|
match: "(a(b)c)",
|
|
captures: ["(a(b)c)"],
|
|
},
|
|
{
|
|
name: "match-start reset",
|
|
pattern: "foo\\Kbar",
|
|
subject: "foobar",
|
|
match: "bar",
|
|
captures: [],
|
|
},
|
|
])("executes PCRE2-only $name", async (testCase) => {
|
|
const result = await adapter.execute(
|
|
request(testCase.pattern, testCase.subject),
|
|
);
|
|
expect(result.accepted).toBe(true);
|
|
expect(result.matches[0]?.value).toBe(testCase.match);
|
|
expect(
|
|
result.matches[0]?.captures
|
|
.filter((capture) => capture.status !== "did-not-participate")
|
|
.map((capture) => capture.value),
|
|
).toEqual(testCase.captures);
|
|
});
|
|
|
|
it("preserves duplicate names from the native name table", async () => {
|
|
const result = await adapter.execute(
|
|
request("(?<value>a)|(?<value>b)", "b", {
|
|
flags: ["J"],
|
|
}),
|
|
);
|
|
expect(result.accepted).toBe(true);
|
|
expect(result.matches[0]?.captures).toEqual([
|
|
expect.objectContaining({
|
|
groupNumber: 1,
|
|
groupName: "value",
|
|
status: "did-not-participate",
|
|
}),
|
|
expect.objectContaining({
|
|
groupNumber: 2,
|
|
groupName: "value",
|
|
value: "b",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
["i", "^abc$", "AbC", "AbC"],
|
|
["m", "^b$", "a\nb\nc", "b"],
|
|
["s", "a.b", "a\nb", "a\nb"],
|
|
["x", "a # ignored\n b", "ab", "ab"],
|
|
["U", "a.+b", "a1b2b", "a1b"],
|
|
] as const)(
|
|
"applies the native %s flag",
|
|
async (flag, pattern, subject, expected) => {
|
|
const result = await adapter.execute(
|
|
request(pattern, subject, { flags: [flag] }),
|
|
);
|
|
expect(result.accepted).toBe(true);
|
|
expect(result.matches[0]?.value).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it("always uses PCRE2 UTF and Unicode-property semantics", async () => {
|
|
const result = await adapter.execute(
|
|
request("^\\w+$", "Grüße", { flags: [] }),
|
|
);
|
|
expect(result.accepted).toBe(true);
|
|
expect(result.matches[0]?.value).toBe("Grüße");
|
|
});
|
|
|
|
it("normalizes astral UTF-8 offsets and terminates global empty matches", async () => {
|
|
const result = await adapter.execute(request("(?=.)", "😀a"));
|
|
expect(result.accepted).toBe(true);
|
|
expect(result.matches.map((match) => match.range)).toEqual([
|
|
{ startUtf16: 0, endUtf16: 0 },
|
|
{ startUtf16: 2, endUtf16: 2 },
|
|
]);
|
|
expect(result.matches.map((match) => match.nativeRange)).toEqual([
|
|
{ start: 0, end: 0, unit: "utf8-byte" },
|
|
{ start: 4, end: 4, unit: "utf8-byte" },
|
|
]);
|
|
});
|
|
|
|
it("uses PCRE2's own CRLF-aware next-match progression", async () => {
|
|
const result = await adapter.execute(request("(*CRLF)(?m)^", "\r\nx"));
|
|
expect(result.accepted).toBe(true);
|
|
expect(result.matches.map((match) => match.nativeRange.start)).toEqual([
|
|
0, 2,
|
|
]);
|
|
});
|
|
|
|
it("executes native named substitution and reports exact output bytes", async () => {
|
|
const replacement: RegexReplacementRequest = {
|
|
...request("(?<word>\\p{L}+)", "Grüße 42"),
|
|
replacement: "<${word}>",
|
|
maximumOutputBytes: 1_024,
|
|
};
|
|
const result = await adapter.replace(replacement);
|
|
expect(result.execution.accepted).toBe(true);
|
|
expect(result.output).toBe("<Grüße> 42");
|
|
expect(result.outputBytes).toBe(
|
|
new TextEncoder().encode(result.output).length,
|
|
);
|
|
expect(result.truncated).toBe(false);
|
|
});
|
|
|
|
it("executes PCRE2 subject, highest-capture and mark substitutions", async () => {
|
|
const result = await adapter.replace({
|
|
...request("(*MARK:route)(a)(b)", "ab", { flags: [] }),
|
|
replacement: "$_:$+:$*MARK",
|
|
maximumOutputBytes: 1_024,
|
|
});
|
|
expect(result.execution.accepted).toBe(true);
|
|
expect(result.output).toBe("ab:b:route");
|
|
});
|
|
|
|
it("marks match and replacement bounds without returning invented completeness", async () => {
|
|
const limitedMatches = await adapter.execute(
|
|
request("\\d", "1 2 3", { maximumMatches: 2 }),
|
|
);
|
|
expect(limitedMatches.matches.map((match) => match.value)).toEqual([
|
|
"1",
|
|
"2",
|
|
]);
|
|
expect(limitedMatches.truncated).toBe(true);
|
|
expect(limitedMatches.diagnostics).toEqual([
|
|
expect.objectContaining({ code: "result-limit" }),
|
|
]);
|
|
|
|
const limitedOutput = await adapter.replace({
|
|
...request("a", "aaaa"),
|
|
replacement: "long",
|
|
maximumOutputBytes: 5,
|
|
});
|
|
expect(limitedOutput.outputTruncated).toBe(true);
|
|
expect(limitedOutput.truncated).toBe(true);
|
|
expect(
|
|
new TextEncoder().encode(limitedOutput.output).length,
|
|
).toBeLessThanOrEqual(5);
|
|
});
|
|
|
|
it("reports native compile and match-limit failures as failures", async () => {
|
|
const compile = await adapter.execute(request("é(", ""));
|
|
expect(compile.accepted).toBe(false);
|
|
expect(compile.diagnostics[0]).toEqual(
|
|
expect.objectContaining({
|
|
code: "compile-error",
|
|
range: { startUtf16: 2, endUtf16: 2 },
|
|
}),
|
|
);
|
|
|
|
const limited = await adapter.execute(
|
|
request("(a+)+$", `${"a".repeat(4_096)}!`, {
|
|
flags: [],
|
|
options: {
|
|
matchLimit: 100,
|
|
depthLimit: 100,
|
|
heapLimitKib: 1_024,
|
|
},
|
|
}),
|
|
);
|
|
expect(limited.accepted).toBe(false);
|
|
expect(limited.diagnostics[0]).toEqual(
|
|
expect.objectContaining({ code: "match-limit" }),
|
|
);
|
|
});
|
|
|
|
it("returns reported automatic callouts and only derived movement labels", async () => {
|
|
const result = await traceAdapter.trace({
|
|
flavour: "pcre2",
|
|
pattern: "(*MARK:route)a+b",
|
|
flags: [],
|
|
options: {
|
|
matchLimit: 1_000_000,
|
|
depthLimit: 1_000,
|
|
heapLimitKib: 32_768,
|
|
},
|
|
subject: "aaab",
|
|
maximumTraceEvents: 1_000,
|
|
maximumTraceBytes: 64 * 1024,
|
|
});
|
|
|
|
expect(result.accepted).toBe(true);
|
|
expect(result.matched).toBe(true);
|
|
expect(result.truncated).toBe(false);
|
|
expect(result.events.length).toBeGreaterThan(2);
|
|
expect(result.events.some((event) => event.reported.mark === "route")).toBe(
|
|
true,
|
|
);
|
|
expect(result.events[0]).toEqual(
|
|
expect.objectContaining({
|
|
eventNumber: 1,
|
|
reported: expect.objectContaining({
|
|
provenance: "reported",
|
|
calloutNumber: 255,
|
|
patternPosition: expect.objectContaining({
|
|
utf16: expect.any(Number),
|
|
nativeByte: expect.any(Number),
|
|
}),
|
|
}),
|
|
movement: expect.objectContaining({
|
|
provenance: "derived",
|
|
classification: "first-event",
|
|
}),
|
|
}),
|
|
);
|
|
expect(
|
|
result.events.every(
|
|
(event) =>
|
|
event.reported.nextPatternItem.startUtf16 <=
|
|
event.reported.nextPatternItem.endUtf16,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("stops natively at the complete-event trace cap", async () => {
|
|
const result = await traceAdapter.trace({
|
|
flavour: "pcre2",
|
|
pattern: "(a+)+$",
|
|
flags: ["g"],
|
|
options: {
|
|
matchLimit: 1_000_000,
|
|
depthLimit: 1_000,
|
|
heapLimitKib: 32_768,
|
|
},
|
|
subject: "aaaa!",
|
|
maximumTraceEvents: 1,
|
|
maximumTraceBytes: 4_096,
|
|
});
|
|
|
|
expect(result.accepted).toBe(true);
|
|
expect(result.events).toHaveLength(1);
|
|
expect(result.totalEventCount).toBe(2);
|
|
expect(result.lastCompleteEvent).toBe(1);
|
|
expect(result.nativeMatchStatus).toBe(-37);
|
|
expect(result.matched).toBe(false);
|
|
expect(result.truncated).toBe(true);
|
|
expect(result.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ code: "trace-single-invocation" }),
|
|
expect.objectContaining({ code: "trace-limit" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("normalizes trace compile errors to UTF-16", async () => {
|
|
const result = await traceAdapter.trace({
|
|
flavour: "pcre2",
|
|
pattern: "é(",
|
|
flags: [],
|
|
subject: "",
|
|
maximumTraceEvents: 100,
|
|
maximumTraceBytes: 4_096,
|
|
});
|
|
expect(result.accepted).toBe(false);
|
|
expect(result.diagnostics[0]).toEqual(
|
|
expect.objectContaining({
|
|
code: "compile-error",
|
|
range: { startUtf16: 2, endUtf16: 2 },
|
|
}),
|
|
);
|
|
});
|
|
});
|