diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index af4bb092f..58f1297b3 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -195,9 +195,31 @@ function emitReplaceWith( return host.own({ name: t, type: e.type }); } +/** Substitutions whose result owns its text: once the runtime call returns, + * no operand is read again. */ +function releasesInputsOnReturn(method: string): boolean { + return method === "replace" || method === "replaceAll"; +} + export function emitRegexIntrinsic( host: LlvmEmitterContext, e: IrExpr & { kind: "regexIntrinsic" }, +): LlValue { + if (!releasesInputsOnReturn(e.method)) return emitRegexOperation(host, e); + // The operands' temps (the subject of a chained substitution, a computed + // replacement) are released when the call returns rather than at the + // end of the statement, so a chain of substitutions holds at most its + // current subject and result. + host.frames.push([]); + const out = emitRegexOperation(host, e); + host.toFrame(out); + host.releaseTemps(host.frames.pop()!); + return host.own(out); +} + +function emitRegexOperation( + host: LlvmEmitterContext, + e: IrExpr & { kind: "regexIntrinsic" }, ): LlValue { const B = host.B; const method = e.method; diff --git a/packages/compiler/test/regex-replace-lifetimes.test.ts b/packages/compiler/test/regex-replace-lifetimes.test.ts new file mode 100644 index 000000000..0c60a9e5e --- /dev/null +++ b/packages/compiler/test/regex-replace-lifetimes.test.ts @@ -0,0 +1,57 @@ +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-replace-")); + 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 }); + } +} + +function functionBody(llvm: string, name: string): string[] { + const start = llvm.split("\n").findIndex((l) => l.startsWith("define") && l.includes(name)); + expect(start).toBeGreaterThanOrEqual(0); + const lines = llvm.split("\n").slice(start); + return lines.slice(0, lines.indexOf("}")); +} + +test("a chain of substitutions releases each intermediate result when the next one returns", async () => { + const llvm = await emitLlvm(` +function chain(s: string): number { + return s + .replace(/a[bc]/g, "<1>") + .replace(/b[cd]/g, "<2>") + .replace(/c[de]/g, "<3>") + .replaceAll(/<[^>]*>/g, "|").length; +} +console.log(chain(process.argv[2] ?? "abcabc")); +`); + const body = functionBody(llvm, "chain"); + const calls = body + .map((line, index) => ({ line, index })) + .filter(({ line }) => /call ptr @scr_regex_replace(_all|_with)?\(/.test(line)); + expect(calls).toHaveLength(4); + const results = calls.map(({ line }) => /^\s*(%[\w.]+) = call/.exec(line)![1]!); + // Each intermediate result is released after the call that consumes it + // and before the next substitution starts. + for (let k = 0; k < 3; k++) { + const release = body.findIndex( + (line, index) => + index > calls[k + 1]!.index && + line.includes("release") && + line.includes(`(ptr ${results[k]})`), + ); + expect(release, `release of ${results[k]}`).toBeGreaterThan(calls[k + 1]!.index); + if (k + 2 < calls.length) expect(release).toBeLessThan(calls[k + 2]!.index); + } +});