diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 8b55e28a93..a1ad81e2c6 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -706,6 +706,76 @@ describe('EditTool', () => { // Ensure the file was not actually changed expect(fs.readFileSync(filePath, 'utf8')).toBe(initialContent); }); + + it('should preserve CRLF line endings when editing a CRLF file', async () => { + const crlfContent = 'line 1\r\nline 2\r\nline 3\r\n'; + fs.writeFileSync(filePath, crlfContent, 'utf8'); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'line 2', + new_string: 'modified line 2', + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeUndefined(); + const writtenContent = fs.readFileSync(filePath, 'utf8'); + // CRLF should be preserved in the output + expect(writtenContent).toBe( + 'line 1\r\nmodified line 2\r\nline 3\r\n', + ); + expect(writtenContent).toContain('\r\n'); + }); + + it('should not inject CRLF when editing a LF file', async () => { + const lfContent = 'line 1\nline 2\nline 3\n'; + fs.writeFileSync(filePath, lfContent, 'utf8'); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'line 2', + new_string: 'modified line 2', + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeUndefined(); + const writtenContent = fs.readFileSync(filePath, 'utf8'); + expect(writtenContent).toBe('line 1\nmodified line 2\nline 3\n'); + expect(writtenContent).not.toContain('\r\n'); + }); + + it('should preserve UTF-8 BOM when editing a BOM file', async () => { + const bomContent = '\uFEFFline 1\nline 2\n'; + // Write raw bytes with BOM + const bomBuffer = Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from('line 1\nline 2\n', 'utf8'), + ]); + fs.writeFileSync(filePath, bomBuffer); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'line 1', + new_string: 'modified line 1', + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeUndefined(); + const rawBytes = fs.readFileSync(filePath); + // BOM should be preserved + expect(rawBytes[0]).toBe(0xef); + expect(rawBytes[1]).toBe(0xbb); + expect(rawBytes[2]).toBe(0xbf); + // Content after BOM should contain the edit + const content = rawBytes.subarray(3).toString('utf8'); + expect(content).toContain('modified line 1'); + }); }); describe('Error Scenarios', () => { diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 61a3181902..4a7afdc14c 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -34,7 +34,7 @@ import type { ModifyContext, } from './modifiable-tool.js'; import { IdeClient } from '../ide/ide-client.js'; -import { safeLiteralReplace } from '../utils/textUtils.js'; +import { normalizeContent, safeLiteralReplace } from '../utils/textUtils.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { countOccurrences, @@ -112,6 +112,8 @@ interface CalculatedEdit { encoding: string; /** Whether the existing file has a UTF-8 BOM */ bom: boolean; + /** Whether the existing file uses CRLF line endings */ + usesCRLF: boolean; } class EditToolInvocation implements ToolInvocation { @@ -140,6 +142,7 @@ class EditToolInvocation implements ToolInvocation { let occurrences = 0; let encoding = 'utf-8'; let bom = false; + let usesCRLF = false; let error: | { display: string; raw: string; type: ToolErrorType } | undefined = undefined; @@ -148,8 +151,10 @@ class EditToolInvocation implements ToolInvocation { const fileInfo = await this.config .getFileSystemService() .readTextFileWithInfo(params.file_path); - // Normalize line endings to LF for consistent processing. - currentContent = fileInfo.content.replace(/\r\n/g, '\n'); + // Detect CRLF before normalizing, so we can restore it on write-back. + usesCRLF = fileInfo.content.includes('\r\n'); + // Normalize line endings and strip BOM for consistent processing. + currentContent = normalizeContent(fileInfo.content); fileExists = true; // Encoding and BOM are returned from the same I/O pass, avoiding redundant reads. encoding = fileInfo.encoding; @@ -249,6 +254,7 @@ class EditToolInvocation implements ToolInvocation { isNewFile, encoding, bom, + usesCRLF, }; } @@ -385,18 +391,23 @@ class EditToolInvocation implements ToolInvocation { // For new files, apply default file encoding setting // For existing files, preserve the original encoding (BOM and charset) + // Restore CRLF line endings if the original file used them. + const contentToWrite = editData.usesCRLF + ? editData.newContent.replace(/\n/g, '\r\n') + : editData.newContent; + if (editData.isNewFile) { const useBOM = this.config.getDefaultFileEncoding() === FileEncoding.UTF8_BOM; await this.config .getFileSystemService() - .writeTextFile(this.params.file_path, editData.newContent, { + .writeTextFile(this.params.file_path, contentToWrite, { bom: useBOM, }); } else { await this.config .getFileSystemService() - .writeTextFile(this.params.file_path, editData.newContent, { + .writeTextFile(this.params.file_path, contentToWrite, { bom: editData.bom, encoding: editData.encoding, });