Merge pull request #364 from daniel-lxs/new_unified

New unified edit strategy
This commit is contained in:
Matt Rubens
2025-01-17 02:12:32 -05:00
committed by GitHub
23 changed files with 2719 additions and 160 deletions

View File

@@ -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 ?? ''),

View File

@@ -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();
});

View File

@@ -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 }

View File

@@ -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<Config> {
+ 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<Config> {
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);
}
});
});
});

View File

@@ -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('</apply_diff>')
})
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.')
})

View File

@@ -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 <Button onClick={() => setCount(count + 1)}>{count}</Button>;
}`
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)

View File

@@ -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")
})
})

View File

@@ -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")
})
})
})

View File

@@ -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<EditResult> {
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<EditResult> {
// 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" }
}

View File

@@ -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/<int:n>')
-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/<int:n>')
+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:
<apply_diff>
<path>File path here</path>
<diff>
Your diff here
</diff>
</apply_diff>`
}
// 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<DiffResult> {
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") }
}
}

View File

@@ -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<number>()
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
}

View File

@@ -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;
};

View File

@@ -127,7 +127,7 @@ Your search/replace content here
</apply_diff>`
}
applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): DiffResult {
async applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): Promise<DiffResult> {
// Extract the search and replace blocks
const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/);
if (!match) {

View File

@@ -108,7 +108,7 @@ Your diff here
</apply_diff>`
}
applyDiff(originalContent: string, diffContent: string): DiffResult {
async applyDiff(originalContent: string, diffContent: string): Promise<DiffResult> {
try {
const result = applyPatch(originalContent, diffContent)
if (result === false) {

View File

@@ -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<DiffResult>
}

View File

@@ -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<ApiProvider | undefined>,
@@ -1864,6 +1880,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("modeApiConfigs") as Promise<Record<Mode, string> | undefined>,
this.getGlobalState("customPrompts") as Promise<CustomPrompts | undefined>,
this.getGlobalState("enhancementApiConfigId") as Promise<string | undefined>,
this.getGlobalState("experimentalDiffStrategy") as Promise<boolean | undefined>,
this.getGlobalState("autoApprovalEnabled") as Promise<boolean | undefined>,
])
@@ -1969,13 +1986,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
modeApiConfigs: modeApiConfigs ?? {} as Record<Mode, string>,
customPrompts: customPrompts ?? {},
enhancementApiConfigId,
experimentalDiffStrategy: experimentalDiffStrategy ?? false,
autoApprovalEnabled: autoApprovalEnabled ?? false,
}
}
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
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 {

View File

@@ -610,6 +610,8 @@ describe('ClineProvider', () => {
true,
1.0,
'Test task',
undefined,
undefined,
undefined
);
});

View File

@@ -94,6 +94,7 @@ export interface ExtensionState {
mode: Mode
modeApiConfigs?: Record<Mode, string>
enhancementApiConfigId?: string
experimentalDiffStrategy?: boolean
autoApprovalEnabled?: boolean
}

View File

@@ -72,6 +72,7 @@ export interface WebviewMessage {
| "getSystemPrompt"
| "systemPrompt"
| "enhancementApiConfigId"
| "experimentalDiffStrategy"
| "autoApprovalEnabled"
text?: string
disabled?: boolean