1use anyhow::{Context as _, Result, anyhow};
2use assistant_tool::{ActionLog, Tool};
3use gpui::{App, Entity, Task};
4use language::{self, Anchor, Buffer, ToPointUtf16};
5use language_model::LanguageModelRequestMessage;
6use project::{self, LspAction, Project};
7use regex::Regex;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::{ops::Range, sync::Arc};
11use ui::IconName;
12
13#[derive(Debug, Serialize, Deserialize, JsonSchema)]
14pub struct CodeActionToolInput {
15 /// The relative path to the file containing the text range.
16 ///
17 /// WARNING: you MUST start this path with one of the project's root directories.
18 pub path: String,
19
20 /// The specific code action to execute.
21 ///
22 /// If this field is provided, the tool will execute the specified action.
23 /// If omitted, the tool will list all available code actions for the text range.
24 ///
25 /// Here are some actions that are commonly supported (but may not be for this particular
26 /// text range; you can omit this field to list all the actions, if you want to know
27 /// what your options are, or you can just try an action and if it fails I'll tell you
28 /// what the available actions were instead):
29 /// - "quickfix.all" - applies all available quick fixes in the range
30 /// - "source.organizeImports" - sorts and cleans up import statements
31 /// - "source.fixAll" - applies all available auto fixes
32 /// - "refactor.extract" - extracts selected code into a new function or variable
33 /// - "refactor.inline" - inlines a variable by replacing references with its value
34 /// - "refactor.rewrite" - general code rewriting operations
35 /// - "source.addMissingImports" - adds imports for references that lack them
36 /// - "source.removeUnusedImports" - removes imports that aren't being used
37 /// - "source.implementInterface" - generates methods required by an interface/trait
38 /// - "source.generateAccessors" - creates getter/setter methods
39 /// - "source.convertToAsyncFunction" - converts callback-style code to async/await
40 ///
41 /// Also, there is a special case: if you specify exactly "textDocument/rename" as the action,
42 /// then this will rename the symbol to whatever string you specified for the `arguments` field.
43 pub action: Option<String>,
44
45 /// Optional arguments to pass to the code action.
46 ///
47 /// For rename operations (when action="textDocument/rename"), this should contain the new name.
48 /// For other code actions, these arguments may be passed to the language server.
49 pub arguments: Option<serde_json::Value>,
50
51 /// The text that comes immediately before the text range in the file.
52 pub context_before_range: String,
53
54 /// The text range. This text must appear in the file right between `context_before_range`
55 /// and `context_after_range`.
56 ///
57 /// The file must contain exactly one occurrence of `context_before_range` followed by
58 /// `text_range` followed by `context_after_range`. If the file contains zero occurrences,
59 /// or if it contains more than one occurrence, the tool will fail, so it is absolutely
60 /// critical that you verify ahead of time that the string is unique. You can search
61 /// the file's contents to verify this ahead of time.
62 ///
63 /// To make the string more likely to be unique, include a minimum of 1 line of context
64 /// before the text range, as well as a minimum of 1 line of context after the text range.
65 /// If these lines of context are not enough to obtain a string that appears only once
66 /// in the file, then double the number of context lines until the string becomes unique.
67 /// (Start with 1 line before and 1 line after though, because too much context is
68 /// needlessly costly.)
69 ///
70 /// Do not alter the context lines of code in any way, and make sure to preserve all
71 /// whitespace and indentation for all lines of code. The combined string must be exactly
72 /// as it appears in the file, or else this tool call will fail.
73 pub text_range: String,
74
75 /// The text that comes immediately after the text range in the file.
76 pub context_after_range: String,
77}
78
79pub struct CodeActionTool;
80
81impl Tool for CodeActionTool {
82 fn name(&self) -> String {
83 "code_actions".into()
84 }
85
86 fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool {
87 false
88 }
89
90 fn description(&self) -> String {
91 include_str!("./code_action_tool/description.md").into()
92 }
93
94 fn icon(&self) -> IconName {
95 IconName::Wand
96 }
97
98 fn input_schema(
99 &self,
100 _format: language_model::LanguageModelToolSchemaFormat,
101 ) -> serde_json::Value {
102 let schema = schemars::schema_for!(CodeActionToolInput);
103 serde_json::to_value(&schema).unwrap()
104 }
105
106 fn ui_text(&self, input: &serde_json::Value) -> String {
107 match serde_json::from_value::<CodeActionToolInput>(input.clone()) {
108 Ok(input) => {
109 if let Some(action) = &input.action {
110 if action == "textDocument/rename" {
111 let new_name = match &input.arguments {
112 Some(serde_json::Value::String(new_name)) => new_name.clone(),
113 Some(value) => {
114 if let Ok(new_name) =
115 serde_json::from_value::<String>(value.clone())
116 {
117 new_name
118 } else {
119 "invalid name".to_string()
120 }
121 }
122 None => "missing name".to_string(),
123 };
124 format!("Rename '{}' to '{}'", input.text_range, new_name)
125 } else {
126 format!(
127 "Execute code action '{}' for '{}'",
128 action, input.text_range
129 )
130 }
131 } else {
132 format!("List available code actions for '{}'", input.text_range)
133 }
134 }
135 Err(_) => "Perform code action".to_string(),
136 }
137 }
138
139 fn run(
140 self: Arc<Self>,
141 input: serde_json::Value,
142 _messages: &[LanguageModelRequestMessage],
143 project: Entity<Project>,
144 action_log: Entity<ActionLog>,
145 cx: &mut App,
146 ) -> Task<Result<String>> {
147 let input = match serde_json::from_value::<CodeActionToolInput>(input) {
148 Ok(input) => input,
149 Err(err) => return Task::ready(Err(anyhow!(err))),
150 };
151
152 cx.spawn(async move |cx| {
153 let buffer = {
154 let project_path = project.read_with(cx, |project, cx| {
155 project
156 .find_project_path(&input.path, cx)
157 .context("Path not found in project")
158 })??;
159
160 project.update(cx, |project, cx| project.open_buffer(project_path, cx))?.await?
161 };
162
163 action_log.update(cx, |action_log, cx| {
164 action_log.buffer_read(buffer.clone(), cx);
165 })?;
166
167 let range = {
168 let Some(range) = buffer.read_with(cx, |buffer, _cx| {
169 find_text_range(&buffer, &input.context_before_range, &input.text_range, &input.context_after_range)
170 })? else {
171 return Err(anyhow!(
172 "Failed to locate the text specified by context_before_range, text_range, and context_after_range. Make sure context_before_range and context_after_range each match exactly once in the file."
173 ));
174 };
175
176 range
177 };
178
179 if let Some(action_type) = &input.action {
180 // Special-case the `rename` operation
181 let response = if action_type == "textDocument/rename" {
182 let Some(new_name) = input.arguments.and_then(|args| serde_json::from_value::<String>(args).ok()) else {
183 return Err(anyhow!("For rename operations, 'arguments' must be a string containing the new name"));
184 };
185
186 let position = buffer.read_with(cx, |buffer, _| {
187 range.start.to_point_utf16(&buffer.snapshot())
188 })?;
189
190 project
191 .update(cx, |project, cx| {
192 project.perform_rename(buffer.clone(), position, new_name.clone(), cx)
193 })?
194 .await?;
195
196 format!("Renamed '{}' to '{}'", input.text_range, new_name)
197 } else {
198 // Get code actions for the range
199 let actions = project
200 .update(cx, |project, cx| {
201 project.code_actions(&buffer, range.clone(), None, cx)
202 })?
203 .await?;
204
205 if actions.is_empty() {
206 return Err(anyhow!("No code actions available for this range"));
207 }
208
209 // Find all matching actions
210 let regex = match Regex::new(action_type) {
211 Ok(regex) => regex,
212 Err(err) => return Err(anyhow!("Invalid regex pattern: {}", err)),
213 };
214 let mut matching_actions = actions
215 .into_iter()
216 .filter(|action| { regex.is_match(action.lsp_action.title()) });
217
218 let Some(action) = matching_actions.next() else {
219 return Err(anyhow!("No code actions match the pattern: {}", action_type));
220 };
221
222 // There should have been exactly one matching action.
223 if let Some(second) = matching_actions.next() {
224 let mut all_matches = vec![action, second];
225
226 all_matches.extend(matching_actions);
227
228 return Err(anyhow!(
229 "Pattern '{}' matches multiple code actions: {}",
230 action_type,
231 all_matches.into_iter().map(|action| action.lsp_action.title().to_string()).collect::<Vec<_>>().join(", ")
232 ));
233 }
234
235 let title = action.lsp_action.title().to_string();
236
237 project
238 .update(cx, |project, cx| {
239 project.apply_code_action(buffer.clone(), action, true, cx)
240 })?
241 .await?;
242
243 format!("Completed code action: {}", title)
244 };
245
246 project
247 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
248 .await?;
249
250 action_log.update(cx, |log, cx| {
251 log.buffer_edited(buffer.clone(), cx)
252 })?;
253
254 Ok(response)
255 } else {
256 // No action specified, so list the available ones.
257 let (position_start, position_end) = buffer.read_with(cx, |buffer, _| {
258 let snapshot = buffer.snapshot();
259 (
260 range.start.to_point_utf16(&snapshot),
261 range.end.to_point_utf16(&snapshot)
262 )
263 })?;
264
265 // Convert position to display coordinates (1-based)
266 let position_start_display = language::Point {
267 row: position_start.row + 1,
268 column: position_start.column + 1,
269 };
270
271 let position_end_display = language::Point {
272 row: position_end.row + 1,
273 column: position_end.column + 1,
274 };
275
276 // Get code actions for the range
277 let actions = project
278 .update(cx, |project, cx| {
279 project.code_actions(&buffer, range.clone(), None, cx)
280 })?
281 .await?;
282
283 let mut response = format!(
284 "Available code actions for text range '{}' at position {}:{} to {}:{} (UTF-16 coordinates):\n\n",
285 input.text_range,
286 position_start_display.row, position_start_display.column,
287 position_end_display.row, position_end_display.column
288 );
289
290 if actions.is_empty() {
291 response.push_str("No code actions available for this range.");
292 } else {
293 for (i, action) in actions.iter().enumerate() {
294 let title = match &action.lsp_action {
295 LspAction::Action(code_action) => code_action.title.as_str(),
296 LspAction::Command(command) => command.title.as_str(),
297 LspAction::CodeLens(code_lens) => {
298 if let Some(cmd) = &code_lens.command {
299 cmd.title.as_str()
300 } else {
301 "Unknown code lens"
302 }
303 },
304 };
305
306 let kind = match &action.lsp_action {
307 LspAction::Action(code_action) => {
308 if let Some(kind) = &code_action.kind {
309 kind.as_str()
310 } else {
311 "unknown"
312 }
313 },
314 LspAction::Command(_) => "command",
315 LspAction::CodeLens(_) => "code_lens",
316 };
317
318 response.push_str(&format!("{}. {title} ({kind})\n", i + 1));
319 }
320 }
321
322 Ok(response)
323 }
324 })
325 }
326}
327
328/// Finds the range of the text in the buffer, if it appears between context_before_range
329/// and context_after_range, and if that combined string has one unique result in the buffer.
330///
331/// If an exact match fails, it tries adding a newline to the end of context_before_range and
332/// to the beginning of context_after_range to accommodate line-based context matching.
333fn find_text_range(
334 buffer: &Buffer,
335 context_before_range: &str,
336 text_range: &str,
337 context_after_range: &str,
338) -> Option<Range<Anchor>> {
339 let snapshot = buffer.snapshot();
340 let text = snapshot.text();
341
342 // First try with exact match
343 let search_string = format!("{context_before_range}{text_range}{context_after_range}");
344 let mut positions = text.match_indices(&search_string);
345 let position_result = positions.next();
346
347 if let Some(position) = position_result {
348 // Check if the matched string is unique
349 if positions.next().is_none() {
350 let range_start = position.0 + context_before_range.len();
351 let range_end = range_start + text_range.len();
352 let range_start_anchor = snapshot.anchor_before(snapshot.offset_to_point(range_start));
353 let range_end_anchor = snapshot.anchor_before(snapshot.offset_to_point(range_end));
354
355 return Some(range_start_anchor..range_end_anchor);
356 }
357 }
358
359 // If exact match fails or is not unique, try with line-based context
360 // Add a newline to the end of before context and beginning of after context
361 let line_based_before = if context_before_range.ends_with('\n') {
362 context_before_range.to_string()
363 } else {
364 format!("{context_before_range}\n")
365 };
366
367 let line_based_after = if context_after_range.starts_with('\n') {
368 context_after_range.to_string()
369 } else {
370 format!("\n{context_after_range}")
371 };
372
373 let line_search_string = format!("{line_based_before}{text_range}{line_based_after}");
374 let mut line_positions = text.match_indices(&line_search_string);
375 let line_position = line_positions.next()?;
376
377 // The line-based search string must also appear exactly once
378 if line_positions.next().is_some() {
379 return None;
380 }
381
382 let line_range_start = line_position.0 + line_based_before.len();
383 let line_range_end = line_range_start + text_range.len();
384 let line_range_start_anchor =
385 snapshot.anchor_before(snapshot.offset_to_point(line_range_start));
386 let line_range_end_anchor = snapshot.anchor_before(snapshot.offset_to_point(line_range_end));
387
388 Some(line_range_start_anchor..line_range_end_anchor)
389}