Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
155 changes: 154 additions & 1 deletion src/core/tools/fileEditTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -513,6 +520,150 @@ function* escapeNormalizedReplacer(content: string, find: string): Generator<str
}
}

/**
* Handles the case where old_string contains actual newlines/tabs/quotes but the file
* contains their escape sequence representations (e.g., source code with string literals).
* This is common when editing string literals in source files.
*
* Strategy: Find string quote positions and convert actual special characters
* within quoted portions to their escape sequence representations.
*/
function* sourceCodeEscapeReplacer(content: string, find: string): Generator<string, void, undefined> {
// 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<string, void, undefined> {
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<string, void, undefined> {
if (find.length === 0) return
let startIndex = 0
Expand Down Expand Up @@ -635,6 +786,8 @@ const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3

const REPLACERS: Replacer[] = [
simpleReplacer,
sourceCodeEscapeReplacer,
flexibleSubstringReplacer,
lineTrimmedReplacer,
blockAnchorReplacer,
whitespaceNormalizedReplacer,
Expand Down
2 changes: 1 addition & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions src/services/ripgrep/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`
}
Expand Down
Loading