diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index b87ad555286..c297815eab7 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -317,7 +317,10 @@ export namespace Patch { throw new Error(`Failed to read file ${filePath}: ${error}`) } - let originalLines = originalContent.split("\n") + const eol = originalContent.includes("\r\n") ? "\r\n" : "\n" + + const normalized = originalContent.replaceAll("\r\n", "\n") + let originalLines = normalized.split("\n") // Drop trailing empty element for consistent line counting if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { @@ -332,7 +335,7 @@ export namespace Patch { newLines.push("") } - const newContent = newLines.join("\n") + const newContent = newLines.join(eol) // Generate unified diff const unifiedDiff = generateUnifiedDiff(originalContent, newContent) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index c23c0dd3d0a..78497a7233c 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -8,7 +8,7 @@ import { Instance } from "../project/instance" import { Patch } from "../patch" import { createTwoFilesPatch, diffLines } from "diff" import { assertExternalDirectory } from "./external-directory" -import { trimDiff } from "./edit" +import { trimDiff, detectLineEnding, normalizeLineEndings, convertToLineEnding } from "./edit" import { LSP } from "../lsp" import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./apply_patch.txt" @@ -108,6 +108,11 @@ export const ApplyPatchTool = Tool.define("apply_patch", { throw new Error(`apply_patch verification failed: ${error}`) } + const hasBOM = oldContent.charCodeAt(0) === 0xfeff + if (hasBOM && newContent.charCodeAt(0) !== 0xfeff) { + newContent = "\uFEFF" + newContent + } + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) let additions = 0 diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 554d547d051..49804ad66e4 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -21,15 +21,15 @@ import { assertExternalDirectory } from "./external-directory" const MAX_DIAGNOSTICS_PER_FILE = 20 -function normalizeLineEndings(text: string): string { +export function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") } -function detectLineEnding(text: string): "\n" | "\r\n" { +export function detectLineEnding(text: string): "\n" | "\r\n" { return text.includes("\r\n") ? "\r\n" : "\n" } -function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string { +export function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string { if (ending === "\n") return text return text.replaceAll("\n", "\r\n") } diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 6b134e5253d..c6dd7f6a6c3 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -11,7 +11,7 @@ import { Format } from "../format" import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" -import { trimDiff } from "./edit" +import { trimDiff, detectLineEnding, normalizeLineEndings, convertToLineEnding } from "./edit" import { assertExternalDirectory } from "./external-directory" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -31,7 +31,18 @@ export const WriteTool = Tool.define("write", { const contentOld = exists ? await Filesystem.readText(filepath) : "" if (exists) await FileTime.assert(ctx.sessionID, filepath) - const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) + let contentToWrite = params.content + if (exists && contentOld.length > 0) { + const ending = detectLineEnding(contentOld) + contentToWrite = convertToLineEnding(normalizeLineEndings(contentToWrite), ending) + + const hasBOM = contentOld.charCodeAt(0) === 0xFEFF + if (hasBOM && contentToWrite.charCodeAt(0) !== 0xFEFF) { + contentToWrite = "\uFEFF" + contentToWrite + } + } + + const diff = trimDiff(createTwoFilesPatch(filepath, filepath, normalizeLineEndings(contentOld), normalizeLineEndings(contentToWrite))) await ctx.ask({ permission: "edit", patterns: [path.relative(Instance.worktree, filepath)], @@ -42,7 +53,7 @@ export const WriteTool = Tool.define("write", { }, }) - await Filesystem.write(filepath, params.content) + await Filesystem.write(filepath, contentToWrite) await Format.file(filepath) Bus.publish(File.Event.Edited, { file: filepath }) await Bus.publish(FileWatcher.Event.Updated, { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 97939c10519..a25e9b99dbb 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -297,6 +297,146 @@ describe("tool.write", () => { }) }) + describe("line ending and BOM preservation", () => { + test("preserves CRLF line endings when overwriting existing file", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "crlf-existing.txt") + await fs.writeFile(filepath, "old line 1\r\nold line 2\r\nold line 3", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { FileTime } = await import("../../src/file/time") + await FileTime.read(ctx.sessionID, filepath) + + const write = await WriteTool.init() + await write.execute( + { + filePath: filepath, + content: "new line 1\nnew line 2\nnew line 3", + }, + ctx, + ) + + const buf = await fs.readFile(filepath) + const written = buf.toString() + expect(written).toBe("new line 1\r\nnew line 2\r\nnew line 3") + expect(written).toContain("\r\n") + expect(written).not.toMatch(/[^\r]\n/) + }, + }) + }) + + test("preserves UTF-8 BOM when overwriting existing file", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "bom-existing.txt") + await fs.writeFile(filepath, "\uFEFFold content with BOM", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { FileTime } = await import("../../src/file/time") + await FileTime.read(ctx.sessionID, filepath) + + const write = await WriteTool.init() + await write.execute( + { + filePath: filepath, + content: "new content without BOM", + }, + ctx, + ) + + const buf = await fs.readFile(filepath) + const written = buf.toString() + expect(written.charCodeAt(0)).toBe(0xFEFF) + expect(written).toBe("\uFEFFnew content without BOM") + }, + }) + }) + + test("preserves both CRLF and BOM when overwriting existing file", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "bom-crlf-existing.txt") + await fs.writeFile(filepath, "\uFEFFold line 1\r\nold line 2\r\n", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { FileTime } = await import("../../src/file/time") + await FileTime.read(ctx.sessionID, filepath) + + const write = await WriteTool.init() + await write.execute( + { + filePath: filepath, + content: "new line 1\nnew line 2\n", + }, + ctx, + ) + + const buf = await fs.readFile(filepath) + const written = buf.toString() + expect(written.charCodeAt(0)).toBe(0xFEFF) + expect(written).toBe("\uFEFFnew line 1\r\nnew line 2\r\n") + }, + }) + }) + + test("does not add BOM or CRLF to new files", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "brand-new.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const write = await WriteTool.init() + await write.execute( + { + filePath: filepath, + content: "line 1\nline 2\nline 3", + }, + ctx, + ) + + const buf = await fs.readFile(filepath) + const written = buf.toString() + expect(written.charCodeAt(0)).not.toBe(0xFEFF) + expect(written).toBe("line 1\nline 2\nline 3") + expect(written).not.toContain("\r\n") + }, + }) + }) + + test("does not modify overwrite of empty file", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "empty-existing.txt") + await fs.writeFile(filepath, "", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { FileTime } = await import("../../src/file/time") + await FileTime.read(ctx.sessionID, filepath) + + const write = await WriteTool.init() + await write.execute( + { + filePath: filepath, + content: "line 1\nline 2", + }, + ctx, + ) + + const buf = await fs.readFile(filepath) + const written = buf.toString() + expect(written).toBe("line 1\nline 2") + expect(written.charCodeAt(0)).not.toBe(0xFEFF) + }, + }) + }) + }) + describe("error handling", () => { test("throws error when OS denies write access", async () => { await using tmp = await tmpdir()