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