feat: publish Regex Tools 0.2.0
This commit is contained in:
348
tests/conformance/pcre2.conformance.test.ts
Normal file
348
tests/conformance/pcre2.conformance.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
// @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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user