Aiden Cline 3 miesięcy temu
rodzic
commit
ab78a46396

+ 69 - 12
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 <<EOF\n...\nEOF
+    const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
+    if (heredocMatch) {
+      return heredocMatch[2]
+    }
+    return input
+  }
+
   export function parsePatch(patchText: string): { hunks: Hunk[] } {
-    const lines = patchText.split("\n")
+    const cleaned = stripHeredoc(patchText.trim())
+    const lines = cleaned.split("\n")
     const hunks: Hunk[] = []
     let i = 0
 
@@ -363,7 +373,7 @@ export namespace Patch {
       // Try to match old lines in the file
       let pattern = chunk.old_lines
       let newSlice = chunk.new_lines
-      let found = seekSequence(originalLines, pattern, lineIndex)
+      let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
 
       // Retry without trailing empty line if not found
       if (found === -1 && pattern.length > 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
+  }
+
+  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
+      }
+    }
 
-    // Simple substring search implementation
+    // 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")

+ 19 - 28
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)
-
-            fileChanges.push({
-              filePath,
-              oldContent,
-              newContent,
-              type: "add",
-            })
-
-            totalDiff += diff + "\n"
-          }
+        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",
+          })
+
+          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)
           }

+ 134 - 0
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 = `<<EOF
+*** Begin Patch
+*** Add File: heredoc_no_cat.txt
++no cat prefix
+*** End Patch
+EOF`
+
+        await execute({ patchText }, ctx)
+        const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8")
+        expect(content).toBe("no cat prefix\n")
+      },
+    })
+  })
+
+  test("matches with trailing 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, "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`)
+      },
+    })
+  })
 })