feat: add Python and Java regex engines
This commit is contained in:
294
tests/conformance/java.conformance.test.ts
Normal file
294
tests/conformance/java.conformance.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
JavaEngineAdapter,
|
||||
JAVA_ENGINE_IDENTITY,
|
||||
JAVA_ENGINE_VERSION,
|
||||
} from "../../src/regex/execution/adapters/java/JavaEngineAdapter";
|
||||
import type { TeaVmJavaRegexModule } from "../../src/regex/execution/adapters/java/java-module";
|
||||
import type {
|
||||
RegexExecutionRequest,
|
||||
RegexReplacementRequest,
|
||||
} from "../../src/regex/model/match";
|
||||
|
||||
const pack = path.resolve("public/engines/java");
|
||||
let adapter: JavaEngineAdapter;
|
||||
|
||||
function request(
|
||||
pattern: string,
|
||||
subject: string,
|
||||
overrides: Partial<RegexExecutionRequest> = {},
|
||||
): RegexExecutionRequest {
|
||||
return {
|
||||
flavour: "java",
|
||||
flavourVersion: JAVA_ENGINE_IDENTITY,
|
||||
pattern,
|
||||
flags: ["g"],
|
||||
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_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const imported = (await import(
|
||||
`${pathToFileURL(path.join(pack, "java-regex.mjs")).href}?conformance`
|
||||
)) as unknown as TeaVmJavaRegexModule;
|
||||
adapter = new JavaEngineAdapter(
|
||||
imported,
|
||||
"TeaVM JavaScript · Node.js conformance · not OpenJDK",
|
||||
);
|
||||
await adapter.load();
|
||||
});
|
||||
|
||||
describe("pinned TeaVM 0.15.0 java.util.regex conformance", () => {
|
||||
it("loads the exact compatibility engine identity", async () => {
|
||||
await expect(adapter.load()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
engineName: "TeaVM java.util.regex",
|
||||
engineVersion: JAVA_ENGINE_VERSION,
|
||||
runtimeVersion: expect.stringContaining("not OpenJDK"),
|
||||
offsetUnit: "utf16",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns native UTF-16 offsets and named/numbered metadata", async () => {
|
||||
const result = await adapter.execute(
|
||||
request("(?<emoji>😀)([A-Z]+)?", "😀AB 😀", {
|
||||
captureMetadata: [
|
||||
{
|
||||
number: 1,
|
||||
name: "emoji",
|
||||
range: { startUtf16: 0, endUtf16: 12 },
|
||||
repeated: false,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
name: "tail",
|
||||
range: { startUtf16: 12, endUtf16: 21 },
|
||||
repeated: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.matches.map(({ range }) => range)).toEqual([
|
||||
{ startUtf16: 0, endUtf16: 4 },
|
||||
{ startUtf16: 5, endUtf16: 7 },
|
||||
]);
|
||||
expect(result.matches[0]?.captures).toEqual([
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "emoji",
|
||||
value: "😀",
|
||||
range: { startUtf16: 0, endUtf16: 2 },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
groupNumber: 2,
|
||||
groupName: "tail",
|
||||
value: "AB",
|
||||
range: { startUtf16: 2, endUtf16: 4 },
|
||||
}),
|
||||
]);
|
||||
expect(result.matches[1]?.captures[1]).toEqual({
|
||||
groupNumber: 2,
|
||||
groupName: "tail",
|
||||
status: "did-not-participate",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports TeaVM lookbehind, numeric backreferences and atomic groups", async () => {
|
||||
const lookbehind = await adapter.execute(request("(?<=a)(b)\\1", "abb ab"));
|
||||
expect(lookbehind.matches.map(({ value }) => value)).toEqual(["bb"]);
|
||||
|
||||
const atomic = await adapter.execute(request("(?>a|ab)c", "abc"));
|
||||
expect(atomic.matches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("advances global zero-length matches in UTF-16 without looping", async () => {
|
||||
const result = await adapter.execute(request("(?=.)", "😀x"));
|
||||
expect(result.matches.map(({ range }) => range)).toEqual([
|
||||
{ startUtf16: 0, endUtf16: 0 },
|
||||
{ startUtf16: 1, endUtf16: 1 },
|
||||
{ startUtf16: 2, endUtf16: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps i, m, s, u and x to TeaVM Pattern flags", async () => {
|
||||
const cases = [
|
||||
["a", "A", ["i"]],
|
||||
["ä", "Ä", ["i", "u"]],
|
||||
["^b$", "a\nb\nc", ["m"]],
|
||||
["a.b", "a\nb", ["s"]],
|
||||
["a b # comment", "ab", ["x"]],
|
||||
] as const;
|
||||
|
||||
for (const [pattern, subject, flags] of cases) {
|
||||
const result = await adapter.execute(
|
||||
request(pattern, subject, { flags: [...flags] }),
|
||||
);
|
||||
expect(result.matches, `${flags.join("")}: ${pattern}`).toHaveLength(1);
|
||||
}
|
||||
|
||||
const normalized = await adapter.execute(
|
||||
request("a", "A", { flags: ["i", "m", "s", "u", "x"] }),
|
||||
);
|
||||
expect(normalized.flags.effectiveFlags).toBe("imsux");
|
||||
});
|
||||
|
||||
it("maps d to UNIX_LINES and reports the honest U compatibility boundary", async () => {
|
||||
const ordinaryLines = await adapter.execute(
|
||||
request(".", "\r", { flags: [] }),
|
||||
);
|
||||
const unixLines = await adapter.execute(
|
||||
request(".", "\r", { flags: ["d"] }),
|
||||
);
|
||||
expect(ordinaryLines.matches).toHaveLength(0);
|
||||
expect(unixLines.matches).toHaveLength(1);
|
||||
|
||||
const result = await adapter.execute(
|
||||
request("\\w+", "é", { flags: ["d", "U"] }),
|
||||
);
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.matches).toHaveLength(0);
|
||||
expect(result.flags.userFlags).toBe("dU");
|
||||
expect(result.flags.effectiveFlags).toBe("duU");
|
||||
expect(result.flags.internallyAddedIndicesFlag).toBe(false);
|
||||
expect(result.diagnostics[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "engine-compatibility",
|
||||
message: expect.stringContaining("does not implement OpenJDK"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses TeaVM Matcher native single-digit $n replacement", async () => {
|
||||
const result = await adapter.replace(replacement("(a)", "a a", "<$1>"));
|
||||
expect(result.output).toBe("<a> <a>");
|
||||
expect(result.outputTruncated).toBe(false);
|
||||
expect(result.execution.matches).toHaveLength(2);
|
||||
|
||||
const escaped = await adapter.replace(replacement("(a)", "a a", "\\$1"));
|
||||
expect(escaped.output).toBe("$1 $1");
|
||||
});
|
||||
|
||||
it("rejects ${name}, which TeaVM 0.15.0 does not support", async () => {
|
||||
const unsupportedNamed = await adapter.replace(
|
||||
replacement("(?<name>a)", "a", "${name}"),
|
||||
);
|
||||
expect(unsupportedNamed.execution.accepted).toBe(false);
|
||||
expect(unsupportedNamed.execution.diagnostics[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "replacement-error",
|
||||
message: expect.stringMatching(/IllegalArgumentException/u),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns compile diagnostics and enforces match/capture/output caps", async () => {
|
||||
const invalid = await adapter.execute(request("(", "x"));
|
||||
expect(invalid.accepted).toBe(false);
|
||||
expect(invalid.diagnostics[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "compile-error",
|
||||
provenance: "reported",
|
||||
}),
|
||||
);
|
||||
|
||||
const matchLimited = await adapter.execute(
|
||||
request("a", "aaa", { maximumMatches: 1 }),
|
||||
);
|
||||
expect(matchLimited.matches).toHaveLength(1);
|
||||
expect(matchLimited.truncated).toBe(true);
|
||||
|
||||
const captureLimited = await adapter.execute(
|
||||
request("(a)(b)", "ab", {
|
||||
maximumCaptureRows: 1,
|
||||
captureMetadata: [
|
||||
{
|
||||
number: 1,
|
||||
range: { startUtf16: 0, endUtf16: 3 },
|
||||
repeated: false,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
range: { startUtf16: 3, endUtf16: 6 },
|
||||
repeated: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(captureLimited.matches).toHaveLength(0);
|
||||
expect(captureLimited.truncated).toBe(true);
|
||||
|
||||
const outputLimited = await adapter.replace(
|
||||
replacement("a", "aaaa", "long", { maximumOutputBytes: 5 }),
|
||||
);
|
||||
expect(outputLimited.output).toBe("longl");
|
||||
expect(outputLimited.outputBytes).toBe(5);
|
||||
expect(outputLimited.outputTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it("ships internally consistent source identity, licence and checksums", async () => {
|
||||
const metadata = JSON.parse(
|
||||
await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
|
||||
) as {
|
||||
engineName: string;
|
||||
engineVersion: string;
|
||||
semanticIdentity: string;
|
||||
source: {
|
||||
tag: string;
|
||||
commitSha1: string;
|
||||
license: string;
|
||||
};
|
||||
};
|
||||
expect(metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
engineName: "TeaVM java.util.regex",
|
||||
engineVersion: "0.15.0",
|
||||
semanticIdentity: expect.stringContaining("not OpenJDK"),
|
||||
source: expect.objectContaining({
|
||||
tag: "0.15.0",
|
||||
commitSha1: "ee91b03e616c4b45401cd11fb0cd7eb0daf6649b",
|
||||
license: "Apache-2.0",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const checksumText = await readFile(path.join(pack, "SHA256SUMS"), "utf8");
|
||||
for (const line of checksumText.trim().split("\n")) {
|
||||
const [expected, name] = line.split(/ {2}/u);
|
||||
const contents = await readFile(path.join(pack, name ?? ""));
|
||||
expect(createHash("sha256").update(contents).digest("hex"), name).toBe(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
expect(await readFile(path.join(pack, "LICENSE.txt"), "utf8")).toContain(
|
||||
"Apache License",
|
||||
);
|
||||
});
|
||||
});
|
||||
240
tests/conformance/python.conformance.test.ts
Normal file
240
tests/conformance/python.conformance.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// @vitest-environment node
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
PythonEngineAdapter,
|
||||
PYODIDE_ENGINE_VERSION,
|
||||
PYTHON_ENGINE_VERSION,
|
||||
} from "../../src/regex/execution/adapters/python/PythonEngineAdapter";
|
||||
import type {
|
||||
PyodideModule,
|
||||
PyodideRuntime,
|
||||
} from "../../src/regex/execution/adapters/python/python-module";
|
||||
import type {
|
||||
RegexExecutionRequest,
|
||||
RegexReplacementRequest,
|
||||
} from "../../src/regex/model/match";
|
||||
|
||||
const engineDirectory = path.resolve(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"engines",
|
||||
"python",
|
||||
);
|
||||
|
||||
function request(
|
||||
overrides: Partial<RegexExecutionRequest> = {},
|
||||
): RegexExecutionRequest {
|
||||
return {
|
||||
flavour: "python",
|
||||
flavourVersion: PYTHON_ENGINE_VERSION,
|
||||
pattern: "a",
|
||||
flags: ["g"],
|
||||
subject: "a",
|
||||
captureMetadata: [],
|
||||
scanAll: false,
|
||||
maximumMatches: 100,
|
||||
maximumCaptureRows: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CPython re 3.14.2 conformance through self-hosted Pyodide", () => {
|
||||
let runtime: PyodideRuntime;
|
||||
let adapter: PythonEngineAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleUrl = pathToFileURL(path.join(engineDirectory, "pyodide.mjs"));
|
||||
const imported = (await import(
|
||||
/* @vite-ignore */ moduleUrl.href
|
||||
)) as PyodideModule;
|
||||
expect(imported.version).toBe(PYODIDE_ENGINE_VERSION);
|
||||
runtime = await imported.loadPyodide({
|
||||
indexURL: `${engineDirectory}${path.sep}`,
|
||||
stdout: () => undefined,
|
||||
stderr: () => undefined,
|
||||
});
|
||||
adapter = new PythonEngineAdapter(runtime, "Node conformance runtime");
|
||||
await adapter.load();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
adapter?.terminate();
|
||||
});
|
||||
|
||||
it("reports the exact engine, runtime and native offset identity", async () => {
|
||||
await expect(adapter.load()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "python",
|
||||
engineName: "CPython re via Pyodide",
|
||||
engineVersion: "CPython 3.14.2 re",
|
||||
runtimeVersion: expect.stringContaining("Pyodide 314.0.3"),
|
||||
offsetUnit: "code-point",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses native Python named groups and preserves optional captures", async () => {
|
||||
const result = await adapter.execute(
|
||||
request({
|
||||
pattern: "(?P<word>[A-Za-z]+)(?:-(\\d+))?",
|
||||
subject: "abc-12 xyz",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.matches).toHaveLength(2);
|
||||
expect(result.matches[0]?.captures).toEqual([
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "word",
|
||||
value: "abc",
|
||||
}),
|
||||
expect.objectContaining({ groupNumber: 2, value: "12" }),
|
||||
]);
|
||||
expect(result.matches[1]?.captures).toEqual([
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "word",
|
||||
value: "xyz",
|
||||
}),
|
||||
{ groupNumber: 2, status: "did-not-participate" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes astral and zero-length code-point offsets to UTF-16", async () => {
|
||||
const result = await adapter.execute(
|
||||
request({
|
||||
pattern: "(?=.)",
|
||||
subject: "😀a",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.matches.map((match) => ({
|
||||
native: match.nativeRange,
|
||||
editor: match.range,
|
||||
status: match.valueStatus,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
native: { start: 0, end: 0, unit: "code-point" },
|
||||
editor: { startUtf16: 0, endUtf16: 0 },
|
||||
status: "complete",
|
||||
},
|
||||
{
|
||||
native: { start: 1, end: 1, unit: "code-point" },
|
||||
editor: { startUtf16: 2, endUtf16: 2 },
|
||||
status: "complete",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("implements g, a, i, m, s and x with scan-all normalization", async () => {
|
||||
const combined = await adapter.execute(
|
||||
request({
|
||||
pattern: "^ a . b $",
|
||||
flags: ["i", "m", "s", "x"],
|
||||
subject: "z\nA\nB\nq",
|
||||
scanAll: true,
|
||||
}),
|
||||
);
|
||||
expect(combined.accepted).toBe(true);
|
||||
expect(combined.flags.effectiveFlags).toBe("gimsx");
|
||||
expect(combined.flags.internallyAddedGlobalFlag).toBe(true);
|
||||
expect(combined.matches.map((match) => match.value)).toEqual(["A\nB"]);
|
||||
|
||||
const ascii = await adapter.execute(
|
||||
request({
|
||||
pattern: "\\w+",
|
||||
flags: ["g", "a"],
|
||||
subject: "é az",
|
||||
}),
|
||||
);
|
||||
expect(ascii.matches.map((match) => match.value)).toEqual(["az"]);
|
||||
});
|
||||
|
||||
it("uses native match.expand syntax for named, numbered and zero-length replacement", async () => {
|
||||
const replacementRequest: RegexReplacementRequest = {
|
||||
...request({
|
||||
pattern: "(?P<word>[a-z]+)-(\\d+)",
|
||||
subject: "a-1 b-22",
|
||||
}),
|
||||
replacement: "\\g<word>[\\2]",
|
||||
maximumOutputBytes: 1_024,
|
||||
};
|
||||
const replaced = await adapter.replace(replacementRequest);
|
||||
|
||||
expect(replaced.execution.accepted).toBe(true);
|
||||
expect(replaced.output).toBe("a[1] b[22]");
|
||||
expect(replaced.outputTruncated).toBe(false);
|
||||
|
||||
const zeroLength = await adapter.replace({
|
||||
...request({
|
||||
pattern: "(?=.)",
|
||||
subject: "😀a",
|
||||
}),
|
||||
replacement: "|",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(zeroLength.output).toBe("|😀|a");
|
||||
});
|
||||
|
||||
it("bounds UTF-8 output without splitting an astral scalar", async () => {
|
||||
const result = await adapter.replace({
|
||||
...request({ pattern: ".", subject: "abc" }),
|
||||
replacement: "😀",
|
||||
maximumOutputBytes: 5,
|
||||
});
|
||||
|
||||
expect(result.execution.accepted).toBe(true);
|
||||
expect(result.output).toBe("😀");
|
||||
expect(result.outputBytes).toBe(4);
|
||||
expect(result.outputTruncated).toBe(true);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("reports native compile and replacement errors", async () => {
|
||||
const compileError = await adapter.execute(request({ pattern: "😀(" }));
|
||||
expect(compileError.accepted).toBe(false);
|
||||
expect(compileError.diagnostics[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "compile-error",
|
||||
range: { startUtf16: 2, endUtf16: 2 },
|
||||
}),
|
||||
);
|
||||
|
||||
const replacementError = await adapter.replace({
|
||||
...request({ pattern: "(?P<word>a)" }),
|
||||
replacement: "\\g<missing>",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(replacementError.execution.accepted).toBe(false);
|
||||
expect(replacementError.execution.diagnostics[0]?.code).toBe(
|
||||
"replacement-error",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces authoritative match and capture-row collection caps", async () => {
|
||||
const matchCap = await adapter.execute(
|
||||
request({
|
||||
pattern: "a",
|
||||
subject: "aaa",
|
||||
maximumMatches: 1,
|
||||
}),
|
||||
);
|
||||
expect(matchCap.matches).toHaveLength(1);
|
||||
expect(matchCap.truncated).toBe(true);
|
||||
|
||||
const rowCap = await adapter.execute(
|
||||
request({
|
||||
pattern: "(a)(b)",
|
||||
subject: "ab",
|
||||
maximumCaptureRows: 1,
|
||||
}),
|
||||
);
|
||||
expect(rowCap.matches).toHaveLength(0);
|
||||
expect(rowCap.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user