Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand Down
17 changes: 15 additions & 2 deletions docs/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
96 changes: 94 additions & 2 deletions src/neovim/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ pub trait NeovimClientTrait: Sync {
include_declaration: bool,
) -> Result<Vec<Location>, NeovimError>;

/// Get definition(s) of a symbol
async fn lsp_definition(
&self,
client_name: &str,
document: DocumentIdentifier,
position: Position,
) -> Result<Option<DefinitionResult>, NeovimError>;

/// Resolve a code action that may have incomplete data
async fn lsp_resolve_code_action(
&self,
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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<Range>,
/// 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<Location>),
LocationLinks(Vec<LocationLink>),
}

/// Represents information about programming constructs like variables, classes, interfaces etc.
#[derive(Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -1589,6 +1628,59 @@ where
}
}

#[instrument(skip(self))]
async fn lsp_definition(
&self,
client_name: &str,
document: DocumentIdentifier,
position: Position,
) -> Result<Option<DefinitionResult>, 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::<NvimExecuteLuaResult<Option<DefinitionResult>>>(
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,
Expand Down
116 changes: 116 additions & 0 deletions src/neovim/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
27 changes: 27 additions & 0 deletions src/neovim/lua/lsp_definition.lua
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading