Skip to content

fix(amazonq): normalize line endings to match local file endings in strReplace #1483

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 30, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { InvokeOutput } from './toolShared'
import { TestFeatures } from '@aws/language-server-runtimes/testing'
import { Workspace } from '@aws/language-server-runtimes/server-interface'
import { StubbedInstance } from 'ts-sinon'
import * as os from 'os'

describe('FsWrite Tool', function () {
let tempFolder: testFolder.TestFolder
Expand Down Expand Up @@ -392,4 +393,125 @@ describe('FsWrite Tool', function () {
await assert.rejects(() => fsWrite.invoke(params), /no such file or directory/)
})
})

describe('getStrReplaceContent', function () {
it('preserves CRLF line endings in file when oldStr uses LF', async () => {
const filePath = await tempFolder.write('test1.txt', 'before\r\nline 1\r\nline 2\r\nline 3\r\nafter')

const params: StrReplaceParams = {
command: 'strReplace',
path: filePath,
oldStr: 'line 1\nline 2\nline 3',
newStr: 'new line 1\nnew line 2\nnew line 3',
}

const fsWrite = new FsWrite(features)
await fsWrite.invoke(params)

const result = await features.workspace.fs.readFile(filePath)
assert.strictEqual(result, 'before\r\nnew line 1\r\nnew line 2\r\nnew line 3\r\nafter')
})

it('preserves LF line endings in file when oldStr uses CRLF', async () => {
const filePath = await tempFolder.write('test2.txt', 'before\nline 1\nline 2\nline 3\nafter')

const params: StrReplaceParams = {
command: 'strReplace',
path: filePath,
oldStr: 'line 1\r\nline 2\r\nline 3',
newStr: 'new line 1\r\nnew line 2\r\nnew line 3',
}

const fsWrite = new FsWrite(features)
await fsWrite.invoke(params)

const result = await features.workspace.fs.readFile(filePath)
assert.strictEqual(result, 'before\nnew line 1\nnew line 2\nnew line 3\nafter')
})

it('preserves CR line endings in file when oldStr uses LF', async () => {
const filePath = await tempFolder.write('test3.txt', 'before\rline 1\rline 2\rline 3\rafter')

const params: StrReplaceParams = {
command: 'strReplace',
path: filePath,
oldStr: 'line 1\nline 2\nline 3',
newStr: 'new line 1\nnew line 2\nnew line 3',
}

const fsWrite = new FsWrite(features)
await fsWrite.invoke(params)

const result = await features.workspace.fs.readFile(filePath)
assert.strictEqual(result, 'before\rnew line 1\rnew line 2\rnew line 3\rafter')
})

it('handles mixed line endings in newStr by normalizing to file line ending', async () => {
const filePath = await tempFolder.write('test4.txt', 'before\r\nline 1\r\nline 2\r\nafter')

const params: StrReplaceParams = {
command: 'strReplace',
path: filePath,
oldStr: 'line 1\nline 2',
newStr: 'new line 1\r\nnew line 2\nnew line 3\rend',
}

const fsWrite = new FsWrite(features)
await fsWrite.invoke(params)

const result = await features.workspace.fs.readFile(filePath)
assert.strictEqual(result, 'before\r\nnew line 1\r\nnew line 2\r\nnew line 3\r\nend\r\nafter')
})

it('handles content with no line endings', async () => {
const filePath = await tempFolder.write('test5.txt', 'before simple text after')

const params: StrReplaceParams = {
command: 'strReplace',
path: filePath,
oldStr: 'simple text',
newStr: 'replacement',
}

const fsWrite = new FsWrite(features)
await fsWrite.invoke(params)

const result = await features.workspace.fs.readFile(filePath)
assert.strictEqual(result, 'before replacement after')
})

it('uses OS default line ending when file has no line endings and adding new lines', async () => {
const filePath = await tempFolder.write('test6.txt', 'before text after')

const params: StrReplaceParams = {
command: 'strReplace',
path: filePath,
oldStr: 'text',
newStr: 'line 1\nline 2',
}

const fsWrite = new FsWrite(features)
await fsWrite.invoke(params)

const result = await features.workspace.fs.readFile(filePath)
assert.strictEqual(result, `before line 1${os.EOL}line 2 after`)
})

it('preserves line endings when only portion of line is replaced', async () => {
const filePath = await tempFolder.write('test8.txt', 'start\r\nprefix middle suffix\r\nend')

const params: StrReplaceParams = {
command: 'strReplace',
path: filePath,
oldStr: 'middle',
newStr: 'center',
}

const fsWrite = new FsWrite(features)
await fsWrite.invoke(params)

const result = await features.workspace.fs.readFile(filePath)
assert.strictEqual(result, 'start\r\nprefix center suffix\r\nend')
})
})
})
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { workspaceUtils } from '@aws/lsp-core'
import { CommandValidation, ExplanatoryParams, InvokeOutput, requiresPathAcceptance } from './toolShared'
import { Features } from '@aws/language-server-runtimes/server-interface/server'
import { sanitize } from '@aws/lsp-core/out/util/path'
import { Change, diffLines } from 'diff'
import { URI } from 'vscode-uri'
import { getWorkspaceFolderPaths } from '@aws/lsp-core/out/util/workspaceUtils'
import * as os from 'os'

// Port of https://github.com/aws/aws-toolkit-vscode/blob/16aa8768834f41ae512522473a6a962bb96abe51/packages/core/src/codewhispererChat/tools/fsWrite.ts#L42

Expand Down Expand Up @@ -259,7 +256,15 @@ const getInsertContent = (params: InsertParams, oldContent: string) => {
}

const getStrReplaceContent = (params: StrReplaceParams, oldContent: string) => {
const matches = [...oldContent.matchAll(new RegExp(escapeRegExp(params.oldStr), 'g'))]
// Detect line ending from oldContent (CRLF, LF, or CR)
const match = oldContent.match(/\r\n|\r|\n/)
const lineEnding = match ? match[0] : os.EOL

// Normalize oldStr and newStr to match oldContent's line ending style
const normalizedOldStr = params.oldStr.split(/\r\n|\r|\n/).join(lineEnding)
const normalizedNewStr = params.newStr.split(/\r\n|\r|\n/).join(lineEnding)

const matches = [...oldContent.matchAll(new RegExp(escapeRegExp(normalizedOldStr), 'g'))]

if (matches.length === 0) {
throw new Error(`No occurrences of "${params.oldStr}" were found`)
Expand All @@ -268,7 +273,7 @@ const getStrReplaceContent = (params: StrReplaceParams, oldContent: string) => {
throw new Error(`${matches.length} occurrences of oldStr were found when only 1 is expected`)
}

return oldContent.replace(params.oldStr, params.newStr)
return oldContent.replace(normalizedOldStr, normalizedNewStr)
}

const escapeRegExp = (string: string) => {
Expand Down
Loading