241 lines
6.8 KiB
TypeScript
241 lines
6.8 KiB
TypeScript
// @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);
|
|
});
|
|
});
|