diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index f7e585af22..13572c4c40 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -555,15 +555,15 @@ describe('Lib Functions', () => { it('handles CRLF line endings in file content', async () => { mockFs.readFile.mockResolvedValue('line1\r\nline2\r\nline3\r\n'); - + const edits = [ { oldText: 'line2', newText: 'modified line2' } ]; - + mockFs.rename.mockResolvedValueOnce(undefined); - + await applyFileEdits('/test/file.txt', edits, false); - + expect(mockFs.writeFile).toHaveBeenCalledWith( expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), 'line1\nmodified line2\nline3\n', @@ -574,6 +574,78 @@ describe('Lib Functions', () => { '/test/file.txt' ); }); + + it('treats $$ in newText as literal $$ not a single $', async () => { + mockFs.readFile.mockResolvedValue('price: PLACEHOLDER\n'); + + const edits = [ + { oldText: 'PLACEHOLDER', newText: '$$100 USD' } + ]; + + mockFs.rename.mockResolvedValueOnce(undefined); + + await applyFileEdits('/test/file.txt', edits, false); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), + 'price: $$100 USD\n', + 'utf-8' + ); + }); + + it('treats $& in newText as literal $& not the matched substring', async () => { + mockFs.readFile.mockResolvedValue('value: OLD\n'); + + const edits = [ + { oldText: 'OLD', newText: '$&NEW' } + ]; + + mockFs.rename.mockResolvedValueOnce(undefined); + + await applyFileEdits('/test/file.txt', edits, false); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), + 'value: $&NEW\n', + 'utf-8' + ); + }); + + it('treats $` in newText as literal $` not the preceding substring', async () => { + mockFs.readFile.mockResolvedValue('hello PLACEHOLDER world\n'); + + const edits = [ + { oldText: 'PLACEHOLDER', newText: '$`literal' } + ]; + + mockFs.rename.mockResolvedValueOnce(undefined); + + await applyFileEdits('/test/file.txt', edits, false); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), + 'hello $`literal world\n', + 'utf-8' + ); + }); + + it("treats $' in newText as literal $' not the following substring", async () => { + mockFs.readFile.mockResolvedValue('hello PLACEHOLDER world\n'); + + const edits = [ + { oldText: 'PLACEHOLDER', newText: "$'literal" } + ]; + + mockFs.rename.mockResolvedValueOnce(undefined); + + await applyFileEdits('/test/file.txt', edits, false); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/), + "hello $'literal world\n", + 'utf-8' + ); + }); }); describe('tailFile', () => { diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 17e4654cd5..7389522521 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -207,7 +207,10 @@ export async function applyFileEdits( // If exact match exists, use it if (modifiedContent.includes(normalizedOld)) { - modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew); + // Use a function as replacement to prevent special replacement patterns + // (e.g. $&, $`, $', $$) in normalizedNew from being interpreted by + // String.prototype.replace — the callback return value is always literal. + modifiedContent = modifiedContent.replace(normalizedOld, () => normalizedNew); continue; }