diff --git a/assets/settings/default.json b/assets/settings/default.json index cd2e164dfb..624dcc0f01 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1108,13 +1108,18 @@ "create_directory": true, "delete_path": true, "diagnostics": true, + "apply_code_action": true, "edit_file": true, "fetch": true, + "find_path": true, + "find_references": true, + "get_code_actions": true, + "go_to_definition": true, "list_directory": true, "project_notifications": false, "move_path": true, "now": true, - "find_path": true, + "rename_symbol": true, "read_file": true, "restore_file_from_disk": true, "save_file": true, @@ -1138,6 +1143,9 @@ "project_notifications": false, "now": true, "find_path": true, + "find_references": true, + "get_code_actions": true, + "go_to_definition": true, "read_file": true, "open": true, "grep": true, diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 308bc843b1..c697939167 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1,14 +1,15 @@ use crate::{ - ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread, - DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, - ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, + ApplyCodeActionTool, CodeActionStore, ContextServerRegistry, CopyPathTool, CreateDirectoryTool, + DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, + FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, + ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, RenameTool, RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool, decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; -use feature_flags::{FeatureFlagAppExt as _, UpdatePlanToolFeatureFlag}; +use feature_flags::{FeatureFlagAppExt as _, LspToolFeatureFlag, UpdatePlanToolFeatureFlag}; use agent_client_protocol::schema as acp; use agent_settings::{ @@ -1542,7 +1543,6 @@ impl Thread { self.project.clone(), self.action_log.clone(), )); - self.add_tool(DiagnosticsTool::new(self.project.clone())); self.add_tool(EditFileTool::new( self.project.clone(), cx.weak_entity(), @@ -1569,6 +1569,22 @@ impl Thread { self.add_tool(TerminalTool::new(self.project.clone(), environment.clone())); self.add_tool(WebSearchTool); + self.add_tool(DiagnosticsTool::new(self.project.clone())); + if cx.has_flag::() { + let code_action_store: CodeActionStore = cx.new(|_cx| None); + self.add_tool(FindReferencesTool::new(self.project.clone())); + self.add_tool(GetCodeActionsTool::new( + self.project.clone(), + code_action_store.clone(), + )); + self.add_tool(ApplyCodeActionTool::new( + self.project.clone(), + code_action_store, + )); + self.add_tool(GoToDefinitionTool::new(self.project.clone())); + self.add_tool(RenameTool::new(self.project.clone())); + } + if self.depth() < MAX_SUBAGENT_DEPTH { self.add_tool(SpawnAgentTool::new(environment)); } diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index b9db30ce43..e629f41e6d 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -1,3 +1,4 @@ +mod apply_code_action_tool; mod context_server_registry; mod copy_path_tool; mod create_directory_tool; @@ -8,15 +9,20 @@ mod edit_file_tool; mod evals; mod fetch_tool; mod find_path_tool; +mod find_references_tool; +mod get_code_actions_tool; +mod go_to_definition_tool; mod grep_tool; mod list_directory_tool; mod move_path_tool; mod now_tool; mod open_tool; mod read_file_tool; +mod rename_tool; mod restore_file_from_disk_tool; mod save_file_tool; mod spawn_agent_tool; +mod symbol_locator; mod terminal_tool; mod tool_permissions; mod update_plan_tool; @@ -25,6 +31,7 @@ mod web_search_tool; use crate::AgentTool; use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat}; +pub use apply_code_action_tool::*; pub use context_server_registry::*; pub use copy_path_tool::*; pub use create_directory_tool::*; @@ -33,15 +40,20 @@ pub use diagnostics_tool::*; pub use edit_file_tool::*; pub use fetch_tool::*; pub use find_path_tool::*; +pub use find_references_tool::*; +pub use get_code_actions_tool::*; +pub use go_to_definition_tool::*; pub use grep_tool::*; pub use list_directory_tool::*; pub use move_path_tool::*; pub use now_tool::*; pub use open_tool::*; pub use read_file_tool::*; +pub use rename_tool::*; pub use restore_file_from_disk_tool::*; pub use save_file_tool::*; pub use spawn_agent_tool::*; +pub use symbol_locator::*; pub use terminal_tool::*; pub use tool_permissions::*; pub use update_plan_tool::*; @@ -116,6 +128,7 @@ macro_rules! tools { } tools! { + ApplyCodeActionTool, CopyPathTool, CreateDirectoryTool, DeletePathTool, @@ -123,12 +136,16 @@ tools! { EditFileTool, FetchTool, FindPathTool, + FindReferencesTool, + GetCodeActionsTool, + GoToDefinitionTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ReadFileTool, + RenameTool, RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, diff --git a/crates/agent/src/tools/apply_code_action_tool.rs b/crates/agent/src/tools/apply_code_action_tool.rs new file mode 100644 index 0000000000..409546d89d --- /dev/null +++ b/crates/agent/src/tools/apply_code_action_tool.rs @@ -0,0 +1,145 @@ +use std::fmt::Write; +use std::sync::Arc; + +use agent_client_protocol::schema as acp; +use gpui::{App, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::symbol_locator::CodeActionStore; +use crate::{AgentTool, ToolCallEventStream, ToolInput}; + +/// Applies a code action previously retrieved by get_code_actions. +/// +/// You must call get_code_actions first to get the list of available actions, +/// then use the number from that list to choose which action to apply. +/// +/// After applying a code action, the list is cleared. If you want to apply +/// another action, call get_code_actions again. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct ApplyCodeActionToolInput { + /// The 1-based index of the code action to apply, from the list + /// returned by get_code_actions. + pub index: u32, +} + +pub struct ApplyCodeActionTool { + project: Entity, + code_action_store: CodeActionStore, +} + +impl ApplyCodeActionTool { + pub fn new(project: Entity, code_action_store: CodeActionStore) -> Self { + Self { + project, + code_action_store, + } + } +} + +impl AgentTool for ApplyCodeActionTool { + type Input = ApplyCodeActionToolInput; + type Output = String; + + const NAME: &'static str = "apply_code_action"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + input: Result, + cx: &mut App, + ) -> SharedString { + if let Ok(input) = input { + let title = self + .code_action_store + .read(cx) + .as_ref() + .and_then(|pending| { + let index = input.index.checked_sub(1)? as usize; + Some(pending.actions.get(index)?.lsp_action.title().to_string()) + }); + if let Some(title) = title { + format!("Apply code action: {title}").into() + } else { + format!("Apply code action #{}", input.index).into() + } + } else { + "Apply code action".into() + } + } + + fn run( + self: Arc, + input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.project.clone(); + let store = self.code_action_store.clone(); + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + let pending = store.update(cx, |store, _cx| store.take()).ok_or_else(|| { + "No code actions available. Call get_code_actions first.".to_string() + })?; + + let zero_based_index = input + .index + .checked_sub(1) + .ok_or_else(|| "Index must be 1 or greater.".to_string())?; + + let action = pending + .actions + .get(zero_based_index as usize) + .cloned() + .ok_or_else(|| { + format!( + "Index {} is out of range. There were {} code action(s) available.", + input.index, + pending.actions.len() + ) + })?; + + let title = action.lsp_action.title().to_string(); + let buffer = pending.buffer.clone(); + + let apply_task = project.update(cx, |project, cx| { + project.apply_code_action(buffer, action, true, cx) + }); + + let transaction = apply_task + .await + .map_err(|e| format!("Failed to apply code action '{title}': {e}"))?; + + if transaction.0.is_empty() { + return Ok(format!( + "Code action '{title}' was applied but made no changes.", + )); + } + + let mut output = format!( + "Applied code action '{title}'. Modified {} file(s):\n", + transaction.0.len() + ); + + for (buffer, _) in &transaction.0 { + buffer.read_with(cx, |buffer, cx| { + let path = buffer + .file() + .map(|f| f.full_path(cx).display().to_string()) + .unwrap_or_else(|| "".to_string()); + writeln!(output, "- {path}").ok(); + }); + } + + Ok(output) + }) + } +} diff --git a/crates/agent/src/tools/find_references_tool.rs b/crates/agent/src/tools/find_references_tool.rs new file mode 100644 index 0000000000..f829e15046 --- /dev/null +++ b/crates/agent/src/tools/find_references_tool.rs @@ -0,0 +1,104 @@ +use std::fmt::Write; +use std::sync::Arc; + +use super::symbol_locator::{LocationDisplay, SymbolLocator}; +use crate::{AgentTool, ToolCallEventStream, ToolInput}; +use agent_client_protocol::schema as acp; +use gpui::{App, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Finds all references to a symbol across the project using the language server. +/// +/// Returns a list of locations where the symbol is referenced, including file paths, +/// line numbers, and code snippets for each reference. +/// +/// Before using this tool, use read_file or grep to find the exact symbol +/// name and line number. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct FindReferencesToolInput { + /// The symbol to find references of. + pub symbol: SymbolLocator, +} + +pub struct FindReferencesTool { + project: Entity, +} + +impl FindReferencesTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for FindReferencesTool { + type Input = FindReferencesToolInput; + type Output = String; + + const NAME: &'static str = "find_references"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Search + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + if let Ok(input) = input { + format!("Find references to `{}`", input.symbol.symbol_name).into() + } else { + "Find references".into() + } + } + + fn run( + self: Arc, + input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.project.clone(); + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + let resolved = input.symbol.resolve(&project, cx).await?; + + let references_task = project.update(cx, |project, cx| { + project.references(&resolved.buffer, resolved.position, cx) + }); + + let references = references_task + .await + .map_err(|e| format!("Find references failed: {e}"))? + .unwrap_or_default(); + + if references.is_empty() { + return Ok(format!( + "No references found for '{}'.", + input.symbol.symbol_name + )); + } + + let mut output = format!( + "Found {} references to `{}`:\n", + references.len(), + input.symbol.symbol_name + ); + + for location in &references { + let display = location + .buffer + .read_with(cx, |_, cx| LocationDisplay::from_location(location, cx)); + write!(output, "\n## {display}\n").ok(); + } + + Ok(output) + }) + } +} diff --git a/crates/agent/src/tools/get_code_actions_tool.rs b/crates/agent/src/tools/get_code_actions_tool.rs new file mode 100644 index 0000000000..7f4fc81ec3 --- /dev/null +++ b/crates/agent/src/tools/get_code_actions_tool.rs @@ -0,0 +1,119 @@ +use std::fmt::Write; +use std::sync::Arc; + +use agent_client_protocol::schema as acp; +use gpui::{App, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::symbol_locator::{CodeActionStore, PendingCodeActions, SymbolLocator}; +use crate::{AgentTool, ToolCallEventStream, ToolInput}; + +/// Gets the list of available code actions at a symbol location from the language server. +/// +/// Code actions include quick fixes, refactorings, and other automated transformations +/// suggested by the language server (e.g. "Add missing import", "Extract to function"). +/// +/// Returns a numbered list of available actions. Use apply_code_action with the +/// corresponding number to apply one. +/// +/// Before using this tool, use read_file or grep to find the exact symbol +/// name and line number. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct GetCodeActionsToolInput { + /// The symbol to get code actions for. + pub symbol: SymbolLocator, +} + +pub struct GetCodeActionsTool { + project: Entity, + code_action_store: CodeActionStore, +} + +impl GetCodeActionsTool { + pub fn new(project: Entity, code_action_store: CodeActionStore) -> Self { + Self { + project, + code_action_store, + } + } +} + +impl AgentTool for GetCodeActionsTool { + type Input = GetCodeActionsToolInput; + type Output = String; + + const NAME: &'static str = "get_code_actions"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Search + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + if let Ok(input) = input { + format!("Get code actions for `{}`", input.symbol.symbol_name).into() + } else { + "Get code actions".into() + } + } + + fn run( + self: Arc, + input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.project.clone(); + let store = self.code_action_store.clone(); + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + let resolved = input.symbol.resolve(&project, cx).await?; + + let actions_task = project.update(cx, |project, cx| { + let range = resolved.position..resolved.position; + project.code_actions(&resolved.buffer, range, None, cx) + }); + + let actions = actions_task + .await + .map_err(|e| format!("Failed to get code actions: {e}"))? + .unwrap_or_default(); + + if actions.is_empty() { + store.update(cx, |store, _cx| *store = None); + return Ok(format!( + "No code actions available for '{}' at this location.", + input.symbol.symbol_name + )); + } + + let mut output = format!("Found {} code action(s):\n", actions.len()); + for (i, action) in actions.iter().enumerate() { + writeln!(output, "{}. {}", i + 1, action.lsp_action.title()).ok(); + } + write!( + output, + "\nUse apply_code_action with the number of the action you want to apply." + ) + .ok(); + + store.update(cx, |store, _cx| { + *store = Some(PendingCodeActions { + actions, + buffer: resolved.buffer, + }); + }); + + Ok(output) + }) + } +} diff --git a/crates/agent/src/tools/go_to_definition_tool.rs b/crates/agent/src/tools/go_to_definition_tool.rs new file mode 100644 index 0000000000..2061da124b --- /dev/null +++ b/crates/agent/src/tools/go_to_definition_tool.rs @@ -0,0 +1,113 @@ +use std::fmt::Write; +use std::sync::Arc; + +use super::symbol_locator::{LocationDisplay, SymbolLocator}; +use crate::{AgentTool, ToolCallEventStream, ToolInput}; +use agent_client_protocol::schema as acp; +use gpui::{App, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Jumps to the definition of a symbol using the language server. +/// +/// Returns the file path and line number of the symbol's definition, +/// along with a snippet of the source code at that location. +/// +/// Before using this tool, use read_file or grep to find the exact symbol +/// name and line number of a usage you want to navigate from. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct GoToDefinitionToolInput { + /// The symbol to find the definition of. + pub symbol: SymbolLocator, +} + +pub struct GoToDefinitionTool { + project: Entity, +} + +impl GoToDefinitionTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for GoToDefinitionTool { + type Input = GoToDefinitionToolInput; + type Output = String; + + const NAME: &'static str = "go_to_definition"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Search + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + if let Ok(input) = input { + format!("Go to definition of `{}`", input.symbol.symbol_name).into() + } else { + "Go to definition".into() + } + } + + fn run( + self: Arc, + input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.project.clone(); + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + let resolved = input.symbol.resolve(&project, cx).await?; + + let definitions_task = project.update(cx, |project, cx| { + project.definitions(&resolved.buffer, resolved.position, cx) + }); + + let definitions = definitions_task + .await + .map_err(|e| format!("Go to definition failed: {e}"))? + .unwrap_or_default(); + + if definitions.is_empty() { + return Ok(format!( + "No definition found for '{}'.", + input.symbol.symbol_name + )); + } + + let mut output = String::new(); + + if definitions.len() == 1 { + write!(output, "Definition of `{}`:\n", input.symbol.symbol_name).ok(); + } else { + write!( + output, + "Found {} definitions of `{}`:\n", + definitions.len(), + input.symbol.symbol_name + ) + .ok(); + } + + for link in &definitions { + let display = link + .target + .buffer + .read_with(cx, |_, cx| LocationDisplay::from_location(&link.target, cx)); + write!(output, "\n## {display}\n").ok(); + } + + Ok(output) + }) + } +} diff --git a/crates/agent/src/tools/rename_tool.rs b/crates/agent/src/tools/rename_tool.rs new file mode 100644 index 0000000000..7abc45d695 --- /dev/null +++ b/crates/agent/src/tools/rename_tool.rs @@ -0,0 +1,121 @@ +use std::fmt::Write; +use std::sync::Arc; + +use agent_client_protocol::schema as acp; +use gpui::{App, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::symbol_locator::SymbolLocator; +use crate::{AgentTool, ToolCallEventStream, ToolInput}; + +/// Renames a symbol across the project using the language server. +/// +/// This performs a semantic rename, updating all references to the symbol +/// across all files in the project. The language server determines which +/// occurrences to rename based on the symbol's type and scope. +/// +/// Before using this tool, use read_file or grep to find the exact symbol +/// name and line number. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct RenameToolInput { + /// The symbol to rename. + pub symbol: SymbolLocator, + + /// The new name for the symbol. + pub new_name: String, +} + +pub struct RenameTool { + project: Entity, +} + +impl RenameTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for RenameTool { + type Input = RenameToolInput; + type Output = String; + + const NAME: &'static str = "rename_symbol"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + if let Ok(input) = input { + format!( + "Rename `{}` to `{}`", + input.symbol.symbol_name, input.new_name + ) + .into() + } else { + "Rename symbol".into() + } + } + + fn run( + self: Arc, + input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.project.clone(); + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + let resolved = input.symbol.resolve(&project, cx).await?; + + let rename_task = project.update(cx, |project, cx| { + project.perform_rename( + resolved.buffer.clone(), + resolved.position, + input.new_name.clone(), + cx, + ) + }); + + let transaction = rename_task + .await + .map_err(|e| format!("Rename failed: {e}"))?; + + if transaction.0.is_empty() { + return Ok(format!( + "No changes were made. The language server could not rename '{}'.", + input.symbol.symbol_name + )); + } + + let mut output = format!( + "Renamed `{}` to `{}` in {} file(s):\n", + input.symbol.symbol_name, + input.new_name, + transaction.0.len() + ); + + for (buffer, _) in &transaction.0 { + buffer.read_with(cx, |buffer, cx| { + let path = buffer + .file() + .map(|f| f.full_path(cx).display().to_string()) + .unwrap_or_else(|| "".to_string()); + writeln!(output, "- {path}").ok(); + }); + } + + Ok(output) + }) + } +} diff --git a/crates/agent/src/tools/symbol_locator.rs b/crates/agent/src/tools/symbol_locator.rs new file mode 100644 index 0000000000..cb38101877 --- /dev/null +++ b/crates/agent/src/tools/symbol_locator.rs @@ -0,0 +1,239 @@ +use std::collections::VecDeque; +use std::fmt; + +use gpui::{App, AsyncApp, Entity}; +use language::{Buffer, Location}; +use project::{CodeAction, Project}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use text::ToPoint as _; +use text::{Anchor, Point}; + +/// Identifies a specific symbol (declaration or usage) in the source code. +/// +/// Use the file path, line number, and symbol name from file outlines, grep results, +/// or other tool outputs to populate these fields. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct SymbolLocator { + /// The relative path of the file containing the symbol + /// (e.g. "crates/editor/src/editor.rs"). + pub file_path: String, + + /// The 1-based line number where the symbol appears. + /// Use the line numbers from file outlines or grep results. + pub line: u32, + + /// The name of the symbol (function name, type name, variable name, etc.) + pub symbol_name: String, +} + +pub struct PendingCodeActions { + pub actions: Vec, + pub buffer: Entity, +} + +pub type CodeActionStore = Entity>; + +pub struct ResolvedSymbol { + pub buffer: Entity, + pub position: Anchor, + pub line_text: String, + pub truncated: bool, +} + +pub const MAX_LINE_DISPLAY_LEN: usize = 200; + +pub struct LocationDisplay { + pub path: String, + pub start_line: u32, + pub end_line: u32, + pub snippet: String, + pub truncated: bool, +} + +impl LocationDisplay { + pub fn from_location(location: &Location, cx: &App) -> Self { + let snapshot = location.buffer.read(cx).snapshot(); + let range = + location.range.start.to_point(&snapshot)..location.range.end.to_point(&snapshot); + let path = location + .buffer + .read(cx) + .file() + .map(|f| f.full_path(cx).display().to_string()) + .unwrap_or_else(|| "".to_string()); + + let start_line = range.start.row + 1; + let end_line = range.end.row + 1; + + let line_len = snapshot.line_len(range.start.row); + let truncated = line_len as usize > MAX_LINE_DISPLAY_LEN; + let snippet: String = snapshot + .text_for_range(Point::new(range.start.row, 0)..Point::new(range.start.row, line_len)) + .flat_map(|chunk| chunk.chars()) + .skip_while(|c| c.is_whitespace()) + .take(MAX_LINE_DISPLAY_LEN) + .collect::(); + let snippet = snippet.trim_end().to_string(); + + Self { + path, + start_line, + end_line, + snippet, + truncated, + } + } +} + +impl fmt::Display for LocationDisplay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let truncated_label = if self.truncated { " (truncated)" } else { "" }; + if self.start_line == self.end_line { + writeln!(f, "{}#L{}{truncated_label}", self.path, self.start_line)?; + } else { + writeln!( + f, + "{}#L{}-{}{truncated_label}", + self.path, self.start_line, self.end_line + )?; + } + writeln!(f, "```")?; + writeln!(f, "{}", self.snippet)?; + write!(f, "```") + } +} + +/// Searches for `needle` in a char iterator, returning the byte offset of the +/// first occurrence without collecting the full iterator into a string. +/// +/// Equivalent to [`str::find`] +fn find_in_char_iter(chars: impl Iterator, needle: &str) -> Option { + let needle_chars: Vec = needle.chars().collect(); + if needle_chars.is_empty() { + return Some(0); + } + + let mut window: VecDeque = VecDeque::with_capacity(needle_chars.len()); + let mut byte_offsets: VecDeque = VecDeque::with_capacity(needle_chars.len()); + let mut byte_offset = 0usize; + + for ch in chars { + window.push_back(ch); + byte_offsets.push_back(byte_offset); + byte_offset += ch.len_utf8(); + + if window.len() > needle_chars.len() { + window.pop_front(); + byte_offsets.pop_front(); + } + + if window.len() == needle_chars.len() + && window.iter().zip(needle_chars.iter()).all(|(a, b)| a == b) + { + return byte_offsets.front().copied(); + } + } + + None +} + +impl SymbolLocator { + /// Resolves this locator into a concrete buffer and position. + /// + /// Opens the file at `file_path`, then searches for `symbol_name` on the + /// specified `line`. Returns an error if the file can't be found, the line + /// is out of range, or the symbol name doesn't appear on that line. + /// If the symbol name appears multiple times on the line, uses the first + /// occurrence. + pub async fn resolve( + &self, + project: &Entity, + cx: &mut AsyncApp, + ) -> Result { + let Self { + file_path, + line, + symbol_name, + } = self; + + let open_buffer_task = project.update(cx, |project, cx| { + let Some(project_path) = project.find_project_path(file_path, cx) else { + return Err(format!("Could not find path '{file_path}' in project",)); + }; + Ok(project.open_buffer(project_path, cx)) + })?; + + let buffer = open_buffer_task + .await + .map_err(|e| format!("Failed to open '{}': {e}", self.file_path))?; + + let (position, line_text, truncated) = buffer.read_with(cx, |buffer, _cx| { + let snapshot = buffer.snapshot(); + let row = line.saturating_sub(1); + + if row > snapshot.max_point().row { + let line_count = snapshot.max_point().row + 1; + return Err(format!( + "Line {line} is beyond the end of '{file_path}' (file has {line_count} lines)", + )); + } + + let line_len = snapshot.line_len(row); + let truncated = line_len as usize > MAX_LINE_DISPLAY_LEN; + let line_start = Point::new(row, 0); + let line_end = Point::new(row, line_len); + let line_chars = || { + snapshot + .text_for_range(line_start..line_end) + .flat_map(|chunk| chunk.chars()) + }; + + let byte_offset = find_in_char_iter(line_chars(), symbol_name).ok_or_else(|| { + let preview: String = line_chars() + .skip_while(|c| c.is_whitespace()) + .take(MAX_LINE_DISPLAY_LEN) + .collect(); + format!( + "Symbol '{symbol_name}' not found on line {line} of '{file_path}'. \ + Line content: '{}'", + preview.trim_end() + ) + })?; + + let position = snapshot.anchor_before(Point::new(row, byte_offset as u32)); + let display_text: String = line_chars() + .skip_while(|c| c.is_whitespace()) + .take(MAX_LINE_DISPLAY_LEN) + .collect::(); + let display_text = display_text.trim_end().to_string(); + + Ok((position, display_text, truncated)) + })?; + + Ok(ResolvedSymbol { + buffer, + position, + line_text, + truncated, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::proptest::prelude::*; + + #[gpui::property_test] + fn find_in_char_iter_test( + // limited character sets to increase odds of finding matches + #[strategy = "[abcd]{100,1000}"] haystack: String, + #[strategy = "[abcd]{1,5}"] needle: String, + ) -> Result<(), TestCaseError> { + let expected = haystack.find(&needle); + let actual = find_in_char_iter(haystack.chars(), &needle); + prop_assert_eq!(actual, expected); + Ok::<_, TestCaseError>(()) + } +} diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 56e3d135d9..d9af542efe 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -59,6 +59,18 @@ impl FeatureFlag for UpdatePlanToolFeatureFlag { } register_feature_flag!(UpdatePlanToolFeatureFlag); +pub struct LspToolFeatureFlag; + +impl FeatureFlag for LspToolFeatureFlag { + const NAME: &'static str = "lsp-tool"; + type Value = PresenceFlag; + + fn enabled_for_staff() -> bool { + false + } +} +register_feature_flag!(LspToolFeatureFlag); + pub struct ProjectPanelUndoRedoFeatureFlag; impl FeatureFlag for ProjectPanelUndoRedoFeatureFlag { diff --git a/crates/gpui/src/test.rs b/crates/gpui/src/test.rs index 9f22487133..a93424edea 100644 --- a/crates/gpui/src/test.rs +++ b/crates/gpui/src/test.rs @@ -71,16 +71,20 @@ pub fn apply_seed_to_proptest_config( /// /// Doesn't support many features of [`run_test`], since these are provided by /// proptest. -pub fn run_test_once(seed: u64, test_fn: Box) { +pub fn run_test_once( + seed: u64, + test_fn: Box R>, +) -> R { let result = panic::catch_unwind(|| { let dispatcher = TestDispatcher::new(seed); let scheduler = dispatcher.scheduler().clone(); - test_fn(dispatcher); + let res = test_fn(dispatcher); scheduler.end_test(); + res }); match result { - Ok(()) => {} + Ok(r) => r, Err(e) => panic::resume_unwind(e), } } diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index e6b49dd6c8..12693cb99d 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -1407,13 +1407,18 @@ mod tests { // 2. Add it to this list with a comment explaining why it's excluded. const EXCLUDED_TOOLS: &[&str] = &[ // Read-only / low-risk tools that don't call decide_permission_from_settings + "apply_code_action", "diagnostics", "find_path", + "find_references", + "get_code_actions", + "go_to_definition", "grep", "list_directory", "now", "open", "read_file", + "rename_symbol", "thinking", // streaming_edit_file uses "edit_file" for permission lookups, // so its rules are configured under the edit_file entry.