use crate::lsp::types::*;
use anyhow::{Context, Result, anyhow};
use lsp_types::*;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::{Duration, timeout};
use tracing::{debug, warn};
use url::Url;
pub struct LspClient {
tx_request: mpsc::Sender<Value>,
pending_requests: Arc<Mutex<HashMap<i64, oneshot::Sender<Result<Value>>>>>,
child: Arc<Mutex<tokio::process::Child>>,
open_documents: Arc<Mutex<HashMap<PathBuf, OpenDocument>>>,
workspace_root: PathBuf,
pub process_id: u32,
}
struct OpenDocument {
version: i32,
text: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct BodySnippet {
pub body: String,
pub total_lines: usize,
pub is_truncated: bool,
pub end_line: u32,
}
impl BodySnippet {
fn empty(end_line: u32) -> Self {
Self {
body: String::new(),
total_lines: 0,
is_truncated: false,
end_line,
}
}
}
impl LspClient {
pub async fn start(workspace_root: &Path) -> Result<Self> {
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
let mut child = tokio::process::Command::new("rust-analyzer")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(&canonical_root)
.spawn()
.context("Failed to spawn rust-analyzer. Please make sure `rust-analyzer` is installed and in PATH.")?;
let pid = child.id().unwrap_or(0);
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow!("Failed to open rust-analyzer stdin"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("Failed to open rust-analyzer stdout"))?;
let stderr = child.stderr.take();
let (tx_request, mut rx_request) = mpsc::channel::<Value>(100);
let pending_requests: Arc<Mutex<HashMap<i64, oneshot::Sender<Result<Value>>>>> =
Arc::new(Mutex::new(HashMap::new()));
let mut writer_stdin = stdin;
tokio::spawn(async move {
while let Some(req) = rx_request.recv().await {
let body = serde_json::to_string(&req).unwrap_or_default();
let header = format!("Content-Length: {}\r\n\r\n", body.len());
let _ = writer_stdin.write_all(header.as_bytes()).await;
let _ = writer_stdin.write_all(body.as_bytes()).await;
let _ = writer_stdin.flush().await;
}
});
if let Some(stderr) = stderr {
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
warn!(target: "rust_analyzer", "{}", line);
}
});
}
let pending_map = pending_requests.clone();
let tx_server_response = tx_request.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stdout);
loop {
let mut line = String::new();
match reader.read_line(&mut line).await {
Ok(0) => break, Ok(_) => {
let line_trimmed = line.trim();
if line_trimmed.starts_with("Content-Length:") {
let len_str = line_trimmed.trim_start_matches("Content-Length:").trim();
if let Ok(len) = len_str.parse::<usize>() {
let mut blank = String::new();
let _ = reader.read_line(&mut blank).await;
let mut buf = vec![0u8; len];
if reader.read_exact(&mut buf).await.is_ok()
&& let Ok(v) = serde_json::from_slice::<Value>(&buf)
&& let Some(id) = v.get("id").and_then(|i| i.as_i64())
{
let mut map = pending_map.lock().await;
if let Some(sender) = map.remove(&id) {
if let Some(err) = v.get("error") {
let _ = sender.send(Err(anyhow!("LSP error: {}", err)));
} else {
let result =
v.get("result").cloned().unwrap_or(Value::Null);
let _ = sender.send(Ok(result));
}
} else if let Some(method) =
v.get("method").and_then(Value::as_str)
{
let response = server_request_response(&v, method, id);
let _ = tx_server_response.send(response).await;
}
}
}
}
}
Err(_) => break,
}
}
});
let client = Self {
tx_request,
pending_requests,
child: Arc::new(Mutex::new(child)),
open_documents: Arc::new(Mutex::new(HashMap::new())),
workspace_root: canonical_root,
process_id: pid,
};
client.initialize().await?;
Ok(client)
}
pub async fn shutdown(&self) {
let open_files = self
.open_documents
.lock()
.await
.keys()
.cloned()
.collect::<Vec<_>>();
for file in open_files {
if let Ok(uri) = Url::from_file_path(file) {
let _ = self
.send_notification(
"textDocument/didClose",
json!({ "textDocument": { "uri": uri.as_str() } }),
)
.await;
}
}
let _ = timeout(
Duration::from_secs(2),
self.send_request("shutdown", Value::Null),
)
.await;
let _ = self.send_notification("exit", Value::Null).await;
let mut child = self.child.lock().await;
match timeout(Duration::from_secs(2), child.wait()).await {
Ok(Ok(_)) => {}
Ok(Err(error)) => warn!(
"Failed to wait for rust-analyzer {}: {}",
self.process_id, error
),
Err(_) => {
warn!(
"Timed out waiting for rust-analyzer {}; killing it",
self.process_id
);
if let Err(error) = child.kill().await {
warn!(
"Failed to kill rust-analyzer {}: {}",
self.process_id, error
);
}
let _ = child.wait().await;
}
}
}
async fn send_request(&self, method: &str, params: Value) -> Result<Value> {
static REQ_ID: AtomicI64 = AtomicI64::new(1);
let id = REQ_ID.fetch_add(1, Ordering::SeqCst);
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params
});
let (tx, rx) = oneshot::channel();
{
let mut map = self.pending_requests.lock().await;
map.insert(id, tx);
}
self.tx_request
.send(req)
.await
.map_err(|_| anyhow!("LSP writer task channel closed"))?;
let result = match timeout(Duration::from_secs(30), rx).await {
Ok(result) => result.map_err(|_| anyhow!("LSP response channel dropped"))?,
Err(_) => {
self.pending_requests.lock().await.remove(&id);
return Err(anyhow!("LSP request '{method}' timed out after 30 seconds"));
}
};
result.map_err(|error| anyhow!("LSP request '{method}' failed: {error}"))
}
async fn send_notification(&self, method: &str, params: Value) -> Result<()> {
let req = json!({
"jsonrpc": "2.0",
"method": method,
"params": params
});
self.tx_request
.send(req)
.await
.map_err(|_| anyhow!("LSP writer task channel closed while sending {method}"))?;
Ok(())
}
fn validate_position(file: &Path, line: u32, col: u32) -> Result<()> {
if line == 0 || col == 0 {
return Err(anyhow!(
"Invalid position for '{}': line and column numbers are 1-based and must be at least 1.",
file.display()
));
}
Ok(())
}
fn resolve_workspace_file(&self, file: &Path) -> Result<PathBuf> {
let candidate = if file.is_absolute() {
file.to_path_buf()
} else {
self.workspace_root.join(file)
};
let canonical = candidate.canonicalize().with_context(|| {
format!(
"Failed to resolve Rust source file '{}'.",
candidate.display()
)
})?;
if !canonical.starts_with(&self.workspace_root) {
return Err(anyhow!(
"Input file '{}' is outside workspace '{}'.",
file.display(),
self.workspace_root.display()
));
}
Ok(canonical)
}
fn display_file(&self, file: &Path) -> String {
file.strip_prefix(&self.workspace_root)
.unwrap_or(file)
.to_string_lossy()
.to_string()
}
async fn initialize(&self) -> Result<()> {
let root_uri = Url::from_directory_path(&self.workspace_root)
.map_err(|_| anyhow!("Failed to convert workspace path to URI"))?;
let init_params = json!({
"processId": self.process_id,
"rootUri": root_uri.as_str(),
"workspaceFolders": [{
"uri": root_uri.as_str(),
"name": self.workspace_root.file_name().and_then(|name| name.to_str()).unwrap_or("workspace")
}],
"capabilities": {
"general": {
"positionEncodings": ["utf-16"]
},
"textDocument": {
"documentSymbol": { "hierarchicalDocumentSymbolSupport": true },
"definition": { "dynamicRegistration": false },
"hover": {
"dynamicRegistration": false,
"contentFormat": ["markdown", "plaintext"]
},
"references": { "dynamicRegistration": false },
"callHierarchy": { "dynamicRegistration": false },
"typeHierarchy": { "dynamicRegistration": false }
},
"workspace": {
"symbol": { "dynamicRegistration": false }
}
}
});
let _res = self.send_request("initialize", init_params).await?;
self.send_notification("initialized", json!({})).await?;
Ok(())
}
pub async fn query_symbol(
&self,
name: &str,
kind_filter: &str,
exact: bool,
include_body: bool,
max_lines: usize,
) -> Result<Vec<SymbolItem>> {
let params = json!({ "query": name });
let res = self.send_request("workspace/symbol", params).await?;
let symbols: Vec<SymbolInformation> =
match serde_json::from_value::<WorkspaceSymbolResponse>(res.clone()) {
Ok(WorkspaceSymbolResponse::Flat(syms)) => syms,
Ok(WorkspaceSymbolResponse::Nested(syms)) => syms
.into_iter()
.map(|s| SymbolInformation {
name: s.name,
kind: s.kind,
tags: s.tags,
#[allow(deprecated)]
deprecated: None,
location: match s.location {
lsp_types::OneOf::Left(loc) => loc,
lsp_types::OneOf::Right(w_loc) => Location {
uri: w_loc.uri,
range: Range::default(),
},
},
container_name: s.container_name,
})
.collect(),
Err(_) => serde_json::from_value(res).unwrap_or_default(),
};
let mut result = Vec::new();
for sym in symbols {
if exact && sym.name != name {
continue;
}
let kind = canonical_symbol_kind(sym.kind, &sym.name, sym.container_name.as_deref());
if kind_filter != "any" {
let Some(expected_kind) = RustSymbolKind::parse_filter(kind_filter) else {
return Err(anyhow!(
"Invalid Rust symbol kind '{}'. Allowed values: {}.",
kind_filter,
RustSymbolKind::CLI_VALUES.join(", ")
));
};
if kind != expected_kind {
continue;
}
}
let file_path = uri_to_file_path(&sym.location.uri);
let relative_file = self.display_file(&file_path);
let snippet = if include_body {
extract_body_snippet(
&file_path,
sym.location.range.start.line + 1,
sym.location.range.end.line + 1,
max_lines,
)
} else {
BodySnippet::empty(0)
};
let body_opt = if include_body && !snippet.body.is_empty() {
Some(snippet.body)
} else {
None
};
result.push(SymbolItem {
name: sym.name,
kind,
file: relative_file,
line: sym.location.range.start.line + 1,
col: sym.location.range.start.character + 1,
container_name: sym.container_name,
body: body_opt,
});
}
Ok(result)
}
async fn ensure_file_open(&self, canonical_file: &Path) -> Result<Url> {
let uri = Url::from_file_path(canonical_file)
.map_err(|_| anyhow!("Failed to convert file path to URI"))?;
let text = tokio::fs::read_to_string(canonical_file)
.await
.with_context(|| {
format!(
"Failed to read Rust source file '{}'.",
canonical_file.display()
)
})?;
let mut documents = self.open_documents.lock().await;
match documents.get_mut(canonical_file) {
None => {
self.send_notification(
"textDocument/didOpen",
json!({
"textDocument": {
"uri": uri.as_str(),
"languageId": "rust",
"version": 1,
"text": text
}
}),
)
.await
.context("Failed to notify rust-analyzer that the source file was opened")?;
documents.insert(
canonical_file.to_path_buf(),
OpenDocument { version: 1, text },
);
}
Some(document) if document.text != text => {
document.version += 1;
self.send_notification(
"textDocument/didChange",
json!({
"textDocument": {
"uri": uri.as_str(),
"version": document.version
},
"contentChanges": [{ "text": text }]
}),
)
.await
.context("Failed to notify rust-analyzer that the source file changed")?;
document.text = text;
}
Some(_) => {}
}
Ok(uri)
}
pub async fn query_outline(
&self,
file: &Path,
include_body: bool,
max_lines: usize,
) -> Result<Vec<OutlineItem>> {
let canonical_file = self.resolve_workspace_file(file)?;
let uri = self.ensure_file_open(&canonical_file).await?;
let params = json!({
"textDocument": { "uri": uri.as_str() }
});
let res = self
.send_request("textDocument/documentSymbol", params)
.await?;
fn convert_symbols(
symbols: Vec<DocumentSymbol>,
file_path: &Path,
include_body: bool,
max_lines: usize,
) -> Vec<OutlineItem> {
symbols
.into_iter()
.map(|s| {
let snippet = if include_body {
extract_body_snippet(
file_path,
s.range.start.line + 1,
s.range.end.line + 1,
max_lines,
)
} else {
BodySnippet::empty(0)
};
let body_opt = if include_body && !snippet.body.is_empty() {
Some(snippet.body)
} else {
None
};
let kind = canonical_symbol_kind(s.kind, &s.name, s.detail.as_deref());
OutlineItem {
name: s.name,
kind,
detail: s.detail,
line: s.range.start.line + 1,
col: s.range.start.character + 1,
end_line: s.range.end.line + 1,
children: convert_symbols(
s.children.unwrap_or_default(),
file_path,
include_body,
max_lines,
),
body: body_opt,
}
})
.collect()
}
let items = match serde_json::from_value::<DocumentSymbolResponse>(res.clone()) {
Ok(DocumentSymbolResponse::Nested(symbols)) => {
convert_symbols(symbols, &canonical_file, include_body, max_lines)
}
Ok(DocumentSymbolResponse::Flat(syms)) => syms
.into_iter()
.map(|s| {
let snippet = if include_body {
extract_body_snippet(
&canonical_file,
s.location.range.start.line + 1,
s.location.range.end.line + 1,
max_lines,
)
} else {
BodySnippet::empty(0)
};
let body_opt = if include_body && !snippet.body.is_empty() {
Some(snippet.body)
} else {
None
};
let kind = canonical_symbol_kind(s.kind, &s.name, s.container_name.as_deref());
OutlineItem {
name: s.name,
kind,
detail: s.container_name,
line: s.location.range.start.line + 1,
col: s.location.range.start.character + 1,
end_line: s.location.range.end.line + 1,
children: vec![],
body: body_opt,
}
})
.collect(),
Err(_) => {
if let Ok(syms) = serde_json::from_value::<Vec<DocumentSymbol>>(res) {
convert_symbols(syms, &canonical_file, include_body, max_lines)
} else {
vec![]
}
}
};
Ok(items)
}
pub async fn query_definition(
&self,
file: &Path,
line: u32,
col: u32,
include_body: bool,
max_lines: usize,
) -> Result<Vec<DefinitionItem>> {
Self::validate_position(file, line, col)?;
let canonical_file = self.resolve_workspace_file(file)?;
let uri = self.ensure_file_open(&canonical_file).await?;
let params = json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": line - 1, "character": col - 1 }
});
let res = self.send_request("textDocument/definition", params).await?;
let locations: Vec<Location> = match serde_json::from_value::<GotoDefinitionResponse>(res) {
Ok(GotoDefinitionResponse::Scalar(loc)) => vec![loc],
Ok(GotoDefinitionResponse::Array(locs)) => locs,
Ok(GotoDefinitionResponse::Link(links)) => links
.into_iter()
.map(|l| Location {
uri: l.target_uri,
range: l.target_range,
})
.collect(),
Err(_) => vec![],
};
let mut items = Vec::new();
for loc in locations {
let loc_file = uri_to_file_path(&loc.uri);
let rel_file = self.display_file(&loc_file);
let snippet = if include_body {
extract_body_snippet(
&loc_file,
loc.range.start.line + 1,
loc.range.end.line + 1,
max_lines,
)
} else {
BodySnippet::empty(0)
};
let body_opt = if include_body && !snippet.body.is_empty() {
Some(snippet.body.clone())
} else {
None
};
items.push(DefinitionItem {
file: rel_file,
line: loc.range.start.line + 1,
col: loc.range.start.character + 1,
end_line: loc.range.end.line + 1,
end_col: loc.range.end.character + 1,
snippet: body_opt.clone(),
body: body_opt,
});
}
Ok(items)
}
pub async fn query_references(
&self,
file: &Path,
line: u32,
col: u32,
) -> Result<Vec<ReferenceItem>> {
Self::validate_position(file, line, col)?;
let canonical_file = self.resolve_workspace_file(file)?;
let uri = self.ensure_file_open(&canonical_file).await?;
let params = json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": line - 1, "character": col - 1 },
"context": { "includeDeclaration": true }
});
let res = self.send_request("textDocument/references", params).await?;
let locs: Vec<Location> = serde_json::from_value(res).unwrap_or_default();
Ok(locs
.into_iter()
.map(|loc| {
let loc_file = uri_to_file_path(&loc.uri);
let rel_file = self.display_file(&loc_file);
ReferenceItem {
file: rel_file,
line: loc.range.start.line + 1,
col: loc.range.start.character + 1,
end_line: loc.range.end.line + 1,
end_col: loc.range.end.character + 1,
}
})
.collect())
}
pub async fn query_calls(
&self,
file: &Path,
line: u32,
col: u32,
direction: CallDirection,
depth: u32,
) -> Result<Vec<CallItem>> {
Self::validate_position(file, line, col)?;
if depth == 0 {
return Err(anyhow!("Call depth must be at least 1."));
}
let canonical_file = self.resolve_workspace_file(file)?;
let uri = self.ensure_file_open(&canonical_file).await?;
let prep_params = json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": line - 1, "character": col - 1 }
});
let prep_res = self
.send_request("textDocument/prepareCallHierarchy", prep_params)
.await?;
let roots: Vec<CallHierarchyItem> = serde_json::from_value(prep_res).unwrap_or_default();
let Some(root) = roots.into_iter().next() else {
return Ok(Vec::new());
};
let mut visited = HashSet::from([call_hierarchy_key(&root)]);
let mut queue = VecDeque::from([(root, 0u32)]);
let mut results = Vec::new();
while let Some((item, level)) = queue.pop_front() {
if level >= depth {
continue;
}
let item_json = serde_json::to_value(&item)?;
if direction == CallDirection::Outgoing {
let out_res = self
.send_request("callHierarchy/outgoingCalls", json!({ "item": item_json }))
.await?;
let out_calls: Vec<CallHierarchyOutgoingCall> =
serde_json::from_value(out_res).unwrap_or_default();
for call in out_calls {
let target = call.to;
if !visited.insert(call_hierarchy_key(&target)) {
continue;
}
results.push(call_item_from_call(
&target,
&self.workspace_root,
direction,
level + 1,
));
queue.push_back((target, level + 1));
}
} else {
let in_res = self
.send_request("callHierarchy/incomingCalls", json!({ "item": item_json }))
.await?;
let in_calls: Vec<CallHierarchyIncomingCall> =
serde_json::from_value(in_res).unwrap_or_default();
for call in in_calls {
let target = call.from;
if !visited.insert(call_hierarchy_key(&target)) {
continue;
}
results.push(call_item_from_call(
&target,
&self.workspace_root,
direction,
level + 1,
));
queue.push_back((target, level + 1));
}
}
}
Ok(results)
}
pub async fn query_relations(
&self,
file: &Path,
line: u32,
col: u32,
mode: RelationMode,
) -> Result<Vec<RelationItem>> {
Self::validate_position(file, line, col)?;
debug_assert_eq!(mode, RelationMode::Implementations);
let canonical_file = self.resolve_workspace_file(file)?;
let uri = self.ensure_file_open(&canonical_file).await?;
let params = json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": line - 1, "character": col - 1 }
});
let response = self
.send_request("textDocument/implementation", params)
.await?;
let locations = match serde_json::from_value::<GotoDefinitionResponse>(response) {
Ok(GotoDefinitionResponse::Scalar(location)) => vec![location],
Ok(GotoDefinitionResponse::Array(locations)) => locations,
Ok(GotoDefinitionResponse::Link(links)) => links
.into_iter()
.map(|link| Location {
uri: link.target_uri,
range: link.target_range,
})
.collect(),
Err(_) => Vec::new(),
};
Ok(locations
.into_iter()
.map(|location| {
let file = uri_to_file_path(&location.uri);
RelationItem {
file: self.display_file(&file),
line: location.range.start.line + 1,
col: location.range.start.character + 1,
end_line: location.range.end.line + 1,
end_col: location.range.end.character + 1,
}
})
.collect())
}
pub async fn query_hover(&self, file: &Path, line: u32, col: u32) -> Result<Option<HoverItem>> {
Self::validate_position(file, line, col)?;
let canonical_file = self.resolve_workspace_file(file)?;
let uri = self.ensure_file_open(&canonical_file).await?;
let params = json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": line - 1, "character": col - 1 }
});
let res = self.send_request("textDocument/hover", params).await?;
let hover: Option<Hover> = serde_json::from_value(res)?;
let Some(hover) = hover else {
return Ok(None);
};
let loc_file = canonical_file;
let rel_file = self.display_file(&loc_file);
let range = hover.range.map(|range| HoverRange {
start_line: range.start.line + 1,
start_col: range.start.character + 1,
end_line: range.end.line + 1,
end_col: range.end.character + 1,
});
Ok(Some(HoverItem {
file: rel_file,
line,
col,
contents: hover_contents_to_markdown(hover.contents),
range,
}))
}
pub async fn query_body(
&self,
file: &Path,
line: u32,
col: u32,
max_lines: usize,
) -> Result<BodyItem> {
Self::validate_position(file, line, col)?;
let canonical_file = self.resolve_workspace_file(file)?;
let uri = self.ensure_file_open(&canonical_file).await?;
let params = json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": line - 1, "character": col - 1 }
});
let res = self.send_request("textDocument/definition", params).await?;
let locations: Vec<Location> = match serde_json::from_value::<GotoDefinitionResponse>(res) {
Ok(GotoDefinitionResponse::Scalar(loc)) => vec![loc],
Ok(GotoDefinitionResponse::Array(locs)) => locs,
Ok(GotoDefinitionResponse::Link(links)) => links
.into_iter()
.map(|l| Location {
uri: l.target_uri,
range: l.target_range,
})
.collect(),
Err(_) => vec![],
};
if let Some(loc) = locations.first() {
let loc_file = uri_to_file_path(&loc.uri);
let rel_file = self.display_file(&loc_file);
let snippet = extract_body_snippet(
&loc_file,
loc.range.start.line + 1,
loc.range.end.line + 1,
max_lines,
);
Ok(BodyItem {
file: rel_file,
line: loc.range.start.line + 1,
col: loc.range.start.character + 1,
end_line: snippet.end_line,
end_col: loc.range.end.character + 1,
body: snippet.body,
total_lines: snippet.total_lines,
is_truncated: snippet.is_truncated,
})
} else {
Err(anyhow::anyhow!(
"No symbol/entity definition found at specified position"
))
}
}
}
fn call_hierarchy_key(item: &CallHierarchyItem) -> String {
format!(
"{}:{}:{}:{}:{}",
item.uri.as_str(),
item.range.start.line,
item.range.start.character,
item.range.end.line,
item.range.end.character
)
}
fn call_item_from_call(
item: &CallHierarchyItem,
workspace_root: &Path,
direction: CallDirection,
depth: u32,
) -> CallItem {
let file_path = uri_to_file_path(&item.uri);
let rel_file = file_path
.strip_prefix(workspace_root)
.unwrap_or(&file_path)
.to_string_lossy()
.to_string();
CallItem {
name: item.name.clone(),
kind: canonical_symbol_kind(item.kind, &item.name, item.detail.as_deref()),
file: rel_file,
line: item.range.start.line + 1,
col: item.range.start.character + 1,
end_line: item.range.end.line + 1,
end_col: item.range.end.character + 1,
direction,
depth,
}
}
fn canonical_symbol_kind(kind: SymbolKind, name: &str, detail: Option<&str>) -> RustSymbolKind {
if name.starts_with("impl ") || detail.is_some_and(|value| value.starts_with("impl ")) {
return RustSymbolKind::Impl;
}
if name.ends_with('!') {
return RustSymbolKind::Macro;
}
match kind {
SymbolKind::MODULE | SymbolKind::NAMESPACE | SymbolKind::PACKAGE => RustSymbolKind::Module,
SymbolKind::STRUCT | SymbolKind::CLASS => RustSymbolKind::Struct,
SymbolKind::ENUM => RustSymbolKind::Enum,
SymbolKind::ENUM_MEMBER => RustSymbolKind::EnumVariant,
SymbolKind::INTERFACE => RustSymbolKind::Trait,
SymbolKind::METHOD => RustSymbolKind::Method,
SymbolKind::CONSTRUCTOR => RustSymbolKind::AssociatedFunction,
SymbolKind::FIELD | SymbolKind::PROPERTY => RustSymbolKind::Field,
SymbolKind::FUNCTION => RustSymbolKind::Function,
SymbolKind::CONSTANT => RustSymbolKind::Const,
SymbolKind::VARIABLE => RustSymbolKind::Variable,
SymbolKind::TYPE_PARAMETER => RustSymbolKind::TypeParameter,
SymbolKind::OBJECT => RustSymbolKind::Impl,
_ => RustSymbolKind::Unknown,
}
}
fn server_request_response(message: &Value, method: &str, id: i64) -> Value {
let result = match method {
"workspace/configuration" => message
.get("params")
.and_then(|params| params.get("items"))
.and_then(Value::as_array)
.map(|items| vec![Value::Null; items.len()])
.map(Value::Array)
.unwrap_or_else(|| Value::Array(Vec::new())),
"client/registerCapability"
| "client/unregisterCapability"
| "window/workDoneProgress/create" => Value::Null,
_ => {
debug!(
"Ignoring unsupported rust-analyzer server request: {}",
method
);
return json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32601,
"message": format!("Unsupported LSP server request: {method}")
}
});
}
};
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
fn uri_to_file_path(uri: &lsp_types::Uri) -> PathBuf {
Url::parse(uri.as_str())
.ok()
.and_then(|u| u.to_file_path().ok())
.unwrap_or_default()
}
fn marked_string_to_markdown(marked: MarkedString) -> String {
match marked {
MarkedString::LanguageString(language_string) => format!(
"```{}\n{}\n```",
language_string.language, language_string.value
),
MarkedString::String(value) => value,
}
}
fn hover_contents_to_markdown(contents: HoverContents) -> String {
match contents {
HoverContents::Markup(markup) => markup.value,
HoverContents::Scalar(marked) => marked_string_to_markdown(marked),
HoverContents::Array(items) => items
.into_iter()
.map(marked_string_to_markdown)
.collect::<Vec<_>>()
.join("\n\n"),
}
}
pub fn extract_body_snippet(
file: &Path,
start_line: u32,
mut end_line: u32,
max_lines: usize,
) -> BodySnippet {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(_) => return BodySnippet::empty(end_line),
};
let lines: Vec<&str> = content.lines().collect();
let start_idx = (start_line.saturating_sub(1)) as usize;
if start_idx >= lines.len() {
return BodySnippet::empty(end_line);
}
if end_line <= start_line || end_line <= start_line + 1 {
let mut depth = 0i32;
let mut found_brace = false;
for (i, line) in lines[start_idx..].iter().enumerate() {
for ch in line.chars() {
if ch == '{' {
depth += 1;
found_brace = true;
} else if ch == '}' {
depth -= 1;
}
}
if (found_brace && depth <= 0) || (!found_brace && line.trim().ends_with(';')) {
end_line = start_line + i as u32 + 1;
break;
}
if i >= 100 {
end_line = start_line + i as u32 + 1;
break;
}
}
}
let end_idx = (end_line as usize).min(lines.len());
let total = if end_idx > start_idx {
end_idx - start_idx
} else {
1
};
let limit = if max_lines == 0 {
total
} else {
max_lines.min(total)
};
let is_truncated = limit < total;
let slice = &lines[start_idx..start_idx + limit];
BodySnippet {
body: slice.join("\n"),
total_lines: total,
is_truncated,
end_line,
}
}
pub fn format_body_with_line_numbers(snippet: &str, start_line: u32) -> String {
snippet
.lines()
.enumerate()
.map(|(idx, line)| format!("{:4} | {}", start_line as usize + idx, line))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::{MarkupContent, MarkupKind};
#[test]
fn test_hover_markup_is_preserved() {
let contents = HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: "/// Documentation\n\n```rust\nfn example() {}\n```".to_string(),
});
assert_eq!(
hover_contents_to_markdown(contents),
"/// Documentation\n\n```rust\nfn example() {}\n```"
);
}
#[test]
fn test_hover_language_string_becomes_markdown_code_block() {
let contents = HoverContents::Scalar(MarkedString::LanguageString(LanguageString {
language: "rust".to_string(),
value: "fn example() {}".to_string(),
}));
assert_eq!(
hover_contents_to_markdown(contents),
"```rust\nfn example() {}\n```"
);
}
#[test]
fn test_hover_array_joins_marked_strings() {
let contents = HoverContents::Array(vec![
MarkedString::String("Summary".to_string()),
MarkedString::LanguageString(LanguageString {
language: "rust".to_string(),
value: "fn example() {}".to_string(),
}),
]);
assert_eq!(
hover_contents_to_markdown(contents),
"Summary\n\n```rust\nfn example() {}\n```"
);
}
#[test]
fn test_body_snippet_json_roundtrip() {
let snippet = BodySnippet {
body: "fn example() {}".to_string(),
total_lines: 1,
is_truncated: false,
end_line: 12,
};
let json = serde_json::to_string(&snippet).unwrap();
let decoded: BodySnippet = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, snippet);
}
#[test]
fn test_body_snippet_reports_truncation() {
let file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/code_example.rs");
let snippet = extract_body_snippet(&file, 39, 39, 2);
assert_eq!(snippet.body.lines().count(), 2);
assert!(snippet.total_lines > 100);
assert!(snippet.is_truncated);
assert!(snippet.end_line >= 39);
}
}