find_replace_file_tool.rs

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