Skip to content

Commit 87b163c

Browse files
committed
Fix(diff): Resolve multi-block search and indentation issues (#1559)
1 parent c228e63 commit 87b163c

File tree

1 file changed

+100
-63
lines changed

1 file changed

+100
-63
lines changed

src/core/diff/strategies/multi-search-replace.ts

Lines changed: 100 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,10 @@ Only use a single line of '=======' between search and replacement content, beca
369369

370370
for (const replacement of replacements) {
371371
let { searchContent, replaceContent } = replacement
372-
let startLine = replacement.startLine + (replacement.startLine === 0 ? 0 : delta)
372+
// Store original start line for search window calculation
373+
const originalStartLine = replacement.startLine
374+
// Calculate the adjusted start line for reporting/context, applying delta *after* finding the match
375+
let adjustedStartLine = originalStartLine + (originalStartLine === 0 ? 0 : delta)
373376

374377
// First unescape any escaped markers in the content
375378
searchContent = this.unescapeMarkers(searchContent)
@@ -411,7 +414,8 @@ Only use a single line of '=======' between search and replacement content, beca
411414
continue
412415
}
413416

414-
let endLine = replacement.startLine + searchLines.length - 1
417+
// Use original start line for end line calculation relative to the original file state
418+
let originalEndLine = originalStartLine + searchLines.length - 1
415419

416420
// Initialize search variables
417421
let matchIndex = -1
@@ -424,40 +428,59 @@ Only use a single line of '=======' between search and replacement content, beca
424428
let searchEndIndex = resultLines.length
425429

426430
// Validate and handle line range if provided
427-
if (startLine) {
428-
// Convert to 0-based index
429-
const exactStartIndex = startLine - 1
431+
if (originalStartLine) {
432+
// Convert original start line to 0-based index for the *current* resultLines
433+
const exactStartIndex = originalStartLine - 1
430434
const searchLen = searchLines.length
431435
const exactEndIndex = exactStartIndex + searchLen - 1
432436

433-
// Try exact match first
434-
const originalChunk = resultLines.slice(exactStartIndex, exactEndIndex + 1).join("\n")
435-
const similarity = getSimilarity(originalChunk, searchChunk)
436-
if (similarity >= this.fuzzyThreshold) {
437-
matchIndex = exactStartIndex
438-
bestMatchScore = similarity
439-
bestMatchContent = originalChunk
440-
} else {
441-
// Set bounds for buffered search
442-
searchStartIndex = Math.max(0, startLine - (this.bufferLines + 1))
443-
searchEndIndex = Math.min(resultLines.length, startLine + searchLines.length + this.bufferLines)
437+
// Check if the exact range is valid within the current resultLines
438+
if (exactStartIndex >= 0 && exactEndIndex < resultLines.length) {
439+
// Try exact match first using a slightly relaxed threshold (0.99)
440+
const originalChunk = resultLines.slice(exactStartIndex, exactEndIndex + 1).join("\n")
441+
const similarity = getSimilarity(originalChunk, searchChunk)
442+
// Use 1.0 threshold if fuzzyThreshold is 1.0, otherwise use 0.99 for initial check
443+
const initialCheckThreshold = this.fuzzyThreshold === 1.0 ? 1.0 : 0.99
444+
if (similarity >= initialCheckThreshold) {
445+
matchIndex = exactStartIndex
446+
bestMatchScore = similarity
447+
bestMatchContent = originalChunk
448+
}
449+
}
450+
451+
// If exact match failed or range was invalid, set bounds for buffered search
452+
// Use the *original* start line to calculate the search window in the *current* resultLines
453+
if (matchIndex === -1) {
454+
searchStartIndex = Math.max(0, originalStartLine - (this.bufferLines + 1))
455+
searchEndIndex = Math.min(
456+
resultLines.length,
457+
originalStartLine + searchLines.length + this.bufferLines,
458+
)
444459
}
445460
}
446461

447-
// If no match found yet, try middle-out search within bounds
462+
// Determine the effective fuzzy threshold for this block
463+
// Use strict 1.0 if fuzzyThreshold is 1.0, otherwise use the specified threshold
464+
const effectiveThreshold = this.fuzzyThreshold === 1.0 ? 1.0 : this.fuzzyThreshold
465+
466+
// If no exact match found yet, try middle-out fuzzy search within bounds
448467
if (matchIndex === -1) {
449468
const {
450469
bestScore,
451470
bestMatchIndex,
452471
bestMatchContent: midContent,
453472
} = fuzzySearch(resultLines, searchChunk, searchStartIndex, searchEndIndex)
454-
matchIndex = bestMatchIndex
455-
bestMatchScore = bestScore
456-
bestMatchContent = midContent
473+
474+
// Check against the effective threshold
475+
if (bestMatchIndex !== -1 && bestScore >= effectiveThreshold) {
476+
matchIndex = bestMatchIndex
477+
bestMatchScore = bestScore
478+
bestMatchContent = midContent
479+
}
457480
}
458481

459482
// Try aggressive line number stripping as a fallback if regular matching fails
460-
if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) {
483+
if (matchIndex === -1) {
461484
// Strip both search and replace content once (simultaneously)
462485
const aggressiveSearchContent = stripLineNumbers(searchContent, true)
463486
const aggressiveReplaceContent = stripLineNumbers(replaceContent, true)
@@ -471,7 +494,9 @@ Only use a single line of '=======' between search and replacement content, beca
471494
bestMatchIndex,
472495
bestMatchContent: aggContent,
473496
} = fuzzySearch(resultLines, aggressiveSearchChunk, searchStartIndex, searchEndIndex)
474-
if (bestMatchIndex !== -1 && bestScore >= this.fuzzyThreshold) {
497+
498+
// Check against the effective threshold
499+
if (bestMatchIndex !== -1 && bestScore >= effectiveThreshold) {
475500
matchIndex = bestMatchIndex
476501
bestMatchScore = bestScore
477502
bestMatchContent = aggContent
@@ -483,77 +508,89 @@ Only use a single line of '=======' between search and replacement content, beca
483508
} else {
484509
// No match found with either method
485510
const originalContentSection =
486-
startLine !== undefined && endLine !== undefined
487-
? `\n\nOriginal Content:\n${addLineNumbers(
511+
originalStartLine !== undefined && originalEndLine !== undefined
512+
? `\n\nOriginal Content (around line ${originalStartLine}):\n${addLineNumbers(
488513
resultLines
489514
.slice(
490-
Math.max(0, startLine - 1 - this.bufferLines),
491-
Math.min(resultLines.length, endLine + this.bufferLines),
515+
// Show context based on original line numbers, clamped to current bounds
516+
Math.max(0, originalStartLine - 1 - this.bufferLines),
517+
Math.min(resultLines.length, originalEndLine + this.bufferLines),
492518
)
493519
.join("\n"),
494-
Math.max(1, startLine - this.bufferLines),
520+
// Start numbering from the calculated start line
521+
Math.max(1, originalStartLine - this.bufferLines),
495522
)}`
496523
: `\n\nOriginal Content:\n${addLineNumbers(resultLines.join("\n"))}`
497524

498525
const bestMatchSection = bestMatchContent
499-
? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}`
500-
: `\n\nBest Match Found:\n(no match)`
526+
? `\n\nBest Fuzzy Match Found (Score: ${Math.floor(bestMatchScore * 100)}%):\n${addLineNumbers(bestMatchContent, matchIndex + 1)}`
527+
: `\n\nBest Fuzzy Match Found:\n(no match below threshold)`
501528

502-
const lineRange = startLine ? ` at line: ${startLine}` : ""
529+
const lineRange = originalStartLine ? ` near original line: ${originalStartLine}` : ""
530+
const thresholdInfo = `(${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(effectiveThreshold * 100)}%)`
503531

504532
diffResults.push({
505533
success: false,
506-
error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine ? `starting at line ${startLine}` : "start to end"}\n- Tried both standard and aggressive line number stripping\n- Tip: Use the read_file tool to get the latest content of the file before attempting to use the apply_diff tool again, as the file content may have changed\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`,
534+
error: `No sufficiently similar match found${lineRange} ${thresholdInfo}\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(effectiveThreshold * 100)}%\n- Search Range: Lines ${searchStartIndex + 1} to ${searchEndIndex}\n- Tried standard and aggressive line number stripping\n- Tip: Use read_file to verify the current file content, as it might have changed.\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`,
507535
})
508-
continue
536+
continue // Skip to the next replacement block
509537
}
510538
}
511539

512-
// Get the matched lines from the original content
540+
// --- Start: Robust Indentation Logic ---
513541
const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length)
514542

515-
// Get the exact indentation (preserving tabs/spaces) of each line
516-
const originalIndents = matchedLines.map((line) => {
517-
const match = line.match(/^[\t ]*/)
518-
return match ? match[0] : ""
519-
})
543+
// Calculate the indentation of the *first line being replaced* in the target file
544+
const targetBaseIndentMatch = matchedLines[0]?.match(/^[\t ]*/)
545+
const targetBaseIndent = targetBaseIndentMatch ? targetBaseIndentMatch[0] : ""
520546

521-
// Get the exact indentation of each line in the search block
522-
const searchIndents = searchLines.map((line) => {
523-
const match = line.match(/^[\t ]*/)
524-
return match ? match[0] : ""
525-
})
547+
// Calculate the indentation of the *first line* of the search block
548+
const searchBaseIndentMatch = searchLines[0]?.match(/^[\t ]*/)
549+
const searchBaseIndent = searchBaseIndentMatch ? searchBaseIndentMatch[0] : ""
526550

527-
// Apply the replacement while preserving exact indentation
528-
const indentedReplaceLines = replaceLines.map((line, i) => {
529-
// Get the matched line's exact indentation
530-
const matchedIndent = originalIndents[0] || ""
551+
// Determine the primary indentation character (tab or space) from targetBaseIndent
552+
const targetIndentChar = targetBaseIndent.startsWith("\t") ? "\t" : " "
531553

532-
// Get the current line's indentation relative to the search content
554+
// Calculate the indentation of the *first line* of the replacement block
555+
const replaceBaseIndentMatch = replaceLines[0]?.match(/^[\t ]*/)
556+
const replaceBaseIndent = replaceBaseIndentMatch ? replaceBaseIndentMatch[0] : ""
557+
558+
// Apply indentation to replacement lines based on difference from searchBaseIndent
559+
const indentedReplaceLines = replaceLines.map((line) => {
560+
// Get current line's indent
533561
const currentIndentMatch = line.match(/^[\t ]*/)
534562
const currentIndent = currentIndentMatch ? currentIndentMatch[0] : ""
535-
const searchBaseIndent = searchIndents[0] || ""
536-
537-
// Calculate the relative indentation level
538-
const searchBaseLevel = searchBaseIndent.length
539-
const currentLevel = currentIndent.length
540-
const relativeLevel = currentLevel - searchBaseLevel
541-
542-
// If relative level is negative, remove indentation from matched indent
543-
// If positive, add to matched indent
544-
const finalIndent =
545-
relativeLevel < 0
546-
? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel))
547-
: matchedIndent + currentIndent.slice(searchBaseLevel)
563+
let finalIndent = ""
564+
565+
// Calculate relative indentation based on the SEARCH block's base indent
566+
if (currentIndent.startsWith(searchBaseIndent)) {
567+
// Indented or same level relative to search base: Append the relative part to target base
568+
const relativePart = currentIndent.substring(searchBaseIndent.length)
569+
finalIndent = targetBaseIndent + relativePart
570+
} else if (searchBaseIndent.startsWith(currentIndent)) {
571+
// De-dented relative to search base: Remove the difference length from target base
572+
const diffLength = searchBaseIndent.length - currentIndent.length
573+
const finalLength = Math.max(0, targetBaseIndent.length - diffLength)
574+
finalIndent = targetBaseIndent.substring(0, finalLength)
575+
} else {
576+
// Unrelated indentation structure (e.g., mixed tabs/spaces):
577+
// Fallback: Use targetBaseIndent. This preserves the original file's
578+
// base level for the block but doesn't apply complex relative changes.
579+
finalIndent = targetBaseIndent
580+
}
548581

549-
return finalIndent + line.trim()
582+
// Combine the calculated final indent with the non-indented part of the line
583+
return finalIndent + line.trimStart()
550584
})
585+
// --- End: Robust Indentation Logic ---
551586

552587
// Construct the final content
553588
const beforeMatch = resultLines.slice(0, matchIndex)
554589
const afterMatch = resultLines.slice(matchIndex + searchLines.length)
555590
resultLines = [...beforeMatch, ...indentedReplaceLines, ...afterMatch]
556-
delta = delta - matchedLines.length + replaceLines.length
591+
592+
// Update delta based on the change in line count for *this specific block*
593+
delta = delta - searchLines.length + replaceLines.length
557594
appliedCount++
558595
}
559596
const finalContent = resultLines.join(lineEnding)

0 commit comments

Comments
 (0)