edit_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 EditFileToolInput {
 16    /// The full path of the file to modify in the project.
 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 text to replace.
 44    pub old_string: String,
 45
 46    /// The text to replace it with.
 47    pub new_string: String,
 48}
 49
 50pub struct EditFileTool;
 51
 52impl Tool for EditFileTool {
 53    fn name(&self) -> String {
 54        "edit_file".into()
 55    }
 56
 57    fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
 58        false
 59    }
 60
 61    fn description(&self) -> String {
 62        include_str!("edit_file_tool/description.md").to_string()
 63    }
 64
 65    fn icon(&self) -> IconName {
 66        IconName::Pencil
 67    }
 68
 69    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
 70        json_schema_for::<EditFileToolInput>(format)
 71    }
 72
 73    fn ui_text(&self, input: &serde_json::Value) -> String {
 74        match serde_json::from_value::<EditFileToolInput>(input.clone()) {
 75            Ok(input) => input.display_description,
 76            Err(_) => "Edit file".to_string(),
 77        }
 78    }
 79
 80    fn run(
 81        self: Arc<Self>,
 82        input: serde_json::Value,
 83        _messages: &[LanguageModelRequestMessage],
 84        project: Entity<Project>,
 85        action_log: Entity<ActionLog>,
 86        cx: &mut App,
 87    ) -> ToolResult {
 88        let input = match serde_json::from_value::<EditFileToolInput>(input) {
 89            Ok(input) => input,
 90            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
 91        };
 92
 93        cx.spawn(async move |cx: &mut AsyncApp| {
 94            let project_path = project.read_with(cx, |project, cx| {
 95                project
 96                    .find_project_path(&input.path, cx)
 97                    .context("Path not found in project")
 98            })??;
 99
100            let buffer = project
101                .update(cx, |project, cx| project.open_buffer(project_path, cx))?
102                .await?;
103
104            let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
105
106            if input.old_string.is_empty() {
107                return Err(anyhow!("`old_string` cannot be empty. Use a different tool if you want to create a file."));
108            }
109
110            if input.old_string == input.new_string {
111                return Err(anyhow!("The `old_string` and `new_string` are identical, so no changes would be made."));
112            }
113
114            let result = cx
115                .background_spawn(async move {
116                    // Try to match exactly
117                    let diff = replace_exact(&input.old_string, &input.new_string, &snapshot)
118                    .await
119                    // If that fails, try being flexible about indentation
120                    .or_else(|| replace_with_flexible_indent(&input.old_string, &input.new_string, &snapshot))?;
121
122                    if diff.edits.is_empty() {
123                        return None;
124                    }
125
126                    let old_text = snapshot.text();
127
128                    Some((old_text, diff))
129                })
130                .await;
131
132            let Some((old_text, diff)) = result else {
133                let err = buffer.read_with(cx, |buffer, _cx| {
134                    let file_exists = buffer
135                        .file()
136                        .map_or(false, |file| file.disk_state().exists());
137
138                    if !file_exists {
139                        anyhow!("{} does not exist", input.path.display())
140                    } else if buffer.is_empty() {
141                        anyhow!(
142                            "{} is empty, so the provided `old_string` wasn't found.",
143                            input.path.display()
144                        )
145                    } else {
146                        anyhow!("Failed to match the provided `old_string`")
147                    }
148                })?;
149
150                return Err(err)
151            };
152
153            let snapshot = cx.update(|cx| {
154                action_log.update(cx, |log, cx| {
155                    log.buffer_read(buffer.clone(), cx)
156                });
157                let snapshot = buffer.update(cx, |buffer, cx| {
158                    buffer.finalize_last_transaction();
159                    buffer.apply_diff(diff, cx);
160                    buffer.finalize_last_transaction();
161                    buffer.snapshot()
162                });
163                action_log.update(cx, |log, cx| {
164                    log.buffer_edited(buffer.clone(), cx)
165                });
166                snapshot
167            })?;
168
169            project.update( cx, |project, cx| {
170                project.save_buffer(buffer, cx)
171            })?.await?;
172
173            let diff_str = cx.background_spawn(async move {
174                let new_text = snapshot.text();
175                language::unified_diff(&old_text, &new_text)
176            }).await;
177
178
179            Ok(format!("Edited {}:\n\n```diff\n{}\n```", input.path.display(), diff_str))
180
181        }).into()
182    }
183}