Skip to content
Closed
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
70 changes: 70 additions & 0 deletions packages/core/src/tools/edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
21 changes: 16 additions & 5 deletions packages/core/src/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<EditToolParams, ToolResult> {
Expand Down Expand Up @@ -140,6 +142,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
let occurrences = 0;
let encoding = 'utf-8';
let bom = false;
let usesCRLF = false;
let error:
| { display: string; raw: string; type: ToolErrorType }
| undefined = undefined;
Expand All @@ -148,8 +151,10 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
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;
Expand Down Expand Up @@ -249,6 +254,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
isNewFile,
encoding,
bom,
usesCRLF,
};
}

Expand Down Expand Up @@ -385,18 +391,23 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {

// 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,
});
Expand Down