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 url::Url;
pub struct LspClient {
tx_request: mpsc::Sender<Value>,
pending_requests: Arc<Mutex<HashMap<i64, oneshot::Sender<Result<Value>>>>>,
workspace_root: PathBuf,
pub process_id: u32,
}
#[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::null())
.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 (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;
}
});
let pending_map = pending_requests.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));
}
}
}
}
}
}
Err(_) => break,
}
}
});
let client = Self {
tx_request,
pending_requests,
workspace_root: canonical_root,
process_id: pid,
};
client.initialize().await?;
Ok(client)
}
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 = rx
.await
.map_err(|_| anyhow!("LSP response channel dropped"))?;
result.with_context(|| format!("LSP request '{method}' failed"))
}
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(())
}
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(),
"capabilities": {
"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_str = format!("{:?}", sym.kind).to_lowercase();
if kind_filter != "any" && !kind_str.contains(&kind_filter.to_lowercase()) {
continue;
}
let file_path = uri_to_file_path(&sym.location.uri);
let relative_file = file_path
.strip_prefix(&self.workspace_root)
.unwrap_or(&file_path)
.to_string_lossy()
.to_string();
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: kind_str,
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()
)
})?;
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")?;
Ok(uri)
}
pub async fn query_outline(
&self,
file: &Path,
include_body: bool,
max_lines: usize,
) -> Result<Vec<OutlineItem>> {
let canonical_file = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
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
};
OutlineItem {
name: s.name,
kind: format!("{:?}", s.kind).to_lowercase(),
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
};
OutlineItem {
name: s.name,
kind: format!("{:?}", s.kind).to_lowercase(),
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 = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
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 = loc_file
.strip_prefix(&self.workspace_root)
.unwrap_or(&loc_file)
.to_string_lossy()
.to_string();
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_cursor(
&self,
file: &Path,
line: u32,
col: u32,
mode: &str,
depth: u32,
) -> Result<Vec<CursorItem>> {
Self::validate_position(file, line, col)?;
if !matches!(mode, "incoming" | "outgoing" | "references") {
return Err(anyhow!(
"Invalid cursor mode '{mode}'. Allowed values: incoming, outgoing, references."
));
}
if depth == 0 {
return Err(anyhow!("Cursor depth must be at least 1."));
}
let canonical_file = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
let uri = self.ensure_file_open(&canonical_file).await?;
if mode == "references" {
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();
let mut items = Vec::new();
for loc in locs {
let loc_file = uri_to_file_path(&loc.uri);
let rel_file = loc_file
.strip_prefix(&self.workspace_root)
.unwrap_or(&loc_file)
.to_string_lossy()
.to_string();
items.push(CursorItem {
name: "reference".to_string(),
kind: "reference".to_string(),
file: rel_file,
line: loc.range.start.line + 1,
col: loc.range.start.character + 1,
caller_or_callee: None,
});
}
return Ok(items);
}
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 mode == "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(cursor_item_from_call(
&target,
&self.workspace_root,
"callee",
));
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(cursor_item_from_call(
&target,
&self.workspace_root,
"caller",
));
queue.push_back((target, level + 1));
}
}
}
Ok(results)
}
pub async fn query_type_hierarchy(
&self,
file: &Path,
line: u32,
col: u32,
mode: &str,
depth: u32,
) -> Result<Vec<TypeHierarchyItemResult>> {
Self::validate_position(file, line, col)?;
if !matches!(mode, "supertypes" | "subtypes") {
return Err(anyhow!(
"Invalid type hierarchy mode '{mode}'. Allowed values: supertypes, subtypes."
));
}
if depth == 0 {
return Err(anyhow!("Type hierarchy depth must be at least 1."));
}
let canonical_file = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
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/prepareTypeHierarchy", prep_params)
.await?;
let roots: Vec<lsp_types::TypeHierarchyItem> =
serde_json::from_value(prep_res).unwrap_or_default();
let Some(root) = roots.into_iter().next() else {
return Ok(Vec::new());
};
let endpoint = if mode == "subtypes" {
"typeHierarchy/subtypes"
} else {
"typeHierarchy/supertypes"
};
let mut visited = HashSet::from([type_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)?;
let type_res = self
.send_request(endpoint, json!({ "item": item_json }))
.await?;
let type_items: Vec<lsp_types::TypeHierarchyItem> =
serde_json::from_value(type_res).unwrap_or_default();
for target in type_items {
if !visited.insert(type_hierarchy_key(&target)) {
continue;
}
results.push(type_hierarchy_result(&target, &self.workspace_root));
queue.push_back((target, level + 1));
}
}
Ok(results)
}
pub async fn query_hover(&self, file: &Path, line: u32, col: u32) -> Result<Option<HoverItem>> {
Self::validate_position(file, line, col)?;
let canonical_file = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
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 = loc_file
.strip_prefix(&self.workspace_root)
.unwrap_or(&loc_file)
.to_string_lossy()
.to_string();
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 = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
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 = loc_file
.strip_prefix(&self.workspace_root)
.unwrap_or(&loc_file)
.to_string_lossy()
.to_string();
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 cursor_item_from_call(
item: &CallHierarchyItem,
workspace_root: &Path,
caller_or_callee: &str,
) -> CursorItem {
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();
CursorItem {
name: item.name.clone(),
kind: format!("{:?}", item.kind).to_lowercase(),
file: rel_file,
line: item.range.start.line + 1,
col: item.range.start.character + 1,
caller_or_callee: Some(caller_or_callee.to_string()),
}
}
fn type_hierarchy_key(item: &lsp_types::TypeHierarchyItem) -> String {
format!(
"{}:{}:{}:{}:{}",
item.uri.as_str(),
item.range.start.line,
item.range.start.character,
item.range.end.line,
item.range.end.character
)
}
fn type_hierarchy_result(
item: &lsp_types::TypeHierarchyItem,
workspace_root: &Path,
) -> TypeHierarchyItemResult {
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();
TypeHierarchyItemResult {
name: item.name.clone(),
kind: format!("{:?}", item.kind).to_lowercase(),
file: rel_file,
line: item.range.start.line + 1,
col: item.range.start.character + 1,
detail: item.detail.clone(),
}
}
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);
}
}