1use crate::{replace::replace_with_flexible_indent, schema::json_schema_for};
2use anyhow::{Context as _, Result, anyhow};
3use assistant_tool::{ActionLog, Tool, ToolResult};
4use gpui::{App, AppContext, AsyncApp, Entity, Task};
5use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
6use project::Project;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::{path::PathBuf, sync::Arc};
10use ui::IconName;
11
12use crate::replace::replace_exact;
13
14#[derive(Debug, Serialize, Deserialize, JsonSchema)]
15pub struct FindReplaceFileToolInput {
16 /// The path of the file to modify.
17 ///
18 /// WARNING: When specifying which file path need changing, you MUST
19 /// start each path with one of the project's root directories.
20 ///
21 /// The following examples assume we have two root directories in the project:
22 /// - backend
23 /// - frontend
24 ///
25 /// <example>
26 /// `backend/src/main.rs`
27 ///
28 /// Notice how the file path starts with root-1. Without that, the path
29 /// would be ambiguous and the call would fail!
30 /// </example>
31 ///
32 /// <example>
33 /// `frontend/db.js`
34 /// </example>
35 pub path: PathBuf,
36
37 /// A user-friendly markdown description of what's being replaced. This will be shown in the UI.
38 ///
39 /// <example>Fix API endpoint URLs</example>
40 /// <example>Update copyright year in `page_footer`</example>
41 pub display_description: String,
42
43 /// The unique string to find in the file. This string cannot be empty;
44 /// if the string is empty, the tool call will fail. Remember, do not use this tool
45 /// to create new files from scratch, or to overwrite existing files! Use a different
46 /// approach if you want to do that.
47 ///
48 /// If this string appears more than once in the file, this tool call will fail,
49 /// so it is absolutely critical that you verify ahead of time that the string
50 /// is unique. You can search within the file to verify this.
51 ///
52 /// To make the string more likely to be unique, include a minimum of 3 lines of context
53 /// before the string you actually want to find, as well as a minimum of 3 lines of
54 /// context after the string you want to find. (These lines of context should appear
55 /// in the `replace` string as well.) If 3 lines of context is not enough to obtain
56 /// a string that appears only once in the file, then double the number of context lines
57 /// until the string becomes unique. (Start with 3 lines before and 3 lines after
58 /// though, because too much context is needlessly costly.)
59 ///
60 /// Do not alter the context lines of code in any way, and make sure to preserve all
61 /// whitespace and indentation for all lines of code. This string must be exactly as
62 /// it appears in the file, because this tool will do a literal find/replace, and if
63 /// even one character in this string is different in any way from how it appears
64 /// in the file, then the tool call will fail.
65 ///
66 /// If you get an error that the `find` string was not found, this means that either
67 /// you made a mistake, or that the file has changed since you last looked at it.
68 /// Either way, when this happens, you should retry doing this tool call until it
69 /// succeeds, up to 3 times. Each time you retry, you should take another look at
70 /// the exact text of the file in question, to make sure that you are searching for
71 /// exactly the right string. Regardless of whether it was because you made a mistake
72 /// or because the file changed since you last looked at it, you should be extra
73 /// careful when retrying in this way. It's a bad experience for the user if
74 /// this `find` string isn't found, so be super careful to get it exactly right!
75 ///
76 /// <example>
77 /// If a file contains this code:
78 ///
79 /// ```ignore
80 /// fn check_user_permissions(user_id: &str) -> Result<bool> {
81 /// // Check if user exists first
82 /// let user = database.find_user(user_id)?;
83 ///
84 /// // This is the part we want to modify
85 /// if user.role == "admin" {
86 /// return Ok(true);
87 /// }
88 ///
89 /// // Check other permissions
90 /// check_custom_permissions(user_id)
91 /// }
92 /// ```
93 ///
94 /// Your find string should include at least 3 lines of context before and after the part
95 /// you want to change:
96 ///
97 /// ```ignore
98 /// fn check_user_permissions(user_id: &str) -> Result<bool> {
99 /// // Check if user exists first
100 /// let user = database.find_user(user_id)?;
101 ///
102 /// // This is the part we want to modify
103 /// if user.role == "admin" {
104 /// return Ok(true);
105 /// }
106 ///
107 /// // Check other permissions
108 /// check_custom_permissions(user_id)
109 /// }
110 /// ```
111 ///
112 /// And your replace string might look like:
113 ///
114 /// ```ignore
115 /// fn check_user_permissions(user_id: &str) -> Result<bool> {
116 /// // Check if user exists first
117 /// let user = database.find_user(user_id)?;
118 ///
119 /// // This is the part we want to modify
120 /// if user.role == "admin" || user.role == "superuser" {
121 /// return Ok(true);
122 /// }
123 ///
124 /// // Check other permissions
125 /// check_custom_permissions(user_id)
126 /// }
127 /// ```
128 /// </example>
129 pub find: String,
130
131 /// The string to replace the one unique occurrence of the find string with.
132 pub replace: String,
133}
134
135pub struct FindReplaceFileTool;
136
137impl Tool for FindReplaceFileTool {
138 fn name(&self) -> String {
139 "find_replace_file".into()
140 }
141
142 fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
143 false
144 }
145
146 fn description(&self) -> String {
147 include_str!("find_replace_tool/description.md").to_string()
148 }
149
150 fn icon(&self) -> IconName {
151 IconName::Pencil
152 }
153
154 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
155 json_schema_for::<FindReplaceFileToolInput>(format)
156 }
157
158 fn ui_text(&self, input: &serde_json::Value) -> String {
159 match serde_json::from_value::<FindReplaceFileToolInput>(input.clone()) {
160 Ok(input) => input.display_description,
161 Err(_) => "Edit file".to_string(),
162 }
163 }
164
165 fn run(
166 self: Arc<Self>,
167 input: serde_json::Value,
168 _messages: &[LanguageModelRequestMessage],
169 project: Entity<Project>,
170 action_log: Entity<ActionLog>,
171 cx: &mut App,
172 ) -> ToolResult {
173 let input = match serde_json::from_value::<FindReplaceFileToolInput>(input) {
174 Ok(input) => input,
175 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
176 };
177
178 cx.spawn(async move |cx: &mut AsyncApp| {
179 let project_path = project.read_with(cx, |project, cx| {
180 project
181 .find_project_path(&input.path, cx)
182 .context("Path not found in project")
183 })??;
184
185 let buffer = project
186 .update(cx, |project, cx| project.open_buffer(project_path, cx))?
187 .await?;
188
189 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
190
191 if input.find.is_empty() {
192 return Err(anyhow!("`find` string cannot be empty. Use a different tool if you want to create a file."));
193 }
194
195 if input.find == input.replace {
196 return Err(anyhow!("The `find` and `replace` strings are identical, so no changes would be made."));
197 }
198
199 let result = cx
200 .background_spawn(async move {
201 // Try to match exactly
202 let diff = replace_exact(&input.find, &input.replace, &snapshot)
203 .await
204 // If that fails, try being flexible about indentation
205 .or_else(|| replace_with_flexible_indent(&input.find, &input.replace, &snapshot))?;
206
207 if diff.edits.is_empty() {
208 return None;
209 }
210
211 let old_text = snapshot.text();
212
213 Some((old_text, diff))
214 })
215 .await;
216
217 let Some((old_text, diff)) = result else {
218 let err = buffer.read_with(cx, |buffer, _cx| {
219 let file_exists = buffer
220 .file()
221 .map_or(false, |file| file.disk_state().exists());
222
223 if !file_exists {
224 anyhow!("{} does not exist", input.path.display())
225 } else if buffer.is_empty() {
226 anyhow!(
227 "{} is empty, so the provided `find` string wasn't found.",
228 input.path.display()
229 )
230 } else {
231 anyhow!("Failed to match the provided `find` string")
232 }
233 })?;
234
235 return Err(err)
236 };
237
238 let snapshot = cx.update(|cx| {
239 action_log.update(cx, |log, cx| {
240 log.buffer_read(buffer.clone(), cx)
241 });
242 let snapshot = buffer.update(cx, |buffer, cx| {
243 buffer.finalize_last_transaction();
244 buffer.apply_diff(diff, cx);
245 buffer.finalize_last_transaction();
246 buffer.snapshot()
247 });
248 action_log.update(cx, |log, cx| {
249 log.buffer_edited(buffer.clone(), cx)
250 });
251 snapshot
252 })?;
253
254 project.update( cx, |project, cx| {
255 project.save_buffer(buffer, cx)
256 })?.await?;
257
258 let diff_str = cx.background_spawn(async move {
259 let new_text = snapshot.text();
260 language::unified_diff(&old_text, &new_text)
261 }).await;
262
263
264 Ok(format!("Edited {}:\n\n```diff\n{}\n```", input.path.display(), diff_str))
265
266 }).into()
267 }
268}