diff --git a/packages/core/src/codewhispererChat/tools/fsWrite.ts b/packages/core/src/codewhispererChat/tools/fsWrite.ts index fc1a32123dc..06242e6e399 100644 --- a/packages/core/src/codewhispererChat/tools/fsWrite.ts +++ b/packages/core/src/codewhispererChat/tools/fsWrite.ts @@ -85,15 +85,55 @@ export class FsWrite { } private static async handleStrReplace(command: StrReplaceCommand, sanitizedPath: string): Promise { - // TODO + const fileContent = await fs.readFileText(sanitizedPath) + + const matches = [...fileContent.matchAll(new RegExp(this.escapeRegExp(command.oldStr), 'g'))] + + if (matches.length === 0) { + throw new Error(`No occurrences of "${command.oldStr}" were found`) + } + if (matches.length > 1) { + throw new Error(`${matches.length} occurrences of oldStr were found when only 1 is expected`) + } + + const newContent = fileContent.replace(command.oldStr, command.newStr) + await fs.writeFile(sanitizedPath, newContent) + + void vscode.window.showInformationMessage(`Updated: ${sanitizedPath}`) } private static async handleInsert(command: InsertCommand, sanitizedPath: string): Promise { - // TODO + const fileContent = await fs.readFileText(sanitizedPath) + const lines = fileContent.split('\n') + + const numLines = lines.length + const insertLine = Math.max(0, Math.min(command.insertLine, numLines)) + + let newContent: string + if (insertLine === 0) { + newContent = command.newStr + '\n' + fileContent + } else { + newContent = [...lines.slice(0, insertLine), command.newStr, ...lines.slice(insertLine)].join('\n') + } + + await fs.writeFile(sanitizedPath, newContent) + + void vscode.window.showInformationMessage(`Updated: ${sanitizedPath}`) } private static async handleAppend(command: AppendCommand, sanitizedPath: string): Promise { - // TODO + const fileContent = await fs.readFileText(sanitizedPath) + const needsNewline = fileContent.length !== 0 && !fileContent.endsWith('\n') + + let contentToAppend = command.newStr + if (needsNewline) { + contentToAppend = '\n' + contentToAppend + } + + const newContent = fileContent + contentToAppend + await fs.writeFile(sanitizedPath, newContent) + + void vscode.window.showInformationMessage(`Updated: ${sanitizedPath}`) } private static getCreateCommandText(command: CreateCommand): string { @@ -107,4 +147,8 @@ export class FsWrite { this.logger.warn('No content provided for the create command') return '' } + + private static escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } } diff --git a/packages/core/src/test/codewhispererChat/tools/fsWrite.test.ts b/packages/core/src/test/codewhispererChat/tools/fsWrite.test.ts index ad04bb61fa9..64f1142476a 100644 --- a/packages/core/src/test/codewhispererChat/tools/fsWrite.test.ts +++ b/packages/core/src/test/codewhispererChat/tools/fsWrite.test.ts @@ -2,7 +2,13 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ -import { CreateCommand, FsWrite } from '../../../codewhispererChat/tools/fsWrite' +import { + AppendCommand, + CreateCommand, + FsWrite, + InsertCommand, + StrReplaceCommand, +} from '../../../codewhispererChat/tools/fsWrite' import { TestFolder } from '../../testUtil' import path from 'path' import assert from 'assert' @@ -18,11 +24,11 @@ describe('FsWrite Tool', function () { }, } - before(async function () { - testFolder = await TestFolder.create() - }) + describe('handleCreate', function () { + before(async function () { + testFolder = await TestFolder.create() + }) - describe('create', function () { it('creates a new file with fileText content', async function () { const filePath = path.join(testFolder.path, 'file1.txt') const fileExists = await fs.existsFile(filePath) @@ -90,4 +96,302 @@ describe('FsWrite Tool', function () { assert.deepStrictEqual(output, expectedOutput) }) }) + + describe('handleStrReplace', async function () { + before(async function () { + testFolder = await TestFolder.create() + }) + + it('replaces a single occurrence of a string', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + await fs.writeFile(filePath, 'Hello World') + + const command: StrReplaceCommand = { + command: 'str_replace', + path: filePath, + oldStr: 'Hello', + newStr: 'Goodbye', + } + const output = await FsWrite.invoke(command) + + const content = await fs.readFileText(filePath) + assert.strictEqual(content, 'Goodbye World') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('throws error when no matches are found', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + + const command: StrReplaceCommand = { + command: 'str_replace', + path: filePath, + oldStr: 'Invalid', + newStr: 'Goodbye', + } + + await assert.rejects(() => FsWrite.invoke(command), /No occurrences of "Invalid" were found/) + }) + + it('throws error when multiple matches are found', async function () { + const filePath = path.join(testFolder.path, 'file2.txt') + await fs.writeFile(filePath, 'Hello Hello World') + + const command: StrReplaceCommand = { + command: 'str_replace', + path: filePath, + oldStr: 'Hello', + newStr: 'Goodbye', + } + + await assert.rejects( + () => FsWrite.invoke(command), + /2 occurrences of oldStr were found when only 1 is expected/ + ) + }) + + it('handles regular expression special characters correctly', async function () { + const filePath = path.join(testFolder.path, 'file3.txt') + await fs.writeFile(filePath, 'Text with special chars: .*+?^${}()|[]\\') + + const command: StrReplaceCommand = { + command: 'str_replace', + path: filePath, + oldStr: '.*+?^${}()|[]\\', + newStr: 'REPLACED', + } + const output = await FsWrite.invoke(command) + + const content = await fs.readFileText(filePath) + assert.strictEqual(content, 'Text with special chars: REPLACED') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('preserves whitespace and newlines during replacement', async function () { + const filePath = path.join(testFolder.path, 'file4.txt') + await fs.writeFile(filePath, 'Line 1\n Indented line\nLine 3') + + const command: StrReplaceCommand = { + command: 'str_replace', + path: filePath, + oldStr: ' Indented line\n', + newStr: ' Double indented\n', + } + const output = await FsWrite.invoke(command) + + const content = await fs.readFileText(filePath) + assert.strictEqual(content, 'Line 1\n Double indented\nLine 3') + + assert.deepStrictEqual(output, expectedOutput) + }) + }) + + describe('handleInsert', function () { + before(async function () { + testFolder = await TestFolder.create() + }) + + it('inserts text after the specified line number', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + await fs.writeFile(filePath, 'Line 1\nLine 2\nLine 3\nLine 4') + + const command: InsertCommand = { + command: 'insert', + path: filePath, + insertLine: 2, + newStr: 'New Line', + } + const output = await FsWrite.invoke(command) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1\nLine 2\nNew Line\nLine 3\nLine 4') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('inserts text at the beginning when line number is 0', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + const command: InsertCommand = { + command: 'insert', + path: filePath, + insertLine: 0, + newStr: 'New First Line', + } + const output = await FsWrite.invoke(command) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'New First Line\nLine 1\nLine 2\nNew Line\nLine 3\nLine 4') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('inserts text at the end when line number exceeds file length', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + const command: InsertCommand = { + command: 'insert', + path: filePath, + insertLine: 10, + newStr: 'New Last Line', + } + const output = await FsWrite.invoke(command) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'New First Line\nLine 1\nLine 2\nNew Line\nLine 3\nLine 4\nNew Last Line') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('handles insertion into an empty file', async function () { + const filePath = path.join(testFolder.path, 'file2.txt') + await fs.writeFile(filePath, '') + + const command: InsertCommand = { + command: 'insert', + path: filePath, + insertLine: 0, + newStr: 'First Line', + } + const output = await FsWrite.invoke(command) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'First Line\n') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('handles negative line numbers by inserting at the beginning', async function () { + const filePath = path.join(testFolder.path, 'file2.txt') + + const command: InsertCommand = { + command: 'insert', + path: filePath, + insertLine: -1, + newStr: 'New First Line', + } + const output = await FsWrite.invoke(command) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'New First Line\nFirst Line\n') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('throws error when file does not exist', async function () { + const filePath = path.join(testFolder.path, 'nonexistent.txt') + + const command: InsertCommand = { + command: 'insert', + path: filePath, + insertLine: 1, + newStr: 'New Line', + } + + await assert.rejects(() => FsWrite.invoke(command), /no such file or directory/) + }) + }) + + describe('handleAppend', function () { + before(async function () { + testFolder = await TestFolder.create() + }) + + it('appends text to the end of a file', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + await fs.writeFile(filePath, 'Line 1\nLine 2\nLine 3\n') + + const command: AppendCommand = { + command: 'append', + path: filePath, + newStr: 'Line 4', + } + + const output = await FsWrite.invoke(command) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1\nLine 2\nLine 3\nLine 4') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('adds a newline before appending if file does not end with one', async function () { + const filePath = path.join(testFolder.path, 'file2.txt') + await fs.writeFile(filePath, 'Line 1\nLine 2\nLine 3') + + const command: AppendCommand = { + command: 'append', + path: filePath, + newStr: 'Line 4', + } + + const output = await FsWrite.invoke(command) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1\nLine 2\nLine 3\nLine 4') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('appends to an empty file', async function () { + const filePath = path.join(testFolder.path, 'file3.txt') + await fs.writeFile(filePath, '') + + const command: AppendCommand = { + command: 'append', + path: filePath, + newStr: 'Line 1', + } + + const output = await FsWrite.invoke(command) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('appends multiple lines correctly', async function () { + const filePath = path.join(testFolder.path, 'file3.txt') + + const command: AppendCommand = { + command: 'append', + path: filePath, + newStr: 'Line 2\nLine 3', + } + const output = await FsWrite.invoke(command) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1\nLine 2\nLine 3') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('handles appending empty string', async function () { + const filePath = path.join(testFolder.path, 'file3.txt') + + const command: AppendCommand = { + command: 'append', + path: filePath, + newStr: '', + } + const output = await FsWrite.invoke(command) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1\nLine 2\nLine 3\n') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('throws error when file does not exist', async function () { + const filePath = path.join(testFolder.path, 'nonexistent.txt') + + const command: AppendCommand = { + command: 'append', + path: filePath, + newStr: 'New Line', + } + + await assert.rejects(() => FsWrite.invoke(command), /no such file or directory/) + }) + }) })