From ab78a46396ec7263f90bebd10edf658322e4832e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 21:15:27 -0600 Subject: [PATCH] wip --- packages/opencode/src/patch/index.ts | 81 +++++++++-- packages/opencode/src/tool/apply_patch.ts | 43 +++--- .../opencode/test/tool/apply_patch.test.ts | 134 ++++++++++++++++++ 3 files changed, 220 insertions(+), 38 deletions(-) diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 91d52065f6..888a4d94b8 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -177,8 +177,18 @@ export namespace Patch { return { content, nextIdx: i } } + function stripHeredoc(input: string): string { + // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or < 0 && pattern[pattern.length - 1] === "") { @@ -371,7 +381,7 @@ export namespace Patch { if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { newSlice = newSlice.slice(0, -1) } - found = seekSequence(originalLines, pattern, lineIndex) + found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) } if (found !== -1) { @@ -407,28 +417,75 @@ export namespace Patch { return result } - function seekSequence(lines: string[], pattern: string[], startIndex: number): number { - if (pattern.length === 0) return -1 + // Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode) + function normalizeUnicode(str: string): string { + return str + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes + .replace(/\u2026/g, "...") // ellipsis + .replace(/\u00A0/g, " ") // non-breaking space + } - // Simple substring search implementation + type Comparator = (a: string, b: string) => boolean + + function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number { + // If EOF anchor, try matching from end of file first + if (eof) { + const fromEnd = lines.length - pattern.length + if (fromEnd >= startIndex) { + let matches = true + for (let j = 0; j < pattern.length; j++) { + if (!compare(lines[fromEnd + j], pattern[j])) { + matches = false + break + } + } + if (matches) return fromEnd + } + } + + // Forward search from startIndex for (let i = startIndex; i <= lines.length - pattern.length; i++) { let matches = true - for (let j = 0; j < pattern.length; j++) { - if (lines[i + j] !== pattern[j]) { + if (!compare(lines[i + j], pattern[j])) { matches = false break } } - - if (matches) { - return i - } + if (matches) return i } return -1 } + function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number { + if (pattern.length === 0) return -1 + + // Pass 1: exact match + const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof) + if (exact !== -1) return exact + + // Pass 2: rstrip (trim trailing whitespace) + const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof) + if (rstrip !== -1) return rstrip + + // Pass 3: trim (both ends) + const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof) + if (trim !== -1) return trim + + // Pass 4: normalized (Unicode punctuation to ASCII) + const normalized = tryMatch( + lines, + pattern, + startIndex, + (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), + eof, + ) + return normalized + } + function generateUnifiedDiff(oldContent: string, newContent: string): string { const oldLines = oldContent.split("\n") const newLines = newContent.split("\n") diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 9df0c099f5..96c27f298e 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -55,23 +55,22 @@ export const ApplyPatchTool = Tool.define("apply_patch", { await assertExternalDirectory(ctx, filePath) switch (hunk.type) { - case "add": - if (hunk.type === "add") { - const oldContent = "" - const newContent = - hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` - const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) + case "add": { + const oldContent = "" + const newContent = + hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` + const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) - fileChanges.push({ - filePath, - oldContent, - newContent, - type: "add", - }) + fileChanges.push({ + filePath, + oldContent, + newContent, + type: "add", + }) - totalDiff += diff + "\n" - } + totalDiff += diff + "\n" break + } case "update": // Check if file exists for update @@ -145,11 +144,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", { for (const change of fileChanges) { switch (change.type) { case "add": - // Create parent directories - const addDir = path.dirname(change.filePath) - if (addDir !== "." && addDir !== "/") { - await fs.mkdir(addDir, { recursive: true }) - } + // Create parent directories (recursive: true is safe on existing/root dirs) + await fs.mkdir(path.dirname(change.filePath), { recursive: true }) await fs.writeFile(change.filePath, change.newContent, "utf-8") changedFiles.push(change.filePath) break @@ -161,14 +157,9 @@ export const ApplyPatchTool = Tool.define("apply_patch", { case "move": if (change.movePath) { - // Create parent directories for destination - const moveDir = path.dirname(change.movePath) - if (moveDir !== "." && moveDir !== "/") { - await fs.mkdir(moveDir, { recursive: true }) - } - // Write to new location + // Create parent directories (recursive: true is safe on existing/root dirs) + await fs.mkdir(path.dirname(change.movePath), { recursive: true }) await fs.writeFile(change.movePath, change.newContent, "utf-8") - // Remove original await fs.unlink(change.filePath) changedFiles.push(change.movePath) } diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 631d28d9e0..d8f05a9d91 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -378,4 +378,138 @@ describe("tool.apply_patch freeform", () => { }, }) }) + + test("EOF anchor matches from end of file first", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "eof_anchor.txt") + // File has duplicate "marker" lines - one in middle, one at end + await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // With EOF anchor, should match the LAST "marker" line, not the first + const patchText = + "*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch" + + await execute({ patchText }, ctx) + // First marker unchanged, second marker changed + expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n") + }, + }) + }) + + test("parses heredoc-wrapped patch", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = `cat <<'EOF' +*** Begin Patch +*** Add File: heredoc_test.txt ++heredoc content +*** End Patch +EOF` + + await execute({ patchText }, ctx) + const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8") + expect(content).toBe("heredoc content\n") + }, + }) + }) + + test("parses heredoc-wrapped patch without cat", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = `< { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "trailing_ws.txt") + // File has trailing spaces on some lines + await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch doesn't have trailing spaces - should still match via rstrip pass + const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n") + }, + }) + }) + + test("matches with leading whitespace differences", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "leading_ws.txt") + // File has leading spaces + await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch without leading spaces - should match via trim pass + const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n") + }, + }) + }) + + test("matches with Unicode punctuation differences", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "unicode.txt") + // File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014) + const leftQuote = "\u201C" + const rightQuote = "\u201D" + const emDash = "\u2014" + await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch uses ASCII equivalents - should match via normalized pass + // The replacement uses ASCII quotes from the patch (not preserving Unicode) + const patchText = + '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' + + await execute({ patchText }, ctx) + // Result has ASCII quotes because that's what the patch specifies + expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`) + }, + }) + }) })