diff --git a/assets/settings/default.json b/assets/settings/default.json index 624dcc0f01233a..c2836b83cab869 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1116,6 +1116,7 @@ "get_code_actions": true, "go_to_definition": true, "list_directory": true, + "lsp_hover": true, "project_notifications": false, "move_path": true, "now": true, @@ -1146,6 +1147,7 @@ "find_references": true, "get_code_actions": true, "go_to_definition": true, + "lsp_hover": true, "read_file": true, "open": true, "grep": true, diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index c6979391673ec6..17ec70b5e23703 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2,10 +2,10 @@ use crate::{ 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, + ListDirectoryTool, LspHoverTool, 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; @@ -1573,6 +1573,7 @@ impl Thread { if cx.has_flag::() { let code_action_store: CodeActionStore = cx.new(|_cx| None); self.add_tool(FindReferencesTool::new(self.project.clone())); + self.add_tool(LspHoverTool::new(self.project.clone())); self.add_tool(GetCodeActionsTool::new( self.project.clone(), code_action_store.clone(), diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 71ee0b2ba1714f..a17e506210aa3a 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -14,6 +14,8 @@ mod get_code_actions_tool; mod go_to_definition_tool; mod grep_tool; mod list_directory_tool; +mod lsp_hover_tool; +mod lsp_tool_utils; mod move_path_tool; mod now_tool; mod open_tool; @@ -72,6 +74,7 @@ pub use get_code_actions_tool::*; pub use go_to_definition_tool::*; pub use grep_tool::*; pub use list_directory_tool::*; +pub use lsp_hover_tool::*; pub use move_path_tool::*; pub use now_tool::*; pub use open_tool::*; @@ -167,6 +170,7 @@ tools! { GetCodeActionsTool, GoToDefinitionTool, GrepTool, + LspHoverTool, ListDirectoryTool, MovePathTool, NowTool, diff --git a/crates/agent/src/tools/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs index 32d872f6578e35..c3257002eff6f7 100644 --- a/crates/agent/src/tools/grep_tool.rs +++ b/crates/agent/src/tools/grep_tool.rs @@ -18,6 +18,11 @@ use util::paths::PathMatcher; /// Searches the contents of files in the project with a regular expression /// +/// - If you need a specific symbol's **type**, **signature**, **enum cases**, or **documentation**, +/// consider using `lsp_hover` or `goto_definition` first — they are faster, more precise, and can +/// resolve things grep cannot (virtual properties, vendor code, trait methods, generic types). +/// - If you need all **usages** of a specific symbol, consider `find_references` first — it returns +/// only semantic references, not textual matches, so there are no false positives. /// - Prefer this tool to path search when searching for symbols in the project, because you won't need to guess what path it's in. /// - Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.) /// - Pass an `include_pattern` if you know how to narrow your search on the files system diff --git a/crates/agent/src/tools/lsp_hover_tool.rs b/crates/agent/src/tools/lsp_hover_tool.rs new file mode 100644 index 00000000000000..d9ce3e9461cf10 --- /dev/null +++ b/crates/agent/src/tools/lsp_hover_tool.rs @@ -0,0 +1,291 @@ +use crate::tools::lsp_tool_utils::{ + format_display_symbol, open_buffer_for_path, resolve_position, validate_lsp_tool_input, +}; +use crate::{AgentTool, ToolCallEventStream, ToolInput}; +use agent_client_protocol::schema as acp; +use anyhow::Result; +use futures::FutureExt as _; +use gpui::{App, Entity, Task}; +use project::{HoverBlockKind, Project}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::fmt::Write; +use std::sync::Arc; +use ui::SharedString; +use util::markdown::MarkdownInlineCode; + +/// Get type information, documentation, and deprecation notices for a symbol — instantly, +/// without reading the file. This is the fastest way to answer "what type is this?", +/// "what parameters does this method take?", "what does it return?", or "what are the +/// cases of this enum?". +/// +/// Use this **before** reaching for `grep` or `read_file` when you need to understand what +/// a symbol is. A single hover call replaces the `grep` → `read_file` → scan-for-the-thing +/// round-trip. +/// +/// **When to use hover vs other tools:** +/// - You see `$order->status` and want to know the type → `lsp_hover` (don't read the whole model file) +/// - You see a method call and want its parameters, return type, or full signature → `lsp_hover` (don't find and read the class) +/// - You see an enum value and want all cases → `lsp_hover` on the enum type name (don't find and read the enum file) +/// - You see a magic/virtual property (e.g. Eloquent casts, accessors) and want its type → `lsp_hover` +/// (grep cannot find these — there is no explicit property declaration to match) +/// - You want to read the full implementation → use `goto_definition` then `read_file` instead +/// - You want to find all usages of a symbol → use `find_references` or `grep` +/// - You are looking at a function/class/method **definition** and want to understand it → just `read_file` +/// the surrounding code. Do NOT hover on definition sites — the language server often returns no +/// information or just repeats the signature you can already see. +/// +/// **Practical pattern:** When exploring an unfamiliar method, hover on every distinct symbol in +/// parallel — the parameters, each method call, each caught exception, each property access. This +/// builds a complete type map of the code in a few batches of parallel calls, far faster than +/// finding and reading each source file individually. +/// +/// Hovering on local variables may return minimal info if the language server cannot infer the type. +/// In that case, fall back to `grep` or `read_file`. +/// +/// +/// To get hover information for a symbol by name: +/// { +/// "path": "my_project/src/main.rs", +/// "line": 42, +/// "symbol": "some_function" +/// } +/// +/// +/// +/// When the same symbol appears multiple times on a line, provide both `symbol` and `column` +/// to disambiguate. The column does NOT need to be exact — it is fuzzy. The tool picks the +/// occurrence of the symbol nearest to the column you provide, so even a rough estimate works. +/// For example, on `$result->token => $e->paymentToken->token->value`, using `column: 0` would +/// hover over the first `token`, while `column: 99` would hover over the second `token`: +/// { +/// "path": "my_project/src/main.rs", +/// "line": 42, +/// "symbol": "token", +/// "column": 50 +/// } +/// +/// +/// +/// - Prefer `symbol` over `column` — it is more robust because the tool searches nearby lines +/// if the exact line doesn't match. +/// - When the same symbol appears multiple times on a line, provide both `symbol` and `column` +/// to pick the right occurrence. The `column` is fuzzy — it selects the nearest match, so an +/// approximate value (e.g. "roughly in the second half of the line") is good enough. +/// - If the language server is not available or doesn't support hover, the tool will return an error. +/// - Do NOT use hover on a symbol at its definition site (e.g., hovering on `Payment` in +/// `class Payment { ... }` or on `capturePayment` in `function capturePayment(...)`). The language +/// server returns little or no information for definitions — you are already looking at the definition. +/// Hover is designed for **usages/references** of a symbol, where you want to understand its type or +/// documentation without navigating to the definition. +/// +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct LspHoverToolInput { + /// The relative path of the file containing the symbol. + /// + /// This path should never be absolute, and the first component + /// of the path should always be a root directory in a project. + /// + /// + /// If the project has the following root directories: + /// + /// - lorem + /// - ipsum + /// + /// If you want to hover a symbol in `ipsum/src/dolor.txt`, you should use the path `ipsum/src/dolor.txt`. + /// + /// Given an absolute path like `/home/user/code/monorepo/packages/ipsum/src/dolor.txt`: + /// ✅ Correct: `ipsum/src/dolor.txt` + /// ❌ Wrong: `packages/ipsum/src/dolor.txt` (includes parent directories above the root) + /// ❌ Wrong: `src/dolor.txt` (missing the root directory) + /// + pub path: String, + + /// Line number (1-based) where the symbol appears. + pub line: u32, + + /// The symbol name to look up on that line. + /// Either `symbol` or `column` must be provided. + #[serde(default)] + pub symbol: Option, + + /// Approximate column position (0-based) on the line to hover at. + /// This is fuzzy — the tool picks the occurrence of `symbol` nearest to this column, so even + /// a rough estimate is useful to disambiguate when the same symbol appears multiple times on + /// a line. Either `symbol` or `column` must be provided. + #[serde(default)] + pub column: Option, +} + +pub struct LspHoverTool { + project: Entity, +} + +impl LspHoverTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for LspHoverTool { + type Input = LspHoverToolInput; + type Output = String; + + const NAME: &'static str = "lsp_hover"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Read + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + if let Ok(input) = input { + let target = format_display_symbol(input.symbol.as_deref(), input.column); + format!( + "Hover {} in {}", + MarkdownInlineCode(&target), + MarkdownInlineCode(&input.path) + ) + .into() + } else { + "LSP Hover".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}"))?; + + validate_lsp_tool_input( + &input.path, + input.line, + input.symbol.as_deref(), + input.column, + )?; + + let display_symbol = format_display_symbol(input.symbol.as_deref(), input.column); + + // Find the project path and open the buffer + let open_buffer_task = project.update(cx, |project, cx| { + open_buffer_for_path(project, &input.path, cx) + })?; + + let buffer = futures::select! { + result = open_buffer_task.fuse() => result.map_err(|e| e.to_string())?, + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Hover cancelled by user".to_string()); + } + }; + + // Convert 1-based line to 0-based row + let row = input.line - 1; + + // Resolve the hover position + let position = buffer.read_with(cx, |buffer, _cx| { + let snapshot = buffer.snapshot(); + resolve_position( + &snapshot, + row, + input.line, + input.column, + input.symbol.as_deref(), + ) + })?; + + let resolved_line = position.row + 1; + + // Request hover from the LSP + let hover_task = project.update(cx, |project, cx| project.hover(&buffer, position, cx)); + + let hovers = futures::select! { + result = hover_task.fuse() => result, + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Hover cancelled by user".to_string()); + } + }; + + // Format the hover results + let resolved_line_hint = if resolved_line != input.line { + format!( + " Note: `{display_symbol}` was not found on the requested line {}, \ + but was found on nearby line {resolved_line}.", + input.line, + ) + } else { + String::new() + }; + + let Some(hovers) = hovers else { + return Err(format!( + "The language server returned no hover information for `{display_symbol}` \ + on line {resolved_line}. The symbol was found in the source text, but the \ + language server could not resolve type or documentation information for it. \ + This can happen with dynamic/magic properties, unresolved types, or symbols \ + the language server doesn't understand.{resolved_line_hint}", + )); + }; + + if hovers.is_empty() { + return Err(format!( + "The language server returned no hover information for `{display_symbol}` \ + on line {resolved_line}. The symbol was found in the source text, but the \ + language server could not resolve type or documentation information for it. \ + This can happen with dynamic/magic properties, unresolved types, or symbols \ + the language server doesn't understand.{resolved_line_hint}", + )); + } + + let mut output = String::new(); + for hover in &hovers { + if hover.is_empty() { + continue; + } + for block in &hover.contents { + if block.text.is_empty() { + continue; + } + match &block.kind { + HoverBlockKind::PlainText => { + writeln!(output, "{}", block.text).ok(); + } + HoverBlockKind::Markdown => { + writeln!(output, "{}", block.text).ok(); + } + HoverBlockKind::Code { language } => { + writeln!(output, "```{}", language).ok(); + writeln!(output, "{}", block.text).ok(); + writeln!(output, "```").ok(); + } + } + } + if hovers.len() > 1 { + writeln!(output, "---").ok(); + } + } + + let output = output.trim().to_string(); + if output.is_empty() { + return Err(format!( + "The language server returned empty hover content for `{display_symbol}` \ + on line {resolved_line}. The symbol was found in the source text, but the \ + language server did not provide any type or documentation \ + information.{resolved_line_hint}", + )); + } + + Ok(output) + }) + } +} diff --git a/crates/agent/src/tools/lsp_tool_utils.rs b/crates/agent/src/tools/lsp_tool_utils.rs new file mode 100644 index 00000000000000..6ede84300abe1f --- /dev/null +++ b/crates/agent/src/tools/lsp_tool_utils.rs @@ -0,0 +1,509 @@ +use gpui::Entity; +use project::Project; +use std::cmp; +use text::{Point, PointUtf16, ToOffset}; + +/// The number of lines to search above and below when a symbol isn't found on the exact line. +/// Selection ranges from the editor often start on a blank line before the actual code. +const NEARBY_LINE_SEARCH_RADIUS: u32 = 4; + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +fn find_word_bounded<'a>(text: &'a str, symbol: &'a str) -> impl Iterator + 'a { + find_word_bounded_impl(text, symbol, false) +} + +fn find_word_bounded_case_insensitive<'a>( + text: &'a str, + symbol: &'a str, +) -> impl Iterator + 'a { + find_word_bounded_impl(text, symbol, true) +} + +fn find_word_bounded_impl<'a>( + text: &'a str, + symbol: &'a str, + case_insensitive: bool, +) -> impl Iterator + 'a { + let has_special_prefix = + symbol.starts_with('$') || symbol.starts_with('@') || symbol.starts_with('#'); + let symbol_len = symbol.len(); + let text_lower = if case_insensitive { + let lowered = text.to_lowercase(); + if lowered.len() != text.len() { + // Byte length changed during lowercasing (e.g. Unicode case folding), + // so byte offsets from the lowered string won't map back to the original. + // Bail out — the caller will get an empty iterator. + None + } else { + Some(lowered) + } + } else { + None + }; + let symbol_lower = if case_insensitive { + let lowered = symbol.to_lowercase(); + if lowered.len() != symbol.len() { + None + } else { + Some(lowered) + } + } else { + None + }; + // If case-insensitive was requested but lowercasing changed byte lengths, yield nothing. + let bail_out = case_insensitive && (text_lower.is_none() || symbol_lower.is_none()); + + let mut search_start = 0; + std::iter::from_fn(move || { + if bail_out { + return None; + } + let haystack = text_lower.as_deref().unwrap_or(text); + let needle = symbol_lower.as_deref().unwrap_or(symbol); + while let Some(relative_offset) = haystack[search_start..].find(needle) { + let absolute_offset = search_start + relative_offset; + search_start = absolute_offset + symbol_len; + + let start_ok = if has_special_prefix { + true + } else if absolute_offset == 0 { + true + } else { + let prev_char = text[..absolute_offset].chars().next_back().unwrap(); + !is_word_char(prev_char) + }; + + let end_offset = absolute_offset + symbol_len; + let end_ok = if end_offset >= text.len() { + true + } else { + let next_char = text[end_offset..].chars().next().unwrap(); + !is_word_char(next_char) + }; + + if start_ok && end_ok { + return Some(absolute_offset); + } + } + None + }) +} + +/// Find the occurrence of `symbol` in `line_text` whose start is nearest to `target_column` +/// (measured in UTF-16 code units). Returns the UTF-16 column of that occurrence. +fn resolve_nearest_symbol_column_utf16( + line_text: &str, + symbol: &str, + target_column: u32, +) -> Option { + // Try case-sensitive first, fall back to case-insensitive. + nearest_symbol_column_with( + find_word_bounded(line_text, symbol), + line_text, + target_column, + ) + .or_else(|| { + nearest_symbol_column_with( + find_word_bounded_case_insensitive(line_text, symbol), + line_text, + target_column, + ) + }) +} + +fn nearest_symbol_column_with( + matches: impl Iterator, + line_text: &str, + target_column: u32, +) -> Option { + let mut best: Option = None; + let mut best_distance = u32::MAX; + for byte_offset in matches { + let prefix = &line_text[..byte_offset]; + let col_utf16 = prefix.encode_utf16().count() as u32; + let distance = col_utf16.abs_diff(target_column); + if distance < best_distance { + best_distance = distance; + best = Some(col_utf16); + } + } + best +} + +/// Extract the text content of a given row from a buffer snapshot. +fn line_text_for_row(snapshot: &text::BufferSnapshot, row: u32) -> String { + let line_start = Point::new(row, 0).to_offset(snapshot); + let line_end_col = snapshot.line_len(row); + let line_end = Point::new(row, line_end_col).to_offset(snapshot); + snapshot.text_for_range(line_start..line_end).collect() +} + +fn resolve_symbol_column_utf16(line_text: &str, symbol: &str) -> Option { + // Try case-sensitive first, fall back to case-insensitive. + let byte_offset = find_word_bounded(line_text, symbol) + .next() + .or_else(|| find_word_bounded_case_insensitive(line_text, symbol).next())?; + let prefix = &line_text[..byte_offset]; + Some(prefix.encode_utf16().count() as u32) +} + +/// Resolve a hover/definition position from either a column or symbol name. +/// +/// When `column` is `Some`, it takes priority and is treated as a character offset (0-based) +/// on the given row, which is then converted to a UTF-16 column for the LSP. +/// +/// When `symbol` is `Some`, the line text is searched for the first occurrence of the symbol +/// and the UTF-16 column of that occurrence is returned. +/// +/// The `row` parameter is 0-based. The `line_1based` parameter is the original 1-based line +/// number from user input, used only for error messages. +pub fn resolve_position( + snapshot: &text::BufferSnapshot, + row: u32, + line_1based: u32, + column: Option, + symbol: Option<&str>, +) -> Result { + let max_row = snapshot.max_point().row; + if row > max_row { + return Err(format!( + "Line {} is out of range (file has {} lines)", + line_1based, + max_row + 1 + )); + } + + // When symbol is provided, always use symbol-based search — it's the most robust + // approach because it searches nearby lines and doesn't depend on exact line/column + // counting. When column is also provided, it disambiguates between multiple + // occurrences of the same symbol on a line (e.g. `$data = $data->toArray($datas)`). + if let Some(symbol) = symbol { + return resolve_by_symbol(snapshot, row, line_1based, max_row, symbol, column); + } + + if let Some(column) = column { + return resolve_by_column(snapshot, row, line_1based, max_row, column); + } + + Err("Either `symbol` or `column` must be provided".to_string()) +} + +/// Resolve position by searching for a symbol name on the target line and nearby lines. +fn resolve_by_symbol( + snapshot: &text::BufferSnapshot, + row: u32, + line_1based: u32, + max_row: u32, + symbol: &str, + column_hint: Option, +) -> Result { + // When a column hint is provided, pick the occurrence of the symbol nearest to it. + // This disambiguates cases like `$data = $data->toArray($datas)`. + let find_on_line = |line_text: &str| -> Option { + if let Some(column) = column_hint { + resolve_nearest_symbol_column_utf16(line_text, symbol, column) + } else { + resolve_symbol_column_utf16(line_text, symbol) + } + }; + + // Try the exact line first + let line_text = line_text_for_row(snapshot, row); + if let Some(column_utf16) = find_on_line(&line_text) { + return Ok(PointUtf16::new(row, column_utf16)); + } + + // Selection ranges often start on a blank line before the code, so search nearby. + let search_start = row.saturating_sub(NEARBY_LINE_SEARCH_RADIUS); + let search_end = cmp::min(row + NEARBY_LINE_SEARCH_RADIUS, max_row); + for candidate_row in search_start..=search_end { + if candidate_row == row { + continue; + } + let candidate_text = line_text_for_row(snapshot, candidate_row); + if let Some(column_utf16) = find_on_line(&candidate_text) { + return Ok(PointUtf16::new(candidate_row, column_utf16)); + } + } + + Err(format!( + "Symbol `{}` not found on or near line {}. \ + The line content is: `{}`", + symbol, line_1based, line_text, + )) +} + +/// Resolve position by column offset. If the column is out of range on the target line, +/// retries on line+1 since agents often land on a blank line before the code. +fn resolve_by_column( + snapshot: &text::BufferSnapshot, + row: u32, + line_1based: u32, + max_row: u32, + column: u32, +) -> Result { + let len = snapshot.line_len(row); + let actual_row = if column <= len { + row + } else if row < max_row && column <= snapshot.line_len(row + 1) { + row + 1 + } else { + let next_info = if row < max_row { + format!( + ", line {} has {} characters", + line_1based + 1, + snapshot.line_len(row + 1) + ) + } else { + String::new() + }; + return Err(format!( + "Column {} is out of range (line {} has {} characters{})", + column, line_1based, len, next_info, + )); + }; + + let line_start = Point::new(actual_row, 0).to_offset(snapshot); + let target_offset = Point::new(actual_row, column).to_offset(snapshot); + let prefix: String = snapshot.text_for_range(line_start..target_offset).collect(); + let column_utf16 = prefix.encode_utf16().count() as u32; + + Ok(PointUtf16::new(actual_row, column_utf16)) +} + +/// Validate the common input fields shared by all LSP tools. +pub fn validate_lsp_tool_input( + path: &str, + line: u32, + symbol: Option<&str>, + column: Option, +) -> Result<(), String> { + if path.is_empty() { + return Err("Path must not be empty".to_string()); + } + if symbol.is_none() && column.is_none() { + return Err("Either `symbol` or `column` must be provided".to_string()); + } + if symbol.is_some_and(|s| s.is_empty()) && column.is_none() { + return Err("Symbol must not be empty (or provide `column` instead)".to_string()); + } + if line == 0 { + return Err("Line number must be 1-based (starting from 1)".to_string()); + } + Ok(()) +} + +/// Open a buffer for the given path string, returning a helpful error if the path +/// isn't found in the project. +pub fn open_buffer_for_path( + project: &mut Project, + path: &str, + cx: &mut gpui::Context, +) -> Result>>, String> { + let Some(project_path) = project.find_project_path(path, cx) else { + let root_names: Vec<&str> = project.worktree_root_names(cx).collect(); + return Err(format!( + "Could not find path `{path}` in project. \ + The path must start with one of the project's root directories: {}. \ + For example: `{}/...`", + root_names.join(", "), + root_names.first().unwrap_or(&"root"), + )); + }; + Ok(project.open_buffer(project_path, cx)) +} + +/// Format a display string for error messages from the combination of symbol/column inputs. +pub fn format_display_symbol(symbol: Option<&str>, column: Option) -> String { + if let Some(symbol) = symbol { + symbol.to_string() + } else if let Some(column) = column { + format!("column {column}") + } else { + "symbol".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_symbol_column_utf16_ascii() { + let line = " fn hello_world() {"; + assert_eq!(resolve_symbol_column_utf16(line, "fn"), Some(4)); + assert_eq!(resolve_symbol_column_utf16(line, "hello_world"), Some(7)); + assert_eq!(resolve_symbol_column_utf16(line, "nonexistent"), None); + } + + #[test] + fn test_resolve_symbol_column_utf16_first_occurrence() { + let line = "foo(foo, bar)"; + assert_eq!(resolve_symbol_column_utf16(line, "foo"), Some(0)); + } + + #[test] + fn test_resolve_symbol_column_utf16_unicode() { + let line = "let café = 42;"; + assert_eq!(resolve_symbol_column_utf16(line, "café"), Some(4)); + assert_eq!(resolve_symbol_column_utf16(line, "42"), Some(11)); + } + + #[test] + fn test_resolve_symbol_column_utf16_surrogate_pairs() { + let line = "let 😀 = x;"; + assert_eq!(resolve_symbol_column_utf16(line, "="), Some(7)); + } + + #[test] + fn test_resolve_symbol_column_utf16_at_start() { + let line = "println!(\"hello\");"; + assert_eq!(resolve_symbol_column_utf16(line, "println"), Some(0)); + } + + #[test] + fn test_resolve_nearest_symbol_first_occurrence() { + let line = "$data = $data->toArray($datas);"; + // Column 0 is nearest to the first "$data" at column 0 + assert_eq!( + resolve_nearest_symbol_column_utf16(line, "$data", 0), + Some(0) + ); + } + + #[test] + fn test_resolve_nearest_symbol_second_occurrence() { + let line = "$data = $data->toArray($datas);"; + // Column 10 is nearest to the second "$data" at column 8 + assert_eq!( + resolve_nearest_symbol_column_utf16(line, "$data", 10), + Some(8) + ); + } + + #[test] + fn test_resolve_nearest_symbol_third_occurrence() { + let line = "$data = $data->toArray($datas);"; + // "$datas" at column 23 is NOT a word-bounded match for "$data" because the + // character after "$data" is 's', which is a word character. With column hint + // 23, the nearest valid word-bounded match is the second "$data" at column 8. + assert_eq!( + resolve_nearest_symbol_column_utf16(line, "$data", 23), + Some(8) + ); + } + + #[test] + fn test_resolve_nearest_symbol_exact_match() { + let line = "foo(bar, foo, baz, foo)"; + // Exactly at the second "foo" (column 9) + assert_eq!(resolve_nearest_symbol_column_utf16(line, "foo", 9), Some(9)); + // Exactly at the third "foo" (column 19) + assert_eq!( + resolve_nearest_symbol_column_utf16(line, "foo", 19), + Some(19) + ); + // Midway between second (9) and third (19), ties go to the closer one + assert_eq!( + resolve_nearest_symbol_column_utf16(line, "foo", 13), + Some(9) + ); + assert_eq!( + resolve_nearest_symbol_column_utf16(line, "foo", 15), + Some(19) + ); + } + + #[test] + fn test_resolve_nearest_symbol_no_match() { + let line = "let x = 42;"; + assert_eq!(resolve_nearest_symbol_column_utf16(line, "foo", 5), None); + } + + #[test] + fn test_resolve_nearest_symbol_single_occurrence() { + let line = "let x = foo();"; + // With only one occurrence, column hint doesn't matter + assert_eq!(resolve_nearest_symbol_column_utf16(line, "foo", 0), Some(8)); + assert_eq!( + resolve_nearest_symbol_column_utf16(line, "foo", 100), + Some(8) + ); + } + + #[test] + fn test_resolve_symbol_column_utf16_word_boundary() { + let line = "public function captureReservedPayment(Payment $payment, Decimal $captureAmount): void"; + // Should match standalone "Payment" (column 39), NOT the one inside "captureReservedPayment" + assert_eq!(resolve_symbol_column_utf16(line, "Payment"), Some(39)); + } + + #[test] + fn test_resolve_symbol_column_utf16_case_insensitive_fallback() { + let line = " 'payment_token_value' => $e->paymentToken->token->value,"; + // "PaymentToken" doesn't match case-sensitively, but matches "paymentToken" case-insensitively + assert_eq!(resolve_symbol_column_utf16(line, "PaymentToken"), Some(45)); + } + + #[test] + fn test_resolve_symbol_column_utf16_case_sensitive_preferred() { + let line = "let PaymentToken = paymentToken;"; + // Both match, but case-sensitive "PaymentToken" at column 4 should be preferred + assert_eq!(resolve_symbol_column_utf16(line, "PaymentToken"), Some(4)); + } + + #[test] + fn test_resolve_nearest_symbol_case_insensitive_fallback() { + let line = " 'payment_token_value' => $e->paymentToken->token->value,"; + // "PaymentToken" doesn't match case-sensitively, falls back to case-insensitive + assert_eq!( + resolve_nearest_symbol_column_utf16(line, "PaymentToken", 45), + Some(45) + ); + } + + #[test] + fn test_resolve_symbol_column_utf16_word_boundary_php_variable() { + let line = "public function captureReservedPayment(Payment $payment, Decimal $captureAmount): void"; + // PHP variable $payment should match at column 47 + assert_eq!(resolve_symbol_column_utf16(line, "$payment"), Some(47)); + } + + #[test] + fn test_resolve_symbol_column_utf16_word_boundary_at_start() { + let line = "Payment::create($data);"; + assert_eq!(resolve_symbol_column_utf16(line, "Payment"), Some(0)); + } + + #[test] + fn test_resolve_symbol_column_utf16_word_boundary_at_end() { + let line = "use App\\Models\\Payment"; + assert_eq!(resolve_symbol_column_utf16(line, "Payment"), Some(15)); + } + + #[test] + fn test_resolve_symbol_column_utf16_no_false_partial_match() { + let line = "let payment_processor = PaymentProcessor::new();"; + // "Payment" appears as a substring of "PaymentProcessor" but not as a standalone word + // It should NOT match "payment_processor" (lowercase) nor "PaymentProcessor" (word continues) + assert_eq!(resolve_symbol_column_utf16(line, "Payment"), None); + } + + #[test] + fn test_resolve_nearest_symbol_word_boundary() { + let line = "public function captureReservedPayment(Payment $payment): void"; + // Column 39 should match the standalone "Payment" at column 39 + assert_eq!( + resolve_nearest_symbol_column_utf16(line, "Payment", 39), + Some(39) + ); + // Column 0 should also match the standalone "Payment" at column 39 (only word-bounded match) + assert_eq!( + resolve_nearest_symbol_column_utf16(line, "Payment", 0), + Some(39) + ); + } +} diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index 12693cb99d98fc..c52daf7dd9409a 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -1415,6 +1415,7 @@ mod tests { "go_to_definition", "grep", "list_directory", + "lsp_hover", "now", "open", "read_file",