diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index ee9089071..af00ebc2c 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -600,6 +600,23 @@ export function emitModuleParts(emitter: LlEmitter): string[] { regexMatchers.set(key, code); for (const decl of code.decls) emitter.declare(decl); } + // Replacers of global substitutions with a literal replacement; the call + // sites register only patterns inside the compiled subset. + const regexReplacers: RegexMatcherCode[] = []; + for (const rr of emitter.regexReplacers.values()) { + const code = compileRegexMatcher( + rr.sym, + rr.pattern, + rr.flags, + emitter.sizeType, + !emitter.wasi, + rr.replacement, + ); + if (code === null) + throw new InternalCompilerError(`llvm emitter bug: replacer ${rr.sym} did not compile`); + regexReplacers.push(code); + for (const decl of code.decls) emitter.declare(decl); + } const out: string[] = [ `; Generated by scriptc (LLVM backend) from ${emitter.mod.sourceFile}. Do not edit.`, @@ -882,6 +899,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { } if (emitter.regexInstances.size > 0) out.push(``); for (const code of regexMatchers.values()) out.push(...code.lines, ``); + for (const code of regexReplacers) out.push(...code.lines, ``); for (const [, inst] of emitter.templateStringsInstances) { // One immortal ScrArr per tagged-template site: a data global of // interned cooked-string literals, and the ScrArr header over it diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 1a2c8bef8..d15a8c017 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1303,6 +1303,12 @@ export class LlEmitter { string, { sym: string; src: string; fl: string; pattern: string; flags: string } >(); + /** Build-time replacers: one per (pattern, flags, replacement) of a + * global substitution with a literal replacement (regex-matchers.ts). */ + readonly regexReplacers = new Map< + string, + { sym: string; pattern: string; flags: string; replacement: string } + >(); /** Interned tagged-template strings objects: per-site key → { symbol, * interned cooked-literal refs }. One immortal ScrArr of string slots * per template SITE (the spec's per-occurrence identity — the C @@ -5439,6 +5445,8 @@ export class LlEmitter { return addNumbered(this.threadSlotsBlocks, key, value as string, "@sc_slots_", names); case "regex": return addNumberedSymbol(this.regexInstances, key, value as never, "sc_re_", names); + case "regexReplacer": + return addNumberedSymbol(this.regexReplacers, key, value as never, "sc_rr_", names); case "templateStrings": return addNumberedSymbol( this.templateStringsInstances, diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index d3b72897e..eed138fe2 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -661,6 +661,29 @@ export function regexLiteralTemplate( return `@${re.sym}`; } +/** The build-time replacer of a global substitution of regex literal `e` + * with the literal `replacement` (regex-matchers.ts), registered on first + * use. */ +export function regexLiteralReplacer( + host: LlvmEmitterContext, + e: IrExpr & { kind: "regexLit" }, + replacement: string, +): string { + const key = `${e.flags}/${e.pattern}\0${replacement}`; + let rr = host.regexReplacers.get(key); + if (!rr) { + rr = { + sym: `sc_rr_${host.regexReplacers.size}`, + pattern: e.pattern, + flags: e.flags, + replacement, + }; + host.regexReplacers.set(key, rr); + } + host.journal?.entry("regexReplacer", key, rr); + return `@${rr.sym}`; +} + /** A string equality or comparison between `s.charAt(i)` and a literal of * exactly one UTF-16 code unit: the charAt call, the unit, and whether the * charAt is the left operand. */ diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index 59ab625e5..51688eafa 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -6,7 +6,8 @@ import { LlvmUnsupportedError } from "./unsupported.js"; import type { LlvmEmitterContext, LlValue } from "./expr-context.js"; import { f64Lit, llvmCommentText } from "./common.js"; import { borrowableInputs, emitBorrowedInputs, emitBorrowedInput } from "./borrowed-inputs.js"; -import { regexLiteralTemplate } from "./expr-primitives.js"; +import { regexLiteralReplacer, regexLiteralTemplate } from "./expr-primitives.js"; +import { canReplaceWith, regexLiteralCompiles } from "./regex-matchers.js"; import { isScalarUnionDef, loadInlineUnion, presentBits } from "./inline-unions.js"; import { preservesRegexInputs } from "./reference-effects.js"; @@ -160,6 +161,39 @@ function directRegexOperandMethod(method: string): boolean { } } +/** The build-time replacer of a substitution: a regex literal running on + * its template, with a literal replacement the replacer can write as is. + * Null when the substitution takes the runtime's loop. */ +function literalReplacer( + host: LlvmEmitterContext, + literal: IrExpr | null, + template: string | null, + replacement: IrExpr, +): string | null { + if (literal === null || template === null || literal.kind !== "regexLit") return null; + if (replacement.kind !== "strLit") return null; + if (!canReplaceWith(literal.pattern, literal.flags, replacement.value)) return null; + if (!regexLiteralCompiles(literal.pattern, literal.flags)) return null; + return regexLiteralReplacer(host, literal, replacement.value); +} + +/** scr_regex_replace_with: the runtime runs `replacer` on an ASCII subject + * and its own loop on any other. */ +function emitReplaceWith( + host: LlvmEmitterContext, + e: IrExpr, + r: LlValue, + args: LlValue[], + replacer: string, +): LlValue { + host.declare(`declare ptr @scr_regex_replace_with(ptr, ptr, ptr, ptr)`); + const t = host.B.tmp(); + host.B.line( + `${t} = call ptr @scr_regex_replace_with(ptr ${r.name}, ptr ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${replacer})`, + ); + return host.own({ name: t, type: e.type }); +} + export function emitRegexIntrinsic( host: LlvmEmitterContext, e: IrExpr & { kind: "regexIntrinsic" }, @@ -234,11 +268,16 @@ export function emitRegexIntrinsic( "scr_regex_match_all_captures_into", `ptr ${r.name}, ptr ${args[0]!.name}, ptr ${args[1]!.name}`, ); - case "replaceAll": + case "replaceAll": { + // A global literal (the only kind a replacer is built for) never takes + // replaceAll's non-global TypeError. + const replacer = literalReplacer(host, literal, template, e.args[1]!); + if (replacer !== null) return emitReplaceWith(host, e, r, args, replacer); return fallible( "scr_regex_replace_all", `ptr ${r.name}, ptr ${args[0]!.name}, ptr ${args[1]!.name}`, ); + } case "split": host.declare(`declare ptr @scr_regex_split_limit(ptr, ptr, double)`); { @@ -323,6 +362,8 @@ export function emitRegexIntrinsic( return host.own({ name: t, type: e.type }); } case "replace": { + const replacer = literalReplacer(host, literal, template, e.args[1]!); + if (replacer !== null) return emitReplaceWith(host, e, r, args, replacer); host.declare(`declare ptr @scr_regex_replace(ptr, ptr, ptr)`); const t = B.tmp(); B.line( diff --git a/packages/compiler/src/backend/llvm/regex-matchers.test.ts b/packages/compiler/src/backend/llvm/regex-matchers.test.ts index d009de7c9..5f93f7295 100644 --- a/packages/compiler/src/backend/llvm/regex-matchers.test.ts +++ b/packages/compiler/src/backend/llvm/regex-matchers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + canReplaceWith, compileRegexMatcher, regexLiteralBailReason, regexLiteralCaptures, @@ -152,3 +153,46 @@ describe("regex literal matcher code", () => { expect(text).not.toContain("constant [128 x i8]"); }); }); + +describe("regex literal replacers", () => { + test("take global, non-sticky, non-plain-text patterns and ASCII replacements without $", () => { + expect(canReplaceWith("a[bc]", "g", "<2>")).toBe(true); + expect(canReplaceWith("a[bc]", "gi", "")).toBe(true); + expect(canReplaceWith("a[bc]", "", "x")).toBe(false); + expect(canReplaceWith("a[bc]", "gy", "x")).toBe(false); + expect(canReplaceWith("a[bc]", "g", "$1")).toBe(false); + expect(canReplaceWith("a[bc]", "g", "é")).toBe(false); + // Plain text is a string search at run time. + expect(canReplaceWith("&", "g", "&")).toBe(false); + expect(canReplaceWith("a\\.b", "g", "x")).toBe(false); + // Letters under i are not plain text. + expect(canReplaceWith("ab", "gi", "x")).toBe(true); + }); + + test("append the gap and the replacement and continue the search", () => { + const code = compileRegexMatcher("r", "<[^>]*>", "g", "i64", true, "|"); + const text = code!.lines.join("\n"); + expect(text).toContain('@r.rep = private unnamed_addr constant [1 x i8] c"|"'); + expect(text).toContain("call void @scr_re_replace_out_reserve(ptr %out, i64 %m.need)"); + expect(text).toContain("load <16 x i8>, ptr %m.src, align 1"); + expect(text).toMatch(/br i1 %m\.past, label %nomatch, label %scan/); + expect(text).not.toContain("m.ret:"); + expect(text).toContain("ret i32 %n.found"); + // The search after a match is a 16-byte scan, not a memchr call. + expect(text).not.toContain("@memchr"); + expect(code!.decls).toContain("declare void @scr_re_replace_out_reserve(ptr, i64)"); + // Without vectors the gap is a memcpy. + const scalar = compileRegexMatcher("r", "<[^>]*>", "g", "i64", false, "|")!.lines.join("\n"); + expect(scalar).not.toContain("<16 x i8>"); + expect(scalar).toContain( + "call void @llvm.memcpy.p0.p0.i64(ptr %m.dst, ptr %m.src, i64 %m.gapw, i1 false)", + ); + }); + + test("escape the replacement bytes and skip the copy of an empty one", () => { + const quoted = compileRegexMatcher("r", "a[bc]", "g", "i64", true, '"\\\n')!.lines.join("\n"); + expect(quoted).toContain('c"\\22\\5C\\0A"'); + const empty = compileRegexMatcher("r", "a[bc]", "g", "i64", true, "")!.lines.join("\n"); + expect(empty).not.toContain("@r.rep"); + }); +}); diff --git a/packages/compiler/src/backend/llvm/regex-matchers.ts b/packages/compiler/src/backend/llvm/regex-matchers.ts index d83d6bae5..0491a6446 100644 --- a/packages/compiler/src/backend/llvm/regex-matchers.ts +++ b/packages/compiler/src/backend/llvm/regex-matchers.ts @@ -24,6 +24,8 @@ * `len`, it returns the capture count (whole match included) so the runtime * can check it against the compiled bytecode. */ +import { InternalCompilerError } from "../../errors.js"; + const ASCII = 128; const INF = -1; /** Larger patterns keep the interpreter (binary size). */ @@ -1853,10 +1855,13 @@ export function compileRegexMatcher( flags: string, sizeType: string, vector: boolean, + replacement: string | null = null, ): RegexMatcherCode | null { const parsed = parseSubset(pattern, flags); if (parsed === null) return null; const sticky = flags.includes("y"); + if (replacement !== null && !canReplaceWith(pattern, flags, replacement)) + throw new InternalCompilerError(`llvm emitter bug: no replacer for /${pattern}/${flags}`); const { root, ncap } = parsed; const gen = new RxGen(sym, ncap, vector); const end: FirstInfo = { set: emptySet(), nullable: true, bol: false, uncond: true }; @@ -1873,6 +1878,12 @@ export function compileRegexMatcher( fn.push(` %stk = alloca ptr`); fn.push(` %sbuf = alloca [64 x i32]`); for (let r = 0; r < gen.regs; r++) fn.push(` %r${r} = alloca i32`); + if (replacement !== null) { + fn.push(` %next = alloca i32`); + fn.push(` %nfound = alloca i32`); + fn.push(` store i32 0, ptr %next`); + fn.push(` store i32 0, ptr %nfound`); + } fn.push(` store ptr %sbuf, ptr %stk`); fn.push(` store i32 64, ptr %scap`); fn.push(` store i32 %start, ptr %at`); @@ -1900,7 +1911,9 @@ export function compileRegexMatcher( } else { fn.push(` br label %seek`); } - if (firstCount === 1) { + // A replacer seeks again after every match, often a few bytes on: the + // 16-byte scan avoids a memchr call per match. + if (firstCount === 1 && replacement === null) { fn.push(`seek:`); const byte = intervals(first.set)[0]![0]!; fn.push(` %rest = sub i32 %len, %a`); @@ -1944,6 +1957,98 @@ export function compileRegexMatcher( fn.push(` br i1 %over, label %nomatch, label %scan`); } + if (replacement !== null) { + emitReplaceMatch(fn, sym, replacement, vector); + } else { + emitReturnMatch(fn, ncap); + } + + const result = replacement !== null ? "%n.found" : "0"; + fn.push(`nomatch:`); + if (replacement !== null) { + fn.push(` %n.next = load i32, ptr %next`); + fn.push(` %n.nextp = getelementptr inbounds i8, ptr %out, i64 16`); + fn.push(` %n.nextw = sext i32 %n.next to i64`); + fn.push(` store i64 %n.nextw, ptr %n.nextp`); + fn.push(` %n.found = load i32, ptr %nfound`); + } + fn.push(` %n.stk = load ptr, ptr %stk`); + fn.push(` %n.heap = icmp ne ptr %n.stk, %sbuf`); + fn.push(` br i1 %n.heap, label %n.free, label %n.ret`); + fn.push(`n.free:`); + fn.push(` call void @scr_regex_stack_free(ptr %n.stk)`); + fn.push(` br label %n.ret`); + fn.push(`n.ret:`); + fn.push(` ret i32 ${result}`); + + gen.emitTail(); + for (const l of gen.body) fn.push(l); + fn.push(`}`); + + const decls = REGEX_MATCHER_DECLS.slice(); + if (gen.body.some((l) => l.includes("@llvm.cttz.i16"))) + decls.push(`declare i16 @llvm.cttz.i16(i16, i1 immarg)`); + if (!sticky && !first.nullable && firstCount === 1 && replacement === null) + decls.push(`declare ptr @memchr(ptr, i32, ${sizeType})`); + if (replacement !== null) { + decls.push(`declare void @scr_re_replace_out_reserve(ptr, i64)`); + decls.push(`declare void @llvm.memcpy.p0.p0.i64(ptr, ptr, i64, i1 immarg)`); + } + const globals = [...gen.globals]; + if (replacement !== null && replacement.length > 0) + globals.push( + `@${sym}.rep = private unnamed_addr constant [${replacement.length} x i8] c"${llvmBytes(replacement)}"`, + ); + return { lines: [...globals, ...fn], decls }; +} + +/** Whether a substitution can run through a build-time replacer: a global, + * non-sticky pattern that is not plain text (the runtime searches those as + * strings) and an ASCII replacement without `$` patterns. */ +export function canReplaceWith(pattern: string, flags: string, replacement: string): boolean { + if (!flags.includes("g") || flags.includes("y") || isPlainTextPattern(pattern, flags)) + return false; + for (let i = 0; i < replacement.length; i++) { + const c = replacement.charCodeAt(i); + if (c >= ASCII || c === 0x24) return false; + } + return true; +} + +/** scr_regex_plain_text's rule: a pattern of literal characters and escaped + * metacharacters (no letters under `i`) that the runtime searches as text. */ +function isPlainTextPattern(pattern: string, flags: string): boolean { + const fold = flags.includes("i"); + if (pattern.length === 0 || pattern.length > 256) return false; + for (let i = 0; i < pattern.length; i++) { + let c = pattern[i]!; + if (c === "\\") { + if (i + 1 >= pattern.length) return false; + c = pattern[++i]!; + if (!"^$\\.*+?()[]{}|/".includes(c)) return false; + } else { + const code = c.charCodeAt(0); + if (code < 0x20 || code >= 0x7f || "^$.*+?()[]{}|".includes(c)) return false; + } + if (fold && /[a-z]/i.test(c)) return false; + } + return true; +} + +function llvmBytes(text: string): string { + let out = ""; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + out += + c >= 0x20 && c < 0x7f && c !== 0x22 && c !== 0x5c + ? text[i] + : `\\${c.toString(16).toUpperCase().padStart(2, "0")}`; + } + return out; +} + +/** The match block of a matcher: store the capture pointers, return 1. */ +function emitReturnMatch(fn: string[], ncap: number): void { fn.push(`match:`); fn.push(` %m.end = load i32, ptr %pos`); fn.push(` %m.start = load i32, ptr %at`); @@ -1969,25 +2074,72 @@ export function compileRegexMatcher( fn.push(` br label %m.ret`); fn.push(`m.ret:`); fn.push(` ret i32 1`); +} - fn.push(`nomatch:`); - fn.push(` %n.stk = load ptr, ptr %stk`); - fn.push(` %n.heap = icmp ne ptr %n.stk, %sbuf`); - fn.push(` br i1 %n.heap, label %n.free, label %n.ret`); - fn.push(`n.free:`); - fn.push(` call void @scr_regex_stack_free(ptr %n.stk)`); - fn.push(` br label %n.ret`); - fn.push(`n.ret:`); - fn.push(` ret i32 0`); - - gen.emitTail(); - for (const l of gen.body) fn.push(l); - fn.push(`}`); - - const decls = REGEX_MATCHER_DECLS.slice(); - if (gen.body.some((l) => l.includes("@llvm.cttz.i16"))) - decls.push(`declare i16 @llvm.cttz.i16(i16, i1 immarg)`); - if (!sticky && !first.nullable && firstCount === 1) - decls.push(`declare ptr @memchr(ptr, i32, ${sizeType})`); - return { lines: [...gen.globals, ...fn], decls }; +/** The match block of a replacer: append the gap since the previous match + * and the replacement to the output (ScrReReplaceOut: len, cap, next at + * 0, 8 and 16, then data), and continue the search at the match end, or one past it for an + * empty match, as the runtime's replace loop does. */ +function emitReplaceMatch(fn: string[], sym: string, replacement: string, vector: boolean): void { + const n = replacement.length; + fn.push(`match:`); + fn.push(` %m.end = load i32, ptr %pos`); + fn.push(` %m.start = load i32, ptr %at`); + fn.push(` %m.next = load i32, ptr %next`); + fn.push(` %m.gap = sub i32 %m.start, %m.next`); + fn.push(` %m.gapw = zext i32 %m.gap to i64`); + fn.push(` %m.lenp = getelementptr inbounds i8, ptr %out, i64 0`); + fn.push(` %m.capp = getelementptr inbounds i8, ptr %out, i64 8`); + fn.push(` %m.datap = getelementptr inbounds i8, ptr %out, i64 24`); + fn.push(` %m.len0 = load i64, ptr %m.lenp`); + fn.push(` %m.cap = load i64, ptr %m.capp`); + fn.push(` %m.room = sub i64 %m.cap, %m.len0`); + // 16 bytes of slack past the gap and the replacement for the 16-byte copy. + fn.push(` %m.need = add i64 %m.gapw, ${n + 32}`); + fn.push(` %m.fits = icmp uge i64 %m.room, %m.need`); + fn.push(` br i1 %m.fits, label %m.put, label %m.grow`); + fn.push(`m.grow:`); + fn.push(` call void @scr_re_replace_out_reserve(ptr %out, i64 %m.need)`); + fn.push(` br label %m.put`); + fn.push(`m.put:`); + fn.push(` %m.data = load ptr, ptr %m.datap`); + fn.push(` %m.len = load i64, ptr %m.lenp`); + fn.push(` %m.dst = getelementptr inbounds i8, ptr %m.data, i64 %m.len`); + fn.push(` %m.nextw = sext i32 %m.next to i64`); + fn.push(` %m.src = getelementptr inbounds i8, ptr %s, i64 %m.nextw`); + if (vector) { + // A gap of at most 16 bytes with 16 readable subject bytes is one + // unaligned 16-byte copy. + fn.push(` %m.small = icmp ule i32 %m.gap, 16`); + fn.push(` %m.rest = sub i32 %len, %m.next`); + fn.push(` %m.readable = icmp sge i32 %m.rest, 16`); + fn.push(` %m.fast = and i1 %m.small, %m.readable`); + fn.push(` br i1 %m.fast, label %m.copy16, label %m.copy`); + fn.push(`m.copy16:`); + fn.push(` %m.v = load <16 x i8>, ptr %m.src, align 1`); + fn.push(` store <16 x i8> %m.v, ptr %m.dst, align 1`); + fn.push(` br label %m.rep`); + } else { + fn.push(` br label %m.copy`); + } + fn.push(`m.copy:`); + fn.push(` call void @llvm.memcpy.p0.p0.i64(ptr %m.dst, ptr %m.src, i64 %m.gapw, i1 false)`); + fn.push(` br label %m.rep`); + fn.push(`m.rep:`); + fn.push(` %m.rdst = getelementptr inbounds i8, ptr %m.dst, i64 %m.gapw`); + if (n > 0) + fn.push(` call void @llvm.memcpy.p0.p0.i64(ptr %m.rdst, ptr @${sym}.rep, i64 ${n}, i1 false)`); + fn.push(` %m.len1 = add i64 %m.len, %m.gapw`); + fn.push(` %m.len2 = add i64 %m.len1, ${n}`); + fn.push(` store i64 %m.len2, ptr %m.lenp`); + fn.push(` store i32 %m.end, ptr %next`); + fn.push(` %m.found0 = load i32, ptr %nfound`); + fn.push(` %m.found1 = add i32 %m.found0, 1`); + fn.push(` store i32 %m.found1, ptr %nfound`); + fn.push(` %m.empty = icmp eq i32 %m.end, %m.start`); + fn.push(` %m.after = add i32 %m.end, 1`); + fn.push(` %m.at = select i1 %m.empty, i32 %m.after, i32 %m.end`); + fn.push(` store i32 %m.at, ptr %at`); + fn.push(` %m.past = icmp sgt i32 %m.at, %len`); + fn.push(` br i1 %m.past, label %nomatch, label %scan`); } diff --git a/packages/compiler/test/regex-replacers.test.ts b/packages/compiler/test/regex-replacers.test.ts new file mode 100644 index 000000000..746279eb4 --- /dev/null +++ b/packages/compiler/test/regex-replacers.test.ts @@ -0,0 +1,42 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function emitLlvm(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-regex-replacers-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + return await readFile(outPath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("global substitutions of regex literals with literal replacements run a replacer", async () => { + const llvm = await emitLlvm(` +const s = process.argv[2] ?? "ac"; +const rep = process.argv[3] ?? "-"; +console.log( + s.replace(/<[^>]*>/g, "|"), + s.replaceAll(/a[bc]/g, "<2>"), + s.replace(/<[^>]*>/g, "|"), + s.replace(/<[^>]*>/g, "$&$&"), + s.replace(/<[^>]*>/, "|"), + s.replace(/<[^>]*>/gy, "|"), + s.replace(/<[^>]*>/g, rep), +); +`); + const replacers = llvm.match(/^define internal i32 @sc_rr_\d+\(/gm) ?? []; + // One replacer per (pattern, flags, replacement): the repeated site shares it. + expect(replacers).toHaveLength(2); + expect(llvm.match(/call ptr @scr_regex_replace_with\(/g)).toHaveLength(3); + // $ patterns, non-global and sticky patterns, and computed replacements + // keep the runtime's loop. + expect(llvm.match(/call ptr @scr_regex_replace\(/g)!.length).toBeGreaterThanOrEqual(4); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8351-regex-literal-replacers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8351-regex-literal-replacers.ts.json new file mode 100644 index 000000000..be0149287 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8351-regex-literal-replacers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8351-regex-literal-replacers.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index ab323c288..2a98e92c6 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -1416,6 +1416,51 @@ ScrStr *scr_regex_replace(ScrStr *s, ScrRegex *re, ScrStr *rep) { return scr_replace_impl(s, re, rep); } +void scr_re_replace_out_reserve(ScrReReplaceOut *o, int64_t need) { + if (need < 0 || (uint64_t)need > SCR_STR_MAX_CAP - (uint64_t)o->len) scr_regex_oom(); + size_t cap = (size_t)o->cap * 2; + if (cap < (size_t)(o->len + need)) cap = (size_t)(o->len + need); + if (cap > SCR_STR_MAX_CAP) cap = SCR_STR_MAX_CAP; + o->str->len = (size_t)o->len; + o->str = scr_str_regrow(o->str, cap); + o->data = o->str->data; + o->cap = (int64_t)o->str->cap; +} + +/* A global substitution with a literal ASCII replacement through the + * build-time replacer of its (pattern, replacement) pair: on an ASCII + * subject the replacer writes each gap and the replacement into the output + * and continues the search after every match without returning. Every + * other subject, and a plain-text pattern (string search), takes + * scr_replace_impl. */ +ScrStr *scr_regex_replace_with(ScrStr *s, ScrRegex *re, ScrStr *rep, + ScrRegexMatcherFn replacer) { + (void)scr_regex_bc(re); + if (scr_regex_lit(re) || !scr_text_ascii(s)) return scr_replace_impl(s, re, rep); + if (s->len > (size_t)INT32_MAX - 1) scr_regex_oom(); + re->last_index = 0; + ScrReReplaceOut o; + o.str = scr_str_alloc_raw(0, s->len + 64); + o.data = o.str->data; + o.len = 0; + o.cap = (int64_t)o.str->cap; + o.next = 0; + int32_t n = replacer((const uint8_t *)s->data, (int32_t)s->len, 0, (uint8_t **)(void *)&o); + if (n == 0) { + o.str->len = 0; + scr_str_release(o.str); + return scr_str_retain(s); + } + size_t tail = s->len - (size_t)o.next; + if ((int64_t)(tail + 1) > o.cap - o.len) scr_re_replace_out_reserve(&o, (int64_t)tail + 1); + scr_copy_bytes(o.data + o.len, s->data + o.next, tail); + o.len += (int64_t)tail; + o.str->len = (size_t)o.len; + o.data[o.len] = '\0'; + scr_str_note_ascii(o.str); + return o.str; +} + typedef struct { ScrDyn *arguments; int start, end; diff --git a/packages/runtime/src/scr_regex.h b/packages/runtime/src/scr_regex.h index cb61e49f3..502a29266 100644 --- a/packages/runtime/src/scr_regex.h +++ b/packages/runtime/src/scr_regex.h @@ -80,6 +80,22 @@ ScrStr *scr_regex_flags(ScrRegex *re); /* +1 */ ScrStr *scr_regex_to_string(ScrRegex *re); /* +1 */ /* replace: first match without /g, every match with it. */ ScrStr *scr_regex_replace(ScrStr *s, ScrRegex *re, ScrStr *rep); +/* The output of a build-time replacer (regex-matchers.ts): the replacer + * appends each gap and the replacement at data[len] after reserving room, + * and leaves in `next` the subject index the unmatched tail starts at. */ +typedef struct { + int64_t len; + int64_t cap; + int64_t next; + char *data; /* at offset 24 on every target */ + ScrStr *str; +} ScrReReplaceOut; +/* Grows o->str so that `need` more bytes fit at o->data[o->len]. */ +void scr_re_replace_out_reserve(ScrReReplaceOut *o, int64_t need); +/* A global substitution with a literal ASCII replacement, through the + * replacer built for that pattern and replacement. Borrows all. */ +ScrStr *scr_regex_replace_with(ScrStr *s, ScrRegex *re, ScrStr *rep, + ScrRegexMatcherFn replacer); /* replace/replaceAll with a replacer closure of `arity` (at most * SCR_REPLACER_MAX_ARITY) parameters. Parameter i receives what Node passes * at position i: the match and captures below the capture count n, the diff --git a/tests/corpus/8351-regex-literal-replacers.ts b/tests/corpus/8351-regex-literal-replacers.ts new file mode 100644 index 000000000..12968d4d9 --- /dev/null +++ b/tests/corpus/8351-regex-literal-replacers.ts @@ -0,0 +1,72 @@ +// Global substitutions of regex literals with literal replacements: matches +// at every offset around 16-character blocks and at the end of the +// subject, empty matches, alternation order, case-insensitive and +// multiline flags, word boundaries, replacements that are empty, longer +// than the match or hold quotes and backslashes, outputs that grow far past +// the subject, subjects without a match, and subjects outside ASCII (which +// take the general loop). replace and replaceAll. +const substitutions: [string, (s: string) => string][] = [ + ["<[^>]*>", (s) => s.replace(/<[^>]*>/g, "|")], + ["a[NSt]|BY", (s) => s.replace(/a[NSt]|BY/g, "<2>")], + ["tHa[Nt]", (s) => s.replace(/tHa[Nt]/g, "<4>")], + ["\\|[^|][^|]*\\|", (s) => s.replace(/\|[^|][^|]*\|/g, "-")], + ["x*", (s) => s.replace(/x*/g, "-")], + ["\\b", (s) => s.replace(/\b/g, "^")], + ["^ab", (s) => s.replace(/^ab/gm, "Z")], + ["ab|abc|b", (s) => s.replace(/ab|abc|b/g, "#")], + ["AB (i)", (s) => s.replace(/AB/gi, "")], + ["(a)(b)?", (s) => s.replace(/(a)(b)?/g, "q")], + ["\\d+", (s) => s.replace(/\d+/g, '"\\\n')], + ["[^a-z]", (s) => s.replace(/[^a-z]/g, "")], + ["a", (s) => s.replace(/a/g, "<<<>>>")], + ["cat (replaceAll)", (s) => s.replaceAll(/cat/g, "dog")], + ["[aeiou]{2} (replaceAll)", (s) => s.replaceAll(/[aeiou]{2}/g, "~")], +]; + +function hash(text: string, h: number): number { + for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0; + return h; +} + +const filler = "qwertyuiop asdfghjkl zxcvbnm"; +const needles = ["<2>", "", "aN", "BY", "tHat", "|ab|", "||", "xx", "ab", "abc", "AB", "123", "cat", "aeio", "a"]; +let checksum = 0; +let lines = 0; +for (let length = 0; length <= 70; length += 7) { + for (const needle of needles) { + for (const offset of [0, 1, 14, 15, 16, 17, 31, 32, 33]) { + if (offset + needle.length > length) continue; + let subject = ""; + while (subject.length < length) subject += filler; + subject = subject.slice(0, offset) + needle + subject.slice(offset + needle.length, length); + for (const [name, sub] of substitutions) { + const out = sub(subject); + checksum = hash(out, checksum); + lines++; + if (length === 35 && (offset === 15 || offset === 16)) console.log(name, offset, JSON.stringify(out)); + } + } + } +} +console.log("lines", lines, "checksum", checksum); + +// Dense matches, matches that end the subject, and an output many times +// larger than the subject. +const dense = "<1><22><333>".repeat(40) + "tail"; +for (const [name, sub] of substitutions) console.log(name, JSON.stringify(sub(dense).slice(-40)), sub(dense).length); +const many = "a".repeat(5000); +console.log("grow", many.replace(/a/g, "<<<>>>").length, many.replace(/a/g, "<<<>>>").slice(4990)); +console.log("empty", JSON.stringify("".replace(/x*/g, "-")), JSON.stringify("".replace(/a/g, "b"))); +console.log("none", "zzzz".replace(/a[bc]/g, "!"), "zzzz".replaceAll(/a[bc]/g, "!")); + +// Subjects outside ASCII run the general loop with the same results. +for (const subject of ["héllo wörld ", "日本<語>", "a\u{1F600}b", "cat café cat"]) { + console.log(JSON.stringify(subject.replace(/<[^>]*>/g, "|")), JSON.stringify(subject.replaceAll(/cat/g, "dog")), JSON.stringify(subject.replace(/x*/g, "-"))); +} + +// A long DNA-like subject, as in regex-redux. +let dna = ""; +for (let i = 0; i < 4000; i++) dna += "acgt"[(i * 7 + (i >> 2)) % 4] + (i % 61 === 0 ? "tHaN" : i % 37 === 0 ? "aNBY" : ""); +const step1 = dna.replace(/tHa[Nt]/g, "<4>").replace(/a[NSt]|BY/g, "<2>"); +const step2 = step1.replace(/<[^>]*>/g, "|").replace(/\|[^|][^|]*\|/g, "-"); +console.log("dna", dna.length, step1.length, step2.length, hash(step2, 0)); diff --git a/tests/harness/corpus-costs/8351-regex-literal-replacers.ts.json b/tests/harness/corpus-costs/8351-regex-literal-replacers.ts.json new file mode 100644 index 000000000..56546d44c --- /dev/null +++ b/tests/harness/corpus-costs/8351-regex-literal-replacers.ts.json @@ -0,0 +1 @@ +{ "plain": 3074, "san": 44176 }