find_replace_file_tool.rs

  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}