summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-06-25 17:54:54 -0400
committerDax Raad <[email protected]>2025-06-25 17:56:14 -0400
commit9c90cdbe0885a14c1f5d7c5fb187444150891425 (patch)
treeb2e77526e1d5d367ab8a58d72d752d62058d6e89
parentfc7af31fe5c208f81557373d241f3bacb8c87da7 (diff)
downloadopencode-9c90cdbe0885a14c1f5d7c5fb187444150891425.tar.gz
opencode-9c90cdbe0885a14c1f5d7c5fb187444150891425.zip
integrate gemini-cli strategies for edit tool
-rw-r--r--packages/opencode/src/tool/edit.ts150
-rw-r--r--packages/opencode/test/tool/edit.test.ts114
2 files changed, 264 insertions, 0 deletions
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index a62f241ee..f23d46dd1 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/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)
diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts
index 4f3b811cd..d0cb5cc8d 100644
--- a/packages/opencode/test/tool/edit.test.ts
+++ b/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", () => {