Просмотр исходного кода

integrate gemini-cli strategies for edit tool

Dax Raad 8 месяцев назад
Родитель
Сommit
9c90cdbe08
2 измененных файлов с 264 добавлено и 0 удалено
  1. 150 0
      packages/opencode/src/tool/edit.ts
  2. 114 0
      packages/opencode/test/tool/edit.test.ts

+ 150 - 0
packages/opencode/src/tool/edit.ts

@@ -1,5 +1,6 @@
 // the approaches in this edit tool are sourced from
 // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
+// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
 
 import { z } from "zod"
 import * as path from "path"
@@ -266,6 +267,151 @@ export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
   }
 }
 
+export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
+  const unescapeString = (str: string): string => {
+    return str.replace(/\\+(n|t|r|'|"|`|\\|\n|\$)/g, (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)
+
+  // Try direct match with unescaped find string
+  if (content.includes(unescapedFind)) {
+    yield unescapedFind
+  }
+
+  // Also try finding escaped versions in content that match unescaped find
+  const lines = content.split("\n")
+  const findLines = unescapedFind.split("\n")
+
+  for (let i = 0; i <= lines.length - findLines.length; i++) {
+    const block = lines.slice(i, i + findLines.length).join("\n")
+    const unescapedBlock = unescapeString(block)
+
+    if (unescapedBlock === unescapedFind) {
+      yield block
+    }
+  }
+}
+
+export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
+  // This replacer yields all exact matches, allowing the replace function
+  // to handle multiple occurrences based on replaceAll parameter
+  let startIndex = 0
+
+  while (true) {
+    const index = content.indexOf(find, startIndex)
+    if (index === -1) break
+
+    yield find
+    startIndex = index + find.length
+  }
+}
+
+export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
+  const trimmedFind = find.trim()
+
+  if (trimmedFind === find) {
+    // Already trimmed, no point in trying
+    return
+  }
+
+  // Try to find the trimmed version
+  if (content.includes(trimmedFind)) {
+    yield trimmedFind
+  }
+
+  // Also try finding blocks where trimmed content matches
+  const lines = content.split("\n")
+  const findLines = find.split("\n")
+
+  for (let i = 0; i <= lines.length - findLines.length; i++) {
+    const block = lines.slice(i, i + findLines.length).join("\n")
+
+    if (block.trim() === trimmedFind) {
+      yield block
+    }
+  }
+}
+
+export const ContextAwareReplacer: Replacer = function* (content, find) {
+  const findLines = find.split("\n")
+  if (findLines.length < 3) {
+    // Need at least 3 lines to have meaningful context
+    return
+  }
+
+  const contentLines = content.split("\n")
+
+  // Extract first and last lines as context anchors
+  const firstLine = findLines[0].trim()
+  const lastLine = findLines[findLines.length - 1].trim()
+
+  // Find blocks that start and end with the context anchors
+  for (let i = 0; i < contentLines.length; i++) {
+    if (contentLines[i].trim() !== firstLine) continue
+
+    // Look for the matching last line
+    for (let j = i + 2; j < contentLines.length; j++) {
+      if (contentLines[j].trim() === lastLine) {
+        // Found a potential context block
+        const blockLines = contentLines.slice(i, j + 1)
+        const block = blockLines.join("\n")
+
+        // Check if the middle content has reasonable similarity
+        // (simple heuristic: at least 50% of non-empty lines should match when trimmed)
+        if (blockLines.length === findLines.length) {
+          let matchingLines = 0
+          let totalNonEmptyLines = 0
+
+          for (let k = 1; k < blockLines.length - 1; k++) {
+            const blockLine = blockLines[k].trim()
+            const findLine = findLines[k].trim()
+
+            if (blockLine.length > 0 || findLine.length > 0) {
+              totalNonEmptyLines++
+              if (blockLine === findLine) {
+                matchingLines++
+              }
+            }
+          }
+
+          if (
+            totalNonEmptyLines === 0 ||
+            matchingLines / totalNonEmptyLines >= 0.5
+          ) {
+            yield block
+            break // Only match the first occurrence
+          }
+        }
+        break
+      }
+    }
+  }
+}
+
 function trimDiff(diff: string): string {
   const lines = diff.split("\n")
   const contentLines = lines.filter(
@@ -314,6 +460,10 @@ export function replace(
     BlockAnchorReplacer,
     WhitespaceNormalizedReplacer,
     IndentationFlexibleReplacer,
+    EscapeNormalizedReplacer,
+    TrimmedBoundaryReplacer,
+    ContextAwareReplacer,
+    MultiOccurrenceReplacer,
   ]) {
     for (const search of replacer(content, oldString)) {
       const index = content.indexOf(search)

+ 114 - 0
packages/opencode/test/tool/edit.test.ts

@@ -188,6 +188,120 @@ const testCases: TestCase[] = [
     find: "Hello 世界! 🌍",
     replace: "Hello World! 🌎",
   },
+
+  // EscapeNormalizedReplacer cases
+  {
+    content: 'console.log("Hello\nWorld");',
+    find: 'console.log("Hello\\nWorld");',
+    replace: 'console.log("Hello\nUniverse");',
+  },
+  {
+    content: "const str = 'It's working';",
+    find: "const str = 'It\\'s working';",
+    replace: "const str = 'It's fixed';",
+  },
+  {
+    content: "const template = `Hello ${name}`;",
+    find: "const template = `Hello \\${name}`;",
+    replace: "const template = `Hi ${name}`;",
+  },
+  {
+    content: "const path = 'C:\\Users\\test';",
+    find: "const path = 'C:\\\\Users\\\\test';",
+    replace: "const path = 'C:\\Users\\admin';",
+  },
+
+  // MultiOccurrenceReplacer cases (with replaceAll)
+  {
+    content: ["debug('start');", "debug('middle');", "debug('end');"].join(
+      "\n",
+    ),
+    find: "debug",
+    replace: "log",
+    all: true,
+  },
+  {
+    content: "const x = 1; const y = 1; const z = 1;",
+    find: "1",
+    replace: "2",
+    all: true,
+  },
+
+  // TrimmedBoundaryReplacer cases
+  {
+    content: ["  function test() {", "    return true;", "  }"].join("\n"),
+    find: ["function test() {", "  return true;", "}"].join("\n"),
+    replace: ["function test() {", "  return false;", "}"].join("\n"),
+  },
+  {
+    content: "\n  const value = 42;  \n",
+    find: "const value = 42;",
+    replace: "const value = 24;",
+  },
+  {
+    content: ["", "  if (condition) {", "    doSomething();", "  }", ""].join(
+      "\n",
+    ),
+    find: ["if (condition) {", "  doSomething();", "}"].join("\n"),
+    replace: ["if (condition) {", "  doNothing();", "}"].join("\n"),
+  },
+
+  // ContextAwareReplacer cases
+  {
+    content: [
+      "function calculate(a, b) {",
+      "  const temp = a + b;",
+      "  const result = temp * 2;",
+      "  return result;",
+      "}",
+    ].join("\n"),
+    find: [
+      "function calculate(a, b) {",
+      "  // some different content here",
+      "  // more different content",
+      "  return result;",
+      "}",
+    ].join("\n"),
+    replace: ["function calculate(a, b) {", "  return (a + b) * 2;", "}"].join(
+      "\n",
+    ),
+  },
+  {
+    content: [
+      "class TestClass {",
+      "  constructor() {",
+      "    this.value = 0;",
+      "  }",
+      "  ",
+      "  method() {",
+      "    return this.value;",
+      "  }",
+      "}",
+    ].join("\n"),
+    find: [
+      "class TestClass {",
+      "  // different implementation",
+      "  // with multiple lines",
+      "}",
+    ].join("\n"),
+    replace: ["class TestClass {", "  getValue() { return 42; }", "}"].join(
+      "\n",
+    ),
+  },
+
+  // Combined edge cases for new replacers
+  {
+    content: '\tconsole.log("test");\t',
+    find: 'console.log("test");',
+    replace: 'console.log("updated");',
+  },
+  {
+    content: ["  ", "function test() {", "  return 'value';", "}", "  "].join(
+      "\n",
+    ),
+    find: ["function test() {", "return 'value';", "}"].join("\n"),
+    replace: ["function test() {", "return 'new value';", "}"].join("\n"),
+  },
 ]
 
 describe("EditTool Replacers", () => {