diff --git a/CHANGELOG.md b/CHANGELOG.md index db3cd6d86f..54a6da760e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [v4.206.0] - 2025-12-29 + +### Added + +- Enhanced file edit tool with substring replacer for inline edit file tool calls +- Improved task handling with better file edit integration +- Enhanced ripgrep service for better file search capabilities + +### Changed + +- Updated file edit tool implementation for more robust inline editing +- Improved task processing with enhanced file edit capabilities +- Enhanced package version management + +--- + ## [v4.205.0] - 2025-12-26 ### Added diff --git a/src/core/tools/fileEditTool.ts b/src/core/tools/fileEditTool.ts index 0bba05b09e..ccdb59e021 100644 --- a/src/core/tools/fileEditTool.ts +++ b/src/core/tools/fileEditTool.ts @@ -247,7 +247,14 @@ function performReplacement( } if (!foundMatch) { - throw new Error("old_string not found in file content.") + // Provide helpful debug info + const preview = oldString.length > 100 ? oldString.slice(0, 100) + "..." : oldString + const contentPreview = content.length > 200 ? content.slice(0, 200) + "..." : content + throw new Error( + `old_string not found in file content.\n` + + `Searched for (${oldString.length} chars): ${JSON.stringify(preview)}\n` + + `File starts with: ${JSON.stringify(contentPreview)}`, + ) } if (sawAmbiguousMatch && !replaceAll) { @@ -513,6 +520,150 @@ function* escapeNormalizedReplacer(content: string, find: string): Generator { + // Find the first quote character (", ', or `) that starts string content + const quoteMatch = find.match(/["'`]/) + if (!quoteMatch || quoteMatch.index === undefined) { + // No quotes found, try simple full escape as fallback + const withEscapedNewlines = find.replace(/\n/g, "\\n") + if (withEscapedNewlines !== find && content.includes(withEscapedNewlines)) { + yield withEscapedNewlines + } + return + } + + const quoteIndex = quoteMatch.index + const quoteChar = quoteMatch[0] + + // Split into structural part (before quote) and content part (from quote onwards) + const structuralPart = find.substring(0, quoteIndex + 1) // Include the opening quote + const contentPart = find.substring(quoteIndex + 1) + + // Create a regex to match the detected quote character + const quoteRegex = new RegExp(escapeRegExp(quoteChar), "g") + const escapedQuote = "\\" + quoteChar + + // Escape special characters in the content part for string literals + // Order matters: escape backslashes first, then other characters + const escapeForStringLiteral = (str: string): string => { + return str + .replace(/\\/g, "\\\\") // Backslashes first + .replace(/\n/g, "\\n") // Newlines + .replace(/\t/g, "\\t") // Tabs + .replace(/\r/g, "\\r") // Carriage returns + .replace(quoteRegex, escapedQuote) // Escape the detected quote type + } + + // Try full escape (all special chars) + const fullyEscaped = escapeForStringLiteral(contentPart) + if (fullyEscaped !== contentPart) { + const hybrid = structuralPart + fullyEscaped + if (content.includes(hybrid)) { + yield hybrid + } + } + + // Try escaping just newlines and quotes (most common case for string literals) + const escapedNewlinesAndQuotes = contentPart.replace(/\n/g, "\\n").replace(quoteRegex, escapedQuote) + if (escapedNewlinesAndQuotes !== contentPart && escapedNewlinesAndQuotes !== fullyEscaped) { + const hybrid = structuralPart + escapedNewlinesAndQuotes + if (content.includes(hybrid)) { + yield hybrid + } + } + + // Try escaping just newlines (simpler case) + const escapedNewlinesOnly = contentPart.replace(/\n/g, "\\n") + if ( + escapedNewlinesOnly !== contentPart && + escapedNewlinesOnly !== fullyEscaped && + escapedNewlinesOnly !== escapedNewlinesAndQuotes + ) { + const hybrid = structuralPart + escapedNewlinesOnly + if (content.includes(hybrid)) { + yield hybrid + } + } +} + +/** + * Flexible substring replacer that handles cases where old_string is a true substring + * of the content (e.g., missing trailing characters like quotes or commas). + * This normalizes line endings and tries multiple matching strategies. + */ +function* flexibleSubstringReplacer(content: string, find: string): Generator { + if (find.length === 0) return + + // Normalize line endings for comparison + const normalizeLineEndings = (str: string) => str.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + + /** + * Maps a position in normalized content back to the original content position. + * Accounts for CRLF sequences that were normalized to LF. + * Accepts optional start parameters to resume scanning from a known position. + */ + const mapNormalizedIndexToOriginal = ( + normalizedTargetPos: number, + startOriginalIndex = 0, + startNormalizedPos = 0, + ): number => { + let originalIndex = startOriginalIndex + let normalizedPos = startNormalizedPos + while (normalizedPos < normalizedTargetPos && originalIndex < content.length) { + if (content[originalIndex] === "\r" && content[originalIndex + 1] === "\n") { + originalIndex += 2 + normalizedPos += 1 + } else { + originalIndex += 1 + normalizedPos += 1 + } + } + return originalIndex + } + + const normalizedContent = normalizeLineEndings(content) + const normalizedFind = normalizeLineEndings(find) + + // Direct substring match with normalized line endings + if (normalizedContent.includes(normalizedFind)) { + const normalizedIndex = normalizedContent.indexOf(normalizedFind) + if (normalizedIndex !== -1) { + const originalStart = mapNormalizedIndexToOriginal(normalizedIndex) + // Resume scanning from originalStart to avoid redundant iteration + const originalEnd = mapNormalizedIndexToOriginal( + normalizedIndex + normalizedFind.length, + originalStart, + normalizedIndex, + ) + yield content.substring(originalStart, originalEnd) + } + } + + // Try with trimmed find (handles trailing/leading whitespace differences) + const trimmedFind = normalizedFind.trim() + if (trimmedFind !== normalizedFind && trimmedFind.length > 0) { + const trimmedIndex = normalizedContent.indexOf(trimmedFind) + if (trimmedIndex !== -1) { + const originalStart = mapNormalizedIndexToOriginal(trimmedIndex) + // Resume scanning from originalStart to avoid redundant iteration + const originalEnd = mapNormalizedIndexToOriginal( + trimmedIndex + trimmedFind.length, + originalStart, + trimmedIndex, + ) + yield content.substring(originalStart, originalEnd) + } + } +} + function* multiOccurrenceReplacer(content: string, find: string): Generator { if (find.length === 0) return let startIndex = 0 @@ -635,6 +786,8 @@ const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3 const REPLACERS: Replacer[] = [ simpleReplacer, + sourceCodeEscapeReplacer, + flexibleSubstringReplacer, lineTrimmedReplacer, blockAnchorReplacer, whitespaceNormalizedReplacer, diff --git a/src/package.json b/src/package.json index cc6d6ae047..c2cb17e71c 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "matterai", - "version": "4.205.0", + "version": "4.206.0", "icon": "assets/icons/matterai-ic.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/src/services/ripgrep/index.ts b/src/services/ripgrep/index.ts index d384b27c91..7e0d3eb58c 100644 --- a/src/services/ripgrep/index.ts +++ b/src/services/ripgrep/index.ts @@ -15,10 +15,10 @@ Key components: 2. execRipgrep: Executes the ripgrep command and returns the output. 3. regexSearchFiles: The main function that performs regex searches on files. - Parameters: - * cwd: The current working directory (for relative path calculation) - * directoryPath: The directory to search in - * regex: The regular expression to search for (Rust regex syntax) - * filePattern: Optional glob pattern to filter files (default: '*') + * cwd: The current working directory (for relative path calculation) + * directoryPath: The directory to search in + * regex: The regular expression to search for (Rust regex syntax) + * filePattern: Optional glob pattern to filter files (default: '*') - Returns: A formatted string containing search results with context The search results include: @@ -227,6 +227,8 @@ function formatResults(fileResults: SearchFileResult[], cwd: string): string { let output = "" if (totalResults >= MAX_RESULTS) { output += `Showing first ${MAX_RESULTS} of ${MAX_RESULTS}+ results. Use a more specific search if necessary.\n\n` + } else if (totalResults === 0) { + output += `Found ${totalResults.toLocaleString()} results.\n\nNOTE: If you need to search again, try different search terms or file patterns. Repeating the same search will yield the same results.` } else { output += `Found ${totalResults === 1 ? "1 result" : `${totalResults.toLocaleString()} results`}.\n\n` }