Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
103 changes: 101 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<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,66 @@ where
}
}

#[instrument(skip(self))]
async fn lsp_definition(
&self,
client_name: &str,
document: DocumentIdentifier,
position: Position,
) -> Result<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())
})?;

// Get buffer ID for Lua execution (needed for some LSP operations)
let buffer_id = match &document {
DocumentIdentifier::BufferId(id) => *id,
_ => 0, // Use buffer 0 as fallback for path-based operations
Comment thread
linw1995 marked this conversation as resolved.
Outdated
};

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
Value::from(buffer_id), // buffer_id
],
)
.await
{
Ok(result) => {
match serde_json::from_str::<NvimExecuteLuaResult<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
111 changes: 111 additions & 0 deletions src/neovim/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,3 +506,114 @@ 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"
Comment thread
linw1995 marked this conversation as resolved.
},
)
.await;

assert!(result.is_ok(), "Failed to get definition: {result:?}");
let definition_result = result.unwrap();
info!("Definition result found: {:?}", definition_result);

// 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, bufnr = 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, bufnr)
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)
Comment thread
linw1995 marked this conversation as resolved.
),
})
end

return vim.json.encode(result)
38 changes: 38 additions & 0 deletions src/server/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<DefinitionParams>,
) -> Result<CallToolResult, McpError> {
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(
Expand Down
Loading