wip
This commit is contained in:
parent
2ed18ea1fe
commit
ab78a46396
3 changed files with 222 additions and 40 deletions
|
|
@ -177,8 +177,18 @@ export namespace Patch {
|
||||||
return { content, nextIdx: i }
|
return { content, nextIdx: i }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripHeredoc(input: string): string {
|
||||||
|
// Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
|
||||||
|
const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
|
||||||
|
if (heredocMatch) {
|
||||||
|
return heredocMatch[2]
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
export function parsePatch(patchText: string): { hunks: Hunk[] } {
|
export function parsePatch(patchText: string): { hunks: Hunk[] } {
|
||||||
const lines = patchText.split("\n")
|
const cleaned = stripHeredoc(patchText.trim())
|
||||||
|
const lines = cleaned.split("\n")
|
||||||
const hunks: Hunk[] = []
|
const hunks: Hunk[] = []
|
||||||
let i = 0
|
let i = 0
|
||||||
|
|
||||||
|
|
@ -363,7 +373,7 @@ export namespace Patch {
|
||||||
// Try to match old lines in the file
|
// Try to match old lines in the file
|
||||||
let pattern = chunk.old_lines
|
let pattern = chunk.old_lines
|
||||||
let newSlice = chunk.new_lines
|
let newSlice = chunk.new_lines
|
||||||
let found = seekSequence(originalLines, pattern, lineIndex)
|
let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
|
||||||
|
|
||||||
// Retry without trailing empty line if not found
|
// Retry without trailing empty line if not found
|
||||||
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
|
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
|
||||||
|
|
@ -371,7 +381,7 @@ export namespace Patch {
|
||||||
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
|
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
|
||||||
newSlice = newSlice.slice(0, -1)
|
newSlice = newSlice.slice(0, -1)
|
||||||
}
|
}
|
||||||
found = seekSequence(originalLines, pattern, lineIndex)
|
found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (found !== -1) {
|
if (found !== -1) {
|
||||||
|
|
@ -407,28 +417,75 @@ export namespace Patch {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekSequence(lines: string[], pattern: string[], startIndex: number): number {
|
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
|
||||||
if (pattern.length === 0) return -1
|
function normalizeUnicode(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
|
||||||
|
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
|
||||||
|
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
|
||||||
|
.replace(/\u2026/g, "...") // ellipsis
|
||||||
|
.replace(/\u00A0/g, " ") // non-breaking space
|
||||||
|
}
|
||||||
|
|
||||||
// Simple substring search implementation
|
type Comparator = (a: string, b: string) => boolean
|
||||||
|
|
||||||
|
function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
|
||||||
|
// If EOF anchor, try matching from end of file first
|
||||||
|
if (eof) {
|
||||||
|
const fromEnd = lines.length - pattern.length
|
||||||
|
if (fromEnd >= startIndex) {
|
||||||
|
let matches = true
|
||||||
|
for (let j = 0; j < pattern.length; j++) {
|
||||||
|
if (!compare(lines[fromEnd + j], pattern[j])) {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches) return fromEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward search from startIndex
|
||||||
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
|
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
|
||||||
let matches = true
|
let matches = true
|
||||||
|
|
||||||
for (let j = 0; j < pattern.length; j++) {
|
for (let j = 0; j < pattern.length; j++) {
|
||||||
if (lines[i + j] !== pattern[j]) {
|
if (!compare(lines[i + j], pattern[j])) {
|
||||||
matches = false
|
matches = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (matches) return i
|
||||||
if (matches) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
|
||||||
|
if (pattern.length === 0) return -1
|
||||||
|
|
||||||
|
// Pass 1: exact match
|
||||||
|
const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
|
||||||
|
if (exact !== -1) return exact
|
||||||
|
|
||||||
|
// Pass 2: rstrip (trim trailing whitespace)
|
||||||
|
const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
|
||||||
|
if (rstrip !== -1) return rstrip
|
||||||
|
|
||||||
|
// Pass 3: trim (both ends)
|
||||||
|
const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
|
||||||
|
if (trim !== -1) return trim
|
||||||
|
|
||||||
|
// Pass 4: normalized (Unicode punctuation to ASCII)
|
||||||
|
const normalized = tryMatch(
|
||||||
|
lines,
|
||||||
|
pattern,
|
||||||
|
startIndex,
|
||||||
|
(a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
|
||||||
|
eof,
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
function generateUnifiedDiff(oldContent: string, newContent: string): string {
|
function generateUnifiedDiff(oldContent: string, newContent: string): string {
|
||||||
const oldLines = oldContent.split("\n")
|
const oldLines = oldContent.split("\n")
|
||||||
const newLines = newContent.split("\n")
|
const newLines = newContent.split("\n")
|
||||||
|
|
|
||||||
|
|
@ -55,23 +55,22 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||||
await assertExternalDirectory(ctx, filePath)
|
await assertExternalDirectory(ctx, filePath)
|
||||||
|
|
||||||
switch (hunk.type) {
|
switch (hunk.type) {
|
||||||
case "add":
|
case "add": {
|
||||||
if (hunk.type === "add") {
|
const oldContent = ""
|
||||||
const oldContent = ""
|
const newContent =
|
||||||
const newContent =
|
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
|
||||||
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
|
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
||||||
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
|
||||||
|
|
||||||
fileChanges.push({
|
fileChanges.push({
|
||||||
filePath,
|
filePath,
|
||||||
oldContent,
|
oldContent,
|
||||||
newContent,
|
newContent,
|
||||||
type: "add",
|
type: "add",
|
||||||
})
|
})
|
||||||
|
|
||||||
totalDiff += diff + "\n"
|
totalDiff += diff + "\n"
|
||||||
}
|
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "update":
|
case "update":
|
||||||
// Check if file exists for update
|
// Check if file exists for update
|
||||||
|
|
@ -145,11 +144,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||||
for (const change of fileChanges) {
|
for (const change of fileChanges) {
|
||||||
switch (change.type) {
|
switch (change.type) {
|
||||||
case "add":
|
case "add":
|
||||||
// Create parent directories
|
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||||
const addDir = path.dirname(change.filePath)
|
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
|
||||||
if (addDir !== "." && addDir !== "/") {
|
|
||||||
await fs.mkdir(addDir, { recursive: true })
|
|
||||||
}
|
|
||||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||||
changedFiles.push(change.filePath)
|
changedFiles.push(change.filePath)
|
||||||
break
|
break
|
||||||
|
|
@ -161,14 +157,9 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||||
|
|
||||||
case "move":
|
case "move":
|
||||||
if (change.movePath) {
|
if (change.movePath) {
|
||||||
// Create parent directories for destination
|
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||||
const moveDir = path.dirname(change.movePath)
|
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
|
||||||
if (moveDir !== "." && moveDir !== "/") {
|
|
||||||
await fs.mkdir(moveDir, { recursive: true })
|
|
||||||
}
|
|
||||||
// Write to new location
|
|
||||||
await fs.writeFile(change.movePath, change.newContent, "utf-8")
|
await fs.writeFile(change.movePath, change.newContent, "utf-8")
|
||||||
// Remove original
|
|
||||||
await fs.unlink(change.filePath)
|
await fs.unlink(change.filePath)
|
||||||
changedFiles.push(change.movePath)
|
changedFiles.push(change.movePath)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -378,4 +378,138 @@ describe("tool.apply_patch freeform", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("EOF anchor matches from end of file first", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "eof_anchor.txt")
|
||||||
|
// File has duplicate "marker" lines - one in middle, one at end
|
||||||
|
await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
// With EOF anchor, should match the LAST "marker" line, not the first
|
||||||
|
const patchText =
|
||||||
|
"*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
// First marker unchanged, second marker changed
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses heredoc-wrapped patch", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const patchText = `cat <<'EOF'
|
||||||
|
*** Begin Patch
|
||||||
|
*** Add File: heredoc_test.txt
|
||||||
|
+heredoc content
|
||||||
|
*** End Patch
|
||||||
|
EOF`
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8")
|
||||||
|
expect(content).toBe("heredoc content\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses heredoc-wrapped patch without cat", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const patchText = `<<EOF
|
||||||
|
*** Begin Patch
|
||||||
|
*** Add File: heredoc_no_cat.txt
|
||||||
|
+no cat prefix
|
||||||
|
*** End Patch
|
||||||
|
EOF`
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8")
|
||||||
|
expect(content).toBe("no cat prefix\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches with trailing whitespace differences", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "trailing_ws.txt")
|
||||||
|
// File has trailing spaces on some lines
|
||||||
|
await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
// Patch doesn't have trailing spaces - should still match via rstrip pass
|
||||||
|
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches with leading whitespace differences", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "leading_ws.txt")
|
||||||
|
// File has leading spaces
|
||||||
|
await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
// Patch without leading spaces - should match via trim pass
|
||||||
|
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches with Unicode punctuation differences", async () => {
|
||||||
|
await using fixture = await tmpdir()
|
||||||
|
const { ctx } = makeCtx()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: fixture.path,
|
||||||
|
fn: async () => {
|
||||||
|
const target = path.join(fixture.path, "unicode.txt")
|
||||||
|
// File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
|
||||||
|
const leftQuote = "\u201C"
|
||||||
|
const rightQuote = "\u201D"
|
||||||
|
const emDash = "\u2014"
|
||||||
|
await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
|
||||||
|
FileTime.read(ctx.sessionID, target)
|
||||||
|
|
||||||
|
// Patch uses ASCII equivalents - should match via normalized pass
|
||||||
|
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
|
||||||
|
const patchText =
|
||||||
|
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
|
||||||
|
|
||||||
|
await execute({ patchText }, ctx)
|
||||||
|
// Result has ASCII quotes because that's what the patch specifies
|
||||||
|
expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue