Skip to content
Closed
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
199 changes: 199 additions & 0 deletions src/core/diff/strategies/__tests__/multi-search-replace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2686,4 +2686,203 @@ function two() {
expect(result.error).toContain(":start_line:5 <-- Invalid location")
})
})

describe("Generalized line duplication heuristic", () => {
let strategy: MultiSearchReplaceDiffStrategy

beforeEach(() => {
strategy = new MultiSearchReplaceDiffStrategy()
})

it("should correctly append content and avoid duplicating a closing brace", async () => {
const originalContent = `function test() {
console.log("original");
}`

const diffContent = `<<<<<<< SEARCH
:start_line:2
-------
console.log("original");
=======
console.log("original");
}
function newFunction() {
console.log("appended");
}
>>>>>>> REPLACE`

const result = await strategy.applyDiff(originalContent, diffContent)

expect(result.success).toBe(true)
if (result.success)
expect(result.content).toBe(`function test() {
console.log("original");
}
function newFunction() {
console.log("appended");
}`)
})

it("should correctly append content and avoid duplicating a common line", async () => {
const originalContent = `Section A
Common Line to keep
Section B`

const diffContent = `<<<<<<< SEARCH
:start_line:1
-------
Section A
=======
Section A
Common Line to keep
New Appended Content
>>>>>>> REPLACE`

const result = await strategy.applyDiff(originalContent, diffContent)

expect(result.success).toBe(true)
if (result.success)
expect(result.content).toBe(`Section A
Common Line to keep
New Appended Content
Section B`)
})

it("should correctly append content and avoid duplicating multiple lines with nested braces", async () => {
const originalContent = `function devConfig() {
return {
name: "test"
};
}`

const diffContent = `<<<<<<< SEARCH
name: "test"
=======
name: "test"
};
}
function prodConfig() {
return {
name: "prod"
};
}
>>>>>>> REPLACE`

const result = await strategy.applyDiff(originalContent, diffContent)

expect(result.success).toBe(true)
if (result.success)
expect(result.content).toBe(`function devConfig() {
return {
name: "test"
};
}
function prodConfig() {
return {
name: "prod"
};
}`)
})

it("should not alter behavior when the line after search prefix in REPLACE differs from original line after SEARCH", async () => {
const originalContent = `Line 1
Line 2 (original next)
Line 3`

const diffContent = `<<<<<<< SEARCH
:start_line:1
-------
Line 1
=======
Line 1
Line X (different next in replace)
Appended Content
>>>>>>> REPLACE`

const result = await strategy.applyDiff(originalContent, diffContent)

expect(result.success).toBe(true)
if (result.success)
expect(result.content).toBe(`Line 1
Line X (different next in replace)
Appended Content
Line 2 (original next)
Line 3`)
})

it("should not trigger heuristic if SEARCH content is not a prefix of REPLACE content", async () => {
const originalContent = `Alpha
Bravo
Charlie`

const diffContent = `<<<<<<< SEARCH
:start_line:1
-------
Alpha
=======
Completely New Content
Even More New Content
Bravo
>>>>>>> REPLACE`

const result = await strategy.applyDiff(originalContent, diffContent)

expect(result.success).toBe(true)
if (result.success)
expect(result.content).toBe(`Completely New Content
Even More New Content
Bravo
Bravo
Charlie`)
})

it("should handle edge case where heuristic conditions are met but no line follows search in original", async () => {
const originalContent = `Last Line`

const diffContent = `<<<<<<< SEARCH
:start_line:1
-------
Last Line
=======
Last Line
New Line
Added Content
>>>>>>> REPLACE`

const result = await strategy.applyDiff(originalContent, diffContent)

expect(result.success).toBe(true)
if (result.success)
expect(result.content).toBe(`Last Line
New Line
Added Content`)
})

it("should handle case where similarity threshold is not met", async () => {
const originalContent = `Original Content
Next Line
Final Line`

const diffContent = `<<<<<<< SEARCH
:start_line:1
-------
Original Content
=======
Completely Different Content
Next Line
Appended Content
>>>>>>> REPLACE`

const result = await strategy.applyDiff(originalContent, diffContent)

expect(result.success).toBe(true)
// Should behave as normal replacement since similarity < 0.95
if (result.success)
expect(result.content).toBe(`Completely Different Content
Next Line
Appended Content
Next Line
Final Line`)
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { SearchReplaceContext } from "../multi-search-replace"

export class SuperfluousDuplicatedLineEngine {
/**
* Processes a search/replace context to detect superfluous duplicated lines.
*
* @param originalContent The complete original content being modified
* @param context The search/replace context containing startLine, searchContent, and replaceContent
* @returns The number of additional lines from the original content that should be consumed
* to prevent duplication. Returns 0 if no additional lines should be consumed.
*/
public static process(originalContent: string, context: SearchReplaceContext): number {
const { startLine, searchContent, replaceContent } = context

// Early detection of superfluous duplicated line pattern
// Check if replace content has more lines than search content
const searchLines = searchContent.split(/\r?\n/)
const replaceLines = replaceContent.split(/\r?\n/)
const originalLines = originalContent.split(/\r?\n/)

if (searchLines.length > 0 && replaceLines.length > searchLines.length && startLine > 0) {
// Check if the first part of replace content is similar to search content
const firstPartOfReplace = replaceLines.slice(0, searchLines.length).join("\n")

// Simple similarity check (we can make this more sophisticated later)
const isFirstPartSimilar = firstPartOfReplace === searchContent

if (isFirstPartSimilar && replaceLines.length > searchLines.length) {
// Check for any number of consecutive matching lines after the search block
let matchingLinesCount = 0
const searchEndIndex = startLine - 1 + searchLines.length
const maxPossibleMatches = Math.min(
replaceLines.length - searchLines.length, // Available lines in replace content
originalLines.length - searchEndIndex, // Available lines in original content after search
)

for (let i = 0; i < maxPossibleMatches; i++) {
const replaceLineIndex = searchLines.length + i
const originalLineIndex = searchEndIndex + i

const lineInReplace = replaceLines[replaceLineIndex]
const lineInOriginal = originalLines[originalLineIndex]

// Check if lines match (trimmed comparison to handle whitespace differences)
if (lineInReplace && lineInOriginal && lineInReplace.trim() === lineInOriginal.trim()) {
matchingLinesCount++
} else {
// Stop at the first non-matching line
break
}
}

// If we found any matching lines, return the count
if (matchingLinesCount > 0) {
return matchingLinesCount
}
}
}

// No additional lines to consume
return 0
}
}
32 changes: 29 additions & 3 deletions src/core/diff/strategies/multi-search-replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { ToolProgressStatus } from "@roo-code/types"
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
import { ToolUse, DiffStrategy, DiffResult } from "../../../shared/tools"
import { normalizeString } from "../../../utils/text-normalization"
import { SuperfluousDuplicatedLineEngine } from "./engines/superfluous-duplicated-line.engine"

export interface SearchReplaceContext {
startLine: number
searchContent: string
replaceContent: string
}

const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches

Expand Down Expand Up @@ -394,7 +401,7 @@ Only use a single line of '=======' between search and replacement content, beca
let delta = 0
let diffResults: DiffResult[] = []
let appliedCount = 0
const replacements = matches
const replacements: SearchReplaceContext[] = matches
.map((match) => ({
startLine: Number(match[2] ?? 0),
searchContent: match[6],
Expand Down Expand Up @@ -548,6 +555,19 @@ Only use a single line of '=======' between search and replacement content, beca
}
}

// --- Start: Replacement Engine Processing ---
// Now that we found the match, call the engine with the actual match location
const engineContext = {
startLine: matchIndex + 1, // Convert back to 1-based index for the engine
searchContent: replacement.searchContent,
replaceContent: replacement.replaceContent,
}
const additionalLinesToConsume = SuperfluousDuplicatedLineEngine.process(
resultLines.join("\n"),
engineContext,
)
// --- End: Replacement Engine Processing ---

// Get the matched lines from the original content
const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length)

Expand Down Expand Up @@ -588,11 +608,17 @@ Only use a single line of '=======' between search and replacement content, beca
return finalIndent + line.trim()
})

// Initialize effectiveSearchLinesCount (determines how many lines from original are considered "replaced")
let effectiveSearchLinesCount = searchLines.length + additionalLinesToConsume

// Construct the final content
const beforeMatch = resultLines.slice(0, matchIndex)
const afterMatch = resultLines.slice(matchIndex + searchLines.length)
// Use effectiveSearchLinesCount here to determine the slice point
const afterMatch = resultLines.slice(matchIndex + effectiveSearchLinesCount)

resultLines = [...beforeMatch, ...indentedReplaceLines, ...afterMatch]
delta = delta - matchedLines.length + replaceLines.length
// Use effectiveSearchLinesCount for delta calculation
delta = delta - effectiveSearchLinesCount + replaceLines.length
appliedCount++
}
const finalContent = resultLines.join(lineEnding)
Expand Down