diff --git a/core/edit/searchAndReplace/findSearchMatch.test.ts b/core/edit/searchAndReplace/findSearchMatch.test.ts index a39322e313..8afa2eb6a6 100644 --- a/core/edit/searchAndReplace/findSearchMatch.test.ts +++ b/core/edit/searchAndReplace/findSearchMatch.test.ts @@ -1,7 +1,7 @@ import { findSearchMatch } from "./findSearchMatch"; describe("Exact matches", () => { - it("should find exact match at the beginning of file", () => { + it("should find exact match at the beginning of file", async () => { const fileContent = `function hello() { console.log("world"); }`; @@ -9,7 +9,7 @@ describe("Exact matches", () => { console.log("world"); }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toEqual({ startIndex: 0, @@ -18,7 +18,7 @@ describe("Exact matches", () => { }); }); - it("should find exact match in the middle of file", () => { + it("should find exact match in the middle of file", async () => { const fileContent = `const a = 1; function hello() { console.log("world"); @@ -28,7 +28,7 @@ const b = 2;`; console.log("world"); }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toEqual({ startIndex: 13, // After "const a = 1;\n" @@ -37,7 +37,7 @@ const b = 2;`; }); }); - it("should find exact match at the end of file", () => { + it("should find exact match at the end of file", async () => { const fileContent = `const a = 1; function hello() { console.log("world"); @@ -46,7 +46,7 @@ function hello() { console.log("world"); }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toEqual({ startIndex: 13, // After "const a = 1;\n" @@ -55,13 +55,13 @@ function hello() { }); }); - it("should find exact match with single line", () => { + it("should find exact match with single line", async () => { const fileContent = `const a = 1; const b = 2; const c = 3;`; const searchContent = `const b = 2;`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toEqual({ startIndex: 13, // After "const a = 1;\n" @@ -70,7 +70,7 @@ const c = 3;`; }); }); - it("should find exact match with whitespace preserved", () => { + it("should find exact match with whitespace preserved", async () => { const fileContent = `function test() { const x = 1; const y = 2; @@ -78,7 +78,7 @@ const c = 3;`; const searchContent = ` const x = 1; const y = 2;`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); // The actual indexOf result is 18, not 17 expect(result).toEqual({ @@ -90,7 +90,7 @@ const c = 3;`; }); describe("Trimmed matches", () => { - it("should find trimmed match when exact match fails due to leading newlines", () => { + it("should find trimmed match when exact match fails due to leading newlines", async () => { const fileContent = `function hello() { console.log("world"); }`; @@ -98,7 +98,7 @@ describe("Trimmed matches", () => { console.log("world"); }\n\n`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toEqual({ startIndex: 0, @@ -107,7 +107,7 @@ describe("Trimmed matches", () => { }); }); - it("should find trimmed match when exact match fails due to trailing newlines", () => { + it("should find trimmed match when exact match fails due to trailing newlines", async () => { const fileContent = `const a = 1; function hello() { return "world"; @@ -117,7 +117,7 @@ const b = 2;`; return "world"; }\n`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); // The function finds the exact match first (at index 12), not the trimmed match expect(result).toEqual({ @@ -127,13 +127,13 @@ const b = 2;`; }); }); - it("should find trimmed match for single line with extra whitespace", () => { + it("should find trimmed match for single line with extra whitespace", async () => { const fileContent = `const a = 1; const b = 2; const c = 3;`; const searchContent = `\n const b = 2; \n`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); const trimmedSearchContent = searchContent.trim(); const expectedStart = fileContent.indexOf(trimmedSearchContent); @@ -147,13 +147,13 @@ const c = 3;`; }); describe("Empty search content", () => { - it("should match at beginning for empty search content", () => { + it("should match at beginning for empty search content", async () => { const fileContent = `function hello() { console.log("world"); }`; const searchContent = ""; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toEqual({ startIndex: 0, @@ -162,13 +162,13 @@ describe("Empty search content", () => { }); }); - it("should match at beginning for whitespace-only search content", () => { + it("should match at beginning for whitespace-only search content", async () => { const fileContent = `function hello() { console.log("world"); }`; const searchContent = "\n\n \t \n"; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toEqual({ startIndex: 0, @@ -177,11 +177,11 @@ describe("Empty search content", () => { }); }); - it("should match at beginning for empty file and empty search", () => { + it("should match at beginning for empty file and empty search", async () => { const fileContent = ""; const searchContent = ""; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toEqual({ startIndex: 0, @@ -192,7 +192,7 @@ describe("Empty search content", () => { }); describe("No matches", () => { - it("should return null when search content is not found", () => { + it("should return null when search content is not found", async () => { const fileContent = `function hello() { console.log("world"); }`; @@ -200,12 +200,12 @@ describe("No matches", () => { console.log("world"); }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result?.strategyName).toEqual("jaroWinklerFuzzyMatch"); }); - it("should return only fuzzy result when trimmed search content is not found", () => { + it("should return only fuzzy result when trimmed search content is not found", async () => { const fileContent = `function hello() { console.log("world"); }`; @@ -213,23 +213,23 @@ describe("No matches", () => { console.log("world"); }\n\n`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result?.strategyName).toEqual("jaroWinklerFuzzyMatch"); }); }); describe("Edge cases", () => { - it("should handle empty file with non-empty search", () => { + it("should handle empty file with non-empty search", async () => { const fileContent = ""; const searchContent = "function hello() {}"; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toBeNull(); }); - it("should handle very large content", () => { + it("should handle very large content", async () => { const repeatedLine = "const x = 1;\n"; const fileContent = repeatedLine.repeat(1000) + @@ -237,7 +237,7 @@ describe("Edge cases", () => { repeatedLine.repeat(1000); const searchContent = "const target = 2;"; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); const expectedStart = repeatedLine.length * 1000; expect(result).toEqual({ @@ -247,13 +247,13 @@ describe("Edge cases", () => { }); }); - it("should handle special characters and symbols", () => { + it("should handle special characters and symbols", async () => { const fileContent = `const regex = /[a-zA-Z]+/g; const symbols = !@#$%^&*(); const unicode = "πŸš€ Hello δΈ–η•Œ";`; const searchContent = `const symbols = !@#$%^&*();`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); const expectedStart = fileContent.indexOf(searchContent); expect(result).toEqual({ @@ -263,7 +263,7 @@ const unicode = "πŸš€ Hello δΈ–η•Œ";`; }); }); - it("should prefer exact match over trimmed match", () => { + it("should prefer exact match over trimmed match", async () => { const fileContent = `function test() { const x = 1; } @@ -275,7 +275,7 @@ function test() { const x = 1; }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); // Should find the first exact match, not a trimmed version expect(result).toEqual({ @@ -285,13 +285,13 @@ function test() { }); }); - it("should handle multiple occurrences and return first match", () => { + it("should handle multiple occurrences and return first match", async () => { const fileContent = `const a = 1; const b = 2; const a = 1;`; const searchContent = `const a = 1;`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toEqual({ startIndex: 0, @@ -302,7 +302,7 @@ const a = 1;`; }); describe("Whitespace-ignored matches", () => { - it("should match code with different indentation", () => { + it("should match code with different indentation", async () => { const fileContent = `function example() { const x = 1; const y = 2; @@ -312,7 +312,7 @@ describe("Whitespace-ignored matches", () => { const y=2; return x+y;`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); expect(result!.startIndex).toBe(20); // After "function example() {\n" @@ -330,13 +330,13 @@ return x+y;`; return x + y;`); }); - it("should match single line with different spacing", () => { + it("should match single line with different spacing", async () => { const fileContent = `const a = 1; const b = 2; const c = 3;`; const searchContent = `const b=2;`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); expect(result!.startIndex).toBe(12); // After "const a = 1;" @@ -352,7 +352,7 @@ const c = 3;`; const b = 2;`); }); - it("should match with tabs vs spaces", () => { + it("should match with tabs vs spaces", async () => { const fileContent = `function test() { \tif (condition) { \t\treturn true; @@ -362,7 +362,7 @@ const b = 2;`); return true; }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); expect(result!.startIndex).toBe(17); // After "function test() {" @@ -380,7 +380,7 @@ const b = 2;`); \t}`); }); - it("should match complex whitespace patterns", () => { + it("should match complex whitespace patterns", async () => { const fileContent = `const obj = { key1 : 'value1', key2 : 'value2', @@ -390,7 +390,7 @@ const b = 2;`); key2:'value2', key3:'value3'`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); expect(result!.startIndex).toBe(13); // After "const obj = {" @@ -408,7 +408,7 @@ key3:'value3'`; key3 : 'value3'`); }); - it("should handle newlines and mixed whitespace", () => { + it("should handle newlines and mixed whitespace", async () => { const fileContent = `function calc() { @@ -421,7 +421,7 @@ key3:'value3'`; }`; const searchContent = `let result=0;result+=5;return result;`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); expect(result!.startIndex).toBe(17); // After "function calc() {\n\n" @@ -444,12 +444,12 @@ key3:'value3'`; return result;`); }); - it("should prefer exact match over whitespace-ignored match", () => { + it("should prefer exact match over whitespace-ignored match", async () => { const fileContent = `const x=1; const x = 1;`; const searchContent = `const x=1;`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); // Should find the exact match first (at index 0), not the whitespace-ignored match expect(result).toEqual({ @@ -459,13 +459,13 @@ const x = 1;`; }); }); - it("should handle empty content after whitespace removal", () => { + it("should handle empty content after whitespace removal", async () => { const fileContent = `function test() { return 1; }`; const searchContent = `\n\n \t \n`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); // Should match at beginning for empty/whitespace-only content expect(result).toEqual({ @@ -475,7 +475,7 @@ const x = 1;`; }); }); - it("should handle complex code blocks with different formatting", () => { + it("should handle complex code blocks with different formatting", async () => { const fileContent = `class Example { constructor(name) { this.name = name; @@ -491,7 +491,7 @@ this.name=name; this.value=0; }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); expect(result!.startIndex).toBe(15); // After "class Example {" @@ -510,24 +510,24 @@ this.value=0; }`); }); - it("should return null when no close match exists", () => { + it("should return null when no close match exists", async () => { const fileContent = `function hello() { console.log("world"); }`; const searchContent = `this has nothing to do with the other option`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toBeNull(); }); - it("should handle multiple potential matches and return first", () => { + it("should handle multiple potential matches and return first", async () => { const fileContent = `const a=1; const a = 1; const a = 1;`; const searchContent = `const a=1;`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); // Should find the first exact match expect(result).toEqual({ @@ -539,7 +539,7 @@ const a = 1;`; }); describe("Real-world scenarios", () => { - it("should match typical function replacement", () => { + it("should match typical function replacement", async () => { const fileContent = `class Calculator { constructor() { this.result = 0; @@ -560,7 +560,7 @@ describe("Real-world scenarios", () => { return this; }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); const expectedStart = fileContent.indexOf(searchContent); expect(result).toEqual({ @@ -570,7 +570,7 @@ describe("Real-world scenarios", () => { }); }); - it("should match import statement", () => { + it("should match import statement", async () => { const fileContent = `import React from 'react'; import { useState, useEffect } from 'react'; import './App.css'; @@ -580,7 +580,7 @@ function App() { }`; const searchContent = `import { useState, useEffect } from 'react';`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); const expectedStart = fileContent.indexOf(searchContent); expect(result).toEqual({ @@ -590,7 +590,7 @@ function App() { }); }); - it("should match comment block", () => { + it("should match comment block", async () => { const fileContent = `/** * This is a comment * that spans multiple lines @@ -604,7 +604,7 @@ function test() { * that spans multiple lines */`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toEqual({ startIndex: 0, @@ -615,7 +615,7 @@ function test() { describe("Jaro-Winkler fuzzy matching", () => { describe("Basic fuzzy matching", () => { - it("should find fuzzy match for similar strings", () => { + it("should find fuzzy match for similar strings", async () => { const fileContent = `function calculateSum(a, b) { return a + b; }`; @@ -623,33 +623,33 @@ function test() { return x + y; }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); }); - it("should find fuzzy match with minor typos", () => { + it("should find fuzzy match with minor typos", async () => { const fileContent = `const message = "Hello World";`; const searchContent = `const mesage = "Hello World";`; // Missing 's' in 'message' - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); expect(result?.startIndex).toBe(0); }); - it("should find fuzzy match with different variable names", () => { + it("should find fuzzy match with different variable names", async () => { const fileContent = `let userAge = 25; let userName = "John";`; const searchContent = `let age = 25; let name = "John";`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); }); - it("should find fuzzy match for similar function signature", () => { + it("should find fuzzy match for similar function signature", async () => { const fileContent = `function processUserData(userData) { validateInput(userData); return formatOutput(userData); @@ -659,14 +659,14 @@ let name = "John";`; return formatOutput(data); }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); }); }); describe("Multi-line fuzzy matching", () => { - it("should find fuzzy match for multi-line blocks", () => { + it("should find fuzzy match for multi-line blocks", async () => { const fileContent = `class Calculator { constructor() { this.result = 0; @@ -688,12 +688,12 @@ let name = "John";`; } }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); }); - it("should find fuzzy match for partial blocks", () => { + it("should find fuzzy match for partial blocks", async () => { const fileContent = `function processData(input) { const validated = validateInput(input); const processed = transformData(validated); @@ -703,18 +703,18 @@ let name = "John";`; const searchContent = `const validated = validateInput(input); const processed = transformData(validated);`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); }); }); describe("Edge cases with fuzzy matching", () => { - it("should handle empty search content with fuzzy matching enabled", () => { + it("should handle empty search content with fuzzy matching enabled", async () => { const fileContent = `function test() {}`; const searchContent = ""; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toEqual({ startIndex: 0, @@ -723,43 +723,43 @@ let name = "John";`; }); }); - it("should handle empty file content with fuzzy matching enabled", () => { + it("should handle empty file content with fuzzy matching enabled", async () => { const fileContent = ""; const searchContent = "function test() {}"; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).toBeNull(); }); - it("should prefer exact match over fuzzy match", () => { + it("should prefer exact match over fuzzy match", async () => { const fileContent = `function test() { return true; } function test() { return false; }`; const searchContent = `function test() { return true; }`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); expect(result?.startIndex).toBe(0); }); - it("should handle very short strings", () => { + it("should handle very short strings", async () => { const fileContent = `a b c`; const searchContent = `a c`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); // Should not match due to low similarity expect(result).toBeNull(); }); - it("should handle special characters in fuzzy matching", () => { + it("should handle special characters in fuzzy matching", async () => { const fileContent = `const regex = /[a-zA-Z]+/g; const symbols = !@#$%^&*();`; const searchContent = `const regex = /[a-zA-Z0-9]+/g; const symbols = !@#$%^&*();`; - const result = findSearchMatch(fileContent, searchContent); + const result = await findSearchMatch(fileContent, searchContent); expect(result).not.toBeNull(); }); diff --git a/core/edit/searchAndReplace/findSearchMatch.ts b/core/edit/searchAndReplace/findSearchMatch.ts index 9597d5070d..3c23d6b865 100644 --- a/core/edit/searchAndReplace/findSearchMatch.ts +++ b/core/edit/searchAndReplace/findSearchMatch.ts @@ -22,15 +22,16 @@ export interface SearchMatchResult extends BasicMatchResult { type MatchStrategy = ( fileContent: string, searchContent: string, -) => BasicMatchResult | null; + extras?: any, +) => Promise; /** * Exact string matching strategy */ -function exactMatch( +async function exactMatch( fileContent: string, searchContent: string, -): BasicMatchResult | null { +): Promise { const exactIndex = fileContent.indexOf(searchContent); if (exactIndex !== -1) { return { @@ -44,10 +45,10 @@ function exactMatch( /** * Trimmed content matching strategy */ -function trimmedMatch( +async function trimmedMatch( fileContent: string, searchContent: string, -): BasicMatchResult | null { +): Promise { const trimmedSearchContent = searchContent.trim(); const trimmedIndex = fileContent.indexOf(trimmedSearchContent); if (trimmedIndex !== -1) { @@ -63,10 +64,10 @@ function trimmedMatch( * Whitespace-ignored matching strategy * Removes all whitespace from both content and search, then finds the match */ -function whitespaceIgnoredMatch( +async function whitespaceIgnoredMatch( fileContent: string, searchContent: string, -): BasicMatchResult | null { +): Promise { // Remove all whitespace (spaces, tabs, newlines, etc.) const strippedFileContent = fileContent.replace(/\s/g, ""); const strippedSearchContent = searchContent.replace(/\s/g, ""); @@ -195,11 +196,11 @@ function jaroWinklerSimilarity( /** * Find the best fuzzy match for search content in file content using Jaro-Winkler */ -function findFuzzyMatch( +async function findFuzzyMatch( fileContent: string, searchContent: string, threshold: number = 0.9, -): BasicMatchResult | null { +): Promise { const searchLines = searchContent.split("\n"); const fileLines = fileContent.split("\n"); @@ -271,6 +272,179 @@ function findFuzzyMatch( return bestMatch; } +/** + * AI-based range matching strategy for fuzzy multi-line content + */ +async function aiRangeMatch( + fileContent: string, + searchContent: string, + extras?: any, +): Promise { + if (!extras) { + return null; + } + + const lines = fileContent.split("\n"); + const searchLines = searchContent.split("\n").length; + + // Handle edge cases + if (lines.length === 0 || searchLines === 0) { + return null; + } + + // Create numbered content for the AI to analyze + const numberedContent = lines + .map((line, index) => `${index + 1}: ${line}`) + .join("\n"); + + // Get the chat model + const state = extras.getState(); + const selectedChatModel = extras.selectSelectedChatModel(state); + if (!selectedChatModel) { + return null; + } + + const streamAborter = state.session.streamAborter; + const completionOptions = {}; + + const maxRetries = 2; + let retryCount = 0; + let lastError: any = null; + + const RANGE_IDENTIFICATION_SYSTEM_MESSAGE = ` +You are a fuzzy range finder. + +You will be given two inputs as XML: +- : the main text with line number prefixes +- : the search text + +Your task is to find the range of consecutive lines in that is the best fuzzy match to , where the number of lines in the range is EXACTLY the same as the number of lines in . "Best fuzzy match" means the lines are similar, but do not need to be identical. It must be the best match in the file. + +Output the result as JSON with two fields: +- startLine: the 1-based index of the first line of the best match in +- endLine: the 1-based index of the last line of the best match in (inclusive) + +If there is no reasonable match, return {"startLine": -1, "endLine": -1}. + +Consider anchors in as you try to find the match. Anchors are the first line of significant complexity, the last line of significant complexity, and the most complex interior line. Anchors are very helpful. + +Only output the JSON result, and nothing else. +`; + + while (retryCount < maxRetries) { + let identificationPrompt = ""; + + if (retryCount > 0 && lastError) { + identificationPrompt = ` + +You selected ${lastError.selectedLines} lines but the search has ${searchLines} lines. Try again and select exactly ${searchLines} lines. + +`; + } + + identificationPrompt += ` + +${numberedContent} + + + +${searchContent} + +`; + + try { + const gen = extras.ideMessenger.llmStreamChat( + { + completionOptions, + title: selectedChatModel.title, + messages: [ + { + role: "system", + content: RANGE_IDENTIFICATION_SYSTEM_MESSAGE, + }, + { + role: "user", + content: identificationPrompt, + }, + ], + }, + streamAborter.signal, + ); + + let responseContent = ""; + + // Collect the AI response + for await (const chunks of gen) { + for (const chunk of chunks) { + if (chunk.role === "assistant" && chunk.content) { + responseContent += chunk.content; + } + } + } + + // Parse the AI response to get line numbers + const jsonMatch = responseContent.match(/\{[^}]+\}/); + if (!jsonMatch) { + throw new Error("AI did not return valid JSON"); + } + + const parsedRange = JSON.parse(jsonMatch[0]); + + // Validate the response + if ( + !parsedRange.startLine || + !parsedRange.endLine || + parsedRange.startLine < 1 || + parsedRange.endLine < 1 || + parsedRange.endLine > lines.length || + parsedRange.startLine > parsedRange.endLine + ) { + throw new Error(`Invalid line range: ${JSON.stringify(parsedRange)}`); + } + + // Check if the number of lines matches + const selectedLines = parsedRange.endLine - parsedRange.startLine + 1; + if (selectedLines !== searchLines) { + lastError = { + selectedLines, + startLine: parsedRange.startLine, + endLine: parsedRange.endLine, + }; + retryCount++; + continue; // Retry + } + + // Convert line numbers to character indices + // Handle edge case where there are no lines before start + let startIndex = 0; + if (parsedRange.startLine > 1) { + startIndex = lines + .slice(0, parsedRange.startLine - 1) + .reduce((acc, line) => acc + line.length + 1, 0); + } + + // Calculate end index + let endIndex = startIndex; + for (let i = parsedRange.startLine - 1; i < parsedRange.endLine; i++) { + endIndex += lines[i].length; + if (i < parsedRange.endLine - 1) { + endIndex += 1; // Add newline + } + } + + return { startIndex, endIndex }; + } catch (error) { + // On last retry, return null to fall back to error + if (retryCount === maxRetries - 1) { + return null; + } + retryCount++; + } + } + + return null; +} + /** * Ordered list of matching strategies to try with their names */ @@ -279,6 +453,7 @@ const matchingStrategies: Array<{ strategy: MatchStrategy; name: string }> = [ { strategy: trimmedMatch, name: "trimmedMatch" }, { strategy: whitespaceIgnoredMatch, name: "whitespaceIgnoredMatch" }, { strategy: findFuzzyMatch, name: "jaroWinklerFuzzyMatch" }, + { strategy: aiRangeMatch, name: "aiRangeMatch" }, ]; /** @@ -291,13 +466,14 @@ const matchingStrategies: Array<{ strategy: MatchStrategy; name: string }> = [ * * @param fileContent - The complete content of the file to search in * @param searchContent - The content to search for - * @param config - Configuration options for matching behavior + * @param extras - Additional context needed by some matchers (AI matcher) * @returns Match result with character positions, or null if no match found */ -export function findSearchMatch( +export async function findSearchMatch( fileContent: string, searchContent: string, -): SearchMatchResult | null { + extras?: any, +): Promise { const trimmedSearchContent = searchContent.trim(); if (trimmedSearchContent === "") { @@ -307,7 +483,7 @@ export function findSearchMatch( // Try each matching strategy in order for (const { strategy, name } of matchingStrategies) { - const result = strategy(fileContent, searchContent); + const result = await strategy(fileContent, searchContent, extras); if (result !== null) { return { ...result, strategyName: name }; } diff --git a/gui/src/util/clientTools/searchReplaceImpl.test.ts b/gui/src/util/clientTools/searchReplaceImpl.test.ts index a8eb74d77c..a0d91324e3 100644 --- a/gui/src/util/clientTools/searchReplaceImpl.test.ts +++ b/gui/src/util/clientTools/searchReplaceImpl.test.ts @@ -139,7 +139,7 @@ describe("searchReplaceToolImpl", () => { const startIndex = originalContent.indexOf(searchText); const endIndex = startIndex + searchText.length; - mockFindSearchMatch.mockReturnValue({ + mockFindSearchMatch.mockResolvedValue({ startIndex, endIndex, strategyName: "exactMatch", @@ -197,7 +197,7 @@ describe("searchReplaceToolImpl", () => { const startIndex = originalContent.indexOf(searchText); const endIndex = startIndex + searchText.length; - mockFindSearchMatch.mockReturnValue({ + mockFindSearchMatch.mockResolvedValue({ startIndex, endIndex, strategyName: "exactMatch", @@ -272,12 +272,12 @@ const c = 3;`; // Mock sequential search matches mockFindSearchMatch - .mockReturnValueOnce({ + .mockResolvedValueOnce({ startIndex: firstStartIndex, endIndex: firstEndIndex, strategyName: "exactMatch", }) - .mockReturnValueOnce({ + .mockResolvedValueOnce({ startIndex: secondStartIndex, endIndex: secondEndIndex, strategyName: "exactMatch", @@ -303,11 +303,23 @@ const c = 3;`; 1, originalContent, "const a = 1;", + expect.objectContaining({ + dispatch: expect.any(Function), + getState: expect.any(Function), + ideMessenger: mockIdeMessenger, + selectSelectedChatModel: expect.any(Function), + }), ); expect(mockFindSearchMatch).toHaveBeenNthCalledWith( 2, contentAfterFirstReplacement, "const b = 2;", + expect.objectContaining({ + dispatch: expect.any(Function), + getState: expect.any(Function), + ideMessenger: mockIdeMessenger, + selectSelectedChatModel: expect.any(Function), + }), ); // Verify final applyToFile call expect(mockIdeMessenger.request).toHaveBeenCalledWith("applyToFile", { @@ -368,12 +380,12 @@ const c = 3;`; // Mock sequential search matches mockFindSearchMatch - .mockReturnValueOnce({ + .mockResolvedValueOnce({ startIndex: firstStartIndex, endIndex: firstEndIndex, strategyName: "exactMatch", }) - .mockReturnValueOnce({ + .mockResolvedValueOnce({ startIndex: secondStartIndex, endIndex: secondEndIndex, strategyName: "exactMatch", @@ -435,7 +447,7 @@ keep this too`; }, ]); mockIdeMessenger.ide.readFile.mockResolvedValue(originalContent); - mockFindSearchMatch.mockReturnValue({ + mockFindSearchMatch.mockResolvedValue({ startIndex: 10, // Position of "remove this line" endIndex: 26, // End of "remove this line" strategyName: "exactMatch", @@ -475,7 +487,7 @@ keep this too`; }, ]); mockIdeMessenger.ide.readFile.mockResolvedValue(originalContent); - mockFindSearchMatch.mockReturnValue({ + mockFindSearchMatch.mockResolvedValue({ startIndex: 6, endIndex: 11, strategyName: "exactMatch", @@ -509,7 +521,7 @@ keep this too`; }, ]); mockIdeMessenger.ide.readFile.mockResolvedValue("some file content"); - mockFindSearchMatch.mockReturnValue(null); // Search content not found + mockFindSearchMatch.mockResolvedValue(null); // Search content not found await expect( searchReplaceToolImpl( @@ -542,12 +554,12 @@ keep this too`; // First search succeeds, second fails mockFindSearchMatch - .mockReturnValueOnce({ + .mockResolvedValueOnce({ startIndex: 0, endIndex: 13, strategyName: "exactMatch", }) - .mockReturnValueOnce(null); // Second search fails + .mockResolvedValueOnce(null); // Second search fails await expect( searchReplaceToolImpl( @@ -582,7 +594,7 @@ keep this too`; ).rejects.toThrow("Failed to apply search and replace: File read error"); }); - it("should handle applyToFile errors", async () => { + it("should handle apply failures", async () => { mockResolveRelativePathInDir.mockResolvedValue("/resolved/path/test.txt"); mockParseAllSearchReplaceBlocks.mockReturnValue([ { @@ -592,7 +604,7 @@ keep this too`; }, ]); mockIdeMessenger.ide.readFile.mockResolvedValue("content"); - mockFindSearchMatch.mockReturnValue({ + mockFindSearchMatch.mockResolvedValue({ startIndex: 0, endIndex: 7, strategyName: "exactMatch", @@ -623,7 +635,7 @@ keep this too`; }, ]); mockIdeMessenger.ide.readFile.mockResolvedValue(originalContent); - mockFindSearchMatch.mockReturnValue({ + mockFindSearchMatch.mockResolvedValue({ startIndex: 0, // Empty search matches at beginning endIndex: 0, strategyName: "exactMatch", @@ -658,7 +670,7 @@ keep this too`; }, ]); mockIdeMessenger.ide.readFile.mockResolvedValue(originalContent); - mockFindSearchMatch.mockReturnValue({ + mockFindSearchMatch.mockResolvedValue({ startIndex: 0, endIndex: originalContent.length, strategyName: "exactMatch", @@ -694,7 +706,7 @@ keep this too`; }, ]); mockIdeMessenger.ide.readFile.mockResolvedValue(originalContent); - mockFindSearchMatch.mockReturnValue({ + mockFindSearchMatch.mockResolvedValue({ startIndex: 0, endIndex: 4, strategyName: "exactMatch", @@ -718,7 +730,16 @@ keep this too`; expect(mockIdeMessenger.ide.readFile).toHaveBeenCalledWith( "/resolved/path/test.txt", ); - expect(mockFindSearchMatch).toHaveBeenCalledWith(originalContent, "test"); + expect(mockFindSearchMatch).toHaveBeenCalledWith( + originalContent, + "test", + expect.objectContaining({ + dispatch: expect.any(Function), + getState: expect.any(Function), + ideMessenger: mockIdeMessenger, + selectSelectedChatModel: expect.any(Function), + }), + ); expect(mockIdeMessenger.request).toHaveBeenCalledWith("applyToFile", { text: "updated content", streamId: "test-stream-id", diff --git a/gui/src/util/clientTools/searchReplaceImpl.ts b/gui/src/util/clientTools/searchReplaceImpl.ts index 1fcf649e5d..550393301c 100644 --- a/gui/src/util/clientTools/searchReplaceImpl.ts +++ b/gui/src/util/clientTools/searchReplaceImpl.ts @@ -4,6 +4,7 @@ import { resolveRelativePathInDir } from "core/util/ideUtils"; import posthog from "posthog-js"; import { v4 as uuid } from "uuid"; import { ClientToolImpl } from "./callClientTool"; +import { selectSelectedChatModel } from "../../redux/slices/configSlice"; export const searchReplaceToolImpl: ClientToolImpl = async ( args, @@ -42,6 +43,7 @@ export const searchReplaceToolImpl: ClientToolImpl = async ( throw new Error("No complete search/replace blocks found in any diffs"); } + let strategyName = ""; try { // Read the current file content const originalContent = @@ -54,16 +56,12 @@ export const searchReplaceToolImpl: ClientToolImpl = async ( const { searchContent, replaceContent } = block; // Find the search content in the current state of the file - const match = findSearchMatch(currentContent, searchContent || ""); - - // Because we don't have access to use hooks, we check `allowAnonymousTelemetry` - // directly rather than using `CustomPostHogProvider` - if (allowAnonymousTelemetry) { - // Capture telemetry for tool calls - posthog.capture("find_replace_match_result", { - matchStrategy: match?.strategyName ?? "noMatch", - }); - } + // Pass extras with selectSelectedChatModel function for AI matcher + const match = await findSearchMatch(currentContent, searchContent || "", { + ...extras, + selectSelectedChatModel, + }); + strategyName = match?.strategyName ?? "noMatch"; if (!match) { throw new Error( @@ -96,8 +94,21 @@ export const searchReplaceToolImpl: ClientToolImpl = async ( output: undefined, }; } catch (error) { + // Because we don't have access to use hooks, we check `allowAnonymousTelemetry` + // directly rather than using `CustomPostHogProvider` + if (allowAnonymousTelemetry) { + posthog.capture("find_replace_match_result:error", { + matchStrategy: strategyName, + error: error instanceof Error ? error.message : String(error), + }); + } throw new Error( `Failed to apply search and replace: ${error instanceof Error ? error.message : String(error)}`, ); } + if (allowAnonymousTelemetry) { + posthog.capture("find_replace_match_result", { + matchStrategy: strategyName, + }); + } };