feat: publish Regex Tools 0.2.0

This commit is contained in:
2026-07-27 01:06:57 +02:00
parent 873b9b218d
commit 4951c966d4
180 changed files with 35344 additions and 503 deletions

View 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 },
}),
);
});
});