diff --git a/package-lock.json b/package-lock.json index 52027e5..bed6476 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@modelcontextprotocol/sdk": "^1.0.1", "@types/clone-deep": "^4.0.4", "@types/pdf-parse": "^1.1.4", + "@types/tmp": "^0.2.6", "@types/turndown": "^5.0.5", "@types/vscode": "^1.95.0", "@vscode/codicons": "^0.0.36", @@ -27,7 +28,9 @@ "default-shell": "^2.2.0", "delay": "^6.0.0", "diff": "^5.2.0", + "diff-match-patch": "^1.0.5", "fast-deep-equal": "^3.1.3", + "fastest-levenshtein": "^1.0.16", "globby": "^14.0.2", "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", @@ -39,8 +42,11 @@ "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "serialize-error": "^11.0.3", + "simple-git": "^3.27.0", "sound-play": "^1.1.0", + "string-similarity": "^4.0.4", "strip-ansi": "^7.1.0", + "tmp": "^0.2.3", "tree-sitter-wasms": "^0.1.11", "turndown": "^7.2.0", "web-tree-sitter": "^0.22.6", @@ -50,9 +56,11 @@ "@changesets/cli": "^2.27.10", "@changesets/types": "^6.0.0", "@types/diff": "^5.2.1", + "@types/diff-match-patch": "^1.0.36", "@types/jest": "^29.5.14", "@types/mocha": "^10.0.7", "@types/node": "20.x", + "@types/string-similarity": "^4.0.2", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.11.0", "@vscode/test-cli": "^0.0.9", @@ -4108,6 +4116,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/@manypkg/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", @@ -6069,6 +6092,13 @@ "integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==", "dev": true }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -6146,6 +6176,19 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "node_modules/@types/string-similarity": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/string-similarity/-/string-similarity-4.0.2.tgz", + "integrity": "sha512-LkJQ/jsXtCVMK+sKYAmX/8zEq+/46f1PTQw7YtmQwb74jemS1SlNLmARM2Zml9DgdDTWKAtc5L13WorpHPDjDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "license": "MIT" + }, "node_modules/@types/turndown": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz", @@ -7913,6 +7956,12 @@ "node": ">=0.3.1" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -8716,6 +8765,19 @@ "node": ">=0.10.0" } }, + "node_modules/external-editor/node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -8807,6 +8869,15 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -12720,6 +12791,7 @@ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -13926,6 +13998,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-git": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.27.0.tgz", + "integrity": "sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -14202,6 +14289,13 @@ "node": ">=8" } }, + "node_modules/string-similarity": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz", + "integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "ISC" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -14544,15 +14638,12 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" }, "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "license": "MIT", "engines": { - "node": ">=0.6.0" + "node": ">=14.14" } }, "node_modules/tmpl": { diff --git a/package.json b/package.json index 7eea35b..5b3dc1d 100644 --- a/package.json +++ b/package.json @@ -202,9 +202,11 @@ "@changesets/cli": "^2.27.10", "@changesets/types": "^6.0.0", "@types/diff": "^5.2.1", + "@types/diff-match-patch": "^1.0.36", "@types/jest": "^29.5.14", "@types/mocha": "^10.0.7", "@types/node": "20.x", + "@types/string-similarity": "^4.0.2", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.11.0", "@vscode/test-cli": "^0.0.9", @@ -230,6 +232,7 @@ "@modelcontextprotocol/sdk": "^1.0.1", "@types/clone-deep": "^4.0.4", "@types/pdf-parse": "^1.1.4", + "@types/tmp": "^0.2.6", "@types/turndown": "^5.0.5", "@types/vscode": "^1.95.0", "@vscode/codicons": "^0.0.36", @@ -240,7 +243,9 @@ "default-shell": "^2.2.0", "delay": "^6.0.0", "diff": "^5.2.0", + "diff-match-patch": "^1.0.5", "fast-deep-equal": "^3.1.3", + "fastest-levenshtein": "^1.0.16", "globby": "^14.0.2", "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", @@ -252,8 +257,11 @@ "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "serialize-error": "^11.0.3", + "simple-git": "^3.27.0", "sound-play": "^1.1.0", + "string-similarity": "^4.0.4", "strip-ansi": "^7.1.0", + "tmp": "^0.2.3", "tree-sitter-wasms": "^0.1.11", "turndown": "^7.2.0", "web-tree-sitter": "^0.22.6", diff --git a/src/core/Cline.ts b/src/core/Cline.ts index c0c3683..4487521 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -52,6 +52,7 @@ import { detectCodeOmission } from "../integrations/editor/detect-omission" import { BrowserSession } from "../services/browser/BrowserSession" import { OpenRouterHandler } from "../api/providers/openrouter" import { McpHub } from "../services/mcp/McpHub" +import crypto from "crypto" const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution @@ -71,6 +72,7 @@ export class Cline { customInstructions?: string diffStrategy?: DiffStrategy diffEnabled: boolean = false + fuzzyMatchThreshold: number = 1.0 apiConversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [] clineMessages: ClineMessage[] = [] @@ -105,28 +107,46 @@ export class Cline { fuzzyMatchThreshold?: number, task?: string | undefined, images?: string[] | undefined, - historyItem?: HistoryItem | undefined + historyItem?: HistoryItem | undefined, + experimentalDiffStrategy: boolean = false, ) { - this.providerRef = new WeakRef(provider) + if (!task && !images && !historyItem) { + throw new Error('Either historyItem or task/images must be provided'); + } + + this.taskId = crypto.randomUUID() this.api = buildApiHandler(apiConfiguration) this.terminalManager = new TerminalManager() this.urlContentFetcher = new UrlContentFetcher(provider.context) this.browserSession = new BrowserSession(provider.context) - this.diffViewProvider = new DiffViewProvider(cwd) this.customInstructions = customInstructions this.diffEnabled = enableDiff ?? false - if (this.diffEnabled && this.api.getModel().id) { - this.diffStrategy = getDiffStrategy(this.api.getModel().id, fuzzyMatchThreshold ?? 1.0) - } + this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0 + this.providerRef = new WeakRef(provider) + this.diffViewProvider = new DiffViewProvider(cwd) + if (historyItem) { this.taskId = historyItem.id - this.resumeTaskFromHistory() - } else if (task || images) { - this.taskId = Date.now().toString() - this.startTask(task, images) - } else { - throw new Error("Either historyItem or task/images must be provided") } + + // Initialize diffStrategy based on current state + this.updateDiffStrategy(experimentalDiffStrategy) + + if (task || images) { + this.startTask(task, images) + } else if (historyItem) { + this.resumeTaskFromHistory() + } + } + + // Add method to update diffStrategy + async updateDiffStrategy(experimentalDiffStrategy?: boolean) { + // If not provided, get from current state + if (experimentalDiffStrategy === undefined) { + const { experimentalDiffStrategy: stateExperimentalDiffStrategy } = await this.providerRef.deref()?.getState() ?? {} + experimentalDiffStrategy = stateExperimentalDiffStrategy ?? false + } + this.diffStrategy = getDiffStrategy(this.api.getModel().id, this.fuzzyMatchThreshold, experimentalDiffStrategy) } // Storing task to disk for history @@ -1326,7 +1346,7 @@ export class Cline { const originalContent = await fs.readFile(absolutePath, "utf-8") // Apply the diff to the original content - const diffResult = this.diffStrategy?.applyDiff( + const diffResult = await this.diffStrategy?.applyDiff( originalContent, diffContent, parseInt(block.params.start_line ?? ''), diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 66bdbf7..11e9f9d 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -322,7 +322,7 @@ describe('Cline', () => { expect(cline.diffEnabled).toBe(true); expect(cline.diffStrategy).toBeDefined(); - expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 0.9); + expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 0.9, false); getDiffStrategySpy.mockRestore(); }); @@ -341,7 +341,7 @@ describe('Cline', () => { expect(cline.diffEnabled).toBe(true); expect(cline.diffStrategy).toBeDefined(); - expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 1.0); + expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 1.0, false); getDiffStrategySpy.mockRestore(); }); diff --git a/src/core/diff/DiffStrategy.ts b/src/core/diff/DiffStrategy.ts index c611856..ac3a0c4 100644 --- a/src/core/diff/DiffStrategy.ts +++ b/src/core/diff/DiffStrategy.ts @@ -1,15 +1,17 @@ import type { DiffStrategy } from './types' import { UnifiedDiffStrategy } from './strategies/unified' import { SearchReplaceDiffStrategy } from './strategies/search-replace' +import { NewUnifiedDiffStrategy } from './strategies/new-unified' /** * Get the appropriate diff strategy for the given model * @param model The name of the model being used (e.g., 'gpt-4', 'claude-3-opus') * @returns The appropriate diff strategy for the model */ -export function getDiffStrategy(model: string, fuzzyMatchThreshold?: number): DiffStrategy { - // For now, return SearchReplaceDiffStrategy for all models - // This architecture allows for future optimizations based on model capabilities - return new SearchReplaceDiffStrategy(fuzzyMatchThreshold ?? 1.0) +export function getDiffStrategy(model: string, fuzzyMatchThreshold?: number, experimentalDiffStrategy: boolean = false): DiffStrategy { + if (experimentalDiffStrategy) { + return new NewUnifiedDiffStrategy(fuzzyMatchThreshold) + } + return new SearchReplaceDiffStrategy(fuzzyMatchThreshold) } export type { DiffStrategy } diff --git a/src/core/diff/strategies/__tests__/new-unified.test.ts b/src/core/diff/strategies/__tests__/new-unified.test.ts new file mode 100644 index 0000000..c49fc80 --- /dev/null +++ b/src/core/diff/strategies/__tests__/new-unified.test.ts @@ -0,0 +1,739 @@ +import { NewUnifiedDiffStrategy } from '../new-unified'; + +describe('main', () => { + + let strategy: NewUnifiedDiffStrategy + + beforeEach(() => { + strategy = new NewUnifiedDiffStrategy(0.97) + }) + + describe('constructor', () => { + it('should use default confidence threshold when not provided', () => { + const defaultStrategy = new NewUnifiedDiffStrategy() + expect(defaultStrategy['confidenceThreshold']).toBe(1) + }) + + it('should use provided confidence threshold', () => { + const customStrategy = new NewUnifiedDiffStrategy(0.85) + expect(customStrategy['confidenceThreshold']).toBe(0.85) + }) + + it('should enforce minimum confidence threshold', () => { + const lowStrategy = new NewUnifiedDiffStrategy(0.7) // Below minimum of 0.8 + expect(lowStrategy['confidenceThreshold']).toBe(0.8) + }) + }) + + describe('getToolDescription', () => { + it('should return tool description with correct cwd', () => { + const cwd = '/test/path' + const description = strategy.getToolDescription(cwd) + + expect(description).toContain('apply_diff') + expect(description).toContain(cwd) + expect(description).toContain('Parameters:') + expect(description).toContain('Format Requirements:') + }) + }) + + it('should apply simple diff correctly', async () => { + const original = `line1 +line2 +line3`; + + const diff = `--- a/file.txt ++++ b/file.txt +@@ ... @@ + line1 ++new line + line2 +-line3 ++modified line3`; + + const result = await strategy.applyDiff(original, diff); + expect(result.success).toBe(true); + if(result.success) { + expect(result.content).toBe(`line1 +new line +line2 +modified line3`); + } + }); + + it('should handle multiple hunks', async () => { + const original = `line1 +line2 +line3 +line4 +line5`; + + const diff = `--- a/file.txt ++++ b/file.txt +@@ ... @@ + line1 ++new line + line2 +-line3 ++modified line3 +@@ ... @@ + line4 +-line5 ++modified line5 ++new line at end`; + + const result = await strategy.applyDiff(original, diff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe(`line1 +new line +line2 +modified line3 +line4 +modified line5 +new line at end`); + } + }); + + it('should handle complex large', async () => { + const original = `line1 +line2 +line3 +line4 +line5 +line6 +line7 +line8 +line9 +line10`; + + const diff = `--- a/file.txt ++++ b/file.txt +@@ ... @@ + line1 ++header line ++another header + line2 +-line3 +-line4 ++modified line3 ++modified line4 ++extra line +@@ ... @@ + line6 ++middle section + line7 +-line8 ++changed line8 ++bonus line +@@ ... @@ + line9 +-line10 ++final line ++very last line`; + + const result = await strategy.applyDiff(original, diff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe(`line1 +header line +another header +line2 +modified line3 +modified line4 +extra line +line5 +line6 +middle section +line7 +changed line8 +bonus line +line9 +final line +very last line`); + } + }); + + it('should handle indentation changes', async () => { + const original = `first line + indented line + double indented line + back to single indent +no indent + indented again + double indent again + triple indent + back to single +last line`; + + const diff = `--- original ++++ modified +@@ ... @@ + first line + indented line ++ tab indented line ++ new indented line + double indented line + back to single indent + no indent + indented again + double indent again +- triple indent ++ hi there mate + back to single + last line`; + + const expected = `first line + indented line + tab indented line + new indented line + double indented line + back to single indent +no indent + indented again + double indent again + hi there mate + back to single +last line`; + + const result = await strategy.applyDiff(original, diff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe(expected); + } + }); + + it('should handle high level edits', async () => { + + const original = `def factorial(n): + if n == 0: + return 1 + else: + return n * factorial(n-1)` + const diff = `@@ ... @@ +-def factorial(n): +- if n == 0: +- return 1 +- else: +- return n * factorial(n-1) ++def factorial(number): ++ if number == 0: ++ return 1 ++ else: ++ return number * factorial(number-1)` + +const expected = `def factorial(number): + if number == 0: + return 1 + else: + return number * factorial(number-1)` + + const result = await strategy.applyDiff(original, diff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe(expected); + } + }); + + it('it should handle very complex edits', async () => { + const original = `//Initialize the array that will hold the primes +var primeArray = []; +/*Write a function that checks for primeness and + pushes those values to t*he array*/ +function PrimeCheck(candidate){ + isPrime = true; + for(var i = 2; i < candidate && isPrime; i++){ + if(candidate%i === 0){ + isPrime = false; + } else { + isPrime = true; + } + } + if(isPrime){ + primeArray.push(candidate); + } + return primeArray; +} +/*Write the code that runs the above until the + l ength of the array equa*ls the number of primes + desired*/ + +var numPrimes = prompt("How many primes?"); + +//Display the finished array of primes + +//for loop starting at 2 as that is the lowest prime number keep going until the array is as long as we requested +for (var i = 2; primeArray.length < numPrimes; i++) { + PrimeCheck(i); // +} +console.log(primeArray); +` + + const diff = `--- test_diff.js ++++ test_diff.js +@@ ... @@ +-//Initialize the array that will hold the primes + var primeArray = []; +-/*Write a function that checks for primeness and +- pushes those values to t*he array*/ + function PrimeCheck(candidate){ + isPrime = true; + for(var i = 2; i < candidate && isPrime; i++){ +@@ ... @@ + return primeArray; + } +-/*Write the code that runs the above until the +- l ength of the array equa*ls the number of primes +- desired*/ + + var numPrimes = prompt("How many primes?"); + +-//Display the finished array of primes +- +-//for loop starting at 2 as that is the lowest prime number keep going until the array is as long as we requested + for (var i = 2; primeArray.length < numPrimes; i++) { +- PrimeCheck(i); // ++ PrimeCheck(i); + } + console.log(primeArray);` + + const expected = `var primeArray = []; +function PrimeCheck(candidate){ + isPrime = true; + for(var i = 2; i < candidate && isPrime; i++){ + if(candidate%i === 0){ + isPrime = false; + } else { + isPrime = true; + } + } + if(isPrime){ + primeArray.push(candidate); + } + return primeArray; +} + +var numPrimes = prompt("How many primes?"); + +for (var i = 2; primeArray.length < numPrimes; i++) { + PrimeCheck(i); +} +console.log(primeArray); +` + + + const result = await strategy.applyDiff(original, diff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe(expected); + } + }); + + describe('error handling and edge cases', () => { + it('should reject completely invalid diff format', async () => { + const original = 'line1\nline2\nline3'; + const invalidDiff = 'this is not a diff at all'; + + const result = await strategy.applyDiff(original, invalidDiff); + expect(result.success).toBe(false); + }); + + it('should reject diff with invalid hunk format', async () => { + const original = 'line1\nline2\nline3'; + const invalidHunkDiff = `--- a/file.txt ++++ b/file.txt +invalid hunk header + line1 +-line2 ++new line`; + + const result = await strategy.applyDiff(original, invalidHunkDiff); + expect(result.success).toBe(false); + }); + + it('should fail when diff tries to modify non-existent content', async () => { + const original = 'line1\nline2\nline3'; + const nonMatchingDiff = `--- a/file.txt ++++ b/file.txt +@@ ... @@ + line1 +-nonexistent line ++new line + line3`; + + const result = await strategy.applyDiff(original, nonMatchingDiff); + expect(result.success).toBe(false); + }); + + it('should handle overlapping hunks', async () => { + const original = `line1 +line2 +line3 +line4 +line5`; + const overlappingDiff = `--- a/file.txt ++++ b/file.txt +@@ ... @@ + line1 + line2 +-line3 ++modified3 + line4 +@@ ... @@ + line2 +-line3 +-line4 ++modified3and4 + line5`; + + const result = await strategy.applyDiff(original, overlappingDiff); + expect(result.success).toBe(false); + }); + + it('should handle empty lines modifications', async () => { + const original = `line1 + +line3 + +line5`; + const emptyLinesDiff = `--- a/file.txt ++++ b/file.txt +@@ ... @@ + line1 + +-line3 ++line3modified + + line5`; + + const result = await strategy.applyDiff(original, emptyLinesDiff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe(`line1 + +line3modified + +line5`); + } + }); + + it('should handle mixed line endings in diff', async () => { + const original = 'line1\r\nline2\nline3\r\n'; + const mixedEndingsDiff = `--- a/file.txt ++++ b/file.txt +@@ ... @@ + line1\r +-line2 ++modified2\r + line3`; + + const result = await strategy.applyDiff(original, mixedEndingsDiff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe('line1\r\nmodified2\r\nline3\r\n'); + } + }); + + it('should handle partial line modifications', async () => { + const original = 'const value = oldValue + 123;'; + const partialDiff = `--- a/file.txt ++++ b/file.txt +@@ ... @@ +-const value = oldValue + 123; ++const value = newValue + 123;`; + + const result = await strategy.applyDiff(original, partialDiff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe('const value = newValue + 123;'); + } + }); + + it('should handle slightly malformed but recoverable diff', async () => { + const original = 'line1\nline2\nline3'; + // Missing space after --- and +++ + const slightlyBadDiff = `---a/file.txt ++++b/file.txt +@@ ... @@ + line1 +-line2 ++new line + line3`; + + const result = await strategy.applyDiff(original, slightlyBadDiff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe('line1\nnew line\nline3'); + } + }); + }); + + describe('similar code sections', () => { + it('should correctly modify the right section when similar code exists', async () => { + const original = `function add(a, b) { + return a + b; +} + +function subtract(a, b) { + return a - b; +} + +function multiply(a, b) { + return a + b; // Bug here +}`; + + const diff = `--- a/math.js ++++ b/math.js +@@ ... @@ + function multiply(a, b) { +- return a + b; // Bug here ++ return a * b; + }`; + + const result = await strategy.applyDiff(original, diff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe(`function add(a, b) { + return a + b; +} + +function subtract(a, b) { + return a - b; +} + +function multiply(a, b) { + return a * b; +}`); + } + }); + + it('should handle multiple similar sections with correct context', async () => { + const original = `if (condition) { + doSomething(); + doSomething(); + doSomething(); +} + +if (otherCondition) { + doSomething(); + doSomething(); + doSomething(); +}`; + + const diff = `--- a/file.js ++++ b/file.js +@@ ... @@ + if (otherCondition) { + doSomething(); +- doSomething(); ++ doSomethingElse(); + doSomething(); + }`; + + const result = await strategy.applyDiff(original, diff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe(`if (condition) { + doSomething(); + doSomething(); + doSomething(); +} + +if (otherCondition) { + doSomething(); + doSomethingElse(); + doSomething(); +}`); + } + }); + }); + + describe('hunk splitting', () => { + it('should handle large diffs with multiple non-contiguous changes', async () => { + const original = `import { readFile } from 'fs'; +import { join } from 'path'; +import { Logger } from './logger'; + +const logger = new Logger(); + +async function processFile(filePath: string) { + try { + const data = await readFile(filePath, 'utf8'); + logger.info('File read successfully'); + return data; + } catch (error) { + logger.error('Failed to read file:', error); + throw error; + } +} + +function validateInput(input: string): boolean { + if (!input) { + logger.warn('Empty input received'); + return false; + } + return input.length > 0; +} + +async function writeOutput(data: string) { + logger.info('Processing output'); + // TODO: Implement output writing + return Promise.resolve(); +} + +function parseConfig(configPath: string) { + logger.debug('Reading config from:', configPath); + // Basic config parsing + return { + enabled: true, + maxRetries: 3 + }; +} + +export { + processFile, + validateInput, + writeOutput, + parseConfig +};`; + + const diff = `--- a/file.ts ++++ b/file.ts +@@ ... @@ +-import { readFile } from 'fs'; ++import { readFile, writeFile } from 'fs'; + import { join } from 'path'; +-import { Logger } from './logger'; ++import { Logger } from './utils/logger'; ++import { Config } from './types'; + +-const logger = new Logger(); ++const logger = new Logger('FileProcessor'); + + async function processFile(filePath: string) { + try { + const data = await readFile(filePath, 'utf8'); +- logger.info('File read successfully'); ++ logger.info(\`File \${filePath} read successfully\`); + return data; + } catch (error) { +- logger.error('Failed to read file:', error); ++ logger.error(\`Failed to read file \${filePath}:\`, error); + throw error; + } + } + + function validateInput(input: string): boolean { + if (!input) { +- logger.warn('Empty input received'); ++ logger.warn('Validation failed: Empty input received'); + return false; + } +- return input.length > 0; ++ return input.trim().length > 0; + } + +-async function writeOutput(data: string) { +- logger.info('Processing output'); +- // TODO: Implement output writing +- return Promise.resolve(); ++async function writeOutput(data: string, outputPath: string) { ++ try { ++ await writeFile(outputPath, data, 'utf8'); ++ logger.info(\`Output written to \${outputPath}\`); ++ } catch (error) { ++ logger.error(\`Failed to write output to \${outputPath}:\`, error); ++ throw error; ++ } + } + +-function parseConfig(configPath: string) { +- logger.debug('Reading config from:', configPath); +- // Basic config parsing +- return { +- enabled: true, +- maxRetries: 3 +- }; ++async function parseConfig(configPath: string): Promise { ++ try { ++ const configData = await readFile(configPath, 'utf8'); ++ logger.debug(\`Reading config from \${configPath}\`); ++ return JSON.parse(configData); ++ } catch (error) { ++ logger.error(\`Failed to parse config from \${configPath}:\`, error); ++ throw error; ++ } + } + + export { + processFile, + validateInput, + writeOutput, +- parseConfig ++ parseConfig, ++ type Config + };`; + + const expected = `import { readFile, writeFile } from 'fs'; +import { join } from 'path'; +import { Logger } from './utils/logger'; +import { Config } from './types'; + +const logger = new Logger('FileProcessor'); + +async function processFile(filePath: string) { + try { + const data = await readFile(filePath, 'utf8'); + logger.info(\`File \${filePath} read successfully\`); + return data; + } catch (error) { + logger.error(\`Failed to read file \${filePath}:\`, error); + throw error; + } +} + +function validateInput(input: string): boolean { + if (!input) { + logger.warn('Validation failed: Empty input received'); + return false; + } + return input.trim().length > 0; +} + +async function writeOutput(data: string, outputPath: string) { + try { + await writeFile(outputPath, data, 'utf8'); + logger.info(\`Output written to \${outputPath}\`); + } catch (error) { + logger.error(\`Failed to write output to \${outputPath}:\`, error); + throw error; + } +} + +async function parseConfig(configPath: string): Promise { + try { + const configData = await readFile(configPath, 'utf8'); + logger.debug(\`Reading config from \${configPath}\`); + return JSON.parse(configData); + } catch (error) { + logger.error(\`Failed to parse config from \${configPath}:\`, error); + throw error; + } +} + +export { + processFile, + validateInput, + writeOutput, + parseConfig, + type Config +};`; + + const result = await strategy.applyDiff(original, diff); + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe(expected); + } + }); + }); +}); \ No newline at end of file diff --git a/src/core/diff/strategies/__tests__/search-replace.test.ts b/src/core/diff/strategies/__tests__/search-replace.test.ts index c016618..ce17d31 100644 --- a/src/core/diff/strategies/__tests__/search-replace.test.ts +++ b/src/core/diff/strategies/__tests__/search-replace.test.ts @@ -8,7 +8,7 @@ describe('SearchReplaceDiffStrategy', () => { strategy = new SearchReplaceDiffStrategy(1.0, 5) // Default 1.0 threshold for exact matching, 5 line buffer for tests }) - it('should replace matching content', () => { + it('should replace matching content', async () => { const originalContent = 'function hello() {\n console.log("hello")\n}\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -21,14 +21,14 @@ function hello() { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('function hello() {\n console.log("hello world")\n}\n') } }) - it('should match content with different surrounding whitespace', () => { + it('should match content with different surrounding whitespace', async () => { const originalContent = '\nfunction example() {\n return 42;\n}\n\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -41,14 +41,14 @@ function example() { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('\nfunction example() {\n return 43;\n}\n\n') } }) - it('should match content with different indentation in search block', () => { + it('should match content with different indentation in search block', async () => { const originalContent = ' function test() {\n return true;\n }\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -61,14 +61,14 @@ function test() { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(' function test() {\n return false;\n }\n') } }) - it('should handle tab-based indentation', () => { + it('should handle tab-based indentation', async () => { const originalContent = "function test() {\n\treturn true;\n}\n" const diffContent = `test.ts <<<<<<< SEARCH @@ -81,14 +81,14 @@ function test() { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe("function test() {\n\treturn false;\n}\n") } }) - it('should preserve mixed tabs and spaces', () => { + it('should preserve mixed tabs and spaces', async () => { const originalContent = "\tclass Example {\n\t constructor() {\n\t\tthis.value = 0;\n\t }\n\t}" const diffContent = `test.ts <<<<<<< SEARCH @@ -105,14 +105,14 @@ function test() { \t} >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe("\tclass Example {\n\t constructor() {\n\t\tthis.value = 1;\n\t }\n\t}") } }) - it('should handle additional indentation with tabs', () => { + it('should handle additional indentation with tabs', async () => { const originalContent = "\tfunction test() {\n\t\treturn true;\n\t}" const diffContent = `test.ts <<<<<<< SEARCH @@ -126,14 +126,14 @@ function test() { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe("\tfunction test() {\n\t\t// Add comment\n\t\treturn false;\n\t}") } }) - it('should preserve exact indentation characters when adding lines', () => { + it('should preserve exact indentation characters when adding lines', async () => { const originalContent = "\tfunction test() {\n\t\treturn true;\n\t}" const diffContent = `test.ts <<<<<<< SEARCH @@ -148,14 +148,14 @@ function test() { \t} >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe("\tfunction test() {\n\t\t// First comment\n\t\t// Second comment\n\t\treturn true;\n\t}") } }) - it('should handle Windows-style CRLF line endings', () => { + it('should handle Windows-style CRLF line endings', async () => { const originalContent = "function test() {\r\n return true;\r\n}\r\n" const diffContent = `test.ts <<<<<<< SEARCH @@ -168,14 +168,14 @@ function test() { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe("function test() {\r\n return false;\r\n}\r\n") } }) - it('should return false if search content does not match', () => { + it('should return false if search content does not match', async () => { const originalContent = 'function hello() {\n console.log("hello")\n}\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -188,19 +188,19 @@ function hello() { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(false) }) - it('should return false if diff format is invalid', () => { + it('should return false if diff format is invalid', async () => { const originalContent = 'function hello() {\n console.log("hello")\n}\n' const diffContent = `test.ts\nInvalid diff format` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(false) }) - it('should handle multiple lines with proper indentation', () => { + it('should handle multiple lines with proper indentation', async () => { const originalContent = 'class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n return this.value\n }\n}\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -215,14 +215,14 @@ function hello() { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n // Add logging\n console.log("Getting value")\n return this.value\n }\n}\n') } }) - it('should preserve whitespace exactly in the output', () => { + it('should preserve whitespace exactly in the output', async () => { const originalContent = " indented\n more indented\n back\n" const diffContent = `test.ts <<<<<<< SEARCH @@ -235,14 +235,14 @@ function hello() { end >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(" modified\n still indented\n end\n") } }) - it('should preserve indentation when adding new lines after existing content', () => { + it('should preserve indentation when adding new lines after existing content', async () => { const originalContent = ' onScroll={() => updateHighlights()}' const diffContent = `test.ts <<<<<<< SEARCH @@ -255,14 +255,14 @@ function hello() { }} >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(' onScroll={() => updateHighlights()}\n onDragOver={(e) => {\n e.preventDefault()\n e.stopPropagation()\n }}') } }) - it('should handle varying indentation levels correctly', () => { + it('should handle varying indentation levels correctly', async () => { const originalContent = ` class Example { constructor() { @@ -296,7 +296,7 @@ class Example { } >>>>>>> REPLACE`.trim(); - const result = strategy.applyDiff(originalContent, diffContent); + const result = await strategy.applyDiff(originalContent, diffContent); expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(` @@ -313,7 +313,7 @@ class Example { } }) - it('should handle mixed indentation styles in the same file', () => { + it('should handle mixed indentation styles in the same file', async () => { const originalContent = `class Example { constructor() { this.value = 0; @@ -340,7 +340,7 @@ class Example { } >>>>>>> REPLACE`; - const result = strategy.applyDiff(originalContent, diffContent); + const result = await strategy.applyDiff(originalContent, diffContent); expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`class Example { @@ -355,7 +355,7 @@ class Example { } }) - it('should handle Python-style significant whitespace', () => { + it('should handle Python-style significant whitespace', async () => { const originalContent = `def example(): if condition: do_something() @@ -376,7 +376,7 @@ class Example { process(item) >>>>>>> REPLACE`; - const result = strategy.applyDiff(originalContent, diffContent); + const result = await strategy.applyDiff(originalContent, diffContent); expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`def example(): @@ -389,7 +389,7 @@ class Example { } }); - it('should preserve empty lines with indentation', () => { + it('should preserve empty lines with indentation', async () => { const originalContent = `function test() { const x = 1; @@ -409,7 +409,7 @@ class Example { if (x) { >>>>>>> REPLACE`; - const result = strategy.applyDiff(originalContent, diffContent); + const result = await strategy.applyDiff(originalContent, diffContent); expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function test() { @@ -423,7 +423,7 @@ class Example { } }); - it('should handle indentation when replacing entire blocks', () => { + it('should handle indentation when replacing entire blocks', async () => { const originalContent = `class Test { method() { if (true) { @@ -450,7 +450,7 @@ class Example { } >>>>>>> REPLACE`; - const result = strategy.applyDiff(originalContent, diffContent); + const result = await strategy.applyDiff(originalContent, diffContent); expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`class Test { @@ -467,7 +467,7 @@ class Example { } }); - it('should handle negative indentation relative to search content', () => { + it('should handle negative indentation relative to search content', async () => { const originalContent = `class Example { constructor() { if (true) { @@ -484,8 +484,8 @@ class Example { this.init(); this.setup(); >>>>>>> REPLACE`; - - const result = strategy.applyDiff(originalContent, diffContent); + + const result = await strategy.applyDiff(originalContent, diffContent); expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`class Example { @@ -499,7 +499,7 @@ class Example { } }); - it('should handle extreme negative indentation (no indent)', () => { + it('should handle extreme negative indentation (no indent)', async () => { const originalContent = `class Example { constructor() { if (true) { @@ -514,7 +514,7 @@ class Example { this.init(); >>>>>>> REPLACE`; - const result = strategy.applyDiff(originalContent, diffContent); + const result = await strategy.applyDiff(originalContent, diffContent); expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`class Example { @@ -527,7 +527,7 @@ this.init(); } }); - it('should handle mixed indentation changes in replace block', () => { + it('should handle mixed indentation changes in replace block', async () => { const originalContent = `class Example { constructor() { if (true) { @@ -548,7 +548,7 @@ this.init(); this.validate(); >>>>>>> REPLACE`; - const result = strategy.applyDiff(originalContent, diffContent); + const result = await strategy.applyDiff(originalContent, diffContent); expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`class Example { @@ -563,7 +563,7 @@ this.init(); } }); - it('should find matches from middle out', () => { + it('should find matches from middle out', async () => { const originalContent = ` function one() { return "target"; @@ -595,7 +595,7 @@ function five() { // Search around the middle (function three) // Even though all functions contain the target text, // it should match the one closest to line 9 first - const result = strategy.applyDiff(originalContent, diffContent, 9, 9) + const result = await strategy.applyDiff(originalContent, diffContent, 9, 9) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function one() { @@ -629,7 +629,7 @@ function five() { strategy = new SearchReplaceDiffStrategy() }) - it('should strip line numbers from both search and replace sections', () => { + it('should strip line numbers from both search and replace sections', async () => { const originalContent = 'function test() {\n return true;\n}\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -642,14 +642,14 @@ function five() { 3 | } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('function test() {\n return false;\n}\n') } }) - it('should strip line numbers with leading spaces', () => { + it('should strip line numbers with leading spaces', async () => { const originalContent = 'function test() {\n return true;\n}\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -662,14 +662,14 @@ function five() { 3 | } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('function test() {\n return false;\n}\n') } }) - it('should not strip when not all lines have numbers in either section', () => { + it('should not strip when not all lines have numbers in either section', async () => { const originalContent = 'function test() {\n return true;\n}\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -682,11 +682,11 @@ function five() { 3 | } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(false) }) - it('should preserve content that naturally starts with pipe', () => { + it('should preserve content that naturally starts with pipe', async () => { const originalContent = '|header|another|\n|---|---|\n|data|more|\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -699,14 +699,14 @@ function five() { 3 | |data|updated| >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('|header|another|\n|---|---|\n|data|updated|\n') } }) - it('should preserve indentation when stripping line numbers', () => { + it('should preserve indentation when stripping line numbers', async () => { const originalContent = ' function test() {\n return true;\n }\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -719,14 +719,14 @@ function five() { 3 | } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(' function test() {\n return false;\n }\n') } }) - it('should handle different line numbers between sections', () => { + it('should handle different line numbers between sections', async () => { const originalContent = 'function test() {\n return true;\n}\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -739,14 +739,14 @@ function five() { 22 | } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('function test() {\n return false;\n}\n') } }) - it('should not strip content that starts with pipe but no line number', () => { + it('should not strip content that starts with pipe but no line number', async () => { const originalContent = '| Pipe\n|---|\n| Data\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -759,14 +759,14 @@ function five() { | Updated >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('| Pipe\n|---|\n| Updated\n') } }) - it('should handle mix of line-numbered and pipe-only content', () => { + it('should handle mix of line-numbered and pipe-only content', async () => { const originalContent = '| Pipe\n|---|\n| Data\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -779,7 +779,7 @@ function five() { 3 | | NewData >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('1 | | Pipe\n2 | |---|\n3 | | NewData\n') @@ -796,7 +796,7 @@ function five() { }) describe('deletion', () => { - it('should delete code when replace block is empty', () => { + it('should delete code when replace block is empty', async () => { const originalContent = `function test() { console.log("hello"); // Comment to remove @@ -808,7 +808,7 @@ function five() { ======= >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function test() { @@ -818,7 +818,7 @@ function five() { } }) - it('should delete multiple lines when replace block is empty', () => { + it('should delete multiple lines when replace block is empty', async () => { const originalContent = `class Example { constructor() { // Initialize @@ -838,7 +838,7 @@ function five() { ======= >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`class Example { @@ -848,7 +848,7 @@ function five() { } }) - it('should preserve indentation when deleting nested code', () => { + it('should preserve indentation when deleting nested code', async () => { const originalContent = `function outer() { if (true) { // Remove this @@ -865,7 +865,7 @@ function five() { ======= >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function outer() { @@ -878,7 +878,7 @@ function five() { }) describe('insertion', () => { - it('should insert code at specified line when search block is empty', () => { + it('should insert code at specified line when search block is empty', async () => { const originalContent = `function test() { const x = 1; return x; @@ -889,7 +889,7 @@ function five() { console.log("Adding log"); >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent, 2, 2) + const result = await strategy.applyDiff(originalContent, diffContent, 2, 2) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function test() { @@ -900,7 +900,7 @@ function five() { } }) - it('should preserve indentation when inserting at nested location', () => { + it('should preserve indentation when inserting at nested location', async () => { const originalContent = `function test() { if (true) { const x = 1; @@ -913,7 +913,7 @@ function five() { console.log("After"); >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent, 3, 3) + const result = await strategy.applyDiff(originalContent, diffContent, 3, 3) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function test() { @@ -926,7 +926,7 @@ function five() { } }) - it('should handle insertion at start of file', () => { + it('should handle insertion at start of file', async () => { const originalContent = `function test() { return true; }` @@ -938,7 +938,7 @@ function five() { >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent, 1, 1) + const result = await strategy.applyDiff(originalContent, diffContent, 1, 1) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`// Copyright 2024 @@ -950,7 +950,7 @@ function test() { } }) - it('should handle insertion at end of file', () => { + it('should handle insertion at end of file', async () => { const originalContent = `function test() { return true; }` @@ -961,7 +961,7 @@ function test() { // End of file >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent, 4, 4) + const result = await strategy.applyDiff(originalContent, diffContent, 4, 4) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function test() { @@ -972,7 +972,7 @@ function test() { } }) - it('should error if no start_line is provided for insertion', () => { + it('should error if no start_line is provided for insertion', async () => { const originalContent = `function test() { return true; }` @@ -982,7 +982,7 @@ function test() { console.log("test"); >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(false) }) }) @@ -994,7 +994,7 @@ console.log("test"); strategy = new SearchReplaceDiffStrategy(0.9, 5) // 90% similarity threshold, 5 line buffer for tests }) - it('should match content with small differences (>90% similar)', () => { + it('should match content with small differences (>90% similar)', async () => { const originalContent = 'function getData() {\n const results = fetchData();\n return results.filter(Boolean);\n}\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -1011,14 +1011,14 @@ function getData() { strategy = new SearchReplaceDiffStrategy(0.9, 5) // Use 5 line buffer for tests - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('function getData() {\n const data = fetchData();\n return data.filter(Boolean);\n}\n') } }) - it('should not match when content is too different (<90% similar)', () => { + it('should not match when content is too different (<90% similar)', async () => { const originalContent = 'function processUsers(data) {\n return data.map(user => user.name);\n}\n' const diffContent = `test.ts <<<<<<< SEARCH @@ -1031,11 +1031,11 @@ function processData(data) { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(false) }) - it('should match content with extra whitespace', () => { + it('should match content with extra whitespace', async () => { const originalContent = 'function sum(a, b) {\n return a + b;\n}' const diffContent = `test.ts <<<<<<< SEARCH @@ -1048,14 +1048,14 @@ function sum(a, b) { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('function sum(a, b) {\n return a + b + 1;\n}') } }) - it('should not exact match empty lines', () => { + it('should not exact match empty lines', async () => { const originalContent = 'function sum(a, b) {\n\n return a + b;\n}' const diffContent = `test.ts <<<<<<< SEARCH @@ -1065,7 +1065,7 @@ import { a } from "a"; function sum(a, b) { >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe('import { a } from "a";\nfunction sum(a, b) {\n\n return a + b;\n}') @@ -1080,7 +1080,7 @@ function sum(a, b) { strategy = new SearchReplaceDiffStrategy(0.9, 5) }) - it('should find and replace within specified line range', () => { + it('should find and replace within specified line range', async () => { const originalContent = ` function one() { return 1; @@ -1105,7 +1105,7 @@ function two() { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent, 5, 7) + const result = await strategy.applyDiff(originalContent, diffContent, 5, 7) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function one() { @@ -1122,7 +1122,7 @@ function three() { } }) - it('should find and replace within buffer zone (5 lines before/after)', () => { + it('should find and replace within buffer zone (5 lines before/after)', async () => { const originalContent = ` function one() { return 1; @@ -1149,7 +1149,7 @@ function three() { // Even though we specify lines 5-7, it should still find the match at lines 9-11 // because it's within the 5-line buffer zone - const result = strategy.applyDiff(originalContent, diffContent, 5, 7) + const result = await strategy.applyDiff(originalContent, diffContent, 5, 7) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function one() { @@ -1166,7 +1166,7 @@ function three() { } }) - it('should not find matches outside search range and buffer zone', () => { + it('should not find matches outside search range and buffer zone', async () => { const originalContent = ` function one() { return 1; @@ -1201,11 +1201,11 @@ function five() { // Searching around function two() (lines 5-7) // function five() is more than 5 lines away, so it shouldn't match - const result = strategy.applyDiff(originalContent, diffContent, 5, 7) + const result = await strategy.applyDiff(originalContent, diffContent, 5, 7) expect(result.success).toBe(false) }) - it('should handle search range at start of file', () => { + it('should handle search range at start of file', async () => { const originalContent = ` function one() { return 1; @@ -1226,7 +1226,7 @@ function one() { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent, 1, 3) + const result = await strategy.applyDiff(originalContent, diffContent, 1, 3) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function one() { @@ -1239,7 +1239,7 @@ function two() { } }) - it('should handle search range at end of file', () => { + it('should handle search range at end of file', async () => { const originalContent = ` function one() { return 1; @@ -1260,7 +1260,7 @@ function two() { } >>>>>>> REPLACE` - const result = strategy.applyDiff(originalContent, diffContent, 5, 7) + const result = await strategy.applyDiff(originalContent, diffContent, 5, 7) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function one() { @@ -1273,7 +1273,7 @@ function two() { } }) - it('should match specific instance of duplicate code using line numbers', () => { + it('should match specific instance of duplicate code using line numbers', async () => { const originalContent = ` function processData(data) { return data.map(x => x * 2); @@ -1306,7 +1306,7 @@ function processData(data) { >>>>>>> REPLACE` // Target the second instance of processData - const result = strategy.applyDiff(originalContent, diffContent, 10, 12) + const result = await strategy.applyDiff(originalContent, diffContent, 10, 12) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function processData(data) { @@ -1330,7 +1330,7 @@ function moreStuff() { } }) - it('should search from start line to end of file when only start_line is provided', () => { + it('should search from start line to end of file when only start_line is provided', async () => { const originalContent = ` function one() { return 1; @@ -1356,7 +1356,7 @@ function three() { >>>>>>> REPLACE` // Only provide start_line, should search from there to end of file - const result = strategy.applyDiff(originalContent, diffContent, 8) + const result = await strategy.applyDiff(originalContent, diffContent, 8) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function one() { @@ -1373,7 +1373,7 @@ function three() { } }) - it('should search from start of file to end line when only end_line is provided', () => { + it('should search from start of file to end line when only end_line is provided', async () => { const originalContent = ` function one() { return 1; @@ -1399,7 +1399,7 @@ function one() { >>>>>>> REPLACE` // Only provide end_line, should search from start of file to there - const result = strategy.applyDiff(originalContent, diffContent, undefined, 4) + const result = await strategy.applyDiff(originalContent, diffContent, undefined, 4) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function one() { @@ -1416,7 +1416,7 @@ function three() { } }) - it('should prioritize exact line match over expanded search', () => { + it('should prioritize exact line match over expanded search', async () => { const originalContent = ` function one() { return 1; @@ -1446,7 +1446,7 @@ function process() { // Should match the second instance exactly at lines 10-12 // even though the first instance at 6-8 is within the expanded search range - const result = strategy.applyDiff(originalContent, diffContent, 10, 12) + const result = await strategy.applyDiff(originalContent, diffContent, 10, 12) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(` @@ -1468,7 +1468,7 @@ function two() { } }) - it('should fall back to expanded search only if exact match fails', () => { + it('should fall back to expanded search only if exact match fails', async () => { const originalContent = ` function one() { return 1; @@ -1494,7 +1494,7 @@ function process() { // Specify wrong line numbers (3-5), but content exists at 6-8 // Should still find and replace it since it's within the expanded range - const result = strategy.applyDiff(originalContent, diffContent, 3, 5) + const result = await strategy.applyDiff(originalContent, diffContent, 3, 5) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(`function one() { @@ -1519,14 +1519,14 @@ function two() { strategy = new SearchReplaceDiffStrategy() }) - it('should include the current working directory', () => { + it('should include the current working directory', async () => { const cwd = '/test/dir' - const description = strategy.getToolDescription(cwd) + const description = await strategy.getToolDescription(cwd) expect(description).toContain(`relative to the current working directory ${cwd}`) }) - it('should include required format elements', () => { - const description = strategy.getToolDescription('/test') + it('should include required format elements', async () => { + const description = await strategy.getToolDescription('/test') expect(description).toContain('<<<<<<< SEARCH') expect(description).toContain('=======') expect(description).toContain('>>>>>>> REPLACE') @@ -1534,8 +1534,8 @@ function two() { expect(description).toContain('') }) - it('should document start_line and end_line parameters', () => { - const description = strategy.getToolDescription('/test') + it('should document start_line and end_line parameters', async () => { + const description = await 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.') }) diff --git a/src/core/diff/strategies/__tests__/unified.test.ts b/src/core/diff/strategies/__tests__/unified.test.ts index 83a53b2..7398b40 100644 --- a/src/core/diff/strategies/__tests__/unified.test.ts +++ b/src/core/diff/strategies/__tests__/unified.test.ts @@ -20,7 +20,7 @@ describe('UnifiedDiffStrategy', () => { }) describe('applyDiff', () => { - it('should successfully apply a function modification diff', () => { + it('should successfully apply a function modification diff', async () => { const originalContent = `import { Logger } from '../logger'; function calculateTotal(items: number[]): number { @@ -58,14 +58,14 @@ function calculateTotal(items: number[]): number { export { calculateTotal };` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(expected) } }) - it('should successfully apply a diff adding a new method', () => { + it('should successfully apply a diff adding a new method', async () => { const originalContent = `class Calculator { add(a: number, b: number): number { return a + b; @@ -95,14 +95,14 @@ export { calculateTotal };` } }` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(expected) } }) - it('should successfully apply a diff modifying imports', () => { + it('should successfully apply a diff modifying imports', async () => { const originalContent = `import { useState } from 'react'; import { Button } from './components'; @@ -132,15 +132,15 @@ function App() { useEffect(() => { document.title = \`Count: \${count}\` }, [count]); return ; }` - - const result = strategy.applyDiff(originalContent, diffContent) + + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(expected) } }) - it('should successfully apply a diff with multiple hunks', () => { + it('should successfully apply a diff with multiple hunks', async () => { const originalContent = `import { readFile, writeFile } from 'fs'; function processFile(path: string) { @@ -198,14 +198,14 @@ async function processFile(path: string) { export { processFile };` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(expected) } }) - it('should handle empty original content', () => { + it('should handle empty original content', async () => { const originalContent = '' const diffContent = `--- empty.ts +++ empty.ts @@ -218,7 +218,7 @@ export { processFile };` return \`Hello, \${name}!\`; }\n` - const result = strategy.applyDiff(originalContent, diffContent) + const result = await strategy.applyDiff(originalContent, diffContent) expect(result.success).toBe(true) if (result.success) { expect(result.content).toBe(expected) diff --git a/src/core/diff/strategies/new-unified/__tests__/edit-strategies.test.ts b/src/core/diff/strategies/new-unified/__tests__/edit-strategies.test.ts new file mode 100644 index 0000000..2ed1cc9 --- /dev/null +++ b/src/core/diff/strategies/new-unified/__tests__/edit-strategies.test.ts @@ -0,0 +1,295 @@ +import { applyContextMatching, applyDMP, applyGitFallback } from "../edit-strategies" +import { Hunk } from "../types" + +const testCases = [ + { + name: "should return original content if no match is found", + hunk: { + changes: [ + { type: "context", content: "line1" }, + { type: "add", content: "line2" }, + ], + } as Hunk, + content: ["line1", "line3"], + matchPosition: -1, + expected: { + confidence: 0, + result: ["line1", "line3"], + }, + expectedResult: "line1\nline3", + strategies: ["context", "dmp"], + }, + { + name: "should apply a simple add change", + hunk: { + changes: [ + { type: "context", content: "line1" }, + { type: "add", content: "line2" }, + ], + } as Hunk, + content: ["line1", "line3"], + matchPosition: 0, + expected: { + confidence: 1, + result: ["line1", "line2", "line3"], + }, + expectedResult: "line1\nline2\nline3", + strategies: ["context", "dmp"], + }, + { + name: "should apply a simple remove change", + hunk: { + changes: [ + { type: "context", content: "line1" }, + { type: "remove", content: "line2" }, + ], + } as Hunk, + content: ["line1", "line2", "line3"], + matchPosition: 0, + expected: { + confidence: 1, + result: ["line1", "line3"], + }, + expectedResult: "line1\nline3", + strategies: ["context", "dmp"], + }, + { + name: "should apply a simple context change", + hunk: { + changes: [{ type: "context", content: "line1" }], + } as Hunk, + content: ["line1", "line2", "line3"], + matchPosition: 0, + expected: { + confidence: 1, + result: ["line1", "line2", "line3"], + }, + expectedResult: "line1\nline2\nline3", + strategies: ["context", "dmp"], + }, + { + name: "should apply a multi-line add change", + hunk: { + changes: [ + { type: "context", content: "line1" }, + { type: "add", content: "line2\nline3" }, + ], + } as Hunk, + content: ["line1", "line4"], + matchPosition: 0, + expected: { + confidence: 1, + result: ["line1", "line2\nline3", "line4"], + }, + expectedResult: "line1\nline2\nline3\nline4", + strategies: ["context", "dmp"], + }, + { + name: "should apply a multi-line remove change", + hunk: { + changes: [ + { type: "context", content: "line1" }, + { type: "remove", content: "line2\nline3" }, + ], + } as Hunk, + content: ["line1", "line2", "line3", "line4"], + matchPosition: 0, + expected: { + confidence: 1, + result: ["line1", "line4"], + }, + expectedResult: "line1\nline4", + strategies: ["context", "dmp"], + }, + { + name: "should apply a multi-line context change", + hunk: { + changes: [ + { type: "context", content: "line1" }, + { type: "context", content: "line2\nline3" }, + ], + } as Hunk, + content: ["line1", "line2", "line3", "line4"], + matchPosition: 0, + expected: { + confidence: 1, + result: ["line1", "line2\nline3", "line4"], + }, + expectedResult: "line1\nline2\nline3\nline4", + strategies: ["context", "dmp"], + }, + { + name: "should apply a change with indentation", + hunk: { + changes: [ + { type: "context", content: " line1" }, + { type: "add", content: " line2" }, + ], + } as Hunk, + content: [" line1", " line3"], + matchPosition: 0, + expected: { + confidence: 1, + result: [" line1", " line2", " line3"], + }, + expectedResult: " line1\n line2\n line3", + strategies: ["context", "dmp"], + }, + { + name: "should apply a change with mixed indentation", + hunk: { + changes: [ + { type: "context", content: "\tline1" }, + { type: "add", content: " line2" }, + ], + } as Hunk, + content: ["\tline1", " line3"], + matchPosition: 0, + expected: { + confidence: 1, + result: ["\tline1", " line2", " line3"], + }, + expectedResult: "\tline1\n line2\n line3", + strategies: ["context", "dmp"], + }, + { + name: "should apply a change with mixed indentation and multi-line", + hunk: { + changes: [ + { type: "context", content: " line1" }, + { type: "add", content: "\tline2\n line3" }, + ], + } as Hunk, + content: [" line1", " line4"], + matchPosition: 0, + expected: { + confidence: 1, + result: [" line1", "\tline2\n line3", " line4"], + }, + expectedResult: " line1\n\tline2\n line3\n line4", + strategies: ["context", "dmp"], + }, + { + name: "should apply a complex change with mixed indentation and multi-line", + hunk: { + changes: [ + { type: "context", content: " line1" }, + { type: "remove", content: " line2" }, + { type: "add", content: "\tline3\n line4" }, + { type: "context", content: " line5" }, + ], + } as Hunk, + content: [" line1", " line2", " line5", " line6"], + matchPosition: 0, + expected: { + confidence: 1, + result: [" line1", "\tline3\n line4", " line5", " line6"], + }, + expectedResult: " line1\n\tline3\n line4\n line5\n line6", + strategies: ["context", "dmp"], + }, + { + name: "should apply a complex change with mixed indentation and multi-line and context", + hunk: { + changes: [ + { type: "context", content: " line1" }, + { type: "remove", content: " line2" }, + { type: "add", content: "\tline3\n line4" }, + { type: "context", content: " line5" }, + { type: "context", content: " line6" }, + ], + } as Hunk, + content: [" line1", " line2", " line5", " line6", " line7"], + matchPosition: 0, + expected: { + confidence: 1, + result: [" line1", "\tline3\n line4", " line5", " line6", " line7"], + }, + expectedResult: " line1\n\tline3\n line4\n line5\n line6\n line7", + strategies: ["context", "dmp"], + }, + { + name: "should apply a complex change with mixed indentation and multi-line and context and a different match position", + hunk: { + changes: [ + { type: "context", content: " line1" }, + { type: "remove", content: " line2" }, + { type: "add", content: "\tline3\n line4" }, + { type: "context", content: " line5" }, + { type: "context", content: " line6" }, + ], + } as Hunk, + content: [" line0", " line1", " line2", " line5", " line6", " line7"], + matchPosition: 1, + expected: { + confidence: 1, + result: [" line0", " line1", "\tline3\n line4", " line5", " line6", " line7"], + }, + expectedResult: " line0\n line1\n\tline3\n line4\n line5\n line6\n line7", + strategies: ["context", "dmp"], + }, +] + +describe("applyContextMatching", () => { + testCases.forEach(({ name, hunk, content, matchPosition, expected, strategies, expectedResult }) => { + if (!strategies?.includes("context")) { + return + } + it(name, () => { + const result = applyContextMatching(hunk, content, matchPosition) + expect(result.result.join("\n")).toEqual(expectedResult) + expect(result.confidence).toBeGreaterThanOrEqual(expected.confidence) + expect(result.strategy).toBe("context") + }) + }) +}) + +describe("applyDMP", () => { + testCases.forEach(({ name, hunk, content, matchPosition, expected, strategies, expectedResult }) => { + if (!strategies?.includes("dmp")) { + return + } + it(name, () => { + const result = applyDMP(hunk, content, matchPosition) + expect(result.result.join("\n")).toEqual(expectedResult) + expect(result.confidence).toBeGreaterThanOrEqual(expected.confidence) + expect(result.strategy).toBe("dmp") + }) + }) +}) + +describe("applyGitFallback", () => { + it("should successfully apply changes using git operations", async () => { + const hunk = { + changes: [ + { type: "context", content: "line1", indent: "" }, + { type: "remove", content: "line2", indent: "" }, + { type: "add", content: "new line2", indent: "" }, + { type: "context", content: "line3", indent: "" } + ] + } as Hunk + + const content = ["line1", "line2", "line3"] + const result = await applyGitFallback(hunk, content) + + expect(result.result.join("\n")).toEqual("line1\nnew line2\nline3") + expect(result.confidence).toBe(1) + expect(result.strategy).toBe("git-fallback") + }) + + it("should return original content with 0 confidence when changes cannot be applied", async () => { + const hunk = { + changes: [ + { type: "context", content: "nonexistent", indent: "" }, + { type: "add", content: "new line", indent: "" } + ] + } as Hunk + + const content = ["line1", "line2", "line3"] + const result = await applyGitFallback(hunk, content) + + expect(result.result).toEqual(content) + expect(result.confidence).toBe(0) + expect(result.strategy).toBe("git-fallback") + }) +}) diff --git a/src/core/diff/strategies/new-unified/__tests__/search-strategies.test.ts b/src/core/diff/strategies/new-unified/__tests__/search-strategies.test.ts new file mode 100644 index 0000000..6c4aba5 --- /dev/null +++ b/src/core/diff/strategies/new-unified/__tests__/search-strategies.test.ts @@ -0,0 +1,262 @@ +import { findAnchorMatch, findExactMatch, findSimilarityMatch, findLevenshteinMatch } from "../search-strategies" + +type SearchStrategy = ( + searchStr: string, + content: string[], + startIndex?: number +) => { + index: number + confidence: number + strategy: string +} + +const testCases = [ + { + name: "should return no match if the search string is not found", + searchStr: "not found", + content: ["line1", "line2", "line3"], + expected: { index: -1, confidence: 0 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match if the search string is found", + searchStr: "line2", + content: ["line1", "line2", "line3"], + expected: { index: 1, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match with correct index when startIndex is provided", + searchStr: "line3", + content: ["line1", "line2", "line3", "line4", "line3"], + startIndex: 3, + expected: { index: 4, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match even if there are more lines in content", + searchStr: "line2", + content: ["line1", "line2", "line3", "line4", "line5"], + expected: { index: 1, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match even if the search string is at the beginning of the content", + searchStr: "line1", + content: ["line1", "line2", "line3"], + expected: { index: 0, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match even if the search string is at the end of the content", + searchStr: "line3", + content: ["line1", "line2", "line3"], + expected: { index: 2, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match for a multi-line search string", + searchStr: "line2\nline3", + content: ["line1", "line2", "line3", "line4"], + expected: { index: 1, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return no match if a multi-line search string is not found", + searchStr: "line2\nline4", + content: ["line1", "line2", "line3", "line4"], + expected: { index: -1, confidence: 0 }, + strategies: ["exact", "similarity"], + }, + { + name: "should return a match with indentation", + searchStr: " line2", + content: ["line1", " line2", "line3"], + expected: { index: 1, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match with more complex indentation", + searchStr: " line3", + content: [" line1", " line2", " line3", " line4"], + expected: { index: 2, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match with mixed indentation", + searchStr: "\tline2", + content: [" line1", "\tline2", " line3"], + expected: { index: 1, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match with mixed indentation and multi-line", + searchStr: " line2\n\tline3", + content: ["line1", " line2", "\tline3", " line4"], + expected: { index: 1, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return no match if mixed indentation and multi-line is not found", + searchStr: " line2\n line4", + content: ["line1", " line2", "\tline3", " line4"], + expected: { index: -1, confidence: 0 }, + strategies: ["exact", "similarity"], + }, + { + name: "should return a match with leading and trailing spaces", + searchStr: " line2 ", + content: ["line1", " line2 ", "line3"], + expected: { index: 1, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match with leading and trailing tabs", + searchStr: "\tline2\t", + content: ["line1", "\tline2\t", "line3"], + expected: { index: 1, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match with mixed leading and trailing spaces and tabs", + searchStr: " \tline2\t ", + content: ["line1", " \tline2\t ", "line3"], + expected: { index: 1, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return a match with mixed leading and trailing spaces and tabs and multi-line", + searchStr: " \tline2\t \n line3 ", + content: ["line1", " \tline2\t ", " line3 ", "line4"], + expected: { index: 1, confidence: 1 }, + strategies: ["exact", "similarity", "levenshtein"], + }, + { + name: "should return no match if mixed leading and trailing spaces and tabs and multi-line is not found", + searchStr: " \tline2\t \n line4 ", + content: ["line1", " \tline2\t ", " line3 ", "line4"], + expected: { index: -1, confidence: 0 }, + strategies: ["exact", "similarity"], + }, +] + +describe("findExactMatch", () => { + testCases.forEach(({ name, searchStr, content, startIndex, expected, strategies }) => { + if (!strategies?.includes("exact")) { + return + } + it(name, () => { + const result = findExactMatch(searchStr, content, startIndex) + expect(result.index).toBe(expected.index) + expect(result.confidence).toBeGreaterThanOrEqual(expected.confidence) + expect(result.strategy).toMatch(/exact(-overlapping)?/) + }) + }) +}) + +describe("findAnchorMatch", () => { + const anchorTestCases = [ + { + name: "should return no match if no anchors are found", + searchStr: " \n \n ", + content: ["line1", "line2", "line3"], + expected: { index: -1, confidence: 0 }, + }, + { + name: "should return no match if anchor positions cannot be validated", + searchStr: "unique line\ncontext line 1\ncontext line 2", + content: [ + "different line 1", + "different line 2", + "different line 3", + "another unique line", + "context line 1", + "context line 2", + ], + expected: { index: -1, confidence: 0 }, + }, + { + name: "should return a match if anchor positions can be validated", + searchStr: "unique line\ncontext line 1\ncontext line 2", + content: ["line1", "line2", "unique line", "context line 1", "context line 2", "line 6"], + expected: { index: 2, confidence: 1 }, + }, + { + name: "should return a match with correct index when startIndex is provided", + searchStr: "unique line\ncontext line 1\ncontext line 2", + content: ["line1", "line2", "line3", "unique line", "context line 1", "context line 2", "line 7"], + startIndex: 3, + expected: { index: 3, confidence: 1 }, + }, + { + name: "should return a match even if there are more lines in content", + searchStr: "unique line\ncontext line 1\ncontext line 2", + content: [ + "line1", + "line2", + "unique line", + "context line 1", + "context line 2", + "line 6", + "extra line 1", + "extra line 2", + ], + expected: { index: 2, confidence: 1 }, + }, + { + name: "should return a match even if the anchor is at the beginning of the content", + searchStr: "unique line\ncontext line 1\ncontext line 2", + content: ["unique line", "context line 1", "context line 2", "line 6"], + expected: { index: 0, confidence: 1 }, + }, + { + name: "should return a match even if the anchor is at the end of the content", + searchStr: "unique line\ncontext line 1\ncontext line 2", + content: ["line1", "line2", "unique line", "context line 1", "context line 2"], + expected: { index: 2, confidence: 1 }, + }, + { + name: "should return no match if no valid anchor is found", + searchStr: "non-unique line\ncontext line 1\ncontext line 2", + content: ["line1", "line2", "non-unique line", "context line 1", "context line 2", "non-unique line"], + expected: { index: -1, confidence: 0 }, + }, + ] + + anchorTestCases.forEach(({ name, searchStr, content, startIndex, expected }) => { + it(name, () => { + const result = findAnchorMatch(searchStr, content, startIndex) + expect(result.index).toBe(expected.index) + expect(result.confidence).toBeGreaterThanOrEqual(expected.confidence) + expect(result.strategy).toBe("anchor") + }) + }) +}) + +describe("findSimilarityMatch", () => { + testCases.forEach(({ name, searchStr, content, startIndex, expected, strategies }) => { + if (!strategies?.includes("similarity")) { + return + } + it(name, () => { + const result = findSimilarityMatch(searchStr, content, startIndex) + expect(result.index).toBe(expected.index) + expect(result.confidence).toBeGreaterThanOrEqual(expected.confidence) + expect(result.strategy).toBe("similarity") + }) + }) +}) + +describe("findLevenshteinMatch", () => { + testCases.forEach(({ name, searchStr, content, startIndex, expected, strategies }) => { + if (!strategies?.includes("levenshtein")) { + return + } + it(name, () => { + const result = findLevenshteinMatch(searchStr, content, startIndex) + expect(result.index).toBe(expected.index) + expect(result.confidence).toBeGreaterThanOrEqual(expected.confidence) + expect(result.strategy).toBe("levenshtein") + }) + }) +}) diff --git a/src/core/diff/strategies/new-unified/edit-strategies.ts b/src/core/diff/strategies/new-unified/edit-strategies.ts new file mode 100644 index 0000000..0828c83 --- /dev/null +++ b/src/core/diff/strategies/new-unified/edit-strategies.ts @@ -0,0 +1,305 @@ +import { diff_match_patch } from "diff-match-patch" +import { EditResult, Hunk } from "./types" +import { getDMPSimilarity, validateEditResult } from "./search-strategies" +import * as path from "path" +import simpleGit, { SimpleGit } from "simple-git" +import * as tmp from "tmp" +import * as fs from "fs" + +// Helper function to infer indentation - simplified version +function inferIndentation(line: string, contextLines: string[], previousIndent: string = ""): string { + // If the line has explicit indentation in the change, use it exactly + const lineMatch = line.match(/^(\s+)/) + if (lineMatch) { + return lineMatch[1] + } + + // If we have context lines, use the indentation from the first context line + const contextLine = contextLines[0] + if (contextLine) { + const contextMatch = contextLine.match(/^(\s+)/) + if (contextMatch) { + return contextMatch[1] + } + } + + // Fallback to previous indent + return previousIndent +} + +// Context matching edit strategy +export function applyContextMatching( + hunk: Hunk, + content: string[], + matchPosition: number, +): EditResult { + if (matchPosition === -1) { + return { confidence: 0, result: content, strategy: "context" } + } + + const newResult = [...content.slice(0, matchPosition)] + let sourceIndex = matchPosition + + for (const change of hunk.changes) { + if (change.type === "context") { + // Use the original line from content if available + if (sourceIndex < content.length) { + newResult.push(content[sourceIndex]) + } else { + const line = change.indent ? change.indent + change.content : change.content + newResult.push(line) + } + sourceIndex++ + } else if (change.type === "add") { + // Use exactly the indentation from the change + const baseIndent = change.indent || "" + + // Handle multi-line additions + const lines = change.content.split("\n").map((line) => { + // If the line already has indentation, preserve it relative to the base indent + const lineIndentMatch = line.match(/^(\s*)(.*)/) + if (lineIndentMatch) { + const [, lineIndent, content] = lineIndentMatch + // Only add base indent if the line doesn't already have it + return lineIndent ? line : baseIndent + content + } + return baseIndent + line + }) + + newResult.push(...lines) + } else if (change.type === "remove") { + // Handle multi-line removes by incrementing sourceIndex for each line + const removedLines = change.content.split("\n").length + sourceIndex += removedLines + } + } + + // Append remaining content + newResult.push(...content.slice(sourceIndex)) + + // Calculate confidence based on the actual changes + const afterText = newResult.slice(matchPosition, newResult.length - (content.length - sourceIndex)).join("\n") + + const confidence = validateEditResult(hunk, afterText) + + return { + confidence, + result: newResult, + strategy: "context" + } +} + +// DMP edit strategy +export function applyDMP( + hunk: Hunk, + content: string[], + matchPosition: number, +): EditResult { + if (matchPosition === -1) { + return { confidence: 0, result: content, strategy: "dmp" } + } + + const dmp = new diff_match_patch() + + // Calculate total lines in before block accounting for multi-line content + const beforeLineCount = hunk.changes + .filter((change) => change.type === "context" || change.type === "remove") + .reduce((count, change) => count + change.content.split("\n").length, 0) + + // Build BEFORE block (context + removals) + const beforeLines = hunk.changes + .filter((change) => change.type === "context" || change.type === "remove") + .map((change) => { + if (change.originalLine) { + return change.originalLine + } + return change.indent ? change.indent + change.content : change.content + }) + + // Build AFTER block (context + additions) + const afterLines = hunk.changes + .filter((change) => change.type === "context" || change.type === "add") + .map((change) => { + if (change.originalLine) { + return change.originalLine + } + return change.indent ? change.indent + change.content : change.content + }) + + // Convert to text with proper line endings + const beforeText = beforeLines.join("\n") + const afterText = afterLines.join("\n") + + // Create and apply patch + const patch = dmp.patch_make(beforeText, afterText) + const targetText = content.slice(matchPosition, matchPosition + beforeLineCount).join("\n") + const [patchedText] = dmp.patch_apply(patch, targetText) + + // Split result and preserve line endings + const patchedLines = patchedText.split("\n") + + // Construct final result + const newResult = [ + ...content.slice(0, matchPosition), + ...patchedLines, + ...content.slice(matchPosition + beforeLineCount), + ] + + const confidence = validateEditResult(hunk, patchedText) + + return { + confidence, + result: newResult, + strategy: "dmp", + } +} + +// Git fallback strategy that works with full content +export async function applyGitFallback(hunk: Hunk, content: string[]): Promise { + let tmpDir: tmp.DirResult | undefined + + try { + tmpDir = tmp.dirSync({ unsafeCleanup: true }) + const git: SimpleGit = simpleGit(tmpDir.name) + + await git.init() + await git.addConfig("user.name", "Temp") + await git.addConfig("user.email", "temp@example.com") + + const filePath = path.join(tmpDir.name, "file.txt") + + const searchLines = hunk.changes + .filter((change) => change.type === "context" || change.type === "remove") + .map((change) => change.originalLine || change.indent + change.content) + + const replaceLines = hunk.changes + .filter((change) => change.type === "context" || change.type === "add") + .map((change) => change.originalLine || change.indent + change.content) + + const searchText = searchLines.join("\n") + const replaceText = replaceLines.join("\n") + const originalText = content.join("\n") + + try { + fs.writeFileSync(filePath, originalText) + await git.add("file.txt") + const originalCommit = await git.commit("original") + console.log("Strategy 1 - Original commit:", originalCommit.commit) + + fs.writeFileSync(filePath, searchText) + await git.add("file.txt") + const searchCommit1 = await git.commit("search") + console.log("Strategy 1 - Search commit:", searchCommit1.commit) + + fs.writeFileSync(filePath, replaceText) + await git.add("file.txt") + const replaceCommit = await git.commit("replace") + console.log("Strategy 1 - Replace commit:", replaceCommit.commit) + + console.log("Strategy 1 - Attempting checkout of:", originalCommit.commit) + await git.raw(["checkout", originalCommit.commit]) + try { + console.log("Strategy 1 - Attempting cherry-pick of:", replaceCommit.commit) + await git.raw(["cherry-pick", "--minimal", replaceCommit.commit]) + + const newText = fs.readFileSync(filePath, "utf-8") + const newLines = newText.split("\n") + return { + confidence: 1, + result: newLines, + strategy: "git-fallback", + } + } catch (cherryPickError) { + console.error("Strategy 1 failed with merge conflict") + } + } catch (error) { + console.error("Strategy 1 failed:", error) + } + + try { + await git.init() + await git.addConfig("user.name", "Temp") + await git.addConfig("user.email", "temp@example.com") + + fs.writeFileSync(filePath, searchText) + await git.add("file.txt") + const searchCommit = await git.commit("search") + const searchHash = searchCommit.commit.replace(/^HEAD /, "") + console.log("Strategy 2 - Search commit:", searchHash) + + fs.writeFileSync(filePath, replaceText) + await git.add("file.txt") + const replaceCommit = await git.commit("replace") + const replaceHash = replaceCommit.commit.replace(/^HEAD /, "") + console.log("Strategy 2 - Replace commit:", replaceHash) + + console.log("Strategy 2 - Attempting checkout of:", searchHash) + await git.raw(["checkout", searchHash]) + fs.writeFileSync(filePath, originalText) + await git.add("file.txt") + const originalCommit2 = await git.commit("original") + console.log("Strategy 2 - Original commit:", originalCommit2.commit) + + try { + console.log("Strategy 2 - Attempting cherry-pick of:", replaceHash) + await git.raw(["cherry-pick", "--minimal", replaceHash]) + + const newText = fs.readFileSync(filePath, "utf-8") + const newLines = newText.split("\n") + return { + confidence: 1, + result: newLines, + strategy: "git-fallback", + } + } catch (cherryPickError) { + console.error("Strategy 2 failed with merge conflict") + } + } catch (error) { + console.error("Strategy 2 failed:", error) + } + + console.error("Git fallback failed") + return { confidence: 0, result: content, strategy: "git-fallback" } + } catch (error) { + console.error("Git fallback strategy failed:", error) + return { confidence: 0, result: content, strategy: "git-fallback" } + } finally { + if (tmpDir) { + tmpDir.removeCallback() + } + } +} + +// Main edit function that tries strategies sequentially +export async function applyEdit( + hunk: Hunk, + content: string[], + matchPosition: number, + confidence: number, + confidenceThreshold: number = 0.97 +): Promise { + // Don't attempt regular edits if confidence is too low + if (confidence < confidenceThreshold) { + console.log( + `Search confidence (${confidence}) below minimum threshold (${confidenceThreshold}), trying git fallback...` + ) + return applyGitFallback(hunk, content) + } + + // Try each strategy in sequence until one succeeds + const strategies = [ + { name: "dmp", apply: () => applyDMP(hunk, content, matchPosition) }, + { name: "context", apply: () => applyContextMatching(hunk, content, matchPosition) }, + { name: "git-fallback", apply: () => applyGitFallback(hunk, content) }, + ] + + // Try strategies sequentially until one succeeds + for (const strategy of strategies) { + const result = await strategy.apply() + if (result.confidence >= confidenceThreshold) { + return result + } + } + + return { confidence: 0, result: content, strategy: "none" } +} diff --git a/src/core/diff/strategies/new-unified/index.ts b/src/core/diff/strategies/new-unified/index.ts new file mode 100644 index 0000000..42d87bd --- /dev/null +++ b/src/core/diff/strategies/new-unified/index.ts @@ -0,0 +1,359 @@ +import { Diff, Hunk, Change } from "./types" +import { findBestMatch, prepareSearchString } from "./search-strategies" +import { applyEdit } from "./edit-strategies" +import { DiffResult, DiffStrategy } from "../../types" + +export class NewUnifiedDiffStrategy implements DiffStrategy { + private readonly confidenceThreshold: number + + constructor(confidenceThreshold: number = 1) { + this.confidenceThreshold = Math.max(confidenceThreshold, 0.8) + } + + private parseUnifiedDiff(diff: string): Diff { + const MAX_CONTEXT_LINES = 6 // Number of context lines to keep before/after changes + const lines = diff.split("\n") + const hunks: Hunk[] = [] + let currentHunk: Hunk | null = null + + let i = 0 + while (i < lines.length && !lines[i].startsWith("@@")) { + i++ + } + + for (; i < lines.length; i++) { + const line = lines[i] + + if (line.startsWith("@@")) { + if ( + currentHunk && + currentHunk.changes.length > 0 && + currentHunk.changes.some((change) => change.type === "add" || change.type === "remove") + ) { + const changes = currentHunk.changes + let startIdx = 0 + let endIdx = changes.length - 1 + + for (let j = 0; j < changes.length; j++) { + if (changes[j].type !== "context") { + startIdx = Math.max(0, j - MAX_CONTEXT_LINES) + break + } + } + + for (let j = changes.length - 1; j >= 0; j--) { + if (changes[j].type !== "context") { + endIdx = Math.min(changes.length - 1, j + MAX_CONTEXT_LINES) + break + } + } + + currentHunk.changes = changes.slice(startIdx, endIdx + 1) + hunks.push(currentHunk) + } + currentHunk = { changes: [] } + continue + } + + if (!currentHunk) { + continue + } + + const content = line.slice(1) + const indentMatch = content.match(/^(\s*)/) + const indent = indentMatch ? indentMatch[0] : "" + const trimmedContent = content.slice(indent.length) + + if (line.startsWith(" ")) { + currentHunk.changes.push({ + type: "context", + content: trimmedContent, + indent, + originalLine: content, + }) + } else if (line.startsWith("+")) { + currentHunk.changes.push({ + type: "add", + content: trimmedContent, + indent, + originalLine: content, + }) + } else if (line.startsWith("-")) { + currentHunk.changes.push({ + type: "remove", + content: trimmedContent, + indent, + originalLine: content, + }) + } else { + const finalContent = trimmedContent ? " " + trimmedContent : " " + currentHunk.changes.push({ + type: "context", + content: finalContent, + indent, + originalLine: content, + }) + } + } + + if ( + currentHunk && + currentHunk.changes.length > 0 && + currentHunk.changes.some((change) => change.type === "add" || change.type === "remove") + ) { + hunks.push(currentHunk) + } + + return { hunks } + } + + getToolDescription(cwd: string): string { + return `# apply_diff Tool Rules: + +Generate a unified diff similar to what "diff -U0" would produce. + +The first two lines must include the file paths, starting with "---" for the original file and "+++" for the updated file. Do not include timestamps with the file paths. + +Each hunk of changes must start with a line containing only "@@ ... @@". Do not include line numbers or ranges in the "@@ ... @@" lines. These are not necessary for the user's patch tool. + +Your output must be a correct, clean patch that applies successfully against the current file contents. Mark all lines that need to be removed or changed with "-". Mark all new or modified lines with "+". Ensure you include all necessary changes; missing or unmarked lines will result in a broken patch. + +Indentation matters! Make sure to preserve the exact indentation of both removed and added lines. + +Start a new hunk for each section of the file that requires changes. However, include only the hunks that contain actual changes. If a hunk consists entirely of unchanged lines, skip it. + +Group related changes together in the same hunk whenever possible. Output hunks in whatever logical order makes the most sense. + +When editing a function, method, loop, or similar code block, replace the *entire* block in one hunk. Use "-" lines to delete the existing block and "+" lines to add the updated block. This ensures accuracy in your diffs. + +If you need to move code within a file, create two hunks: one to delete the code from its original location and another to insert it at the new location. + +To create a new file, show a diff from "--- /dev/null" to "+++ path/to/new/file.ext". + +Format Requirements: + +\`\`\`diff +--- mathweb/flask/app.py ++++ mathweb/flask/app.py +@@ ... @@ +-class MathWeb: ++import sympy + ++ ++class MathWeb: +@@ ... @@ +-def is_prime(x): +- if x < 2: +- return False +- for i in range(2, int(math.sqrt(x)) + 1): +- if x % i == 0: +- return False +- return True +@@ ... @@ +-@app.route('/prime/') +-def nth_prime(n): +- count = 0 +- num = 1 +- while count < n: +- num += 1 +- if is_prime(num): +- count += 1 +- return str(num) ++@app.route('/prime/') ++def nth_prime(n): ++ count = 0 ++ num = 1 ++ while count < n: ++ num += 1 ++ if sympy.isprime(num): ++ count += 1 ++ return str(num) +\`\`\` + +Be precise, consistent, and follow these rules carefully to generate correct diffs! + +Parameters: +- path: (required) The path of the file to apply the diff to (relative to the current working directory ${cwd}) +- diff: (required) The diff content in unified format to apply to the file. + +Usage: + +File path here + +Your diff here + +` + } + + // Helper function to split a hunk into smaller hunks based on contiguous changes + private splitHunk(hunk: Hunk): Hunk[] { + const result: Hunk[] = [] + let currentHunk: Hunk | null = null + let contextBefore: Change[] = [] + let contextAfter: Change[] = [] + const MAX_CONTEXT_LINES = 3 // Keep 3 lines of context before/after changes + + for (let i = 0; i < hunk.changes.length; i++) { + const change = hunk.changes[i] + + if (change.type === "context") { + if (!currentHunk) { + contextBefore.push(change) + if (contextBefore.length > MAX_CONTEXT_LINES) { + contextBefore.shift() + } + } else { + contextAfter.push(change) + if (contextAfter.length > MAX_CONTEXT_LINES) { + // We've collected enough context after changes, create a new hunk + currentHunk.changes.push(...contextAfter) + result.push(currentHunk) + currentHunk = null + // Keep the last few context lines for the next hunk + contextBefore = contextAfter + contextAfter = [] + } + } + } else { + if (!currentHunk) { + currentHunk = { changes: [...contextBefore] } + contextAfter = [] + } else if (contextAfter.length > 0) { + // Add accumulated context to current hunk + currentHunk.changes.push(...contextAfter) + contextAfter = [] + } + currentHunk.changes.push(change) + } + } + + // Add any remaining changes + if (currentHunk) { + if (contextAfter.length > 0) { + currentHunk.changes.push(...contextAfter) + } + result.push(currentHunk) + } + + return result + } + + async applyDiff( + originalContent: string, + diffContent: string, + startLine?: number, + endLine?: number + ): Promise { + const parsedDiff = this.parseUnifiedDiff(diffContent) + const originalLines = originalContent.split("\n") + let result = [...originalLines] + + if (!parsedDiff.hunks.length) { + return { + success: false, + error: "No hunks found in diff. Please ensure your diff includes actual changes and follows the unified diff format.", + } + } + + for (const hunk of parsedDiff.hunks) { + const contextStr = prepareSearchString(hunk.changes) + const { + index: matchPosition, + confidence, + strategy, + } = findBestMatch(contextStr, result, 0, this.confidenceThreshold) + + if (confidence < this.confidenceThreshold) { + console.log("Full hunk application failed, trying sub-hunks strategy") + // Try splitting the hunk into smaller hunks + const subHunks = this.splitHunk(hunk) + let subHunkSuccess = true + let subHunkResult = [...result] + + for (const subHunk of subHunks) { + const subContextStr = prepareSearchString(subHunk.changes) + const subSearchResult = findBestMatch(subContextStr, subHunkResult, 0, this.confidenceThreshold) + + if (subSearchResult.confidence >= this.confidenceThreshold) { + const subEditResult = await applyEdit( + subHunk, + subHunkResult, + subSearchResult.index, + subSearchResult.confidence, + this.confidenceThreshold + ) + if (subEditResult.confidence >= this.confidenceThreshold) { + subHunkResult = subEditResult.result + continue + } + } + subHunkSuccess = false + break + } + + if (subHunkSuccess) { + result = subHunkResult + continue + } + + // If sub-hunks also failed, return the original error + const contextLines = hunk.changes.filter((c) => c.type === "context").length + const totalLines = hunk.changes.length + const contextRatio = contextLines / totalLines + + let errorMsg = `Failed to find a matching location in the file (${Math.floor( + confidence * 100 + )}% confidence, needs ${Math.floor(this.confidenceThreshold * 100)}%)\n\n` + errorMsg += "Debug Info:\n" + errorMsg += `- Search Strategy Used: ${strategy}\n` + errorMsg += `- Context Lines: ${contextLines} out of ${totalLines} total lines (${Math.floor( + contextRatio * 100 + )}%)\n` + errorMsg += `- Attempted to split into ${subHunks.length} sub-hunks but still failed\n` + + if (contextRatio < 0.2) { + errorMsg += "\nPossible Issues:\n" + errorMsg += "- Not enough context lines to uniquely identify the location\n" + errorMsg += "- Add a few more lines of unchanged code around your changes\n" + } else if (contextRatio > 0.5) { + errorMsg += "\nPossible Issues:\n" + errorMsg += "- Too many context lines may reduce search accuracy\n" + errorMsg += "- Try to keep only 2-3 lines of context before and after changes\n" + } else { + errorMsg += "\nPossible Issues:\n" + errorMsg += "- The diff may be targeting a different version of the file\n" + errorMsg += + "- There may be too many changes in a single hunk, try splitting the changes into multiple hunks\n" + } + + if (startLine && endLine) { + errorMsg += `\nSearch Range: lines ${startLine}-${endLine}\n` + } + + return { success: false, error: errorMsg } + } + + const editResult = await applyEdit(hunk, result, matchPosition, confidence, this.confidenceThreshold) + if (editResult.confidence >= this.confidenceThreshold) { + result = editResult.result + } else { + // Edit failure - likely due to content mismatch + let errorMsg = `Failed to apply the edit using ${editResult.strategy} strategy (${Math.floor( + editResult.confidence * 100 + )}% confidence)\n\n` + errorMsg += "Debug Info:\n" + errorMsg += "- The location was found but the content didn't match exactly\n" + errorMsg += "- This usually means the file has been modified since the diff was created\n" + errorMsg += "- Or the diff may be targeting a different version of the file\n" + errorMsg += "\nPossible Solutions:\n" + errorMsg += "1. Refresh your view of the file and create a new diff\n" + errorMsg += "2. Double-check that the removed lines (-) match the current file content\n" + errorMsg += "3. Ensure your diff targets the correct version of the file" + + return { success: false, error: errorMsg } + } + } + + return { success: true, content: result.join("\n") } + } +} diff --git a/src/core/diff/strategies/new-unified/search-strategies.ts b/src/core/diff/strategies/new-unified/search-strategies.ts new file mode 100644 index 0000000..7bee5ba --- /dev/null +++ b/src/core/diff/strategies/new-unified/search-strategies.ts @@ -0,0 +1,408 @@ +import { compareTwoStrings } from "string-similarity" +import { closest } from "fastest-levenshtein" +import { diff_match_patch } from "diff-match-patch" +import { Change, Hunk } from "./types" + +export type SearchResult = { + index: number + confidence: number + strategy: string +} + +const LARGE_FILE_THRESHOLD = 1000 // lines +const UNIQUE_CONTENT_BOOST = 0.05 +const DEFAULT_OVERLAP_SIZE = 3 // lines of overlap between windows +const MAX_WINDOW_SIZE = 500 // maximum lines in a window + +// Helper function to calculate adaptive confidence threshold based on file size +function getAdaptiveThreshold(contentLength: number, baseThreshold: number): number { + if (contentLength <= LARGE_FILE_THRESHOLD) { + return baseThreshold + } + return Math.max(baseThreshold - 0.07, 0.8) // Reduce threshold for large files but keep minimum at 80% +} + +// Helper function to evaluate content uniqueness +function evaluateContentUniqueness(searchStr: string, content: string[]): number { + const searchLines = searchStr.split("\n") + const uniqueLines = new Set(searchLines) + const contentStr = content.join("\n") + + // Calculate how many search lines are relatively unique in the content + let uniqueCount = 0 + for (const line of uniqueLines) { + const regex = new RegExp(line.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g") + const matches = contentStr.match(regex) + if (matches && matches.length <= 2) { + // Line appears at most twice + uniqueCount++ + } + } + + return uniqueCount / uniqueLines.size +} + +// Helper function to prepare search string from context +export function prepareSearchString(changes: Change[]): string { + const lines = changes.filter((c) => c.type === "context" || c.type === "remove").map((c) => c.originalLine) + return lines.join("\n") +} + +// Helper function to evaluate similarity between two texts +export function evaluateSimilarity(original: string, modified: string): number { + return compareTwoStrings(original, modified) +} + +// Helper function to validate using diff-match-patch +export function getDMPSimilarity(original: string, modified: string): number { + const dmp = new diff_match_patch() + const diffs = dmp.diff_main(original, modified) + dmp.diff_cleanupSemantic(diffs) + const patches = dmp.patch_make(original, diffs) + const [expectedText] = dmp.patch_apply(patches, original) + + const similarity = evaluateSimilarity(expectedText, modified) + return similarity +} + +// Helper function to validate edit results using hunk information +export function validateEditResult(hunk: Hunk, result: string): number { + // Build the expected text from the hunk + const expectedText = hunk.changes + .filter(change => change.type === "context" || change.type === "add") + .map(change => change.indent ? change.indent + change.content : change.content) + .join("\n"); + + // Calculate similarity between the result and expected text + const similarity = getDMPSimilarity(expectedText, result); + + // If the result is unchanged from original, return low confidence + const originalText = hunk.changes + .filter(change => change.type === "context" || change.type === "remove") + .map(change => change.indent ? change.indent + change.content : change.content) + .join("\n"); + + const originalSimilarity = getDMPSimilarity(originalText, result); + if (originalSimilarity > 0.97 && similarity !== 1) { + return 0.8 * similarity; // Some confidence since we found the right location + } + + // For partial matches, scale the confidence but keep it high if we're close + return similarity; +} + +// Helper function to validate context lines against original content +function validateContextLines(searchStr: string, content: string, confidenceThreshold: number): number { + // Extract just the context lines from the search string + const contextLines = searchStr.split("\n").filter((line) => !line.startsWith("-")) // Exclude removed lines + + // Compare context lines with content + const similarity = evaluateSimilarity(contextLines.join("\n"), content) + + // Get adaptive threshold based on content size + const threshold = getAdaptiveThreshold(content.split("\n").length, confidenceThreshold) + + // Calculate uniqueness boost + const uniquenessScore = evaluateContentUniqueness(searchStr, content.split("\n")) + const uniquenessBoost = uniquenessScore * UNIQUE_CONTENT_BOOST + + // Adjust confidence based on threshold and uniqueness + return similarity < threshold ? similarity * 0.3 + uniquenessBoost : similarity + uniquenessBoost +} + +// Helper function to create overlapping windows +function createOverlappingWindows( + content: string[], + searchSize: number, + overlapSize: number = DEFAULT_OVERLAP_SIZE +): { window: string[]; startIndex: number }[] { + const windows: { window: string[]; startIndex: number }[] = [] + + // Ensure minimum window size is at least searchSize + const effectiveWindowSize = Math.max(searchSize, Math.min(searchSize * 2, MAX_WINDOW_SIZE)) + + // Ensure overlap size doesn't exceed window size + const effectiveOverlapSize = Math.min(overlapSize, effectiveWindowSize - 1) + + // Calculate step size, ensure it's at least 1 + const stepSize = Math.max(1, effectiveWindowSize - effectiveOverlapSize) + + for (let i = 0; i < content.length; i += stepSize) { + const windowContent = content.slice(i, i + effectiveWindowSize) + if (windowContent.length >= searchSize) { + windows.push({ window: windowContent, startIndex: i }) + } + } + + return windows +} + +// Helper function to combine overlapping matches +function combineOverlappingMatches( + matches: (SearchResult & { windowIndex: number })[], + overlapSize: number = DEFAULT_OVERLAP_SIZE +): SearchResult[] { + if (matches.length === 0) { + return [] + } + + // Sort matches by confidence + matches.sort((a, b) => b.confidence - a.confidence) + + const combinedMatches: SearchResult[] = [] + const usedIndices = new Set() + + for (const match of matches) { + if (usedIndices.has(match.windowIndex)) { + continue + } + + // Find overlapping matches + const overlapping = matches.filter( + (m) => + Math.abs(m.windowIndex - match.windowIndex) === 1 && + Math.abs(m.index - match.index) <= overlapSize && + !usedIndices.has(m.windowIndex) + ) + + if (overlapping.length > 0) { + // Boost confidence if we find same match in overlapping windows + const avgConfidence = + (match.confidence + overlapping.reduce((sum, m) => sum + m.confidence, 0)) / (overlapping.length + 1) + const boost = Math.min(0.05 * overlapping.length, 0.1) // Max 10% boost + + combinedMatches.push({ + index: match.index, + confidence: Math.min(1, avgConfidence + boost), + strategy: `${match.strategy}-overlapping`, + }) + + usedIndices.add(match.windowIndex) + overlapping.forEach((m) => usedIndices.add(m.windowIndex)) + } else { + combinedMatches.push({ + index: match.index, + confidence: match.confidence, + strategy: match.strategy, + }) + usedIndices.add(match.windowIndex) + } + } + + return combinedMatches +} + +export function findExactMatch( + searchStr: string, + content: string[], + startIndex: number = 0, + confidenceThreshold: number = 0.97 +): SearchResult { + const searchLines = searchStr.split("\n") + const windows = createOverlappingWindows(content.slice(startIndex), searchLines.length) + const matches: (SearchResult & { windowIndex: number })[] = [] + + windows.forEach((windowData, windowIndex) => { + const windowStr = windowData.window.join("\n") + const exactMatch = windowStr.indexOf(searchStr) + + if (exactMatch !== -1) { + const matchedContent = windowData.window + .slice( + windowStr.slice(0, exactMatch).split("\n").length - 1, + windowStr.slice(0, exactMatch).split("\n").length - 1 + searchLines.length + ) + .join("\n") + + const similarity = getDMPSimilarity(searchStr, matchedContent) + const contextSimilarity = validateContextLines(searchStr, matchedContent, confidenceThreshold) + const confidence = Math.min(similarity, contextSimilarity) + + matches.push({ + index: startIndex + windowData.startIndex + windowStr.slice(0, exactMatch).split("\n").length - 1, + confidence, + strategy: "exact", + windowIndex, + }) + } + }) + + const combinedMatches = combineOverlappingMatches(matches) + return combinedMatches.length > 0 ? combinedMatches[0] : { index: -1, confidence: 0, strategy: "exact" } +} + +// String similarity strategy +export function findSimilarityMatch( + searchStr: string, + content: string[], + startIndex: number = 0, + confidenceThreshold: number = 0.97 +): SearchResult { + const searchLines = searchStr.split("\n") + let bestScore = 0 + let bestIndex = -1 + + for (let i = startIndex; i < content.length - searchLines.length + 1; i++) { + const windowStr = content.slice(i, i + searchLines.length).join("\n") + const score = compareTwoStrings(searchStr, windowStr) + if (score > bestScore && score >= confidenceThreshold) { + const similarity = getDMPSimilarity(searchStr, windowStr) + const contextSimilarity = validateContextLines(searchStr, windowStr, confidenceThreshold) + const adjustedScore = Math.min(similarity, contextSimilarity) * score + + if (adjustedScore > bestScore) { + bestScore = adjustedScore + bestIndex = i + } + } + } + + return { + index: bestIndex, + confidence: bestIndex !== -1 ? bestScore : 0, + strategy: "similarity", + } +} + +// Levenshtein strategy +export function findLevenshteinMatch( + searchStr: string, + content: string[], + startIndex: number = 0, + confidenceThreshold: number = 0.97 +): SearchResult { + const searchLines = searchStr.split("\n") + const candidates = [] + + for (let i = startIndex; i < content.length - searchLines.length + 1; i++) { + candidates.push(content.slice(i, i + searchLines.length).join("\n")) + } + + if (candidates.length > 0) { + const closestMatch = closest(searchStr, candidates) + const index = startIndex + candidates.indexOf(closestMatch) + const similarity = getDMPSimilarity(searchStr, closestMatch) + const contextSimilarity = validateContextLines(searchStr, closestMatch, confidenceThreshold) + const confidence = Math.min(similarity, contextSimilarity) + return { + index: confidence === 0 ? -1 : index, + confidence: index !== -1 ? confidence : 0, + strategy: "levenshtein", + } + } + + return { index: -1, confidence: 0, strategy: "levenshtein" } +} + +// Helper function to identify anchor lines +function identifyAnchors(searchStr: string): { first: string | null; last: string | null } { + const searchLines = searchStr.split("\n") + let first: string | null = null + let last: string | null = null + + // Find the first non-empty line + for (const line of searchLines) { + if (line.trim()) { + first = line + break + } + } + + // Find the last non-empty line + for (let i = searchLines.length - 1; i >= 0; i--) { + if (searchLines[i].trim()) { + last = searchLines[i] + break + } + } + + return { first, last } +} + +// Anchor-based search strategy +export function findAnchorMatch( + searchStr: string, + content: string[], + startIndex: number = 0, + confidenceThreshold: number = 0.97 +): SearchResult { + const searchLines = searchStr.split("\n") + const { first, last } = identifyAnchors(searchStr) + + if (!first || !last) { + return { index: -1, confidence: 0, strategy: "anchor" } + } + + let firstIndex = -1 + let lastIndex = -1 + + // Check if the first anchor is unique + let firstOccurrences = 0 + for (const contentLine of content) { + if (contentLine === first) { + firstOccurrences++ + } + } + + if (firstOccurrences !== 1) { + return { index: -1, confidence: 0, strategy: "anchor" } + } + + // Find the first anchor + for (let i = startIndex; i < content.length; i++) { + if (content[i] === first) { + firstIndex = i + break + } + } + + // Find the last anchor + for (let i = content.length - 1; i >= startIndex; i--) { + if (content[i] === last) { + lastIndex = i + break + } + } + + if (firstIndex === -1 || lastIndex === -1 || lastIndex <= firstIndex) { + return { index: -1, confidence: 0, strategy: "anchor" } + } + + // Validate the context + const expectedContext = searchLines.slice(searchLines.indexOf(first) + 1, searchLines.indexOf(last)).join("\n") + const actualContext = content.slice(firstIndex + 1, lastIndex).join("\n") + const contextSimilarity = evaluateSimilarity(expectedContext, actualContext) + + if (contextSimilarity < getAdaptiveThreshold(content.length, confidenceThreshold)) { + return { index: -1, confidence: 0, strategy: "anchor" } + } + + const confidence = 1 + + return { + index: firstIndex, + confidence: confidence, + strategy: "anchor", + } +} + +// Main search function that tries all strategies +export function findBestMatch( + searchStr: string, + content: string[], + startIndex: number = 0, + confidenceThreshold: number = 0.97 +): SearchResult { + const strategies = [findExactMatch, findAnchorMatch, findSimilarityMatch, findLevenshteinMatch] + + let bestResult: SearchResult = { index: -1, confidence: 0, strategy: "none" } + + for (const strategy of strategies) { + const result = strategy(searchStr, content, startIndex, confidenceThreshold) + if (result.confidence > bestResult.confidence) { + bestResult = result + } + } + + return bestResult +} diff --git a/src/core/diff/strategies/new-unified/types.ts b/src/core/diff/strategies/new-unified/types.ts new file mode 100644 index 0000000..a734f6e --- /dev/null +++ b/src/core/diff/strategies/new-unified/types.ts @@ -0,0 +1,20 @@ +export type Change = { + type: 'context' | 'add' | 'remove'; + content: string; + indent: string; + originalLine?: string; +}; + +export type Hunk = { + changes: Change[]; +}; + +export type Diff = { + hunks: Hunk[]; +}; + +export type EditResult = { + confidence: number; + result: string[]; + strategy: string; +}; \ No newline at end of file diff --git a/src/core/diff/strategies/search-replace.ts b/src/core/diff/strategies/search-replace.ts index 4fc28b4..1b7e1ca 100644 --- a/src/core/diff/strategies/search-replace.ts +++ b/src/core/diff/strategies/search-replace.ts @@ -127,7 +127,7 @@ Your search/replace content here ` } - applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): DiffResult { + async applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): Promise { // Extract the search and replace blocks const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/); if (!match) { diff --git a/src/core/diff/strategies/unified.ts b/src/core/diff/strategies/unified.ts index 2f80a61..cd5e31e 100644 --- a/src/core/diff/strategies/unified.ts +++ b/src/core/diff/strategies/unified.ts @@ -108,7 +108,7 @@ Your diff here ` } - applyDiff(originalContent: string, diffContent: string): DiffResult { + async applyDiff(originalContent: string, diffContent: string): Promise { try { const result = applyPatch(originalContent, diffContent) if (result === false) { diff --git a/src/core/diff/types.ts b/src/core/diff/types.ts index 3957a1f..7666d0f 100644 --- a/src/core/diff/types.ts +++ b/src/core/diff/types.ts @@ -28,5 +28,5 @@ export interface DiffStrategy { * @param endLine Optional line number where the search block ends. If not provided, searches the entire file. * @returns A DiffResult object containing either the successful result or error details */ - applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): DiffResult -} + applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): Promise +} \ No newline at end of file diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b4e2f76..4e03b8f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -99,6 +99,7 @@ type GlobalStateKey = | "modeApiConfigs" | "customPrompts" | "enhancementApiConfigId" + | "experimentalDiffStrategy" | "autoApprovalEnabled" export const GlobalFileNames = { @@ -254,6 +255,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { fuzzyMatchThreshold, mode, customInstructions: globalInstructions, + experimentalDiffStrategy } = await this.getState() const modeInstructions = customPrompts?.[mode]?.customInstructions @@ -268,7 +270,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold, task, - images + images, + undefined, + experimentalDiffStrategy ) } @@ -281,6 +285,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { fuzzyMatchThreshold, mode, customInstructions: globalInstructions, + experimentalDiffStrategy } = await this.getState() const modeInstructions = customPrompts?.[mode]?.customInstructions @@ -296,7 +301,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { fuzzyMatchThreshold, undefined, undefined, - historyItem + historyItem, + experimentalDiffStrategy ) } @@ -1070,6 +1076,13 @@ export class ClineProvider implements vscode.WebviewViewProvider { vscode.window.showErrorMessage("Failed to get list api configuration") } break + case "experimentalDiffStrategy": + await this.updateGlobalState("experimentalDiffStrategy", message.bool ?? false) + // Update diffStrategy in current Cline instance if it exists + if (this.cline) { + await this.cline.updateDiffStrategy(message.bool ?? false) + } + await this.postStateToWebview() } }, null, @@ -1541,7 +1554,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { uiMessagesFilePath: string apiConversationHistory: Anthropic.MessageParam[] }> { - const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || [] + const history = (await this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || [] const historyItem = history.find((item) => item.id === id) if (historyItem) { const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks", id) @@ -1606,7 +1619,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { async deleteTaskFromState(id: string) { // Remove the task from history - const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[]) || [] + const taskHistory = (await this.getGlobalState("taskHistory") as HistoryItem[]) || [] const updatedTaskHistory = taskHistory.filter((task) => task.id !== id) await this.updateGlobalState("taskHistory", updatedTaskHistory) @@ -1647,6 +1660,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mode, customPrompts, enhancementApiConfigId, + experimentalDiffStrategy, autoApprovalEnabled, } = await this.getState() @@ -1687,6 +1701,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mode: mode ?? codeMode, customPrompts: customPrompts ?? {}, enhancementApiConfigId, + experimentalDiffStrategy: experimentalDiffStrategy ?? false, autoApprovalEnabled: autoApprovalEnabled ?? false, } } @@ -1803,6 +1818,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { modeApiConfigs, customPrompts, enhancementApiConfigId, + experimentalDiffStrategy, autoApprovalEnabled, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, @@ -1864,6 +1880,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("modeApiConfigs") as Promise | undefined>, this.getGlobalState("customPrompts") as Promise, this.getGlobalState("enhancementApiConfigId") as Promise, + this.getGlobalState("experimentalDiffStrategy") as Promise, this.getGlobalState("autoApprovalEnabled") as Promise, ]) @@ -1969,13 +1986,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { modeApiConfigs: modeApiConfigs ?? {} as Record, customPrompts: customPrompts ?? {}, enhancementApiConfigId, + experimentalDiffStrategy: experimentalDiffStrategy ?? false, autoApprovalEnabled: autoApprovalEnabled ?? false, } } async updateTaskHistory(item: HistoryItem): Promise { - const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[]) || [] + const history = (await this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || [] const existingItemIndex = history.findIndex((h) => h.id === item.id) + if (existingItemIndex !== -1) { history[existingItemIndex] = item } else { diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index ddcd7e1..389f333 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -610,6 +610,8 @@ describe('ClineProvider', () => { true, 1.0, 'Test task', + undefined, + undefined, undefined ); }); diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 0135a46..73682a4 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -94,6 +94,7 @@ export interface ExtensionState { mode: Mode modeApiConfigs?: Record enhancementApiConfigId?: string + experimentalDiffStrategy?: boolean autoApprovalEnabled?: boolean } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index dc5be28..1e6e19f 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -72,6 +72,7 @@ export interface WebviewMessage { | "getSystemPrompt" | "systemPrompt" | "enhancementApiConfigId" + | "experimentalDiffStrategy" | "autoApprovalEnabled" text?: string disabled?: boolean diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 88731c6..beafc74 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -61,6 +61,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { listApiConfigMeta, mode, setMode, + experimentalDiffStrategy, + setExperimentalDiffStrategy, } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) @@ -103,6 +105,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { apiConfiguration }) vscode.postMessage({ type: "mode", text: mode }) + vscode.postMessage({ type: "experimentalDiffStrategy", bool: experimentalDiffStrategy }) onDone() } } @@ -328,7 +331,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
- setDiffEnabled(e.target.checked)}> + { + setDiffEnabled(e.target.checked) + if (!e.target.checked) { + // Reset experimental strategy when diffs are disabled + setExperimentalDiffStrategy(false) + } + }}> Enable editing through diffs

{ {diffEnabled && (

+
+ ⚠️ + setExperimentalDiffStrategy(e.target.checked)}> + Use experimental unified diff strategy + +
+

+ Enable the experimental unified diff strategy. This strategy might reduce the number of retries caused by model errors but may cause unexpected behavior or incorrect edits. + Only enable if you understand the risks and are willing to carefully review all changes. +

+
Match precision { {Math.round((fuzzyMatchThreshold || 1) * 100)}%
-

+

This slider controls how precisely code sections must match when applying diffs. Lower values allow more flexible matching but increase the risk of incorrect replacements. Use values below 100% with extreme caution.

diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 52fabad..2291204 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -63,6 +63,8 @@ export interface ExtensionStateContextType extends ExtensionState { setCustomPrompts: (value: CustomPrompts) => void enhancementApiConfigId?: string setEnhancementApiConfigId: (value: string) => void + experimentalDiffStrategy: boolean + setExperimentalDiffStrategy: (value: boolean) => void autoApprovalEnabled?: boolean setAutoApprovalEnabled: (value: boolean) => void } @@ -93,6 +95,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode mode: codeMode, customPrompts: defaultPrompts, enhancementApiConfigId: '', + experimentalDiffStrategy: false, autoApprovalEnabled: false, }) const [didHydrateState, setDidHydrateState] = useState(false) @@ -211,6 +214,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode fuzzyMatchThreshold: state.fuzzyMatchThreshold, writeDelayMs: state.writeDelayMs, screenshotQuality: state.screenshotQuality, + experimentalDiffStrategy: state.experimentalDiffStrategy ?? false, setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value @@ -241,6 +245,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })), setCustomPrompts: (value) => setState((prevState) => ({ ...prevState, customPrompts: value })), setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })), + setExperimentalDiffStrategy: (value) => setState((prevState) => ({ ...prevState, experimentalDiffStrategy: value })), setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })), }