feat: add multi-engine regex flavour support
This commit is contained in:
344
tests/conformance/ruby.conformance.test.ts
Normal file
344
tests/conformance/ruby.conformance.test.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
// @vitest-environment node
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { File, PreopenDirectory } from "@bjorn3/browser_wasi_shim";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
RubyEngineAdapter,
|
||||
RUBY_ENGINE_VERSION,
|
||||
RUBY_FLAVOUR_VERSION,
|
||||
} from "../../src/regex/execution/adapters/ruby/RubyEngineAdapter";
|
||||
import { DefaultRubyVM } from "../../src/regex/execution/adapters/ruby/ruby-module";
|
||||
import type {
|
||||
RegexExecutionRequest,
|
||||
RegexReplacementRequest,
|
||||
} from "../../src/regex/model/match";
|
||||
|
||||
const runtimeFile = path.resolve(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"engines",
|
||||
"ruby",
|
||||
"ruby.wasm",
|
||||
);
|
||||
|
||||
function request(
|
||||
overrides: Partial<RegexExecutionRequest> = {},
|
||||
): RegexExecutionRequest {
|
||||
return {
|
||||
flavour: "ruby",
|
||||
flavourVersion: RUBY_FLAVOUR_VERSION,
|
||||
pattern: "a",
|
||||
flags: ["g"],
|
||||
subject: "a",
|
||||
captureMetadata: [],
|
||||
scanAll: false,
|
||||
maximumMatches: 100,
|
||||
maximumCaptureRows: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CRuby 4.0.0 Regexp conformance through ruby.wasm", () => {
|
||||
let adapter: RubyEngineAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await WebAssembly.compile(await readFile(runtimeFile));
|
||||
const { vm, wasi } = await DefaultRubyVM(module, {
|
||||
consolePrint: false,
|
||||
});
|
||||
const requestFile = new File([]);
|
||||
const root = wasi.fds[3];
|
||||
if (!(root instanceof PreopenDirectory)) {
|
||||
throw new Error("Ruby conformance runtime has no writable root.");
|
||||
}
|
||||
root.dir.contents.set("regex-tools-request.bin", requestFile);
|
||||
const outputFile = new File([]);
|
||||
root.dir.contents.set("regex-tools-output.bin", outputFile);
|
||||
adapter = new RubyEngineAdapter(
|
||||
vm,
|
||||
"Node conformance runtime",
|
||||
(payload) => {
|
||||
requestFile.data = payload;
|
||||
},
|
||||
() => outputFile.data,
|
||||
);
|
||||
await adapter.load();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
adapter?.terminate();
|
||||
});
|
||||
|
||||
it("reports the exact CRuby, ruby.wasm, platform and offset identity", async () => {
|
||||
await expect(adapter.load()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "ruby",
|
||||
engineName: "CRuby Regexp via ruby.wasm",
|
||||
engineVersion: RUBY_ENGINE_VERSION,
|
||||
runtimeVersion: expect.stringMatching(
|
||||
/ruby\.wasm 2\.9\.3-2\.9\.4 · wasm32-wasi/u,
|
||||
),
|
||||
offsetUnit: "code-point",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses native Ruby named groups, intersections and optional captures", async () => {
|
||||
const result = await adapter.execute(
|
||||
request({
|
||||
pattern: "(?<word>[a-z&&[^aeiou]]+)(?:-(?<number>\\d+))?",
|
||||
flags: ["g", "i"],
|
||||
subject: "BCD-12 xyz",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.matches.map((match) => match.value)).toEqual([
|
||||
"BCD-12",
|
||||
"xyz",
|
||||
]);
|
||||
expect(result.matches[0]?.captures).toEqual([
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "word",
|
||||
value: "BCD",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
groupNumber: 2,
|
||||
groupName: "number",
|
||||
value: "12",
|
||||
}),
|
||||
]);
|
||||
expect(result.matches[1]?.captures).toEqual([
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "word",
|
||||
value: "xyz",
|
||||
}),
|
||||
{
|
||||
groupNumber: 2,
|
||||
groupName: "number",
|
||||
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,
|
||||
value: match.value,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
native: { start: 0, end: 0, unit: "code-point" },
|
||||
editor: { startUtf16: 0, endUtf16: 0 },
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
native: { start: 1, end: 1, unit: "code-point" },
|
||||
editor: { startUtf16: 2, endUtf16: 2 },
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("implements Ruby g, i, m and x with scan-all normalization", async () => {
|
||||
const combined = await adapter.execute(
|
||||
request({
|
||||
pattern: " a . b ",
|
||||
flags: ["i", "m", "x"],
|
||||
subject: "z A\nB q",
|
||||
scanAll: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(combined.accepted).toBe(true);
|
||||
expect(combined.flags.effectiveFlags).toBe("gimx");
|
||||
expect(combined.flags.internallyAddedGlobalFlag).toBe(true);
|
||||
expect(combined.matches.map((match) => match.value)).toEqual(["A\nB"]);
|
||||
});
|
||||
|
||||
it("uses native String#gsub/sub named, numbered and zero-length replacement", async () => {
|
||||
const replacementRequest: RegexReplacementRequest = {
|
||||
...request({
|
||||
pattern: "(?<word>[a-z]+)-(?<number>\\d+)",
|
||||
subject: "a-1 b-22",
|
||||
}),
|
||||
replacement: "\\k<word>[\\k<number>]",
|
||||
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 numbered = await adapter.replace({
|
||||
...request({
|
||||
pattern: "([a-z]+)-(\\d+)",
|
||||
subject: "a-1 b-22",
|
||||
}),
|
||||
replacement: "\\1[\\2]",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(numbered.output).toBe("a[1] b[22]");
|
||||
|
||||
const firstOnly = await adapter.replace({
|
||||
...request({
|
||||
pattern: "(?<word>[a-z]+)",
|
||||
flags: [],
|
||||
subject: "a b",
|
||||
}),
|
||||
replacement: "<\\k<word>>",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(firstOnly.output).toBe("<a> b");
|
||||
|
||||
const zeroLength = await adapter.replace({
|
||||
...request({
|
||||
pattern: "(?=.)",
|
||||
subject: "😀a",
|
||||
}),
|
||||
replacement: "|",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(zeroLength.output).toBe("|😀|a");
|
||||
});
|
||||
|
||||
it("matches CRuby replacement grammar without materializing expanded output", async () => {
|
||||
const base = request({
|
||||
pattern: "(a)(b)?",
|
||||
subject: "ab a",
|
||||
});
|
||||
const cases = [
|
||||
["\\0", "ab a"],
|
||||
["\\&", "ab a"],
|
||||
["\\1", "a a"],
|
||||
["\\2", "b "],
|
||||
["\\9", " "],
|
||||
["\\+", "b a"],
|
||||
["\\\\", "\\ \\"],
|
||||
["\\q", "\\q \\q"],
|
||||
["\\`", " ab "],
|
||||
["\\'", " a "],
|
||||
["$1", "$1 $1"],
|
||||
] as const;
|
||||
for (const [template, expected] of cases) {
|
||||
const result = await adapter.replace({
|
||||
...base,
|
||||
replacement: template,
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(result.execution.accepted, template).toBe(true);
|
||||
expect(result.output, template).toBe(expected);
|
||||
expect(result.outputTruncated, template).toBe(false);
|
||||
}
|
||||
|
||||
const namedDisablesNumeric = await adapter.replace({
|
||||
...request({
|
||||
pattern: "(?<word>a)",
|
||||
subject: "a",
|
||||
}),
|
||||
replacement: "\\1",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(namedDisablesNumeric.output).toBe("");
|
||||
});
|
||||
|
||||
it("stops expansion at the byte bound and replacement at result caps", async () => {
|
||||
const boundedExpansion = await adapter.replace({
|
||||
...request({
|
||||
pattern: "(a+)",
|
||||
subject: "a".repeat(4_096),
|
||||
}),
|
||||
replacement: "\\1".repeat(32_768),
|
||||
maximumOutputBytes: 5,
|
||||
});
|
||||
expect(boundedExpansion.execution.accepted).toBe(true);
|
||||
expect(boundedExpansion.output).toBe("aaaaa");
|
||||
expect(boundedExpansion.outputBytes).toBe(5);
|
||||
expect(boundedExpansion.outputTruncated).toBe(true);
|
||||
|
||||
const partial = await adapter.replace({
|
||||
...request({
|
||||
pattern: "a",
|
||||
subject: "aaa",
|
||||
maximumMatches: 1,
|
||||
}),
|
||||
replacement: "x",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(partial.execution.matches).toHaveLength(1);
|
||||
expect(partial.execution.truncated).toBe(true);
|
||||
expect(partial.output).toBe("xaa");
|
||||
expect(partial.outputTruncated).toBe(false);
|
||||
});
|
||||
|
||||
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]?.code).toBe("compile-error");
|
||||
|
||||
const replacementError = await adapter.replace({
|
||||
...request({ pattern: "(?<word>a)" }),
|
||||
replacement: "\\k<missing>",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(replacementError.execution.accepted).toBe(false);
|
||||
expect(replacementError.execution.diagnostics[0]?.code).toBe(
|
||||
"replacement-error",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects lone UTF-16 surrogates before entering ruby.wasm", async () => {
|
||||
await expect(
|
||||
adapter.execute(request({ subject: `a${String.fromCharCode(0xd800)}` })),
|
||||
).rejects.toThrow(/unpaired UTF-16 surrogate/u);
|
||||
});
|
||||
|
||||
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