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
45 changes: 45 additions & 0 deletions src/core/diff/strategies/__tests__/multi-search-replace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,51 @@ describe("MultiSearchReplaceDiffStrategy", () => {
expect(strategy["validateMarkerSequencing"](diff).success).toBe(true)
})

it("validates correct marker sequence with extra > in SEARCH", () => {
const diff = "<<<<<<< SEARCH>\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff).success).toBe(true)
})

it("validates correct marker sequence with multiple > in SEARCH", () => {
const diff = "<<<<<<< SEARCH>>\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff).success).toBe(false)
})

it("validates mixed cases with and without extra > in the same diff", () => {
const diff =
"<<<<<<< SEARCH>\n" +
"content1\n" +
"=======\n" +
"new1\n" +
">>>>>>> REPLACE\n\n" +
"<<<<<<< SEARCH\n" +
"content2\n" +
"=======\n" +
"new2\n" +
">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff).success).toBe(true)
})

it("validates extra > with whitespace variations", () => {
const diff1 = "<<<<<<< SEARCH> \n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff1).success).toBe(true)

const diff2 = "<<<<<<< SEARCH >\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff2).success).toBe(false)
})

it("validates extra > with line numbers", () => {
const diff =
"<<<<<<< SEARCH>\n" +
":start_line:10\n" +
"-------\n" +
"content1\n" +
"=======\n" +
"new1\n" +
">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff).success).toBe(true)
})

it("validates multiple correct marker sequences", () => {
const diff =
"<<<<<<< SEARCH\n" +
Expand Down
17 changes: 10 additions & 7 deletions src/core/diff/strategies/multi-file-search-replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,10 @@ Each file requires its own path, start_line, and diff elements.

const state = { current: State.START, line: 0 }

const SEARCH = "<<<<<<< SEARCH"
// Pattern allows optional '>' after SEARCH to handle AI-generated diffs
// (e.g., Sonnet 4 sometimes adds an extra '>')
const SEARCH_PATTERN = /^<<<<<<< SEARCH>?$/
const SEARCH = SEARCH_PATTERN.source.replace(/[\^$]/g, "") // Remove regex anchors for display
const SEP = "======="
const REPLACE = ">>>>>>> REPLACE"
const SEARCH_PREFIX = "<<<<<<< "
Expand Down Expand Up @@ -329,7 +332,7 @@ Each file requires its own path, start_line, and diff elements.
})

const lines = diffContent.split("\n")
const searchCount = lines.filter((l) => l.trim() === SEARCH).length
const searchCount = lines.filter((l) => SEARCH_PATTERN.test(l.trim())).length
const sepCount = lines.filter((l) => l.trim() === SEP).length
const replaceCount = lines.filter((l) => l.trim() === REPLACE).length

Expand Down Expand Up @@ -357,20 +360,20 @@ Each file requires its own path, start_line, and diff elements.
: reportMergeConflictError(SEP, SEARCH)
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEARCH)
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
if (marker === SEARCH) state.current = State.AFTER_SEARCH
if (SEARCH_PATTERN.test(marker)) state.current = State.AFTER_SEARCH
else if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
break

case State.AFTER_SEARCH:
if (marker === SEARCH) return reportInvalidDiffError(SEARCH, SEP)
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, SEP)
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEP)
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
if (marker === SEP) state.current = State.AFTER_SEPARATOR
break

case State.AFTER_SEPARATOR:
if (marker === SEARCH) return reportInvalidDiffError(SEARCH, REPLACE)
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, REPLACE)
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, REPLACE)
if (marker === SEP)
return likelyBadStructure
Expand Down Expand Up @@ -456,7 +459,7 @@ Each file requires its own path, start_line, and diff elements.

/* Regex parts:
1. (?:^|\n) Ensures the first marker starts at the beginning of the file or right after a newline.
2. (?<!\\)<<<<<<< SEARCH\s*\n Matches the line "<<<<<<< SEARCH" (ignoring any trailing spaces) – the negative lookbehind makes sure it isn't escaped.
2. (?<!\\)<<<<<<< SEARCH>?\s*\n Matches the line "<<<<<<< SEARCH" with optional '>' (ignoring any trailing spaces) – the negative lookbehind makes sure it isn't escaped.
3. ((?:\:start_line:\s*(\d+)\s*\n))? Optionally matches a ":start_line:" line. The outer capturing group is group 1 and the inner (\d+) is group 2.
4. ((?:\:end_line:\s*(\d+)\s*\n))? Optionally matches a ":end_line:" line. Group 3 is the whole match and group 4 is the digits.
5. ((?<!\\)-------\s*\n)? Optionally matches the "-------" marker line (group 5).
Expand All @@ -467,7 +470,7 @@ Each file requires its own path, start_line, and diff elements.
*/
let matches = [
...diffContent.matchAll(
/(?:^|\n)(?<!\\)<<<<<<< SEARCH\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>>>>>>> REPLACE)(?=\n|$)/g,
/(?:^|\n)(?<!\\)<<<<<<< SEARCH>?\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>>>>>>> REPLACE)(?=\n|$)/g,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detailed regex comment here should be updated to mention the optional > character. Currently it says:

2. (?<!\\)<<<<<<< SEARCH\s*\n Matches the line "<<<<<<< SEARCH" ...

Could we update it to:

2. (?<!\\)<<<<<<< SEARCH>?\s*\n Matches the line "<<<<<<< SEARCH" with optional '>' ...

),
]

Expand Down
15 changes: 9 additions & 6 deletions src/core/diff/strategies/multi-search-replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@ Only use a single line of '=======' between search and replacement content, beca
}
const state = { current: State.START, line: 0 }

const SEARCH = "<<<<<<< SEARCH"
// Pattern allows optional '>' after SEARCH to handle AI-generated diffs
// (e.g., Sonnet 4 sometimes adds an extra '>')
const SEARCH_PATTERN = /^<<<<<<< SEARCH>?$/
const SEARCH = SEARCH_PATTERN.source.replace(/[\^$]/g, "") // Remove regex anchors for display
const SEP = "======="
const REPLACE = ">>>>>>> REPLACE"
const SEARCH_PREFIX = "<<<<<<<"
Expand Down Expand Up @@ -268,7 +271,7 @@ Only use a single line of '=======' between search and replacement content, beca
})

const lines = diffContent.split("\n")
const searchCount = lines.filter((l) => l.trim() === SEARCH).length
const searchCount = lines.filter((l) => SEARCH_PATTERN.test(l.trim())).length
const sepCount = lines.filter((l) => l.trim() === SEP).length
const replaceCount = lines.filter((l) => l.trim() === REPLACE).length

Expand Down Expand Up @@ -296,20 +299,20 @@ Only use a single line of '=======' between search and replacement content, beca
: reportMergeConflictError(SEP, SEARCH)
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEARCH)
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
if (marker === SEARCH) state.current = State.AFTER_SEARCH
if (SEARCH_PATTERN.test(marker)) state.current = State.AFTER_SEARCH
else if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
break

case State.AFTER_SEARCH:
if (marker === SEARCH) return reportInvalidDiffError(SEARCH, SEP)
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, SEP)
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEP)
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
if (marker === SEP) state.current = State.AFTER_SEPARATOR
break

case State.AFTER_SEPARATOR:
if (marker === SEARCH) return reportInvalidDiffError(SEARCH, REPLACE)
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, REPLACE)
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, REPLACE)
if (marker === SEP)
return likelyBadStructure
Expand Down Expand Up @@ -378,7 +381,7 @@ Only use a single line of '=======' between search and replacement content, beca

let matches = [
...diffContent.matchAll(
/(?:^|\n)(?<!\\)<<<<<<< SEARCH\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>>>>>>> REPLACE)(?=\n|$)/g,
/(?:^|\n)(?<!\\)<<<<<<< SEARCH>?\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>>>>>>> REPLACE)(?=\n|$)/g,
),
]

Expand Down