Skip to content

Commit be9b4d1

Browse files
ranqnHona
andauthored
fix(opencode): preserve original line endings in 'edit' tool (anomalyco#9443)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
1 parent 5b5b791 commit be9b4d1

2 files changed

Lines changed: 198 additions & 1 deletion

File tree

packages/opencode/src/tool/edit.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ function normalizeLineEndings(text: string): string {
2424
return text.replaceAll("\r\n", "\n")
2525
}
2626

27+
function detectLineEnding(text: string): "\n" | "\r\n" {
28+
return text.includes("\r\n") ? "\r\n" : "\n"
29+
}
30+
31+
function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
32+
if (ending === "\n") return text
33+
return text.replaceAll("\n", "\r\n")
34+
}
35+
2736
export const EditTool = Tool.define("edit", {
2837
description: DESCRIPTION,
2938
parameters: z.object({
@@ -78,7 +87,12 @@ export const EditTool = Tool.define("edit", {
7887
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
7988
await FileTime.assert(ctx.sessionID, filePath)
8089
contentOld = await Filesystem.readText(filePath)
81-
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
90+
91+
const ending = detectLineEnding(contentOld)
92+
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
93+
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
94+
95+
contentNew = replace(contentOld, old, next, params.replaceAll)
8296

8397
diff = trimDiff(
8498
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),

packages/opencode/test/tool/edit.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,189 @@ describe("tool.edit", () => {
451451
})
452452
})
453453

454+
describe("line endings", () => {
455+
const old = "alpha\nbeta\ngamma"
456+
const next = "alpha\nbeta-updated\ngamma"
457+
const alt = "alpha\nbeta\nomega"
458+
459+
const normalize = (text: string, ending: "\n" | "\r\n") => {
460+
const normalized = text.replaceAll("\r\n", "\n")
461+
if (ending === "\n") return normalized
462+
return normalized.replaceAll("\n", "\r\n")
463+
}
464+
465+
const count = (content: string) => {
466+
const crlf = content.match(/\r\n/g)?.length ?? 0
467+
const lf = content.match(/\n/g)?.length ?? 0
468+
return {
469+
crlf,
470+
lf: lf - crlf,
471+
}
472+
}
473+
474+
const expectLf = (content: string) => {
475+
const counts = count(content)
476+
expect(counts.crlf).toBe(0)
477+
expect(counts.lf).toBeGreaterThan(0)
478+
}
479+
480+
const expectCrlf = (content: string) => {
481+
const counts = count(content)
482+
expect(counts.lf).toBe(0)
483+
expect(counts.crlf).toBeGreaterThan(0)
484+
}
485+
486+
type Input = {
487+
content: string
488+
oldString: string
489+
newString: string
490+
replaceAll?: boolean
491+
}
492+
493+
const apply = async (input: Input) => {
494+
await using tmp = await tmpdir({
495+
init: async (dir) => {
496+
await Bun.write(path.join(dir, "test.txt"), input.content)
497+
},
498+
})
499+
500+
return await Instance.provide({
501+
directory: tmp.path,
502+
fn: async () => {
503+
const edit = await EditTool.init()
504+
const filePath = path.join(tmp.path, "test.txt")
505+
FileTime.read(ctx.sessionID, filePath)
506+
await edit.execute(
507+
{
508+
filePath,
509+
oldString: input.oldString,
510+
newString: input.newString,
511+
replaceAll: input.replaceAll,
512+
},
513+
ctx,
514+
)
515+
return await Bun.file(filePath).text()
516+
},
517+
})
518+
}
519+
520+
test("preserves LF with LF multi-line strings", async () => {
521+
const content = normalize(old + "\n", "\n")
522+
const output = await apply({
523+
content,
524+
oldString: normalize(old, "\n"),
525+
newString: normalize(next, "\n"),
526+
})
527+
expect(output).toBe(normalize(next + "\n", "\n"))
528+
expectLf(output)
529+
})
530+
531+
test("preserves CRLF with CRLF multi-line strings", async () => {
532+
const content = normalize(old + "\n", "\r\n")
533+
const output = await apply({
534+
content,
535+
oldString: normalize(old, "\r\n"),
536+
newString: normalize(next, "\r\n"),
537+
})
538+
expect(output).toBe(normalize(next + "\n", "\r\n"))
539+
expectCrlf(output)
540+
})
541+
542+
test("preserves LF when old/new use CRLF", async () => {
543+
const content = normalize(old + "\n", "\n")
544+
const output = await apply({
545+
content,
546+
oldString: normalize(old, "\r\n"),
547+
newString: normalize(next, "\r\n"),
548+
})
549+
expect(output).toBe(normalize(next + "\n", "\n"))
550+
expectLf(output)
551+
})
552+
553+
test("preserves CRLF when old/new use LF", async () => {
554+
const content = normalize(old + "\n", "\r\n")
555+
const output = await apply({
556+
content,
557+
oldString: normalize(old, "\n"),
558+
newString: normalize(next, "\n"),
559+
})
560+
expect(output).toBe(normalize(next + "\n", "\r\n"))
561+
expectCrlf(output)
562+
})
563+
564+
test("preserves LF when newString uses CRLF", async () => {
565+
const content = normalize(old + "\n", "\n")
566+
const output = await apply({
567+
content,
568+
oldString: normalize(old, "\n"),
569+
newString: normalize(next, "\r\n"),
570+
})
571+
expect(output).toBe(normalize(next + "\n", "\n"))
572+
expectLf(output)
573+
})
574+
575+
test("preserves CRLF when newString uses LF", async () => {
576+
const content = normalize(old + "\n", "\r\n")
577+
const output = await apply({
578+
content,
579+
oldString: normalize(old, "\r\n"),
580+
newString: normalize(next, "\n"),
581+
})
582+
expect(output).toBe(normalize(next + "\n", "\r\n"))
583+
expectCrlf(output)
584+
})
585+
586+
test("preserves LF with mixed old/new line endings", async () => {
587+
const content = normalize(old + "\n", "\n")
588+
const output = await apply({
589+
content,
590+
oldString: "alpha\nbeta\r\ngamma",
591+
newString: "alpha\r\nbeta\nomega",
592+
})
593+
expect(output).toBe(normalize(alt + "\n", "\n"))
594+
expectLf(output)
595+
})
596+
597+
test("preserves CRLF with mixed old/new line endings", async () => {
598+
const content = normalize(old + "\n", "\r\n")
599+
const output = await apply({
600+
content,
601+
oldString: "alpha\r\nbeta\ngamma",
602+
newString: "alpha\nbeta\r\nomega",
603+
})
604+
expect(output).toBe(normalize(alt + "\n", "\r\n"))
605+
expectCrlf(output)
606+
})
607+
608+
test("replaceAll preserves LF for multi-line blocks", async () => {
609+
const blockOld = "alpha\nbeta"
610+
const blockNew = "alpha\nbeta-updated"
611+
const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
612+
const output = await apply({
613+
content,
614+
oldString: normalize(blockOld, "\n"),
615+
newString: normalize(blockNew, "\n"),
616+
replaceAll: true,
617+
})
618+
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
619+
expectLf(output)
620+
})
621+
622+
test("replaceAll preserves CRLF for multi-line blocks", async () => {
623+
const blockOld = "alpha\nbeta"
624+
const blockNew = "alpha\nbeta-updated"
625+
const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
626+
const output = await apply({
627+
content,
628+
oldString: normalize(blockOld, "\r\n"),
629+
newString: normalize(blockNew, "\r\n"),
630+
replaceAll: true,
631+
})
632+
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
633+
expectCrlf(output)
634+
})
635+
})
636+
454637
describe("concurrent editing", () => {
455638
test("serializes concurrent edits to same file", async () => {
456639
await using tmp = await tmpdir()

0 commit comments

Comments
 (0)