Fix dollar sign replacement bug in file editing (#7871)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
fuyou
2025-09-13 14:05:43 +08:00
committed by GitHub
parent 087c192e51
commit 73466b626d
8 changed files with 239 additions and 24 deletions
+80
View File
@@ -194,6 +194,86 @@ describe('EditTool', () => {
'hello world',
);
});
it('should treat $ literally and not as replacement pattern', () => {
const current = "price is $100 and pattern end is ' '";
const oldStr = 'price is $100';
const newStr = 'price is $200';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("price is $200 and pattern end is ' '");
});
it("should treat $' literally and not as a replacement pattern", () => {
const current = 'foo';
const oldStr = 'foo';
const newStr = "bar$'baz";
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("bar$'baz");
});
it('should treat $& literally and not as a replacement pattern', () => {
const current = 'hello world';
const oldStr = 'hello';
const newStr = '$&-replacement';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('$&-replacement world');
});
it('should treat $` literally and not as a replacement pattern', () => {
const current = 'prefix-middle-suffix';
const oldStr = 'middle';
const newStr = 'new$`content';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('prefix-new$`content-suffix');
});
it('should treat $1, $2 capture groups literally', () => {
const current = 'test string';
const oldStr = 'test';
const newStr = '$1$2replacement';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('$1$2replacement string');
});
it('should use replaceAll for normal strings without problematic $ sequences', () => {
const current = 'normal text replacement';
const oldStr = 'text';
const newStr = 'string';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('normal string replacement');
});
it('should handle multiple occurrences with problematic $ sequences', () => {
const current = 'foo bar foo baz';
const oldStr = 'foo';
const newStr = "test$'end";
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("test$'end bar test$'end baz");
});
it('should handle complex regex patterns with $ at end', () => {
const current = "| select('match', '^[sv]d[a-z]$')";
const oldStr = "'^[sv]d[a-z]$'";
const newStr = "'^[sv]d[a-z]$' # updated";
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("| select('match', '^[sv]d[a-z]$' # updated)");
});
it('should handle empty replacement with problematic $ in newString', () => {
const current = 'test content';
const oldStr = 'nothing';
const newStr = "replacement$'text";
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('test content'); // No replacement because oldStr not found
});
it('should handle $$ (escaped dollar) correctly', () => {
const current = 'price value';
const oldStr = 'value';
const newStr = '$$100';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('price $$100');
});
});
describe('validateToolParams', () => {
+4 -1
View File
@@ -33,6 +33,7 @@ import type {
ModifyContext,
} from './modifiable-tool.js';
import { IdeClient } from '../ide/ide-client.js';
import { safeLiteralReplace } from '../utils/textUtils.js';
export function applyReplacement(
currentContent: string | null,
@@ -51,7 +52,9 @@ export function applyReplacement(
if (oldString === '' && !isNewFile) {
return currentContent;
}
return currentContent.replaceAll(oldString, newString);
// Use intelligent replacement that handles $ sequences safely
return safeLiteralReplace(currentContent, oldString, newString);
}
/**
+17 -1
View File
@@ -42,11 +42,11 @@ import {
type Mock,
} from 'vitest';
import {
applyReplacement,
SmartEditTool,
type EditToolParams,
calculateReplacement,
} from './smart-edit.js';
import { applyReplacement } from './edit.js';
import { type FileDiff, ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import path from 'node:path';
@@ -172,6 +172,22 @@ describe('SmartEditTool', () => {
'hello new world new',
);
});
it('should treat $ literally and not as replacement pattern', () => {
const current = 'regex end is $ and more';
const oldStr = 'regex end is $';
const newStr = 'regex end is $ and correct';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('regex end is $ and correct and more');
});
it("should treat $' literally and not as a replacement pattern", () => {
const current = 'foo';
const oldStr = 'foo';
const newStr = "bar$'baz";
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("bar$'baz");
});
});
describe('calculateReplacement', () => {
+4 -21
View File
@@ -30,26 +30,8 @@ import {
} from './modifiable-tool.js';
import { IdeClient } from '../ide/ide-client.js';
import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js';
export function applyReplacement(
currentContent: string | null,
oldString: string,
newString: string,
isNewFile: boolean,
): string {
if (isNewFile) {
return newString;
}
if (currentContent === null) {
// Should not happen if not a new file, but defensively return empty or newString if oldString is also empty
return oldString === '' ? newString : '';
}
// If oldString is empty and it's not a new file, do not modify the content.
if (oldString === '' && !isNewFile) {
return currentContent;
}
return currentContent.replaceAll(oldString, newString);
}
import { applyReplacement } from './edit.js';
import { safeLiteralReplace } from '../utils/textUtils.js';
interface ReplacementContext {
params: EditToolParams;
@@ -89,7 +71,8 @@ async function calculateExactReplacement(
const exactOccurrences = normalizedCode.split(normalizedSearch).length - 1;
if (exactOccurrences > 0) {
let modifiedCode = normalizedCode.replaceAll(
let modifiedCode = safeLiteralReplace(
normalizedCode,
normalizedSearch,
normalizedReplace,
);