Jelajahi Sumber

Handle pure insertions and deletions with diffs

Matt Rubens 1 tahun lalu
induk
melakukan
905c68dd9e

+ 4 - 0
CHANGELOG.md

@@ -1,5 +1,9 @@
 # Roo Cline Changelog
 
+## [2.2.12]
+
+-   Better support for pure deletion and insertion diffs
+
 ## [2.2.11]
 
 -   Added settings checkbox for verbose diff debugging

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "roo-cline",
-  "version": "2.2.11",
+  "version": "2.2.12",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "roo-cline",
-      "version": "2.2.11",
+      "version": "2.2.12",
       "dependencies": {
         "@anthropic-ai/bedrock-sdk": "^0.10.2",
         "@anthropic-ai/sdk": "^0.26.0",

+ 1 - 1
package.json

@@ -3,7 +3,7 @@
   "displayName": "Roo Cline",
   "description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
   "publisher": "RooVeterinaryInc",
-  "version": "2.2.11",
+  "version": "2.2.12",
   "icon": "assets/icons/rocket.png",
   "galleryBanner": {
     "color": "#617A91",

+ 208 - 2
src/core/diff/strategies/__tests__/search-replace.test.ts

@@ -711,6 +711,212 @@ this.init();
         })
     });
 
+    describe('insertion/deletion', () => {
+        let strategy: SearchReplaceDiffStrategy
+    
+        beforeEach(() => {
+            strategy = new SearchReplaceDiffStrategy()
+        })
+    
+        describe('deletion', () => {
+            it('should delete code when replace block is empty', () => {
+                const originalContent = `function test() {
+    console.log("hello");
+    // Comment to remove
+    console.log("world");
+}`
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+    // Comment to remove
+=======
+>>>>>>> REPLACE`
+    
+                const result = strategy.applyDiff(originalContent, diffContent)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe(`function test() {
+    console.log("hello");
+    console.log("world");
+}`)
+                }
+            })
+    
+            it('should delete multiple lines when replace block is empty', () => {
+                const originalContent = `class Example {
+    constructor() {
+        // Initialize
+        this.value = 0;
+        // Set defaults
+        this.name = "";
+        // End init
+    }
+}`
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+        // Initialize
+        this.value = 0;
+        // Set defaults
+        this.name = "";
+        // End init
+=======
+>>>>>>> REPLACE`
+    
+                const result = strategy.applyDiff(originalContent, diffContent)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe(`class Example {
+    constructor() {
+    }
+}`)
+                }
+            })
+    
+            it('should preserve indentation when deleting nested code', () => {
+                const originalContent = `function outer() {
+    if (true) {
+        // Remove this
+        console.log("test");
+        // And this
+    }
+    return true;
+}`
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+        // Remove this
+        console.log("test");
+        // And this
+=======
+>>>>>>> REPLACE`
+    
+                const result = strategy.applyDiff(originalContent, diffContent)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe(`function outer() {
+    if (true) {
+    }
+    return true;
+}`)
+                }
+            })
+        })
+    
+        describe('insertion', () => {
+            it('should insert code at specified line when search block is empty', () => {
+            const originalContent = `function test() {
+    const x = 1;
+    return x;
+}`
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+=======
+    console.log("Adding log");
+>>>>>>> REPLACE`
+    
+                const result = strategy.applyDiff(originalContent, diffContent, 2, 2)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe(`function test() {
+    console.log("Adding log");
+    const x = 1;
+    return x;
+}`)
+                }
+            })
+    
+            it('should preserve indentation when inserting at nested location', () => {
+                const originalContent = `function test() {
+    if (true) {
+        const x = 1;
+    }
+}`
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+=======
+        console.log("Before");
+        console.log("After");
+>>>>>>> REPLACE`
+    
+                const result = strategy.applyDiff(originalContent, diffContent, 3, 3)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe(`function test() {
+    if (true) {
+        console.log("Before");
+        console.log("After");
+        const x = 1;
+    }
+}`)
+                }
+            })
+    
+            it('should handle insertion at start of file', () => {
+                const originalContent = `function test() {
+    return true;
+}`
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+=======
+// Copyright 2024
+// License: MIT
+
+>>>>>>> REPLACE`
+    
+                const result = strategy.applyDiff(originalContent, diffContent, 1, 1)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe(`// Copyright 2024
+// License: MIT
+
+function test() {
+    return true;
+}`)
+                }
+            })
+    
+            it('should handle insertion at end of file', () => {
+                const originalContent = `function test() {
+    return true;
+}`
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+=======
+
+// End of file
+>>>>>>> REPLACE`
+    
+                const result = strategy.applyDiff(originalContent, diffContent, 4, 4)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe(`function test() {
+    return true;
+}
+
+// End of file`)
+                }
+            })
+    
+            it('should insert at the start of the file if no start_line is provided for insertion', () => {
+                const originalContent = `function test() {
+    return true;
+}`
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+=======
+console.log("test");
+>>>>>>> REPLACE`
+    
+                const result = strategy.applyDiff(originalContent, diffContent)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe(`console.log("test");
+function test() {
+    return true;
+}`)
+                }
+            })
+        })
+    })
+
     describe('fuzzy matching', () => {
         let strategy: SearchReplaceDiffStrategy
 
@@ -1241,8 +1447,8 @@ function two() {
 
         it('should document start_line and end_line parameters', () => {
             const description = strategy.getToolDescription('/test')
-            expect(description).toContain('start_line: (required) The line number where the search block starts.')
-            expect(description).toContain('end_line: (required) The line number where the search block ends.')
+            expect(description).toContain('start_line: (required) The line number where the search block starts (inclusive).')
+            expect(description).toContain('end_line: (required) The line number where the search block ends (inclusive).')
         })
     })
 })

+ 75 - 16
src/core/diff/strategies/search-replace.ts

@@ -33,6 +33,10 @@ function levenshteinDistance(a: string, b: string): number {
 }
 
 function getSimilarity(original: string, search: string): number {
+    if (original === '' || search === '') {
+        return 1;
+    }
+
     // Normalize strings by removing extra whitespace but preserve case
     const normalizeStr = (str: string) => str.replace(/\s+/g, ' ').trim();
     
@@ -71,8 +75,8 @@ If you're not confident in the exact content to search for, use the read_file to
 Parameters:
 - path: (required) The path of the file to modify (relative to the current working directory ${cwd})
 - diff: (required) The search/replace block defining the changes.
-- start_line: (required) The line number where the search block starts.
-- end_line: (required) The line number where the search block ends.
+- start_line: (required) The line number where the search block starts (inclusive).
+- end_line: (required) The line number where the search block ends (inclusive).
 
 Diff format:
 \`\`\`
@@ -94,35 +98,84 @@ Original file:
 5 |     return total
 \`\`\`
 
-Search/Replace content:
+1. Search/replace a specific chunk of code:
 \`\`\`
+<apply_diff>
+<path>File path here</path>
+<diff>
 <<<<<<< SEARCH
-def calculate_total(items):
     total = 0
     for item in items:
         total += item
     return total
 =======
-def calculate_total(items):
     """Calculate total with 10% markup"""
     return sum(item * 1.1 for item in items)
 >>>>>>> REPLACE
+</diff>
+<start_line>2</start_line>
+<end_line>5</end_line>
+</apply_diff>
+\`\`\`
+
+Result:
+\`\`\`
+1 | def calculate_total(items):
+2 |     """Calculate total with 10% markup"""
+3 |     return sum(item * 1.1 for item in items)
 \`\`\`
 
-Usage:
+2. Insert code at a specific line (start_line and end_line must be the same, and the content gets inserted before whatever is currently at that line):
+\`\`\`
 <apply_diff>
 <path>File path here</path>
 <diff>
-Your search/replace content here
+<<<<<<< SEARCH
+=======
+    """TODO: Write a test for this"""
+>>>>>>> REPLACE
 </diff>
-<start_line>1</start_line>
+<start_line>2</start_line>
+<end_line>2</end_line>
+</apply_diff>
+\`\`\`
+
+Result:
+\`\`\`
+1 | def calculate_total(items):
+2 |     """TODO: Write a test for this"""
+3 |     """Calculate total with 10% markup"""
+4 |     return sum(item * 1.1 for item in items)
+\`\`\`
+
+3. Delete code at a specific line range:
+\`\`\`
+<apply_diff>
+<path>File path here</path>
+<diff>
+<<<<<<< SEARCH
+    total = 0
+    for item in items:
+        total += item
+    return total
+=======
+>>>>>>> REPLACE
+</diff>
+<start_line>2</start_line>
 <end_line>5</end_line>
-</apply_diff>`
+</apply_diff>
+\`\`\`
+
+Result:
+\`\`\`
+1 | def calculate_total(items):
+\`\`\`
+`
     }
 
     applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): DiffResult {
         // Extract the search and replace blocks
-        const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/);
+        const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/);
         if (!match) {
             const debugInfo = this.debugEnabled ? `\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include both SEARCH and REPLACE sections with correct markers` : '';
 
@@ -133,7 +186,7 @@ Your search/replace content here
         }
 
         let [_, searchContent, replaceContent] = match;
-        
+
         // Detect line ending from original content
         const lineEnding = originalContent.includes('\r\n') ? '\r\n' : '\n';
 
@@ -145,7 +198,7 @@ Your search/replace content here
 
         if (hasLineNumbers(searchContent) && hasLineNumbers(replaceContent)) {
             const stripLineNumbers = (content: string) => {
-                return content.replace(/^\d+\s+\|(?!\|)/gm, '') 
+                return content.replace(/^\d+\s+\|(?!\|)/gm, '');
             };
 
             searchContent = stripLineNumbers(searchContent);
@@ -153,8 +206,8 @@ Your search/replace content here
         }
         
         // Split content into lines, handling both \n and \r\n
-        const searchLines = searchContent.split(/\r?\n/);
-        const replaceLines = replaceContent.split(/\r?\n/);
+        const searchLines = searchContent === '' ? [] : searchContent.split(/\r?\n/);
+        const replaceLines = replaceContent === '' ? [] : replaceContent.split(/\r?\n/);
         const originalLines = originalContent.split(/\r?\n/);
         
         // First try exact line range if provided
@@ -167,9 +220,15 @@ Your search/replace content here
             const exactStartIndex = startLine - 1;
             const exactEndIndex = endLine - 1;
 
-            if (exactStartIndex < 0 || exactEndIndex >= originalLines.length || exactStartIndex > exactEndIndex) {
+            if (exactStartIndex < 0 || exactEndIndex > originalLines.length || exactStartIndex > exactEndIndex) {
                 const debugInfo = this.debugEnabled ? `\n\nDebug Info:\n- Requested Range: lines ${startLine}-${endLine}\n- File Bounds: lines 1-${originalLines.length}` : '';
     
+                // Log detailed debug information
+                console.log('Invalid Line Range Debug:', {
+                    requestedRange: { start: startLine, end: endLine },
+                    fileBounds: { start: 1, end: originalLines.length }
+                });
+
                 return {
                     success: false,
                     error: `Line range ${startLine}-${endLine} is invalid (file has ${originalLines.length} lines)${debugInfo}`,
@@ -263,7 +322,7 @@ Your search/replace content here
         // Apply the replacement while preserving exact indentation
         const indentedReplaceLines = replaceLines.map((line, i) => {
             // Get the matched line's exact indentation
-            const matchedIndent = originalIndents[0];
+            const matchedIndent = originalIndents[0] || '';
             
             // Get the current line's indentation relative to the search content
             const currentIndentMatch = line.match(/^[\t ]*/);