diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 4908bac3e..58acc1cfa 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -26,7 +26,9 @@ import { MAX_OUTPUT_BYTES, } from "./constants.js"; import { resolveOrgPrefetched } from "./prefetch.js"; +import { replace } from "./replacers.js"; import type { + ApplyPatchsetPatch, ApplyPatchsetPayload, CreateSentryProjectPayload, DetectSentryPayload, @@ -59,25 +61,6 @@ const DEFAULT_JSON_INDENT: JsonIndent = { length: 2, }; -/** Matches the first indented line in a string to detect whitespace style. */ -const INDENT_PATTERN = /^(\s+)/m; - -/** - * Detect the indentation style of a JSON string by inspecting the first - * indented line. Returns a default of 2 spaces if no indentation is found. - */ -function detectJsonIndent(content: string): JsonIndent { - const match = content.match(INDENT_PATTERN); - if (!match?.[1]) { - return DEFAULT_JSON_INDENT; - } - const indent = match[1]; - if (indent.includes("\t")) { - return { replacer: Indenter.TAB, length: indent.length }; - } - return { replacer: Indenter.SPACE, length: indent.length }; -} - /** Build the third argument for `JSON.stringify` from a `JsonIndent`. */ function jsonIndentArg(indent: JsonIndent): string { return indent.replacer.repeat(indent.length); @@ -600,39 +583,59 @@ function applyPatchsetDryRun(payload: ApplyPatchsetPayload): LocalOpResult { } /** - * Resolve the final file content for a patch, pretty-printing JSON files - * to preserve readable formatting. For `modify` actions, the existing file's - * indentation style is detected and preserved. For `create` actions, a default - * of 2-space indentation is used. + * Resolve the final file content for a full-content patch (create only), + * pretty-printing JSON files to preserve readable formatting. */ -async function resolvePatchContent( - absPath: string, - patch: ApplyPatchsetPayload["params"]["patches"][number] -): Promise { +function resolvePatchContent(patch: { path: string; patch: string }): string { if (!patch.path.endsWith(".json")) { return patch.patch; } - if (patch.action === "modify") { - const existing = await fs.promises.readFile(absPath, "utf-8"); - return prettyPrintJson(patch.patch, detectJsonIndent(existing)); - } return prettyPrintJson(patch.patch, DEFAULT_JSON_INDENT); } -type Patch = ApplyPatchsetPayload["params"]["patches"][number]; - const VALID_PATCH_ACTIONS = new Set(["create", "modify", "delete"]); -async function applySinglePatch(absPath: string, patch: Patch): Promise { +/** + * Apply edits (oldString/newString pairs) to a file using fuzzy matching. + * Edits are applied sequentially — each edit operates on the result of the + * previous one. Returns the final file content. + */ +async function applyEdits( + absPath: string, + filePath: string, + edits: Array<{ oldString: string; newString: string }> +): Promise { + let content = await fs.promises.readFile(absPath, "utf-8"); + + for (let i = 0; i < edits.length; i++) { + const edit = edits[i] as (typeof edits)[number]; + try { + content = replace(content, edit.oldString, edit.newString); + } catch (err) { + throw new Error( + `Edit #${i + 1} failed on "${filePath}": ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + return content; +} + +async function applySinglePatch( + absPath: string, + patch: ApplyPatchsetPatch +): Promise { switch (patch.action) { case "create": { await fs.promises.mkdir(path.dirname(absPath), { recursive: true }); - const content = await resolvePatchContent(absPath, patch); + const content = resolvePatchContent( + patch as ApplyPatchsetPatch & { patch: string } + ); await fs.promises.writeFile(absPath, content, "utf-8"); break; } case "modify": { - const content = await resolvePatchContent(absPath, patch); + const content = await applyEdits(absPath, patch.path, patch.edits); await fs.promises.writeFile(absPath, content, "utf-8"); break; } diff --git a/src/lib/init/replacers.ts b/src/lib/init/replacers.ts new file mode 100644 index 000000000..42c069b76 --- /dev/null +++ b/src/lib/init/replacers.ts @@ -0,0 +1,528 @@ +/** + * Fuzzy find-and-replace for applying code edits. + * + * Ported from opencode — 9 cascading strategies that try increasingly + * relaxed matching so minor LLM imprecisions (whitespace, indentation, + * escapes) don't cause failures. + * + * Source: https://github.com/anomalyco/opencode/blob/46f243fea71c65464471fcf1f5a807dd860c0f8f/packages/opencode/src/tool/edit.ts + * + * Pure string functions, zero external dependencies. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Replacer = ( + content: string, + find: string +) => Generator; + +// --------------------------------------------------------------------------- +// Thresholds +// --------------------------------------------------------------------------- + +const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0; +const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3; + +// --------------------------------------------------------------------------- +// Top-level regexes (biome: useTopLevelRegex) +// --------------------------------------------------------------------------- + +const WHITESPACE_RUN = /\s+/g; +const WHITESPACE_SPLIT = /\s+/; +const REGEX_SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g; +const LEADING_WHITESPACE = /^(\s*)/; +const ESCAPE_SEQUENCES = /\\(n|t|r|'|"|`|\\|\n|\$)/g; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Safe array index access — used throughout to satisfy strict null checks. */ +function at(arr: T[], idx: number): T { + return arr[idx] as T; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: standard Levenshtein algorithm +function levenshtein(a: string, b: string): number { + if (a === "" || b === "") { + return Math.max(a.length, b.length); + } + const matrix: number[][] = []; + for (let row = 0; row <= a.length; row += 1) { + const cols: number[] = []; + for (let col = 0; col <= b.length; col += 1) { + if (row === 0) { + cols.push(col); + } else if (col === 0) { + cols.push(row); + } else { + cols.push(0); + } + } + matrix.push(cols); + } + + for (let idx = 1; idx <= a.length; idx += 1) { + for (let jdx = 1; jdx <= b.length; jdx += 1) { + const cost = a[idx - 1] === b[jdx - 1] ? 0 : 1; + at(matrix, idx)[jdx] = Math.min( + at(at(matrix, idx - 1), jdx) + 1, + at(at(matrix, idx), jdx - 1) + 1, + at(at(matrix, idx - 1), jdx - 1) + cost + ); + } + } + return at(at(matrix, a.length), b.length); +} + +function lastItem(arr: string[]): string | undefined { + return arr.at(-1); +} + +/** + * Extract a span of original lines as a substring of `content`. + */ +function extractSpan( + content: string, + originalLines: string[], + startLine: number, + endLine: number +): string { + let matchStartIndex = 0; + for (let k = 0; k < startLine; k += 1) { + matchStartIndex += at(originalLines, k).length + 1; + } + let matchEndIndex = matchStartIndex; + for (let k = startLine; k <= endLine; k += 1) { + matchEndIndex += at(originalLines, k).length; + if (k < endLine) { + matchEndIndex += 1; + } + } + return content.substring(matchStartIndex, matchEndIndex); +} + +// --------------------------------------------------------------------------- +// Replacers — each yields candidate substrings from `content` that might +// correspond to `find`. The main `replace()` function checks uniqueness. +// --------------------------------------------------------------------------- + +/** 1. Exact match — the happy path. */ +export const SimpleReplacer: Replacer = function* (_content, find) { + yield find; +}; + +/** 2. Per-line trim comparison — handles indentation differences. */ +export const LineTrimmedReplacer: Replacer = function* (content, find) { + const originalLines = content.split("\n"); + const searchLines = find.split("\n"); + + if (lastItem(searchLines) === "") { + searchLines.pop(); + } + + for (let i = 0; i <= originalLines.length - searchLines.length; i += 1) { + let matches = true; + + for (let j = 0; j < searchLines.length; j += 1) { + if (at(originalLines, i + j).trim() !== at(searchLines, j).trim()) { + matches = false; + break; + } + } + + if (matches) { + yield extractSpan(content, originalLines, i, i + searchLines.length - 1); + } + } +}; + +/** 3. First/last line anchors + Levenshtein on middle lines. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ported fuzzy matching algorithm +export const BlockAnchorReplacer: Replacer = function* (content, find) { + const originalLines = content.split("\n"); + const searchLines = find.split("\n"); + + if (searchLines.length < 3) { + return; + } + + if (lastItem(searchLines) === "") { + searchLines.pop(); + } + + const firstLineSearch = at(searchLines, 0).trim(); + const lastLineSearch = (lastItem(searchLines) ?? "").trim(); + const searchBlockSize = searchLines.length; + + const candidates: Array<{ startLine: number; endLine: number }> = []; + for (let i = 0; i < originalLines.length; i += 1) { + if (at(originalLines, i).trim() !== firstLineSearch) { + continue; + } + for (let j = i + 2; j < originalLines.length; j += 1) { + if (at(originalLines, j).trim() === lastLineSearch) { + candidates.push({ startLine: i, endLine: j }); + break; + } + } + } + + if (candidates.length === 0) { + return; + } + + function middleSimilarity(startLine: number, endLine: number): number { + const actualBlockSize = endLine - startLine + 1; + const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2); + + if (linesToCheck <= 0) { + return 1.0; + } + + let similarity = 0; + for ( + let j = 1; + j < searchBlockSize - 1 && j < actualBlockSize - 1; + j += 1 + ) { + const originalLine = at(originalLines, startLine + j).trim(); + const searchLine = at(searchLines, j).trim(); + const maxLen = Math.max(originalLine.length, searchLine.length); + if (maxLen === 0) { + continue; + } + const distance = levenshtein(originalLine, searchLine); + similarity += 1 - distance / maxLen; + } + return similarity / linesToCheck; + } + + if (candidates.length === 1) { + const { startLine, endLine } = at(candidates, 0); + if ( + middleSimilarity(startLine, endLine) >= + SINGLE_CANDIDATE_SIMILARITY_THRESHOLD + ) { + yield extractSpan(content, originalLines, startLine, endLine); + } + return; + } + + let bestMatch: (typeof candidates)[0] | null = null; + let maxSimilarity = -1; + + for (const candidate of candidates) { + const sim = middleSimilarity(candidate.startLine, candidate.endLine); + if (sim > maxSimilarity) { + maxSimilarity = sim; + bestMatch = candidate; + } + } + + if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) { + yield extractSpan( + content, + originalLines, + bestMatch.startLine, + bestMatch.endLine + ); + } +}; + +/** 4. Collapse whitespace runs into single space before comparing. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ported fuzzy matching algorithm +export const WhitespaceNormalizedReplacer: Replacer = function* ( + content, + find +) { + const normalizeWhitespace = (text: string) => + text.replace(WHITESPACE_RUN, " ").trim(); + const normalizedFind = normalizeWhitespace(find); + + const lines = content.split("\n"); + for (const line of lines) { + if (normalizeWhitespace(line) === normalizedFind) { + yield line; + } else { + const normalizedLine = normalizeWhitespace(line); + if (normalizedLine.includes(normalizedFind)) { + const words = find.trim().split(WHITESPACE_SPLIT); + if (words.length > 0) { + const pattern = words + .map((word) => word.replace(REGEX_SPECIAL_CHARS, "\\$&")) + .join("\\s+"); + try { + const regex = new RegExp(pattern); + const match = line.match(regex); + if (match) { + yield match[0]; + } + } catch { + // skip + } + } + } + } + } + + const findLines = find.split("\n"); + if (findLines.length > 1) { + for (let i = 0; i <= lines.length - findLines.length; i += 1) { + const block = lines.slice(i, i + findLines.length); + if (normalizeWhitespace(block.join("\n")) === normalizedFind) { + yield block.join("\n"); + } + } + } +}; + +/** 5. Strip common leading indentation, then compare. */ +export const IndentationFlexibleReplacer: Replacer = function* (content, find) { + const removeIndentation = (text: string) => { + const textLines = text.split("\n"); + const nonEmptyLines = textLines.filter((line) => line.trim().length > 0); + if (nonEmptyLines.length === 0) { + return text; + } + + const minIndent = Math.min( + ...nonEmptyLines.map((line) => { + const match = line.match(LEADING_WHITESPACE); + return match?.[1]?.length ?? 0; + }) + ); + + return textLines + .map((line) => { + if (line.trim().length === 0) { + return line; + } + return line.slice(minIndent); + }) + .join("\n"); + }; + + const normalizedFind = removeIndentation(find); + const contentLines = content.split("\n"); + const findLines = find.split("\n"); + + for (let i = 0; i <= contentLines.length - findLines.length; i += 1) { + const block = contentLines.slice(i, i + findLines.length).join("\n"); + if (removeIndentation(block) === normalizedFind) { + yield block; + } + } +}; + +/** 6. Unescape common escape sequences before comparing. */ +export const EscapeNormalizedReplacer: Replacer = function* (content, find) { + function unescapeString(str: string): string { + return str.replace(ESCAPE_SEQUENCES, (match, capturedChar) => { + switch (capturedChar) { + case "n": + return "\n"; + case "t": + return "\t"; + case "r": + return "\r"; + case "'": + return "'"; + case '"': + return '"'; + case "`": + return "`"; + case "\\": + return "\\"; + case "\n": + return "\n"; + case "$": + return "$"; + default: + return match; + } + }); + } + + const unescapedFind = unescapeString(find); + + if (content.includes(unescapedFind)) { + yield unescapedFind; + } + + const lines = content.split("\n"); + const findLines = unescapedFind.split("\n"); + + for (let i = 0; i <= lines.length - findLines.length; i += 1) { + const block = lines.slice(i, i + findLines.length).join("\n"); + if (unescapeString(block) === unescapedFind) { + yield block; + } + } +}; + +/** 7. Trim leading/trailing whitespace from the search string. */ +export const TrimmedBoundaryReplacer: Replacer = function* (content, find) { + const trimmedFind = find.trim(); + + if (trimmedFind === find) { + return; + } + + if (content.includes(trimmedFind)) { + yield trimmedFind; + } + + const lines = content.split("\n"); + const findLines = find.split("\n"); + + for (let i = 0; i <= lines.length - findLines.length; i += 1) { + const block = lines.slice(i, i + findLines.length).join("\n"); + if (block.trim() === trimmedFind) { + yield block; + } + } +}; + +/** 8. Anchor on first+last line (trimmed), accept if >=50% middles match. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ported fuzzy matching algorithm +export const ContextAwareReplacer: Replacer = function* (content, find) { + const findLines = find.split("\n"); + if (findLines.length < 3) { + return; + } + + if (lastItem(findLines) === "") { + findLines.pop(); + } + + const contentLines = content.split("\n"); + const firstLine = at(findLines, 0).trim(); + const lastLine = (lastItem(findLines) ?? "").trim(); + + for (let i = 0; i < contentLines.length; i += 1) { + if (at(contentLines, i).trim() !== firstLine) { + continue; + } + + for (let j = i + 2; j < contentLines.length; j += 1) { + if (at(contentLines, j).trim() !== lastLine) { + continue; + } + + const blockLines = contentLines.slice(i, j + 1); + const block = blockLines.join("\n"); + + if (blockLines.length === findLines.length) { + let matchingLines = 0; + let totalNonEmptyLines = 0; + + for (let k = 1; k < blockLines.length - 1; k += 1) { + const blockLine = at(blockLines, k).trim(); + const findLine = at(findLines, k).trim(); + + if (blockLine.length > 0 || findLine.length > 0) { + totalNonEmptyLines += 1; + if (blockLine === findLine) { + matchingLines += 1; + } + } + } + + if ( + totalNonEmptyLines === 0 || + matchingLines / totalNonEmptyLines >= 0.5 + ) { + yield block; + break; + } + } + break; + } + } +}; + +/** 9. Yield every exact match (used with replaceAll). */ +export const MultiOccurrenceReplacer: Replacer = function* (content, find) { + let startIndex = 0; + + for (;;) { + const index = content.indexOf(find, startIndex); + if (index === -1) { + break; + } + + yield find; + startIndex = index + find.length; + } +}; + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +const REPLACERS: Replacer[] = [ + SimpleReplacer, + LineTrimmedReplacer, + BlockAnchorReplacer, + WhitespaceNormalizedReplacer, + IndentationFlexibleReplacer, + EscapeNormalizedReplacer, + TrimmedBoundaryReplacer, + ContextAwareReplacer, + MultiOccurrenceReplacer, +]; + +/** + * Find `oldString` in `content` using cascading fuzzy strategies and replace + * it with `newString`. Throws if no match is found or if the match is + * ambiguous (multiple occurrences without `replaceAll`). + */ +export function replace( + content: string, + oldString: string, + newString: string, + replaceAll = false +): string { + if (oldString === newString) { + throw new Error( + "No changes to apply: oldString and newString are identical." + ); + } + + let notFound = true; + + for (const replacer of REPLACERS) { + for (const search of replacer(content, oldString)) { + const index = content.indexOf(search); + if (index === -1) { + continue; + } + notFound = false; + if (replaceAll) { + return content.replaceAll(search, newString); + } + const lastIndex = content.lastIndexOf(search); + if (index !== lastIndex) { + continue; + } + return ( + content.substring(0, index) + + newString + + content.substring(index + search.length) + ); + } + } + + if (notFound) { + throw new Error( + "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings." + ); + } + throw new Error( + "Found multiple matches for oldString. Provide more surrounding context to make the match unique." + ); +} diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 2f5f39f1e..255488d26 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -69,16 +69,22 @@ export type RunCommandsPayload = { }; }; +export type PatchEdit = { + oldString: string; + newString: string; +}; + +export type ApplyPatchsetPatch = + | { path: string; action: "create"; patch: string } + | { path: string; action: "modify"; edits: PatchEdit[] } + | { path: string; action: "delete"; patch?: string }; + export type ApplyPatchsetPayload = { type: "local-op"; operation: "apply-patchset"; cwd: string; params: { - patches: Array<{ - path: string; - action: "create" | "modify" | "delete"; - patch: string; - }>; + patches: ApplyPatchsetPatch[]; }; }; diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index eaf35b075..577f20929 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import fs, { mkdirSync, mkdtempSync, + readFileSync, rmSync, symlinkSync, writeFileSync, @@ -751,7 +752,7 @@ describe("handleLocalOp", () => { }); test("modifies an existing file", async () => { - writeFileSync(join(testDir, "existing.txt"), "old"); + writeFileSync(join(testDir, "existing.txt"), "old content here"); const payload: ApplyPatchsetPayload = { type: "local-op", @@ -759,7 +760,11 @@ describe("handleLocalOp", () => { cwd: testDir, params: { patches: [ - { path: "existing.txt", action: "modify", patch: "new content" }, + { + path: "existing.txt", + action: "modify", + edits: [{ oldString: "old content", newString: "new content" }], + }, ], }, }; @@ -767,7 +772,7 @@ describe("handleLocalOp", () => { const result = await handleLocalOp(payload, options); expect(result.ok).toBe(true); expect(fs.readFileSync(join(testDir, "existing.txt"), "utf-8")).toBe( - "new content" + "new content here" ); }); @@ -777,7 +782,13 @@ describe("handleLocalOp", () => { operation: "apply-patchset", cwd: testDir, params: { - patches: [{ path: "ghost.txt", action: "modify", patch: "content" }], + patches: [ + { + path: "ghost.txt", + action: "modify", + edits: [{ oldString: "x", newString: "y" }], + }, + ], }, }; @@ -818,7 +829,7 @@ describe("handleLocalOp", () => { }); test("applies multiple patches in sequence", async () => { - writeFileSync(join(testDir, "to-modify.txt"), "old"); + writeFileSync(join(testDir, "to-modify.txt"), "old content"); writeFileSync(join(testDir, "to-delete.txt"), "bye"); const payload: ApplyPatchsetPayload = { @@ -828,8 +839,12 @@ describe("handleLocalOp", () => { params: { patches: [ { path: "created.txt", action: "create", patch: "new" }, - { path: "to-modify.txt", action: "modify", patch: "updated" }, - { path: "to-delete.txt", action: "delete", patch: "" }, + { + path: "to-modify.txt", + action: "modify", + edits: [{ oldString: "old content", newString: "updated" }], + }, + { path: "to-delete.txt", action: "delete" }, ], }, }; @@ -911,6 +926,152 @@ describe("handleLocalOp", () => { expect(result.ok).toBe(false); expect(result.error).toContain("outside project directory"); }); + + test("modifies file using edits (oldString/newString)", async () => { + writeFileSync( + join(testDir, "config.ts"), + [ + 'import * as Sentry from "@sentry/nextjs";', + "", + "Sentry.init({", + ' dsn: "https://old@sentry.io/1",', + " tracesSampleRate: 1.0,", + "});", + ].join("\n") + ); + + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: "config.ts", + action: "modify", + edits: [ + { + oldString: ' dsn: "https://old@sentry.io/1",', + newString: ' dsn: "https://new@sentry.io/2",', + }, + { + oldString: " tracesSampleRate: 1.0,", + newString: + " tracesSampleRate: 0.5,\n replaysSessionSampleRate: 0.1,", + }, + ], + }, + ], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + + const content = readFileSync(join(testDir, "config.ts"), "utf-8"); + expect(content).toContain("new@sentry.io/2"); + expect(content).not.toContain("old@sentry.io/1"); + expect(content).toContain("tracesSampleRate: 0.5,"); + expect(content).toContain("replaysSessionSampleRate: 0.1,"); + }); + + test("edits-based modify fails gracefully when oldString not found", async () => { + writeFileSync(join(testDir, "app.ts"), "const x = 1;\n"); + + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: "app.ts", + action: "modify", + edits: [ + { + oldString: "this text does not exist", + newString: "replacement", + }, + ], + }, + ], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(false); + expect(result.error).toContain('Edit #1 failed on "app.ts"'); + }); + + test("edits-based modify with fuzzy matching (indentation difference)", async () => { + writeFileSync( + join(testDir, "fuzzy.ts"), + " const x = 1;\n const y = 2;\n" + ); + + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: "fuzzy.ts", + action: "modify", + edits: [ + { + oldString: " const x = 1;\n const y = 2;", + newString: " const x = 10;\n const y = 20;", + }, + ], + }, + ], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + + const content = readFileSync(join(testDir, "fuzzy.ts"), "utf-8"); + expect(content).toContain("const x = 10;"); + expect(content).toContain("const y = 20;"); + }); + + test("mixed create + edits-based modify in single patchset", async () => { + writeFileSync(join(testDir, "existing.ts"), 'const old = "value";\n'); + + const payload: ApplyPatchsetPayload = { + type: "local-op", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: "new-file.ts", + action: "create", + patch: 'import * as Sentry from "@sentry/node";\n', + }, + { + path: "existing.ts", + action: "modify", + edits: [ + { + oldString: 'const old = "value";', + newString: 'const updated = "new-value";', + }, + ], + }, + ], + }, + }; + + const result = await handleLocalOp(payload, options); + expect(result.ok).toBe(true); + + expect(fs.existsSync(join(testDir, "new-file.ts"))).toBe(true); + const content = readFileSync(join(testDir, "existing.ts"), "utf-8"); + expect(content).toContain('const updated = "new-value"'); + }); }); }); diff --git a/test/lib/init/replacers.test.ts b/test/lib/init/replacers.test.ts new file mode 100644 index 000000000..4d62170d2 --- /dev/null +++ b/test/lib/init/replacers.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; +import { replace } from "../../../src/lib/init/replacers.js"; + +describe("replace", () => { + test("exact match", () => { + const result = replace("hello world", "world", "there"); + expect(result).toBe("hello there"); + }); + + test("multiline exact match", () => { + const content = "line1\nline2\nline3\n"; + const result = replace(content, "line2\n", "replaced\n"); + expect(result).toBe("line1\nreplaced\nline3\n"); + }); + + test("throws when oldString not found", () => { + expect(() => replace("hello", "missing", "x")).toThrow( + "Could not find oldString" + ); + }); + + test("throws when oldString equals newString", () => { + expect(() => replace("hello", "hello", "hello")).toThrow( + "No changes to apply" + ); + }); + + test("throws on ambiguous match (multiple occurrences)", () => { + expect(() => replace("aaa", "a", "b")).toThrow("multiple matches"); + }); + + test("replaceAll replaces all occurrences", () => { + const result = replace("a b a b a", "a", "x", true); + expect(result).toBe("x b x b x"); + }); + + describe("LineTrimmedReplacer", () => { + test("matches despite different indentation", () => { + const content = " if (true) {\n foo();\n }"; + const result = replace(content, "if (true) {\n foo();\n}", "replaced"); + expect(result).toBe("replaced"); + }); + + test("matches with trailing spaces on lines", () => { + const content = "function foo() { \n return 1;\n}"; + const result = replace( + content, + "function foo() {\n return 1;\n}", + "replaced" + ); + expect(result).toBe("replaced"); + }); + }); + + describe("BlockAnchorReplacer", () => { + test("matches block by first/last line anchors with different middle", () => { + const content = [ + "function setup() {", + " const a = 1;", + " const b = 2;", + " return a + b;", + "}", + ].join("\n"); + + const search = [ + "function setup() {", + " const x = 1;", + " const y = 2;", + " return x + y;", + "}", + ].join("\n"); + + const result = replace(content, search, "replaced"); + expect(result).toBe("replaced"); + }); + }); + + describe("WhitespaceNormalizedReplacer", () => { + test("matches with different whitespace runs", () => { + const content = "import { foo } from 'bar';"; + const result = replace(content, "import { foo } from 'bar';", "replaced"); + expect(result).toBe("replaced"); + }); + }); + + describe("IndentationFlexibleReplacer", () => { + test("matches block with different indentation level", () => { + const content = " const x = 1;\n const y = 2;"; + const result = replace( + content, + " const x = 1;\n const y = 2;", + "replaced" + ); + expect(result).toBe("replaced"); + }); + }); + + describe("TrimmedBoundaryReplacer", () => { + test("matches when search has extra whitespace around it", () => { + const content = "hello world"; + const result = replace(content, " hello world ", "replaced"); + expect(result).toBe("replaced"); + }); + }); + + describe("real-world Sentry codemod scenarios", () => { + test("adding Sentry import to existing imports", () => { + const content = [ + 'import React from "react";', + 'import { useState } from "react";', + "", + "function App() {", + " return
Hello
;", + "}", + ].join("\n"); + + const result = replace( + content, + 'import React from "react";', + 'import React from "react";\nimport * as Sentry from "@sentry/react";' + ); + + expect(result).toContain("@sentry/react"); + expect(result).toContain('import React from "react"'); + }); + + test("wrapping next.config.js default export", () => { + const content = [ + "/** @type {import('next').NextConfig} */", + "const nextConfig = {", + " reactStrictMode: true,", + "};", + "", + "module.exports = nextConfig;", + ].join("\n"); + + const result = replace( + content, + "module.exports = nextConfig;", + "module.exports = withSentryConfig(nextConfig, sentryOptions);" + ); + + expect(result).toContain("withSentryConfig(nextConfig, sentryOptions)"); + expect(result).toContain("reactStrictMode: true"); + }); + + test("modifying sentry.client.config.ts init options", () => { + const content = [ + 'import * as Sentry from "@sentry/nextjs";', + "", + "Sentry.init({", + ' dsn: "https://old-dsn@sentry.io/123",', + " tracesSampleRate: 1.0,", + "});", + ].join("\n"); + + const result = replace( + content, + ' dsn: "https://old-dsn@sentry.io/123",', + ' dsn: "https://new-dsn@sentry.io/456",' + ); + + expect(result).toContain("new-dsn@sentry.io/456"); + expect(result).not.toContain("old-dsn"); + }); + }); +});