// @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 { 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: "(?[a-z&&[^aeiou]]+)(?:-(?\\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: "(?[a-z]+)-(?\\d+)", subject: "a-1 b-22", }), replacement: "\\k[\\k]", 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: "(?[a-z]+)", flags: [], subject: "a b", }), replacement: "<\\k>", maximumOutputBytes: 1_024, }); expect(firstOnly.output).toBe(" 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: "(?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: "(?a)" }), replacement: "\\k", 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); }); });