diff --git a/CLAUDE.md b/CLAUDE.md index d76d5c3..f70aa0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,7 +161,7 @@ This modular architecture provides several advantages: ### Available MCP Tools -The server provides these 13 tools (implemented with `#[tool]` attribute): +The server provides these 14 tools (implemented with `#[tool]` attribute): **Connection Management:** @@ -191,6 +191,7 @@ The server provides these 13 tools (implemented with `#[tool]` attribute): incomplete data 11. **`lsp_apply_edit`**: Apply workspace edits using Neovim's LSP utility functions +12. **`lsp_definition`**: Get LSP definition with universal document identification ### Universal Document Identifier System @@ -207,8 +208,9 @@ by supporting multiple ways of referencing documents: This system enables LSP operations on files that may not be open in Neovim buffers, providing -enhanced flexibility for code analysis and navigation. The universal LSP tools (`lsp_code_actions`, -`lsp_hover`, `lsp_document_symbols`, `lsp_references`) accept any of these +enhanced flexibility for code analysis and navigation. The universal LSP tools +(`lsp_code_actions`, `lsp_hover`, `lsp_document_symbols`, `lsp_references`, +`lsp_definition`) accept any of these document identifier types. ### MCP Resources diff --git a/README.md b/README.md index 4ffa899..c8d081e 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Once both the MCP server and Neovim are running, here's a typical workflow: ## Available Tools -The server provides 13 MCP tools for interacting with Neovim: +The server provides 14 MCP tools for interacting with Neovim: ### Connection Management @@ -189,6 +189,13 @@ establishment phase: - Parameters: `connection_id` (string), `lsp_client_name` (string), `workspace_edit` (WorkspaceEdit object) - Workspace edit to apply +- **`lsp_definition`**: Get LSP definition with universal document identification + - Parameters: `connection_id` (string), `document` (DocumentIdentifier), + `lsp_client_name` (string), `line` (number), `character` (number) + (all positions are 0-indexed) + - Returns: Definition result supporting Location arrays, LocationLink arrays, + or null responses + ### Universal Document Identifier The `document` parameter in the universal LSP tools accepts a `DocumentIdentifier` diff --git a/docs/instructions.md b/docs/instructions.md index 6525fa5..32ec707 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -4,7 +4,7 @@ ### Tools -The server provides 13 MCP tools for interacting with Neovim instances: +The server provides 14 MCP tools for interacting with Neovim instances: #### Connection Management @@ -134,6 +134,19 @@ All tools below require a `connection_id` parameter from connection establishmen - **Usage**: Apply code changes from resolved code actions to files using `vim.lsp.util.apply_workspace_edit()` with proper position encoding handling +- **`lsp_definition`**: Get LSP definition with universal document identification + - **Parameters**: + - `connection_id` (string): Target Neovim instance ID + - `document` (DocumentIdentifier): Universal document identifier + (BufferId, ProjectRelativePath, or AbsolutePath) + - `lsp_client_name` (string): LSP client name from lsp_clients + - `line` (number): Symbol position line (0-indexed) + - `character` (number): Symbol position character (0-indexed) + - **Returns**: Definition result supporting Location arrays, LocationLink + arrays, or null responses + - **Usage**: Find symbol definitions with enhanced type information and + robust result handling + ### Resources ### Universal Document Identifier System @@ -153,7 +166,7 @@ by supporting multiple ways of referencing documents: This system enables LSP operations on files that may not be open in Neovim buffers, providing enhanced flexibility for code analysis and navigation. The universal LSP tools (`lsp_code_actions`, `lsp_hover`, `lsp_document_symbols`, -`lsp_references`) accept +`lsp_references`, `lsp_definition`) accept any of these document identifier types. ### MCP Resources diff --git a/src/neovim/client.rs b/src/neovim/client.rs index b70cde7..dc92dae 100644 --- a/src/neovim/client.rs +++ b/src/neovim/client.rs @@ -82,6 +82,14 @@ pub trait NeovimClientTrait: Sync { include_declaration: bool, ) -> Result, NeovimError>; + /// Get definition(s) of a symbol + async fn lsp_definition( + &self, + client_name: &str, + document: DocumentIdentifier, + position: Position, + ) -> Result, NeovimError>; + /// Resolve a code action that may have incomplete data async fn lsp_resolve_code_action( &self, @@ -586,7 +594,7 @@ impl_fromstr_serde_json!(CodeAction); #[derive(Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] -pub struct HoverParams { +pub struct TextDocumentPositionParams { pub text_document: TextDocumentIdentifier, pub position: Position, } @@ -785,6 +793,37 @@ pub struct Location { pub range: Range, } +/// Represents a link between a source and a target location. +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocationLink { + /// Span of the origin of this link. + /// Used as the underlined span for mouse interaction. Defaults to the word + /// range at the definition position. + pub origin_selection_range: Option, + /// The target resource identifier of this link. + pub target_uri: String, + /// The full target range of this link. If the target for example is a symbol + /// then target range is the range enclosing this symbol not including + /// leading/trailing whitespace but everything else like comments. This + /// information is typically used for highlighting the range in the editor. + pub target_range: Range, + /// The range that should be selected and revealed when this link is being + /// followed, e.g the name of a function. Must be contained by the + /// `target_range`. See also `DocumentSymbol#range` + pub target_selection_range: Range, +} + +/// The result of a textDocument/definition request. +/// Can be a single Location, a list of Locations, or a list of LocationLinks. +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(untagged)] +pub enum DefinitionResult { + Single(Location), + Locations(Vec), + LocationLinks(Vec), +} + /// Represents information about programming constructs like variables, classes, interfaces etc. #[derive(Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -1386,7 +1425,7 @@ where vec![ Value::from(client_name), // client_name Value::from( - serde_json::to_string(&HoverParams { + serde_json::to_string(&TextDocumentPositionParams { text_document, position, }) @@ -1589,6 +1628,59 @@ where } } + #[instrument(skip(self))] + async fn lsp_definition( + &self, + client_name: &str, + document: DocumentIdentifier, + position: Position, + ) -> Result, NeovimError> { + let text_document = self.resolve_text_document_identifier(&document).await?; + + let conn = self.connection.as_ref().ok_or_else(|| { + NeovimError::Connection("Not connected to any Neovim instance".to_string()) + })?; + + match conn + .nvim + .execute_lua( + include_str!("lua/lsp_definition.lua"), + vec![ + Value::from(client_name), // client_name + Value::from( + serde_json::to_string(&TextDocumentPositionParams { + text_document, + position, + }) + .unwrap(), + ), // params + Value::from(1000), // timeout_ms + ], + ) + .await + { + Ok(result) => { + match serde_json::from_str::>>( + result.as_str().unwrap(), + ) { + Ok(d) => d.into(), + Err(e) => { + debug!("Failed to parse definition result: {e}"); + Err(NeovimError::Api(format!( + "Failed to parse definition result: {e}" + ))) + } + } + } + Err(e) => { + debug!("Failed to get LSP definition: {}", e); + Err(NeovimError::Api(format!( + "Failed to get LSP definition: {e}" + ))) + } + } + } + #[instrument(skip(self))] async fn lsp_resolve_code_action( &self, diff --git a/src/neovim/integration_tests.rs b/src/neovim/integration_tests.rs index d7d23f8..eb059cb 100644 --- a/src/neovim/integration_tests.rs +++ b/src/neovim/integration_tests.rs @@ -506,3 +506,119 @@ async fn test_lsp_apply_workspace_edit() { // Temp directory and file automatically cleaned up when temp_dir is dropped } + +#[tokio::test] +#[traced_test] +async fn test_lsp_definition() { + // Create a temporary directory and file + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let temp_file_path = temp_dir.path().join("test_definition.go"); + + // Create a Go file with a function definition and call + let go_content = r#"package main + +import "fmt" + +func sayHello(name string) string { + return "Hello, " + name +} + +func main() { + message := sayHello("World") + fmt.Println(message) +} +"#; + + fs::write(&temp_file_path, go_content).expect("Failed to write Go file"); + + // Setup Neovim with gopls + let ipc_path = generate_random_ipc_path(); + let child = setup_neovim_instance_ipc_advance( + &ipc_path, + get_testdata_path("cfg_lsp.lua").to_str().unwrap(), + temp_file_path.to_str().unwrap(), + ) + .await; + let _guard = NeovimIpcGuard::new(child, ipc_path.clone()); + let mut client = NeovimClient::new(); + + // Connect to instance + let result = client.connect_path(&ipc_path).await; + assert!(result.is_ok(), "Failed to connect to instance"); + + // Set up diagnostics and wait for LSP + let result = client.setup_diagnostics_changed_autocmd().await; + assert!( + result.is_ok(), + "Failed to setup diagnostics autocmd: {result:?}" + ); + + sleep(Duration::from_secs(15)).await; // Allow time for LSP to initialize + + // Get LSP clients + let lsp_clients = client.lsp_get_clients().await.unwrap(); + info!("LSP clients: {:?}", lsp_clients); + assert!(!lsp_clients.is_empty(), "No LSP clients found"); + + // Test definition lookup for sayHello function call on line 9 (0-indexed) + // Position cursor on "sayHello" in the function call + let result = client + .lsp_definition( + "gopls", + DocumentIdentifier::from_buffer_id(1), // First opened file + Position { + line: 9, // Line with sayHello call + character: 17, // Position on "sayHello" + }, + ) + .await; + + assert!(result.is_ok(), "Failed to get definition: {result:?}"); + let definition_result = result.unwrap(); + info!("Definition result found: {:?}", definition_result); + assert!( + definition_result.is_some(), + "Definition result should not be empty" + ); + let definition_result = definition_result.unwrap(); + + // Extract the first location from the definition result + let first_location = match &definition_result { + crate::neovim::client::DefinitionResult::Single(loc) => loc, + crate::neovim::client::DefinitionResult::Locations(locs) => { + assert!(!locs.is_empty(), "No definitions found"); + &locs[0] + } + crate::neovim::client::DefinitionResult::LocationLinks(links) => { + assert!(!links.is_empty(), "No definitions found"); + // For LocationLinks, we create a Location from the target info + let link = &links[0]; + assert!( + link.target_uri.contains("test_definition.go"), + "Definition should point to the same file" + ); + // The definition should point to line 4 (0-indexed) where the function is defined + assert_eq!( + link.target_range.start.line, 4, + "Definition should point to line 4 where sayHello function is defined" + ); + return; // Early return for LocationLinks case + } + }; + + // For Location cases + assert!( + first_location.uri.contains("test_definition.go"), + "Definition should point to the same file" + ); + + // The definition should point to line 4 (0-indexed) where the function is defined + assert_eq!( + first_location.range.start.line, 4, + "Definition should point to line 4 where sayHello function is defined" + ); + + info!("✅ LSP definition lookup successful!"); + + // Temp directory and file automatically cleaned up when temp_dir is dropped +} diff --git a/src/neovim/lua/lsp_definition.lua b/src/neovim/lua/lsp_definition.lua new file mode 100644 index 0000000..8fef269 --- /dev/null +++ b/src/neovim/lua/lsp_definition.lua @@ -0,0 +1,27 @@ +local clients = vim.lsp.get_clients() +local client_name, params_raw, timeout_ms = unpack({ ... }) +local client +for _, v in ipairs(clients) do + if v.name == client_name then + client = v + end +end +if client == nil then + return vim.json.encode({ + err_msg = string.format("LSP client %s not found", vim.json.encode(client_name)), + }) +end + +local params = vim.json.decode(params_raw) +local result, err = client:request_sync("textDocument/definition", params, timeout_ms) +if err then + return vim.json.encode({ + err_msg = string.format( + "LSP client %s request_sync error: %s", + vim.json.encode(client_name), + vim.json.encode(err) + ), + }) +end + +return vim.json.encode(result) diff --git a/src/server/tools.rs b/src/server/tools.rs index cf9a6ca..6dd8d54 100644 --- a/src/server/tools.rs +++ b/src/server/tools.rs @@ -129,6 +129,24 @@ pub struct ReferencesParams { pub include_declaration: bool, } +/// Definition parameters +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct DefinitionParams { + /// Unique identifier for the target Neovim instance + pub connection_id: String, + /// Universal document identifier + // Supports both string and struct deserialization. + // Compatible with Claude Code when using subscription. + #[serde(deserialize_with = "string_or_struct")] + pub document: DocumentIdentifier, + /// Lsp client name + pub lsp_client_name: String, + /// Symbol position, line number starts from 0 + pub line: u64, + /// Symbol position, character number starts from 0 + pub character: u64, +} + /// Code action resolve parameters #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct ResolveCodeActionParams { @@ -423,6 +441,26 @@ impl NeovimMcpServer { Ok(CallToolResult::success(vec![Content::json(references)?])) } + #[tool(description = "Get LSP definition")] + #[instrument(skip(self))] + pub async fn lsp_definition( + &self, + Parameters(DefinitionParams { + connection_id, + document, + lsp_client_name, + line, + character, + }): Parameters, + ) -> Result { + let client = self.get_connection(&connection_id)?; + let position = Position { line, character }; + let definition = client + .lsp_definition(&lsp_client_name, document, position) + .await?; + Ok(CallToolResult::success(vec![Content::json(definition)?])) + } + #[tool(description = "Resolve a code action that may have incomplete data")] #[instrument(skip(self))] pub async fn lsp_resolve_code_action(