fix(opencode): preserve original line endings in 'edit' tool (#9443)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
Quan Ran 2026-03-07 16:42:54 +09:00 committed by GitHub
parent 5b5b791d75
commit be9b4d1bcd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 198 additions and 1 deletions

View File

@ -24,6 +24,15 @@ function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
}
function detectLineEnding(text: string): "\n" | "\r\n" {
return text.includes("\r\n") ? "\r\n" : "\n"
}
function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
if (ending === "\n") return text
return text.replaceAll("\n", "\r\n")
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
@ -78,7 +87,12 @@ export const EditTool = Tool.define("edit", {
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),

View File

@ -451,6 +451,189 @@ describe("tool.edit", () => {
})
})
describe("line endings", () => {
const old = "alpha\nbeta\ngamma"
const next = "alpha\nbeta-updated\ngamma"
const alt = "alpha\nbeta\nomega"
const normalize = (text: string, ending: "\n" | "\r\n") => {
const normalized = text.replaceAll("\r\n", "\n")
if (ending === "\n") return normalized
return normalized.replaceAll("\n", "\r\n")
}
const count = (content: string) => {
const crlf = content.match(/\r\n/g)?.length ?? 0
const lf = content.match(/\n/g)?.length ?? 0
return {
crlf,
lf: lf - crlf,
}
}
const expectLf = (content: string) => {
const counts = count(content)
expect(counts.crlf).toBe(0)
expect(counts.lf).toBeGreaterThan(0)
}
const expectCrlf = (content: string) => {
const counts = count(content)
expect(counts.lf).toBe(0)
expect(counts.crlf).toBeGreaterThan(0)
}
type Input = {
content: string
oldString: string
newString: string
replaceAll?: boolean
}
const apply = async (input: Input) => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), input.content)
},
})
return await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
const filePath = path.join(tmp.path, "test.txt")
FileTime.read(ctx.sessionID, filePath)
await edit.execute(
{
filePath,
oldString: input.oldString,
newString: input.newString,
replaceAll: input.replaceAll,
},
ctx,
)
return await Bun.file(filePath).text()
},
})
}
test("preserves LF with LF multi-line strings", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF with CRLF multi-line strings", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF when old/new use CRLF", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF when old/new use LF", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF when newString uses CRLF", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF when newString uses LF", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF with mixed old/new line endings", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: "alpha\nbeta\r\ngamma",
newString: "alpha\r\nbeta\nomega",
})
expect(output).toBe(normalize(alt + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF with mixed old/new line endings", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: "alpha\r\nbeta\ngamma",
newString: "alpha\nbeta\r\nomega",
})
expect(output).toBe(normalize(alt + "\n", "\r\n"))
expectCrlf(output)
})
test("replaceAll preserves LF for multi-line blocks", async () => {
const blockOld = "alpha\nbeta"
const blockNew = "alpha\nbeta-updated"
const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
const output = await apply({
content,
oldString: normalize(blockOld, "\n"),
newString: normalize(blockNew, "\n"),
replaceAll: true,
})
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
expectLf(output)
})
test("replaceAll preserves CRLF for multi-line blocks", async () => {
const blockOld = "alpha\nbeta"
const blockNew = "alpha\nbeta-updated"
const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(blockOld, "\r\n"),
newString: normalize(blockNew, "\r\n"),
replaceAll: true,
})
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
expectCrlf(output)
})
})
describe("concurrent editing", () => {
test("serializes concurrent edits to same file", async () => {
await using tmp = await tmpdir()