feat: add multi-engine regex flavour support

This commit is contained in:
2026-07-27 11:43:51 +02:00
parent 7079cde15f
commit 7f3a91ad37
340 changed files with 643286 additions and 483 deletions

View 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);
});
});