feat: add Python and Java regex engines
This commit is contained in:
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