Skip to content

Commit b0cee7f

Browse files
committed
feat(init): add fuzzy edit replacers and edits-based apply-patchset
Port opencode's 9-strategy fuzzy matching chain as replacers.ts for finding and replacing text in files despite minor whitespace, indentation, or escape differences from LLM output. Extend apply-patchset so modify actions use edits (oldString/newString pairs) applied sequentially with the fuzzy replacer chain. Create actions still use full-content patch strings. Errors pinpoint the exact failing edit by file and index. Made-with: Cursor
1 parent 419a033 commit b0cee7f

File tree

5 files changed

+911
-48
lines changed

5 files changed

+911
-48
lines changed

src/lib/init/local-ops.ts

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ import {
2626
MAX_OUTPUT_BYTES,
2727
} from "./constants.js";
2828
import { resolveOrgPrefetched } from "./prefetch.js";
29+
import { replace } from "./replacers.js";
2930
import type {
31+
ApplyPatchsetPatch,
3032
ApplyPatchsetPayload,
3133
CreateSentryProjectPayload,
3234
DetectSentryPayload,
@@ -59,25 +61,6 @@ const DEFAULT_JSON_INDENT: JsonIndent = {
5961
length: 2,
6062
};
6163

62-
/** Matches the first indented line in a string to detect whitespace style. */
63-
const INDENT_PATTERN = /^(\s+)/m;
64-
65-
/**
66-
* Detect the indentation style of a JSON string by inspecting the first
67-
* indented line. Returns a default of 2 spaces if no indentation is found.
68-
*/
69-
function detectJsonIndent(content: string): JsonIndent {
70-
const match = content.match(INDENT_PATTERN);
71-
if (!match?.[1]) {
72-
return DEFAULT_JSON_INDENT;
73-
}
74-
const indent = match[1];
75-
if (indent.includes("\t")) {
76-
return { replacer: Indenter.TAB, length: indent.length };
77-
}
78-
return { replacer: Indenter.SPACE, length: indent.length };
79-
}
80-
8164
/** Build the third argument for `JSON.stringify` from a `JsonIndent`. */
8265
function jsonIndentArg(indent: JsonIndent): string {
8366
return indent.replacer.repeat(indent.length);
@@ -600,39 +583,59 @@ function applyPatchsetDryRun(payload: ApplyPatchsetPayload): LocalOpResult {
600583
}
601584

602585
/**
603-
* Resolve the final file content for a patch, pretty-printing JSON files
604-
* to preserve readable formatting. For `modify` actions, the existing file's
605-
* indentation style is detected and preserved. For `create` actions, a default
606-
* of 2-space indentation is used.
586+
* Resolve the final file content for a full-content patch (create only),
587+
* pretty-printing JSON files to preserve readable formatting.
607588
*/
608-
async function resolvePatchContent(
609-
absPath: string,
610-
patch: ApplyPatchsetPayload["params"]["patches"][number]
611-
): Promise<string> {
589+
function resolvePatchContent(patch: { path: string; patch: string }): string {
612590
if (!patch.path.endsWith(".json")) {
613591
return patch.patch;
614592
}
615-
if (patch.action === "modify") {
616-
const existing = await fs.promises.readFile(absPath, "utf-8");
617-
return prettyPrintJson(patch.patch, detectJsonIndent(existing));
618-
}
619593
return prettyPrintJson(patch.patch, DEFAULT_JSON_INDENT);
620594
}
621595

622-
type Patch = ApplyPatchsetPayload["params"]["patches"][number];
623-
624596
const VALID_PATCH_ACTIONS = new Set(["create", "modify", "delete"]);
625597

626-
async function applySinglePatch(absPath: string, patch: Patch): Promise<void> {
598+
/**
599+
* Apply edits (oldString/newString pairs) to a file using fuzzy matching.
600+
* Edits are applied sequentially — each edit operates on the result of the
601+
* previous one. Returns the final file content.
602+
*/
603+
async function applyEdits(
604+
absPath: string,
605+
filePath: string,
606+
edits: Array<{ oldString: string; newString: string }>
607+
): Promise<string> {
608+
let content = await fs.promises.readFile(absPath, "utf-8");
609+
610+
for (let i = 0; i < edits.length; i++) {
611+
const edit = edits[i] as (typeof edits)[number];
612+
try {
613+
content = replace(content, edit.oldString, edit.newString);
614+
} catch (err) {
615+
throw new Error(
616+
`Edit #${i + 1} failed on "${filePath}": ${err instanceof Error ? err.message : String(err)}`
617+
);
618+
}
619+
}
620+
621+
return content;
622+
}
623+
624+
async function applySinglePatch(
625+
absPath: string,
626+
patch: ApplyPatchsetPatch
627+
): Promise<void> {
627628
switch (patch.action) {
628629
case "create": {
629630
await fs.promises.mkdir(path.dirname(absPath), { recursive: true });
630-
const content = await resolvePatchContent(absPath, patch);
631+
const content = resolvePatchContent(
632+
patch as ApplyPatchsetPatch & { patch: string }
633+
);
631634
await fs.promises.writeFile(absPath, content, "utf-8");
632635
break;
633636
}
634637
case "modify": {
635-
const content = await resolvePatchContent(absPath, patch);
638+
const content = await applyEdits(absPath, patch.path, patch.edits);
636639
await fs.promises.writeFile(absPath, content, "utf-8");
637640
break;
638641
}

0 commit comments

Comments
 (0)