Skip to content
Open
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
7 changes: 5 additions & 2 deletions packages/opencode/src/patch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] === "") {
Expand All @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/tool/apply_patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
17 changes: 14 additions & 3 deletions packages/opencode/src/tool/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)],
Expand All @@ -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, {
Expand Down
140 changes: 140 additions & 0 deletions packages/opencode/test/tool/write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading