feat: add multi-engine regex flavour support
This commit is contained in:
278
tests/conformance/rust.conformance.test.ts
Normal file
278
tests/conformance/rust.conformance.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
// @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 { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
RUST_ENGINE_VERSION,
|
||||
RUST_FLAVOUR_VERSION,
|
||||
RustEngineAdapter,
|
||||
} from "../../src/regex/execution/adapters/rust/RustEngineAdapter";
|
||||
import {
|
||||
RustWasmBridge,
|
||||
type RustRegexWasmModule,
|
||||
} from "../../src/regex/execution/adapters/rust/RustWasmBridge";
|
||||
import type {
|
||||
RegexExecutionRequest,
|
||||
RegexReplacementRequest,
|
||||
} from "../../src/regex/model/match";
|
||||
|
||||
const pack = path.resolve("public/engines/rust");
|
||||
let adapter: RustEngineAdapter;
|
||||
|
||||
function request(
|
||||
pattern: string,
|
||||
subject: string,
|
||||
overrides: Partial<RegexExecutionRequest> = {},
|
||||
): RegexExecutionRequest {
|
||||
return {
|
||||
flavour: "rust",
|
||||
flavourVersion: RUST_FLAVOUR_VERSION,
|
||||
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_024,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const imported = (await import(
|
||||
`${pathToFileURL(path.join(pack, "rust-regex.mjs")).href}?conformance`
|
||||
)) as RustRegexWasmModule;
|
||||
imported.initSync({
|
||||
module: await WebAssembly.compile(
|
||||
await readFile(path.join(pack, "rust-regex_bg.wasm")),
|
||||
),
|
||||
});
|
||||
adapter = new RustEngineAdapter(
|
||||
new RustWasmBridge(imported),
|
||||
"Node.js conformance",
|
||||
);
|
||||
await adapter.load();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
adapter?.terminate();
|
||||
});
|
||||
|
||||
describe("Rust regex 1.13.1 WebAssembly conformance", () => {
|
||||
it("loads the exact crate and compiler identity", async () => {
|
||||
await expect(adapter.load()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "rust",
|
||||
engineName: "Rust regex crate",
|
||||
engineVersion: RUST_ENGINE_VERSION,
|
||||
runtimeVersion: expect.stringContaining(
|
||||
"rustc 1.97.1 wasm32-unknown-unknown",
|
||||
),
|
||||
offsetUnit: "utf8-byte",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns native UTF-8 byte offsets and named captures", async () => {
|
||||
const result = await adapter.execute(
|
||||
request("(?P<emoji>😀+)([A-Z]+)?", "x😀😀AB 😀"),
|
||||
);
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(
|
||||
result.matches.map(({ nativeRange, range }) => ({ nativeRange, range })),
|
||||
).toEqual([
|
||||
{
|
||||
nativeRange: { start: 1, end: 11, unit: "utf8-byte" },
|
||||
range: { startUtf16: 1, endUtf16: 7 },
|
||||
},
|
||||
{
|
||||
nativeRange: { start: 12, end: 16, unit: "utf8-byte" },
|
||||
range: { startUtf16: 8, endUtf16: 10 },
|
||||
},
|
||||
]);
|
||||
expect(result.matches[0]?.captures[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "emoji",
|
||||
value: "😀😀",
|
||||
nativeRange: { start: 1, end: 9, unit: "utf8-byte" },
|
||||
}),
|
||||
);
|
||||
expect(result.matches[1]?.captures[1]).toEqual({
|
||||
groupNumber: 2,
|
||||
status: "did-not-participate",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Rust's native Unicode default and rejects unsupported constructs", async () => {
|
||||
const unicode = await adapter.execute(
|
||||
request("^\\w+$", "Grüße", { flags: [] }),
|
||||
);
|
||||
expect(unicode.matches).toHaveLength(1);
|
||||
|
||||
const unsupported = await adapter.execute(request("(?<=a)b", "ab"));
|
||||
expect(unsupported.accepted).toBe(false);
|
||||
expect(unsupported.diagnostics[0]).toEqual(
|
||||
expect.objectContaining({ code: "compile-error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps i, m, s, U, u, x and R through RegexBuilder", async () => {
|
||||
const cases = [
|
||||
["a", "A", ["i"]],
|
||||
["^b$", "a\nb\nc", ["m"]],
|
||||
["a.b", "a\nb", ["s"]],
|
||||
["a+?", "aaa", ["U"]],
|
||||
["^ a + $", "aaa", ["x"]],
|
||||
["^b$", "a\r\nb\r\nc", ["m", "R"]],
|
||||
["\\p{Greek}+", "λ", ["u"]],
|
||||
] 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 ungreedy = await adapter.execute(
|
||||
request("a+", "aaa", { flags: ["U"] }),
|
||||
);
|
||||
expect(ungreedy.matches[0]?.value).toBe("a");
|
||||
});
|
||||
|
||||
it("uses native Captures::expand replacement syntax", async () => {
|
||||
const result = await adapter.replace(
|
||||
replacement(
|
||||
"(?P<word>[a-z]+)-(\\d+)",
|
||||
"a-1 b-22",
|
||||
"${word}[$2]/$word/$$",
|
||||
),
|
||||
);
|
||||
expect(result.execution.accepted).toBe(true);
|
||||
expect(result.output).toBe("a[1]/a/$ b[22]/b/$");
|
||||
|
||||
const longest = await adapter.replace(replacement("(a)", "a", "$1x|${1}x"));
|
||||
expect(longest.output).toBe("|ax");
|
||||
});
|
||||
|
||||
it("bounds capture amplification and preserves the untouched partial suffix", async () => {
|
||||
const bounded = await adapter.replace(
|
||||
replacement("(a+)", "a".repeat(256 * 1024), "$1".repeat(4_096), {
|
||||
maximumOutputBytes: 5,
|
||||
}),
|
||||
);
|
||||
expect(bounded.execution.accepted).toBe(true);
|
||||
expect(bounded.output).toBe("aaaaa");
|
||||
expect(bounded.outputBytes).toBe(5);
|
||||
expect(bounded.outputTruncated).toBe(true);
|
||||
|
||||
const partial = await adapter.replace(
|
||||
replacement("a", "aaa", "x", { maximumMatches: 1 }),
|
||||
);
|
||||
expect(partial.execution.matches).toHaveLength(1);
|
||||
expect(partial.execution.truncated).toBe(true);
|
||||
expect(partial.output).toBe("xaa");
|
||||
expect(partial.outputTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces match/capture/output bounds without splitting UTF-8", async () => {
|
||||
const limited = await adapter.execute(
|
||||
request("a", "aaa", { maximumMatches: 1 }),
|
||||
);
|
||||
expect(limited.matches).toHaveLength(1);
|
||||
expect(limited.truncated).toBe(true);
|
||||
|
||||
const rows = await adapter.execute(
|
||||
request("(a)(b)", "ab", { maximumCaptureRows: 1 }),
|
||||
);
|
||||
expect(rows.matches).toHaveLength(0);
|
||||
expect(rows.truncated).toBe(true);
|
||||
|
||||
const output = await adapter.replace(
|
||||
replacement(".", "abc", "😀", { maximumOutputBytes: 5 }),
|
||||
);
|
||||
expect(output.output).toBe("😀");
|
||||
expect(output.outputBytes).toBe(4);
|
||||
expect(output.outputTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects lone UTF-16 surrogates before entering Rust", async () => {
|
||||
await expect(adapter.execute(request(".", "\ud800"))).rejects.toThrow(
|
||||
/unpaired UTF-16 surrogate/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts control-heavy subjects within the decoded 16 MiB contract", async () => {
|
||||
const subject = "\u0000".repeat(3 * 1024 * 1024);
|
||||
const result = await adapter.execute(
|
||||
request("^\\x00+$", subject, {
|
||||
flags: [],
|
||||
maximumMatches: 1,
|
||||
maximumCaptureRows: 1,
|
||||
}),
|
||||
);
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.matches[0]?.nativeRange).toEqual({
|
||||
start: 0,
|
||||
end: subject.length,
|
||||
unit: "utf8-byte",
|
||||
});
|
||||
});
|
||||
|
||||
it("ships verified metadata, licences, Cargo.lock and checksums", async () => {
|
||||
const metadata = JSON.parse(
|
||||
await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
|
||||
) as {
|
||||
engineVersion: string;
|
||||
source: { crateChecksumSha256: string; license: string };
|
||||
toolchain: {
|
||||
rustcCommitSha1: string;
|
||||
wasmBindgenVersion: string;
|
||||
};
|
||||
};
|
||||
expect(metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
engineVersion: "1.13.1",
|
||||
source: expect.objectContaining({
|
||||
crateChecksumSha256:
|
||||
"f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d",
|
||||
license: "MIT OR Apache-2.0",
|
||||
}),
|
||||
toolchain: expect.objectContaining({
|
||||
rustcCommitSha1: "8bab26f4f68e0e26f0bb7960be334d5b520ea452",
|
||||
wasmBindgenVersion: "0.2.126",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const checksums = await readFile(path.join(pack, "SHA256SUMS"), "utf8");
|
||||
for (const line of checksums.trim().split("\n")) {
|
||||
const [expected, name] = line.split(/ {2}/u);
|
||||
const value = await readFile(path.join(pack, name ?? ""));
|
||||
expect(createHash("sha256").update(value).digest("hex"), name).toBe(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
await readFile(path.join(pack, "LICENSE-MIT.txt"), "utf8"),
|
||||
).toContain("Permission is hereby granted");
|
||||
expect(
|
||||
await readFile(path.join(pack, "LICENSE-Apache-2.0.txt"), "utf8"),
|
||||
).toContain("Apache License");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user