Skip to content
Open
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
80 changes: 76 additions & 4 deletions src/filesystem/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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', () => {
Expand Down
5 changes: 4 additions & 1 deletion src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading