1use anyhow::{Context as _, Result, anyhow};
2use assistant_tool::{ActionLog, Tool, ToolResult};
3use gpui::{AnyWindowHandle, App, Entity, Task};
4use language::{self, Buffer, ToPointUtf16};
5use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
6use project::Project;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::sync::Arc;
10use ui::IconName;
11
12use crate::schema::json_schema_for;
13
14#[derive(Debug, Serialize, Deserialize, JsonSchema)]
15pub struct RenameToolInput {
16 /// The relative path to the file containing the symbol to rename.
17 ///
18 /// WARNING: you MUST start this path with one of the project's root directories.
19 pub path: String,
20
21 /// The new name to give to the symbol.
22 pub new_name: String,
23
24 /// The text that comes immediately before the symbol in the file.
25 pub context_before_symbol: String,
26
27 /// The symbol to rename. This text must appear in the file right between
28 /// `context_before_symbol` and `context_after_symbol`.
29 ///
30 /// The file must contain exactly one occurrence of `context_before_symbol` followed by
31 /// `symbol` followed by `context_after_symbol`. If the file contains zero occurrences,
32 /// or if it contains more than one occurrence, the tool will fail, so it is absolutely
33 /// critical that you verify ahead of time that the string is unique. You can search
34 /// the file's contents to verify this ahead of time.
35 ///
36 /// To make the string more likely to be unique, include a minimum of 1 line of context
37 /// before the symbol, as well as a minimum of 1 line of context after the symbol.
38 /// If these lines of context are not enough to obtain a string that appears only once
39 /// in the file, then double the number of context lines until the string becomes unique.
40 /// (Start with 1 line before and 1 line after though, because too much context is
41 /// needlessly costly.)
42 ///
43 /// Do not alter the context lines of code in any way, and make sure to preserve all
44 /// whitespace and indentation for all lines of code. The combined string must be exactly
45 /// as it appears in the file, or else this tool call will fail.
46 pub symbol: String,
47
48 /// The text that comes immediately after the symbol in the file.
49 pub context_after_symbol: String,
50}
51
52pub struct RenameTool;
53
54impl Tool for RenameTool {
55 fn name(&self) -> String {
56 "rename".into()
57 }
58
59 fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool {
60 false
61 }
62
63 fn description(&self) -> String {
64 include_str!("./rename_tool/description.md").into()
65 }
66
67 fn icon(&self) -> IconName {
68 IconName::Pencil
69 }
70
71 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
72 json_schema_for::<RenameToolInput>(format)
73 }
74
75 fn ui_text(&self, input: &serde_json::Value) -> String {
76 match serde_json::from_value::<RenameToolInput>(input.clone()) {
77 Ok(input) => {
78 format!("Rename '{}' to '{}'", input.symbol, input.new_name)
79 }
80 Err(_) => "Rename symbol".to_string(),
81 }
82 }
83
84 fn run(
85 self: Arc<Self>,
86 input: serde_json::Value,
87 _messages: &[LanguageModelRequestMessage],
88 project: Entity<Project>,
89 action_log: Entity<ActionLog>,
90 _window: Option<AnyWindowHandle>,
91 cx: &mut App,
92 ) -> ToolResult {
93 let input = match serde_json::from_value::<RenameToolInput>(input) {
94 Ok(input) => input,
95 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
96 };
97
98 cx.spawn(async move |cx| {
99 let buffer = {
100 let project_path = project.read_with(cx, |project, cx| {
101 project
102 .find_project_path(&input.path, cx)
103 .context("Path not found in project")
104 })??;
105
106 project.update(cx, |project, cx| project.open_buffer(project_path, cx))?.await?
107 };
108
109 action_log.update(cx, |action_log, cx| {
110 action_log.track_buffer(buffer.clone(), cx);
111 })?;
112
113 let position = {
114 let Some(position) = buffer.read_with(cx, |buffer, _cx| {
115 find_symbol_position(&buffer, &input.context_before_symbol, &input.symbol, &input.context_after_symbol)
116 })? else {
117 return Err(anyhow!(
118 "Failed to locate the symbol specified by context_before_symbol, symbol, and context_after_symbol. Make sure context_before_symbol and context_after_symbol each match exactly once in the file."
119 ));
120 };
121
122 buffer.read_with(cx, |buffer, _| {
123 position.to_point_utf16(&buffer.snapshot())
124 })?
125 };
126
127 project
128 .update(cx, |project, cx| {
129 project.perform_rename(buffer.clone(), position, input.new_name.clone(), cx)
130 })?
131 .await?;
132
133 project
134 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
135 .await?;
136
137 action_log.update(cx, |log, cx| {
138 log.buffer_edited(buffer.clone(), cx)
139 })?;
140
141 Ok(format!("Renamed '{}' to '{}'", input.symbol, input.new_name))
142 }).into()
143 }
144}
145
146/// Finds the position of the symbol in the buffer, if it appears between context_before_symbol
147/// and context_after_symbol, and if that combined string has one unique result in the buffer.
148///
149/// If an exact match fails, it tries adding a newline to the end of context_before_symbol and
150/// to the beginning of context_after_symbol to accommodate line-based context matching.
151fn find_symbol_position(
152 buffer: &Buffer,
153 context_before_symbol: &str,
154 symbol: &str,
155 context_after_symbol: &str,
156) -> Option<language::Anchor> {
157 let snapshot = buffer.snapshot();
158 let text = snapshot.text();
159
160 // First try with exact match
161 let search_string = format!("{context_before_symbol}{symbol}{context_after_symbol}");
162 let mut positions = text.match_indices(&search_string);
163 let position_result = positions.next();
164
165 if let Some(position) = position_result {
166 // Check if the matched string is unique
167 if positions.next().is_none() {
168 let symbol_start = position.0 + context_before_symbol.len();
169 let symbol_start_anchor =
170 snapshot.anchor_before(snapshot.offset_to_point(symbol_start));
171
172 return Some(symbol_start_anchor);
173 }
174 }
175
176 // If exact match fails or is not unique, try with line-based context
177 // Add a newline to the end of before context and beginning of after context
178 let line_based_before = if context_before_symbol.ends_with('\n') {
179 context_before_symbol.to_string()
180 } else {
181 format!("{context_before_symbol}\n")
182 };
183
184 let line_based_after = if context_after_symbol.starts_with('\n') {
185 context_after_symbol.to_string()
186 } else {
187 format!("\n{context_after_symbol}")
188 };
189
190 let line_search_string = format!("{line_based_before}{symbol}{line_based_after}");
191 let mut line_positions = text.match_indices(&line_search_string);
192 let line_position = line_positions.next()?;
193
194 // The line-based search string must also appear exactly once
195 if line_positions.next().is_some() {
196 return None;
197 }
198
199 let line_symbol_start = line_position.0 + line_based_before.len();
200 let line_symbol_start_anchor =
201 snapshot.anchor_before(snapshot.offset_to_point(line_symbol_start));
202
203 Some(line_symbol_start_anchor)
204}