Skip to content

Commit bdb668b

Browse files
authored
Move search_and_replace to a tool file (#2096)
1 parent d532877 commit bdb668b

File tree

2 files changed

+195
-179
lines changed

2 files changed

+195
-179
lines changed

src/core/Cline.ts

Lines changed: 14 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import { getWorkspacePath } from "../utils/path"
8888
import { writeToFileTool } from "./tools/writeToFileTool"
8989
import { applyDiffTool } from "./tools/applyDiffTool"
9090
import { insertContentTool } from "./tools/insertContentTool"
91+
import { searchAndReplaceTool } from "./tools/searchAndReplaceTool"
9192

9293
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
9394
type UserContent = Array<Anthropic.Messages.ContentBlockParam>
@@ -1576,187 +1577,25 @@ export class Cline extends EventEmitter<ClineEvents> {
15761577
case "insert_content":
15771578
await insertContentTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
15781579
break
1579-
case "search_and_replace": {
1580-
const relPath: string | undefined = block.params.path
1581-
const operations: string | undefined = block.params.operations
1582-
1583-
const sharedMessageProps: ClineSayTool = {
1584-
tool: "appliedDiff",
1585-
path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
1586-
}
1587-
1588-
try {
1589-
if (block.partial) {
1590-
const partialMessage = JSON.stringify({
1591-
path: removeClosingTag("path", relPath),
1592-
operations: removeClosingTag("operations", operations),
1593-
})
1594-
await this.ask("tool", partialMessage, block.partial).catch(() => {})
1595-
break
1596-
} else {
1597-
if (!relPath) {
1598-
this.consecutiveMistakeCount++
1599-
pushToolResult(
1600-
await this.sayAndCreateMissingParamError("search_and_replace", "path"),
1601-
)
1602-
break
1603-
}
1604-
if (!operations) {
1605-
this.consecutiveMistakeCount++
1606-
pushToolResult(
1607-
await this.sayAndCreateMissingParamError("search_and_replace", "operations"),
1608-
)
1609-
break
1610-
}
1611-
1612-
const absolutePath = path.resolve(this.cwd, relPath)
1613-
const fileExists = await fileExistsAtPath(absolutePath)
1614-
1615-
if (!fileExists) {
1616-
this.consecutiveMistakeCount++
1617-
const formattedError = `File does not exist at path: ${absolutePath}\n\n<error_details>\nThe specified file could not be found. Please verify the file path and try again.\n</error_details>`
1618-
await this.say("error", formattedError)
1619-
pushToolResult(formattedError)
1620-
break
1621-
}
1622-
1623-
let parsedOperations: Array<{
1624-
search: string
1625-
replace: string
1626-
start_line?: number
1627-
end_line?: number
1628-
use_regex?: boolean
1629-
ignore_case?: boolean
1630-
regex_flags?: string
1631-
}>
1632-
1633-
try {
1634-
parsedOperations = JSON.parse(operations)
1635-
if (!Array.isArray(parsedOperations)) {
1636-
throw new Error("Operations must be an array")
1637-
}
1638-
} catch (error) {
1639-
this.consecutiveMistakeCount++
1640-
await this.say("error", `Failed to parse operations JSON: ${error.message}`)
1641-
pushToolResult(formatResponse.toolError("Invalid operations JSON format"))
1642-
break
1643-
}
1644-
1645-
// Read the original file content
1646-
const fileContent = await fs.readFile(absolutePath, "utf-8")
1647-
this.diffViewProvider.editType = "modify"
1648-
this.diffViewProvider.originalContent = fileContent
1649-
let lines = fileContent.split("\n")
1650-
1651-
for (const op of parsedOperations) {
1652-
const flags = op.regex_flags ?? (op.ignore_case ? "gi" : "g")
1653-
const multilineFlags = flags.includes("m") ? flags : flags + "m"
1654-
1655-
const searchPattern = op.use_regex
1656-
? new RegExp(op.search, multilineFlags)
1657-
: new RegExp(escapeRegExp(op.search), multilineFlags)
1658-
1659-
if (op.start_line || op.end_line) {
1660-
const startLine = Math.max((op.start_line ?? 1) - 1, 0)
1661-
const endLine = Math.min((op.end_line ?? lines.length) - 1, lines.length - 1)
1662-
1663-
// Get the content before and after the target section
1664-
const beforeLines = lines.slice(0, startLine)
1665-
const afterLines = lines.slice(endLine + 1)
1666-
1667-
// Get the target section and perform replacement
1668-
const targetContent = lines.slice(startLine, endLine + 1).join("\n")
1669-
const modifiedContent = targetContent.replace(searchPattern, op.replace)
1670-
const modifiedLines = modifiedContent.split("\n")
1671-
1672-
// Reconstruct the full content with the modified section
1673-
lines = [...beforeLines, ...modifiedLines, ...afterLines]
1674-
} else {
1675-
// Global replacement
1676-
const fullContent = lines.join("\n")
1677-
const modifiedContent = fullContent.replace(searchPattern, op.replace)
1678-
lines = modifiedContent.split("\n")
1679-
}
1680-
}
1681-
1682-
const newContent = lines.join("\n")
1683-
1684-
this.consecutiveMistakeCount = 0
1685-
1686-
// Show diff preview
1687-
const diff = formatResponse.createPrettyPatch(relPath, fileContent, newContent)
1688-
1689-
if (!diff) {
1690-
pushToolResult(`No changes needed for '${relPath}'`)
1691-
break
1692-
}
1693-
1694-
await this.diffViewProvider.open(relPath)
1695-
await this.diffViewProvider.update(newContent, true)
1696-
this.diffViewProvider.scrollToFirstDiff()
1697-
1698-
const completeMessage = JSON.stringify({
1699-
...sharedMessageProps,
1700-
diff: diff,
1701-
} satisfies ClineSayTool)
1702-
1703-
const didApprove = await askApproval("tool", completeMessage)
1704-
if (!didApprove) {
1705-
await this.diffViewProvider.revertChanges() // This likely handles closing the diff view
1706-
break
1707-
}
1708-
1709-
const { newProblemsMessage, userEdits, finalContent } =
1710-
await this.diffViewProvider.saveChanges()
1711-
this.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
1712-
if (userEdits) {
1713-
await this.say(
1714-
"user_feedback_diff",
1715-
JSON.stringify({
1716-
tool: fileExists ? "editedExistingFile" : "newFileCreated",
1717-
path: getReadablePath(this.cwd, relPath),
1718-
diff: userEdits,
1719-
} satisfies ClineSayTool),
1720-
)
1721-
pushToolResult(
1722-
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
1723-
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
1724-
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(finalContent || "")}\n</final_file_content>\n\n` +
1725-
`Please note:\n` +
1726-
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
1727-
`2. Proceed with the task using this updated file content as the new baseline.\n` +
1728-
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
1729-
`${newProblemsMessage}`,
1730-
)
1731-
} else {
1732-
pushToolResult(
1733-
`Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}`,
1734-
)
1735-
}
1736-
await this.diffViewProvider.reset()
1737-
break
1738-
}
1739-
} catch (error) {
1740-
await handleError("applying search and replace", error)
1741-
await this.diffViewProvider.reset()
1742-
break
1743-
}
1744-
}
1745-
1746-
case "read_file": {
1580+
case "search_and_replace":
1581+
await searchAndReplaceTool(
1582+
this,
1583+
block,
1584+
askApproval,
1585+
handleError,
1586+
pushToolResult,
1587+
removeClosingTag,
1588+
)
1589+
break
1590+
case "read_file":
17471591
await readFileTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
17481592
break
1749-
}
1750-
1751-
case "fetch_instructions": {
1593+
case "fetch_instructions":
17521594
await fetchInstructionsTool(this, block, askApproval, handleError, pushToolResult)
17531595
break
1754-
}
1755-
1756-
case "list_files": {
1596+
case "list_files":
17571597
await listFilesTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
17581598
break
1759-
}
17601599
case "list_code_definition_names": {
17611600
const relPath: string | undefined = block.params.path
17621601
const sharedMessageProps: ClineSayTool = {
@@ -3562,7 +3401,3 @@ export class Cline extends EventEmitter<ClineEvents> {
35623401
}
35633402
}
35643403
}
3565-
3566-
function escapeRegExp(string: string): string {
3567-
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
3568-
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { Cline } from "../Cline"
2+
import { ToolUse } from "../assistant-message"
3+
import { AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "./types"
4+
import { formatResponse } from "../prompts/responses"
5+
import { ClineSayTool } from "../../shared/ExtensionMessage"
6+
import { getReadablePath } from "../../utils/path"
7+
import path from "path"
8+
import { fileExistsAtPath } from "../../utils/fs"
9+
import { addLineNumbers } from "../../integrations/misc/extract-text"
10+
import fs from "fs/promises"
11+
12+
export async function searchAndReplaceTool(
13+
cline: Cline,
14+
block: ToolUse,
15+
askApproval: AskApproval,
16+
handleError: HandleError,
17+
pushToolResult: PushToolResult,
18+
removeClosingTag: RemoveClosingTag,
19+
) {
20+
const relPath: string | undefined = block.params.path
21+
const operations: string | undefined = block.params.operations
22+
23+
const sharedMessageProps: ClineSayTool = {
24+
tool: "appliedDiff",
25+
path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),
26+
}
27+
28+
try {
29+
if (block.partial) {
30+
const partialMessage = JSON.stringify({
31+
path: removeClosingTag("path", relPath),
32+
operations: removeClosingTag("operations", operations),
33+
})
34+
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
35+
return
36+
} else {
37+
if (!relPath) {
38+
cline.consecutiveMistakeCount++
39+
pushToolResult(await cline.sayAndCreateMissingParamError("search_and_replace", "path"))
40+
return
41+
}
42+
if (!operations) {
43+
cline.consecutiveMistakeCount++
44+
pushToolResult(await cline.sayAndCreateMissingParamError("search_and_replace", "operations"))
45+
return
46+
}
47+
48+
const absolutePath = path.resolve(cline.cwd, relPath)
49+
const fileExists = await fileExistsAtPath(absolutePath)
50+
51+
if (!fileExists) {
52+
cline.consecutiveMistakeCount++
53+
const formattedError = `File does not exist at path: ${absolutePath}\n\n<error_details>\nThe specified file could not be found. Please verify the file path and try again.\n</error_details>`
54+
await cline.say("error", formattedError)
55+
pushToolResult(formattedError)
56+
return
57+
}
58+
59+
let parsedOperations: Array<{
60+
search: string
61+
replace: string
62+
start_line?: number
63+
end_line?: number
64+
use_regex?: boolean
65+
ignore_case?: boolean
66+
regex_flags?: string
67+
}>
68+
69+
try {
70+
parsedOperations = JSON.parse(operations)
71+
if (!Array.isArray(parsedOperations)) {
72+
throw new Error("Operations must be an array")
73+
}
74+
} catch (error) {
75+
cline.consecutiveMistakeCount++
76+
await cline.say("error", `Failed to parse operations JSON: ${error.message}`)
77+
pushToolResult(formatResponse.toolError("Invalid operations JSON format"))
78+
return
79+
}
80+
81+
// Read the original file content
82+
const fileContent = await fs.readFile(absolutePath, "utf-8")
83+
cline.diffViewProvider.editType = "modify"
84+
cline.diffViewProvider.originalContent = fileContent
85+
let lines = fileContent.split("\n")
86+
87+
for (const op of parsedOperations) {
88+
const flags = op.regex_flags ?? (op.ignore_case ? "gi" : "g")
89+
const multilineFlags = flags.includes("m") ? flags : flags + "m"
90+
91+
const searchPattern = op.use_regex
92+
? new RegExp(op.search, multilineFlags)
93+
: new RegExp(escapeRegExp(op.search), multilineFlags)
94+
95+
if (op.start_line || op.end_line) {
96+
const startLine = Math.max((op.start_line ?? 1) - 1, 0)
97+
const endLine = Math.min((op.end_line ?? lines.length) - 1, lines.length - 1)
98+
99+
// Get the content before and after the target section
100+
const beforeLines = lines.slice(0, startLine)
101+
const afterLines = lines.slice(endLine + 1)
102+
103+
// Get the target section and perform replacement
104+
const targetContent = lines.slice(startLine, endLine + 1).join("\n")
105+
const modifiedContent = targetContent.replace(searchPattern, op.replace)
106+
const modifiedLines = modifiedContent.split("\n")
107+
108+
// Reconstruct the full content with the modified section
109+
lines = [...beforeLines, ...modifiedLines, ...afterLines]
110+
} else {
111+
// Global replacement
112+
const fullContent = lines.join("\n")
113+
const modifiedContent = fullContent.replace(searchPattern, op.replace)
114+
lines = modifiedContent.split("\n")
115+
}
116+
}
117+
118+
const newContent = lines.join("\n")
119+
120+
cline.consecutiveMistakeCount = 0
121+
122+
// Show diff preview
123+
const diff = formatResponse.createPrettyPatch(relPath, fileContent, newContent)
124+
125+
if (!diff) {
126+
pushToolResult(`No changes needed for '${relPath}'`)
127+
return
128+
}
129+
130+
await cline.diffViewProvider.open(relPath)
131+
await cline.diffViewProvider.update(newContent, true)
132+
cline.diffViewProvider.scrollToFirstDiff()
133+
134+
const completeMessage = JSON.stringify({
135+
...sharedMessageProps,
136+
diff: diff,
137+
} satisfies ClineSayTool)
138+
139+
const didApprove = await askApproval("tool", completeMessage)
140+
if (!didApprove) {
141+
await cline.diffViewProvider.revertChanges() // cline likely handles closing the diff view
142+
return
143+
}
144+
145+
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
146+
cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
147+
if (userEdits) {
148+
await cline.say(
149+
"user_feedback_diff",
150+
JSON.stringify({
151+
tool: fileExists ? "editedExistingFile" : "newFileCreated",
152+
path: getReadablePath(cline.cwd, relPath),
153+
diff: userEdits,
154+
} satisfies ClineSayTool),
155+
)
156+
pushToolResult(
157+
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
158+
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
159+
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(finalContent || "")}\n</final_file_content>\n\n` +
160+
`Please note:\n` +
161+
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
162+
`2. Proceed with the task using cline updated file content as the new baseline.\n` +
163+
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
164+
`${newProblemsMessage}`,
165+
)
166+
} else {
167+
pushToolResult(`Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}`)
168+
}
169+
await cline.diffViewProvider.reset()
170+
return
171+
}
172+
} catch (error) {
173+
await handleError("applying search and replace", error)
174+
await cline.diffViewProvider.reset()
175+
return
176+
}
177+
}
178+
179+
function escapeRegExp(string: string): string {
180+
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
181+
}

0 commit comments

Comments
 (0)