use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use tokio::io::AsyncWrite;
use tokio::io::BufReader;
use tokio::process::Command;
use tokio::sync::{oneshot, Mutex};
use tokio::task::JoinHandle;
type PendingRequests = Arc<StdMutex<HashMap<i64, oneshot::Sender<Result<Value>>>>>;
use super::transport;
use crate::tools::RecoverableError;
const MAX_STDERR_LINES: usize = 200;
use super::call_hierarchy::supports_call_hierarchy;
fn uri_to_path(uri: &lsp_types::Uri) -> PathBuf {
crate::util::file_address::FileAddress::from_lsp_uri(uri)
.map(crate::util::file_address::FileAddress::into_path)
.unwrap_or_else(|| PathBuf::from(uri.path().as_str()))
}
fn path_to_uri(path: &Path) -> Result<lsp_types::Uri> {
crate::util::file_address::FileAddress::from_path(path).as_lsp_uri()
}
fn is_idempotent_lsp_method(method: &str) -> bool {
matches!(
method,
"textDocument/documentSymbol"
| "textDocument/references"
| "textDocument/hover"
| "textDocument/definition"
| "textDocument/declaration"
| "textDocument/typeDefinition"
| "textDocument/implementation"
| "textDocument/completion"
| "textDocument/signatureHelp"
| "textDocument/codeAction"
| "textDocument/codeLens"
| "textDocument/foldingRange"
| "textDocument/selectionRange"
| "textDocument/prepareRename"
| "textDocument/prepareCallHierarchy"
| "callHierarchy/incomingCalls"
| "callHierarchy/outgoingCalls"
| "workspace/symbol"
| "initialize"
)
}
fn is_retryable_lsp_error(err: &anyhow::Error) -> bool {
let s = err.to_string();
s.contains("code -32800") || s.contains("code -32801")
}
fn uses_cold_start_retry_budget(method: &str) -> bool {
!matches!(method, "workspace/symbol")
}
const fn cold_start_max_retries() -> usize {
if cfg!(target_os = "windows") {
20
} else if cfg!(target_os = "macos") {
15
} else {
10
}
}
fn detect_fatal_stderr(lines: &[String]) -> Option<RecoverableError> {
for line in lines {
if line.contains("Multiple editing sessions") {
return Some(RecoverableError::with_hint(
"kotlin-lsp rejected this workspace: \
\"Multiple editing sessions for one workspace are not supported yet\"",
"Another codescout instance or editor is already serving this \
project with kotlin-lsp. Only one Kotlin LSP session per \
workspace is allowed in the current kotlin-lsp release. \
Stop the other session and retry.",
));
}
if line.contains("Unknown binary 'rust-analyzer'") {
return Some(RecoverableError::with_hint(
"rust-analyzer is unreachable. The rustup shim is on PATH but the \
component is not installed: \
\"error: Unknown binary 'rust-analyzer' in official toolchain\"",
"Run `rustup component add rust-analyzer` to install it for the active \
toolchain, or install rust-analyzer outside rustup \
(https://rust-analyzer.github.io). Rust LSP tools (edit_code, symbols, \
references, call_graph on .rs files) will keep returning this error \
until rust-analyzer launches successfully.",
));
}
}
None
}
fn convert_document_symbols(
symbols: &[lsp_types::DocumentSymbol],
file: &PathBuf,
parent_path: &str,
) -> Vec<super::SymbolInfo> {
symbols
.iter()
.map(|ds| {
let name_path = if parent_path.is_empty() {
ds.name.clone()
} else {
format!("{}/{}", parent_path, ds.name)
};
let children = ds
.children
.as_ref()
.map(|c| convert_document_symbols(c, file, &name_path))
.unwrap_or_default();
super::SymbolInfo {
name: ds.name.clone(),
name_path: name_path.clone(),
kind: ds.kind.into(),
file: file.clone(),
start_line: ds.selection_range.start.line,
end_line: ds.range.end.line,
start_col: ds.selection_range.start.character,
range_start_line: Some(ds.range.start.line),
children,
detail: ds.detail.clone().filter(|s| !s.is_empty()),
}
})
.collect()
}
#[derive(Debug, Clone)]
pub struct LspServerConfig {
pub command: String,
#[allow(dead_code)]
pub args: Vec<String>,
pub workspace_root: std::path::PathBuf,
pub init_timeout: Option<std::time::Duration>,
pub mux: bool,
pub env: Vec<(String, String)>,
pub idle_timeout_secs: Option<u64>,
}
#[derive(Debug)]
#[allow(dead_code)] pub(crate) enum LspTransport {
Process { child_pid: Option<u32> },
Socket { socket_path: std::path::PathBuf },
}
pub struct LspClient {
writer: Arc<Mutex<Box<dyn AsyncWrite + Unpin + Send>>>,
#[allow(dead_code)]
next_id: AtomicI64,
#[allow(dead_code)]
pending: Arc<StdMutex<HashMap<i64, oneshot::Sender<Result<Value>>>>>,
#[allow(dead_code)]
alive: Arc<AtomicBool>,
#[allow(dead_code)]
reader_handle: StdMutex<Option<JoinHandle<()>>>,
pub workspace_root: std::path::PathBuf,
#[allow(dead_code)]
pub(crate) capabilities: StdMutex<lsp_types::ServerCapabilities>,
transport: LspTransport,
init_timeout: std::time::Duration,
open_files: StdMutex<HashMap<PathBuf, i32>>,
stderr_lines: Arc<StdMutex<Vec<String>>>,
pub(crate) started_at: std::time::Instant,
pub(crate) init_completed_at: std::sync::OnceLock<std::time::Instant>,
}
impl LspClient {
async fn dispatch_lsp_message(
msg: Value,
pending: &PendingRequests,
writer: &Arc<Mutex<Box<dyn AsyncWrite + Unpin + Send>>>,
request_label: &str,
notification_label: &str,
) {
if let Some(id) = msg.get("id").and_then(|v| v.as_i64()) {
if msg.get("method").is_some() {
tracing::debug!(
"{} (id={}): {} — auto-responding null",
request_label,
id,
msg["method"]
);
let response = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": null,
});
let mut w = writer.lock().await;
let _ = transport::write_message(&mut *w, &response).await;
} else {
if let Some(sender) = pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id)
{
if let Some(error) = msg.get("error") {
let err_msg = error["message"].as_str().unwrap_or("unknown LSP error");
let _ = sender.send(Err(anyhow::anyhow!(
"LSP error (code {}): {}",
error["code"],
err_msg
)));
} else {
let result = msg.get("result").cloned().unwrap_or(Value::Null);
let _ = sender.send(Ok(result));
}
}
}
} else if let Some(method) = msg.get("method").and_then(|v| v.as_str()) {
tracing::debug!("{}: {}", notification_label, method);
}
}
async fn run_dispatch_loop<R>(
mut reader: BufReader<R>,
pending: PendingRequests,
writer: Arc<Mutex<Box<dyn AsyncWrite + Unpin + Send>>>,
request_label: &'static str,
notif_label: &'static str,
) -> anyhow::Error
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
loop {
match transport::read_message(&mut reader).await {
Ok(msg) => {
Self::dispatch_lsp_message(msg, &pending, &writer, request_label, notif_label)
.await;
}
Err(e) => return e,
}
}
}
fn drain_pending_disconnect(pending: &PendingRequests, msg: &'static str) {
let mut map = pending.lock().unwrap_or_else(|e| e.into_inner());
for (_, sender) in map.drain() {
let _ = sender.send(Err(anyhow::anyhow!(msg)));
}
}
pub async fn start(config: LspServerConfig) -> Result<Self> {
tracing::info!("Starting LSP server: {} {:?}", config.command, config.args);
let mut cmd = Command::new(&config.command);
cmd.args(&config.args)
.current_dir(&config.workspace_root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
for (key, val) in &config.env {
cmd.env(key, val);
}
let mut child = cmd
.spawn()
.with_context(|| format!("Failed to start LSP server: {}", config.command))?;
let stdin = child.stdin.take().expect("stdin must be piped");
let stdout = child.stdout.take().expect("stdout must be piped");
let stderr = child.stderr.take().expect("stderr must be piped");
let child_pid = child.id();
tracing::debug!(
pid = ?child_pid,
binary = %config.command,
"LSP server spawned"
);
let pending: PendingRequests = Arc::new(StdMutex::new(HashMap::new()));
let alive = Arc::new(AtomicBool::new(true));
let writer = Arc::new(Mutex::new(
Box::new(stdin) as Box<dyn AsyncWrite + Unpin + Send>
));
let stderr_lines: Arc<StdMutex<Vec<String>>> = Arc::new(StdMutex::new(Vec::new()));
let stderr_lines_clone = stderr_lines.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
let mut line = String::new();
loop {
line.clear();
match tokio::io::AsyncBufReadExt::read_line(&mut reader, &mut line).await {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim_end();
let lower = trimmed.to_lowercase();
if lower.contains("error")
|| lower.contains("exception")
|| lower.contains("fatal")
{
tracing::warn!(target: "lsp_stderr", "{}", trimmed);
let mut buf =
stderr_lines_clone.lock().unwrap_or_else(|e| e.into_inner());
if buf.len() >= MAX_STDERR_LINES {
buf.remove(0);
}
buf.push(trimmed.to_string());
} else {
tracing::debug!(target: "lsp_stderr", "{}", trimmed);
}
}
Err(_) => break,
}
}
});
let pending_clone = pending.clone();
let alive_clone = alive.clone();
let writer_clone = writer.clone();
let reader_handle = tokio::spawn(async move {
let read_err = Self::run_dispatch_loop(
BufReader::new(stdout),
pending_clone.clone(),
writer_clone,
"LSP server request",
"LSP notification",
)
.await;
if alive_clone.load(Ordering::SeqCst) {
tracing::warn!("LSP reader error: {}", read_err);
}
match child.try_wait() {
Ok(Some(status)) => {
tracing::warn!(exit_status = ?status, "LSP server exited")
}
Ok(None) => tracing::warn!("LSP reader EOF but child still running"),
Err(wait_err) => {
tracing::warn!("could not get LSP exit status: {wait_err}")
}
}
alive_clone.store(false, Ordering::SeqCst);
Self::drain_pending_disconnect(&pending_clone, "LSP server disconnected");
let _ = child.wait().await;
});
let init_timeout = config
.init_timeout
.unwrap_or(std::time::Duration::from_secs(30));
let client = Self {
writer,
next_id: AtomicI64::new(1),
pending,
alive,
reader_handle: StdMutex::new(Some(reader_handle)),
workspace_root: config.workspace_root.clone(),
capabilities: StdMutex::new(lsp_types::ServerCapabilities::default()),
transport: LspTransport::Process { child_pid },
init_timeout,
open_files: StdMutex::new(HashMap::new()),
stderr_lines,
started_at: std::time::Instant::now(),
init_completed_at: std::sync::OnceLock::new(),
};
client.initialize().await?;
Ok(client)
}
#[cfg(unix)]
pub async fn connect(
socket_path: &std::path::Path,
workspace_root: std::path::PathBuf,
) -> Result<Self> {
use tokio::net::UnixStream;
let stream = UnixStream::connect(socket_path)
.await
.with_context(|| format!("Failed to connect to mux socket: {:?}", socket_path))?;
let (read_half, write_half) = stream.into_split();
let pending: PendingRequests = Arc::new(StdMutex::new(HashMap::new()));
let alive = Arc::new(AtomicBool::new(true));
let writer: Arc<Mutex<Box<dyn AsyncWrite + Unpin + Send>>> =
Arc::new(Mutex::new(Box::new(write_half)));
let mut buf_reader = BufReader::new(read_half);
let init_msg = transport::read_message(&mut buf_reader)
.await
.context("Failed to read mux init message")?;
let capabilities = if let Some(result) = init_msg.get("result") {
let init_result: lsp_types::InitializeResult =
serde_json::from_value(result.clone())
.context("Failed to parse InitializeResult from mux")?;
init_result.capabilities
} else {
tracing::warn!("Mux init message missing 'result' field, using default capabilities");
lsp_types::ServerCapabilities::default()
};
let pending_clone = pending.clone();
let alive_clone = alive.clone();
let writer_clone = writer.clone();
let reader_handle = tokio::spawn(async move {
let _read_err = Self::run_dispatch_loop(
buf_reader,
pending_clone.clone(),
writer_clone,
"mux forwarded server request",
"LSP notification from mux",
)
.await;
alive_clone.store(false, Ordering::SeqCst);
Self::drain_pending_disconnect(&pending_clone, "Mux connection lost");
});
Ok(Self {
writer,
next_id: AtomicI64::new(1),
pending,
alive,
reader_handle: StdMutex::new(Some(reader_handle)),
workspace_root,
capabilities: StdMutex::new(capabilities),
transport: LspTransport::Socket {
socket_path: socket_path.to_path_buf(),
},
init_timeout: std::time::Duration::from_secs(30),
open_files: StdMutex::new(HashMap::new()),
stderr_lines: Arc::new(StdMutex::new(Vec::new())),
started_at: std::time::Instant::now(),
init_completed_at: std::sync::OnceLock::new(),
})
}
pub async fn request(&self, method: &str, params: Value) -> Result<Value> {
const COLD_START_WINDOW: std::time::Duration = std::time::Duration::from_secs(5 * 60);
const MAX_RETRIES_COLD: usize = cold_start_max_retries();
const RETRY_DELAY_COLD_MS: u64 = 3_000;
const MAX_RETRIES_WARM: usize = 3;
const RETRY_DELAY_WARM_MS: u64 = 300;
let anchor = self.init_completed_at.get().unwrap_or(&self.started_at);
let in_cold_start = anchor.elapsed() < COLD_START_WINDOW;
let (max_retries, retry_delay_ms) = if in_cold_start && uses_cold_start_retry_budget(method)
{
(MAX_RETRIES_COLD, RETRY_DELAY_COLD_MS)
} else {
(MAX_RETRIES_WARM, RETRY_DELAY_WARM_MS)
};
let retry_on_cancel = is_idempotent_lsp_method(method);
let effective_max_retries = if retry_on_cancel { max_retries } else { 0 };
let mut last_err = None;
for attempt in 0..=effective_max_retries {
if attempt > 0 {
let delay = std::time::Duration::from_millis(retry_delay_ms * attempt as u64);
tokio::time::sleep(delay).await;
tracing::debug!(
"LSP transient error, retrying {}/{}: {} (cold_start={})",
attempt,
effective_max_retries,
method,
in_cold_start,
);
}
match self
.request_with_timeout(method, params.clone(), std::time::Duration::from_secs(30))
.await
{
Ok(result) => return Ok(result),
Err(e) if is_retryable_lsp_error(&e) => {
if !retry_on_cancel {
return Err(RecoverableError::with_hint(
format!("LSP cancelled non-idempotent request: {method}"),
"The server cancelled mid-operation. Retry is unsafe here because \
the edit may have partially applied. Re-issue the request manually.",
)
.into());
}
last_err = Some(e);
}
Err(e) => return Err(e),
}
}
Err(last_err.unwrap())
}
#[tracing::instrument(skip(self, params, timeout), fields(lsp_method = %method))]
pub async fn request_with_timeout(
&self,
method: &str,
params: Value,
timeout: std::time::Duration,
) -> Result<Value> {
if !self.alive.load(Ordering::SeqCst) {
return Err(RecoverableError::with_hint(
"LSP server is not running",
"The language server exited or failed to start. Try re-activating the \
project or check logs for server startup errors.",
)
.into());
}
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let (tx, rx) = oneshot::channel();
self.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(id, tx);
let msg = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
{
let mut writer = self.writer.lock().await;
if let Err(e) = transport::write_message(&mut *writer, &msg).await {
self.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id);
return Err(e);
}
}
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(result)) => {
match &result {
Ok(v) => {
tracing::debug!(response_bytes = v.to_string().len(), "lsp response");
}
Err(e) => {
tracing::debug!(error = %e, "lsp response error");
}
}
result
}
Ok(Err(_)) => bail!("LSP response channel closed"),
Err(_) => {
self.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id);
let _ = self.notify("$/cancelRequest", json!({ "id": id })).await;
Err(RecoverableError::with_hint(
format!(
"LSP request timed out after {}s: {}",
timeout.as_secs(),
method
),
"The server did not respond in time. This is common during cold \
start or heavy indexing; retry in a moment.",
)
.into())
}
}
}
pub async fn notify(&self, method: &str, params: Value) -> Result<()> {
if !self.alive.load(Ordering::SeqCst) {
return Err(RecoverableError::with_hint(
"LSP server is not running",
"The language server exited or failed to start.",
)
.into());
}
let msg = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
let mut writer = self.writer.lock().await;
transport::write_message(&mut *writer, &msg).await
}
fn fatal_stderr_hint(&self) -> Option<RecoverableError> {
let stderr = self.stderr_lines.lock().unwrap_or_else(|e| e.into_inner());
detect_fatal_stderr(&stderr)
}
async fn initialize(&self) -> Result<()> {
let root_uri = path_to_uri(&self.workspace_root)?;
let params = lsp_types::InitializeParams {
process_id: Some(std::process::id()),
capabilities: lsp_types::ClientCapabilities {
text_document: Some(lsp_types::TextDocumentClientCapabilities {
document_symbol: Some(lsp_types::DocumentSymbolClientCapabilities {
hierarchical_document_symbol_support: Some(true),
..Default::default()
}),
references: Some(lsp_types::DynamicRegistrationClientCapabilities {
dynamic_registration: Some(false),
}),
definition: Some(lsp_types::GotoCapability {
dynamic_registration: Some(false),
link_support: Some(false),
}),
rename: Some(lsp_types::RenameClientCapabilities {
prepare_support: Some(true),
..Default::default()
}),
..Default::default()
}),
..Default::default()
},
workspace_folders: Some(vec![lsp_types::WorkspaceFolder {
uri: root_uri,
name: self
.workspace_root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(),
}]),
..Default::default()
};
const MAX_INIT_RETRIES: usize = 5;
const INIT_RETRY_DELAY_MS: u64 = 3000;
let params_value = serde_json::to_value(params)?;
let mut last_err = None;
for attempt in 0..=MAX_INIT_RETRIES {
if attempt > 0 {
let delay = std::time::Duration::from_millis(INIT_RETRY_DELAY_MS * attempt as u64);
tokio::time::sleep(delay).await;
tracing::info!(
"LSP initialize cancelled, retrying {}/{}: {}",
attempt,
MAX_INIT_RETRIES,
self.workspace_root.display()
);
}
if let Some(fatal) = self.fatal_stderr_hint() {
return Err(fatal.into());
}
match self
.request_with_timeout("initialize", params_value.clone(), self.init_timeout)
.await
{
Ok(result) => {
let init_result: lsp_types::InitializeResult = serde_json::from_value(result)?;
*self.capabilities.lock().unwrap_or_else(|e| e.into_inner()) =
init_result.capabilities;
self.notify("initialized", json!({})).await?;
let _ = self.init_completed_at.set(std::time::Instant::now());
tracing::info!("LSP server initialized successfully");
return Ok(());
}
Err(e) => {
if let Some(fatal) = self.fatal_stderr_hint() {
return Err(fatal.into());
}
if e.to_string().contains("code -32800") {
last_err = Some(e);
} else {
return Err(e);
}
}
}
}
Err(last_err.unwrap())
}
pub fn is_alive(&self) -> bool {
self.alive.load(Ordering::SeqCst)
}
pub async fn shutdown(&self) -> Result<()> {
if !self.alive.load(Ordering::SeqCst) {
return Ok(());
}
let _ = self.request("shutdown", Value::Null).await;
let _ = self.notify("exit", Value::Null).await;
self.alive.store(false, Ordering::SeqCst);
let handle = self
.reader_handle
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();
if let Some(handle) = handle {
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
}
Ok(())
}
pub async fn workspace_symbols(&self, query: &str) -> Result<Vec<super::SymbolInfo>> {
let params = lsp_types::WorkspaceSymbolParams {
query: query.to_string(),
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let result = self
.request("workspace/symbol", serde_json::to_value(params)?)
.await?;
if result.is_null() {
return Ok(vec![]);
}
let infos: Vec<lsp_types::SymbolInformation> =
serde_json::from_value(result).context("failed to parse workspace/symbol response")?;
Ok(infos
.into_iter()
.map(|si| {
let file = uri_to_path(&si.location.uri);
let name_path = match &si.container_name {
Some(container) if !container.is_empty() => {
format!("{}/{}", container, si.name)
}
_ => si.name.clone(),
};
super::SymbolInfo {
name: si.name,
name_path,
kind: si.kind.into(),
file,
start_line: si.location.range.start.line,
end_line: si.location.range.end.line,
start_col: si.location.range.start.character,
range_start_line: None,
children: vec![],
detail: None,
}
})
.collect())
}
pub async fn did_open(&self, path: &Path, language_id: &str) -> Result<()> {
let is_socket = matches!(self.transport, LspTransport::Socket { .. });
if !is_socket {
let canonical = std::fs::canonicalize(path)
.with_context(|| format!("Failed to canonicalize path for didOpen: {:?}", path))?;
{
let mut open_files = self.open_files.lock().unwrap_or_else(|e| e.into_inner());
if open_files.contains_key(&canonical) {
return Ok(());
}
open_files.insert(canonical, 1);
}
}
const MAX_DID_OPEN_SIZE: u64 = 10 * 1024 * 1024; if let Ok(metadata) = std::fs::metadata(path) {
if metadata.len() > MAX_DID_OPEN_SIZE {
tracing::debug!(
"skipping didOpen for large file ({} bytes): {}",
metadata.len(),
path.display()
);
return Ok(());
}
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read file for didOpen: {:?}", path))?;
let uri = path_to_uri(path)?;
self.notify(
"textDocument/didOpen",
serde_json::to_value(lsp_types::DidOpenTextDocumentParams {
text_document: lsp_types::TextDocumentItem {
uri,
language_id: language_id.to_string(),
version: 1,
text: content,
},
})?,
)
.await
}
pub async fn document_symbols(
&self,
path: &Path,
language_id: &str,
) -> Result<Vec<super::SymbolInfo>> {
self.did_open(path, language_id).await?;
let uri = path_to_uri(path)?;
let params = lsp_types::DocumentSymbolParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let result = self
.request("textDocument/documentSymbol", serde_json::to_value(params)?)
.await?;
if result.is_null() {
return Ok(vec![]);
}
let file_path = path.to_path_buf();
if let Ok(symbols) =
serde_json::from_value::<Vec<lsp_types::DocumentSymbol>>(result.clone())
{
return Ok(convert_document_symbols(&symbols, &file_path, ""));
}
if let Ok(infos) = serde_json::from_value::<Vec<lsp_types::SymbolInformation>>(result) {
return Ok(infos
.iter()
.map(|si| {
let name_path = match &si.container_name {
Some(container) if !container.is_empty() => {
format!("{}/{}", container, si.name)
}
_ => si.name.clone(),
};
super::SymbolInfo {
name: si.name.clone(),
name_path,
kind: si.kind.into(),
file: file_path.clone(),
start_line: si.location.range.start.line,
end_line: si.location.range.end.line,
start_col: si.location.range.start.character,
range_start_line: None,
children: vec![],
detail: None,
}
})
.collect());
}
Ok(vec![])
}
pub async fn references(
&self,
path: &Path,
line: u32,
col: u32,
language_id: &str,
) -> Result<Vec<lsp_types::Location>> {
self.did_open(path, language_id).await?;
let uri = path_to_uri(path)?;
let params = lsp_types::ReferenceParams {
text_document_position: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: lsp_types::Position {
line,
character: col,
},
},
context: lsp_types::ReferenceContext {
include_declaration: true,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let result = self
.request("textDocument/references", serde_json::to_value(params)?)
.await?;
if result.is_null() {
return Ok(vec![]);
}
Ok(serde_json::from_value(result)?)
}
pub async fn goto_definition(
&self,
path: &Path,
line: u32,
col: u32,
language_id: &str,
) -> Result<Vec<lsp_types::Location>> {
self.did_open(path, language_id).await?;
let uri = path_to_uri(path)?;
let params = lsp_types::GotoDefinitionParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: lsp_types::Position {
line,
character: col,
},
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let result = self
.request("textDocument/definition", serde_json::to_value(params)?)
.await?;
if result.is_null() {
return Ok(vec![]);
}
if let Ok(loc) = serde_json::from_value::<lsp_types::Location>(result.clone()) {
return Ok(vec![loc]);
}
if let Ok(locs) = serde_json::from_value::<Vec<lsp_types::Location>>(result.clone()) {
return Ok(locs);
}
if let Ok(links) = serde_json::from_value::<Vec<lsp_types::LocationLink>>(result) {
return Ok(links
.into_iter()
.map(|l| lsp_types::Location {
uri: l.target_uri,
range: l.target_selection_range,
})
.collect());
}
Ok(vec![])
}
pub async fn hover(
&self,
path: &Path,
line: u32,
col: u32,
language_id: &str,
) -> Result<Option<String>> {
self.did_open(path, language_id).await?;
let uri = path_to_uri(path)?;
let params = lsp_types::HoverParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: lsp_types::Position {
line,
character: col,
},
},
work_done_progress_params: Default::default(),
};
let result = self
.request("textDocument/hover", serde_json::to_value(params)?)
.await?;
if result.is_null() {
return Ok(None);
}
let hover: lsp_types::Hover = serde_json::from_value(result)?;
let text = match hover.contents {
lsp_types::HoverContents::Scalar(ms) => match ms {
lsp_types::MarkedString::String(s) => s,
lsp_types::MarkedString::LanguageString(ls) => ls.value,
},
lsp_types::HoverContents::Array(arr) => arr
.into_iter()
.map(|ms| match ms {
lsp_types::MarkedString::String(s) => s,
lsp_types::MarkedString::LanguageString(ls) => ls.value,
})
.collect::<Vec<_>>()
.join("\n\n"),
lsp_types::HoverContents::Markup(mc) => mc.value,
};
Ok(Some(text))
}
pub async fn rename(
&self,
path: &Path,
line: u32,
col: u32,
new_name: &str,
language_id: &str,
) -> Result<lsp_types::WorkspaceEdit> {
self.did_open(path, language_id).await?;
let uri = path_to_uri(path)?;
let params = lsp_types::RenameParams {
text_document_position: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: lsp_types::Position {
line,
character: col,
},
},
new_name: new_name.to_string(),
work_done_progress_params: Default::default(),
};
let result = self
.request("textDocument/rename", serde_json::to_value(params)?)
.await?;
Ok(serde_json::from_value(result)?)
}
pub async fn did_close(&self, path: &Path) -> Result<()> {
if !matches!(self.transport, LspTransport::Socket { .. }) {
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
self.open_files
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&canonical);
}
let uri = path_to_uri(path)?;
self.notify(
"textDocument/didClose",
serde_json::to_value(lsp_types::DidCloseTextDocumentParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
})?,
)
.await
}
pub async fn did_change(&self, path: &Path) -> Result<()> {
let is_socket = matches!(self.transport, LspTransport::Socket { .. });
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let version = if is_socket {
0
} else {
let maybe_version = {
let mut open_files = self.open_files.lock().unwrap_or_else(|e| e.into_inner());
open_files.get_mut(&canonical).map(|v| {
*v = v.saturating_add(1);
*v
})
}; match maybe_version {
Some(v) => v,
None => {
if let Some(lang) = crate::ast::detect_language(path) {
if crate::lsp::servers::has_lsp_config(lang) {
let _ = self.did_open(path, lang).await;
}
}
return Ok(());
}
}
};
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read file for didChange: {:?}", path))?;
let uri = path_to_uri(path)?;
self.notify(
"textDocument/didChange",
serde_json::to_value(lsp_types::DidChangeTextDocumentParams {
text_document: lsp_types::VersionedTextDocumentIdentifier { uri, version },
content_changes: vec![lsp_types::TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: content,
}],
})?,
)
.await
}
pub async fn prepare_call_hierarchy(
&self,
path: &Path,
line: u32,
col: u32,
language_id: &str,
) -> Result<Option<lsp_types::CallHierarchyItem>> {
{
let caps = self.capabilities.lock().unwrap_or_else(|e| e.into_inner());
if !supports_call_hierarchy(&caps) {
return Ok(None);
}
}
self.did_open(path, language_id).await?;
let uri = path_to_uri(path)?;
let params = lsp_types::CallHierarchyPrepareParams {
text_document_position_params: lsp_types::TextDocumentPositionParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
position: lsp_types::Position {
line,
character: col,
},
},
work_done_progress_params: Default::default(),
};
let result = self
.request(
"textDocument/prepareCallHierarchy",
serde_json::to_value(params)?,
)
.await?;
if result.is_null() {
return Ok(None);
}
let items: Vec<lsp_types::CallHierarchyItem> = serde_json::from_value(result)?;
Ok(items.into_iter().next())
}
pub async fn incoming_calls(
&self,
item: &lsp_types::CallHierarchyItem,
_language_id: &str,
) -> Result<Vec<lsp_types::CallHierarchyIncomingCall>> {
{
let caps = self.capabilities.lock().unwrap_or_else(|e| e.into_inner());
if !supports_call_hierarchy(&caps) {
return Ok(vec![]);
}
}
let params = lsp_types::CallHierarchyIncomingCallsParams {
item: item.clone(),
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let result = self
.request("callHierarchy/incomingCalls", serde_json::to_value(params)?)
.await?;
if result.is_null() {
return Ok(vec![]);
}
let calls: Vec<lsp_types::CallHierarchyIncomingCall> = serde_json::from_value(result)?;
Ok(calls)
}
pub async fn outgoing_calls(
&self,
item: &lsp_types::CallHierarchyItem,
_language_id: &str,
) -> Result<Vec<lsp_types::CallHierarchyOutgoingCall>> {
{
let caps = self.capabilities.lock().unwrap_or_else(|e| e.into_inner());
if !supports_call_hierarchy(&caps) {
return Ok(vec![]);
}
}
let params = lsp_types::CallHierarchyOutgoingCallsParams {
item: item.clone(),
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let result = self
.request("callHierarchy/outgoingCalls", serde_json::to_value(params)?)
.await?;
if result.is_null() {
return Ok(vec![]);
}
let calls: Vec<lsp_types::CallHierarchyOutgoingCall> = serde_json::from_value(result)?;
Ok(calls)
}
}
impl Drop for LspClient {
fn drop(&mut self) {
{
let mut guard = self.reader_handle.lock().unwrap_or_else(|e| e.into_inner());
if let Some(handle) = guard.take() {
handle.abort();
}
}
if let LspTransport::Process {
child_pid: Some(pid),
} = &self.transport
{
let _ = crate::platform::terminate_process(*pid);
}
}
}
#[async_trait::async_trait]
impl crate::lsp::ops::LspClientOps for LspClient {
async fn document_symbols(
&self,
path: &std::path::Path,
language_id: &str,
) -> anyhow::Result<Vec<crate::lsp::SymbolInfo>> {
LspClient::document_symbols(self, path, language_id).await
}
async fn workspace_symbols(&self, query: &str) -> anyhow::Result<Vec<crate::lsp::SymbolInfo>> {
LspClient::workspace_symbols(self, query).await
}
async fn references(
&self,
path: &std::path::Path,
line: u32,
col: u32,
language_id: &str,
) -> anyhow::Result<Vec<lsp_types::Location>> {
LspClient::references(self, path, line, col, language_id).await
}
async fn goto_definition(
&self,
path: &std::path::Path,
line: u32,
col: u32,
language_id: &str,
) -> anyhow::Result<Vec<lsp_types::Location>> {
LspClient::goto_definition(self, path, line, col, language_id).await
}
async fn hover(
&self,
path: &std::path::Path,
line: u32,
col: u32,
language_id: &str,
) -> anyhow::Result<Option<String>> {
LspClient::hover(self, path, line, col, language_id).await
}
async fn rename(
&self,
path: &std::path::Path,
line: u32,
col: u32,
new_name: &str,
language_id: &str,
) -> anyhow::Result<lsp_types::WorkspaceEdit> {
LspClient::rename(self, path, line, col, new_name, language_id).await
}
async fn did_change(&self, path: &std::path::Path) -> anyhow::Result<()> {
LspClient::did_change(self, path).await
}
async fn prepare_call_hierarchy(
&self,
path: &std::path::Path,
line: u32,
col: u32,
language_id: &str,
) -> anyhow::Result<Option<lsp_types::CallHierarchyItem>> {
LspClient::prepare_call_hierarchy(self, path, line, col, language_id).await
}
async fn incoming_calls(
&self,
item: &lsp_types::CallHierarchyItem,
language_id: &str,
) -> anyhow::Result<Vec<lsp_types::CallHierarchyIncomingCall>> {
LspClient::incoming_calls(self, item, language_id).await
}
async fn outgoing_calls(
&self,
item: &lsp_types::CallHierarchyItem,
language_id: &str,
) -> anyhow::Result<Vec<lsp_types::CallHierarchyOutgoingCall>> {
LspClient::outgoing_calls(self, item, language_id).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn workspace_symbol_skips_cold_start_retry_budget() {
assert!(!uses_cold_start_retry_budget("workspace/symbol"));
assert!(uses_cold_start_retry_budget("textDocument/documentSymbol"));
assert!(uses_cold_start_retry_budget("textDocument/references"));
assert!(uses_cold_start_retry_budget("textDocument/hover"));
assert!(is_idempotent_lsp_method("workspace/symbol"));
}
#[test]
fn cold_start_max_retries_matches_host_platform() {
let actual = cold_start_max_retries();
let expected = if cfg!(target_os = "windows") {
20
} else if cfg!(target_os = "macos") {
15
} else {
10
};
assert_eq!(actual, expected);
assert!(actual >= 3, "cold-start retries must exceed warm budget");
}
#[test]
fn is_retryable_lsp_error_matches_both_transient_codes() {
let cancelled = anyhow::anyhow!("LSP error (code -32800): cancelled");
let modified = anyhow::anyhow!("LSP error (code -32801): content modified");
let internal = anyhow::anyhow!("LSP error (code -32603): internal error");
let timeout = anyhow::anyhow!("LSP request timed out after 30s");
assert!(is_retryable_lsp_error(&cancelled), "-32800 must retry");
assert!(is_retryable_lsp_error(&modified), "-32801 must retry");
assert!(
!is_retryable_lsp_error(&internal),
"-32603 is a real fault — must NOT retry"
);
assert!(
!is_retryable_lsp_error(&timeout),
"timeouts are surfaced as RecoverableError separately — must NOT match"
);
}
#[test]
fn detect_fatal_stderr_flags_kotlin_multi_session() {
let lines = vec![
"Exception in thread \"main\" com.jetbrains.lsp.implementation.\
LspException: Multiple editing sessions for one workspace are not supported yet"
.to_string(),
];
let hint = detect_fatal_stderr(&lines).expect("should detect fatal pattern");
let msg = hint.to_string();
assert!(
msg.contains("Multiple editing sessions"),
"error message should surface the original pattern: {msg}"
);
}
#[test]
fn detect_fatal_stderr_flags_rustup_missing_component() {
let lines = vec![
"error: Unknown binary 'rust-analyzer' in official toolchain \
'stable-x86_64-unknown-linux-gnu'."
.to_string(),
];
let hint = detect_fatal_stderr(&lines).expect("should detect rustup pattern");
let msg = hint.to_string();
assert!(
msg.contains("rust-analyzer"),
"error message should name rust-analyzer: {msg}"
);
assert!(
msg.contains("rustup component add rust-analyzer"),
"error message should include the rustup fix command: {msg}"
);
}
#[test]
fn detect_fatal_stderr_ignores_benign_lines() {
let lines = vec![
"WARN notify error: No path was found.".to_string(),
"INFO LSP server starting".to_string(),
"Gradle import in progress".to_string(),
];
assert!(detect_fatal_stderr(&lines).is_none());
}
fn create_test_cargo_project(dir: &Path) {
std::fs::write(
dir.join("Cargo.toml"),
r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#,
)
.unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("src/main.rs"),
r#"fn main() {
println!("hello");
}
fn add(a: i32, b: i32) -> i32 {
a + b
}
struct Point {
x: f64,
y: f64,
}
"#,
)
.unwrap();
}
fn rust_analyzer_available() -> bool {
std::process::Command::new("rust-analyzer")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[tokio::test]
async fn client_initializes_with_rust_analyzer() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let dir = tempdir().unwrap();
create_test_cargo_project(dir.path());
let config = LspServerConfig {
command: "rust-analyzer".into(),
args: vec![],
workspace_root: dir.path().to_path_buf(),
init_timeout: None,
mux: false,
env: vec![],
idle_timeout_secs: None,
};
let client = LspClient::start(config).await.unwrap();
assert!(client.is_alive());
{
let caps = client.capabilities.lock().unwrap();
assert!(caps.document_symbol_provider.is_some());
}
client.shutdown().await.unwrap();
assert!(!client.is_alive());
}
#[tokio::test]
async fn client_detects_missing_server() {
let dir = tempdir().unwrap();
let config = LspServerConfig {
command: "nonexistent-lsp-server-xyz".into(),
args: vec![],
workspace_root: dir.path().to_path_buf(),
init_timeout: None,
mux: false,
env: vec![],
idle_timeout_secs: None,
};
let result = LspClient::start(config).await;
assert!(result.is_err());
}
#[tokio::test]
async fn workspace_symbols_returns_project_symbols() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let dir = tempdir().unwrap();
create_test_cargo_project(dir.path());
let config = LspServerConfig {
command: "rust-analyzer".into(),
args: vec![],
workspace_root: dir.path().to_path_buf(),
init_timeout: None,
mux: false,
env: vec![],
idle_timeout_secs: None,
};
let client = LspClient::start(config).await.unwrap();
client
.did_open(&dir.path().join("src/main.rs"), "rust")
.await
.unwrap();
let mut symbols = vec![];
for _ in 0..30 {
symbols = client.workspace_symbols("add").await.unwrap();
if !symbols.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
assert!(
!symbols.is_empty(),
"workspace/symbol 'add' should return results within the retry budget"
);
assert!(
symbols.iter().any(|s| s.name == "add"),
"should find the 'add' function, got: {:?}",
symbols.iter().map(|s| &s.name).collect::<Vec<_>>()
);
client.shutdown().await.unwrap();
}
#[tokio::test]
async fn client_did_open_with_rust_analyzer() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let dir = tempdir().unwrap();
create_test_cargo_project(dir.path());
let config = LspServerConfig {
command: "rust-analyzer".into(),
args: vec![],
workspace_root: dir.path().to_path_buf(),
init_timeout: None,
mux: false,
env: vec![],
idle_timeout_secs: None,
};
let client = LspClient::start(config).await.unwrap();
client
.did_open(&dir.path().join("src/main.rs"), "rust")
.await
.unwrap();
client.shutdown().await.unwrap();
}
#[test]
fn convert_document_symbols_uses_selection_range() {
use lsp_types::{DocumentSymbol, Position, Range, SymbolKind as LspSymbolKind};
let symbols = vec![DocumentSymbol {
name: "my_func".to_string(),
detail: None,
kind: LspSymbolKind::FUNCTION,
tags: None,
#[allow(deprecated)]
deprecated: None,
range: Range {
start: Position {
line: 5,
character: 0,
},
end: Position {
line: 10,
character: 1,
},
},
selection_range: Range {
start: Position {
line: 8,
character: 4,
},
end: Position {
line: 8,
character: 11,
},
},
children: None,
}];
let path = std::env::temp_dir().join("test.rs");
let result = convert_document_symbols(&symbols, &path, "");
assert_eq!(result.len(), 1);
assert_eq!(
result[0].start_line, 8,
"start_line should use selection_range"
);
assert_eq!(
result[0].start_col, 4,
"start_col should use selection_range"
);
assert_eq!(
result[0].end_line, 10,
"end_line should use range for body extent"
);
assert_eq!(
result[0].range_start_line,
Some(5),
"range_start_line should use range.start for full declaration (including attributes)"
);
}
#[test]
fn convert_document_symbols_captures_detail() {
use lsp_types::{DocumentSymbol, Position, Range, SymbolKind as LspSymbolKind};
let symbols = vec![DocumentSymbol {
name: "my_func".to_string(),
detail: Some("(x: i32) -> bool".to_string()),
kind: LspSymbolKind::FUNCTION,
tags: None,
#[allow(deprecated)]
deprecated: None,
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 5,
character: 1,
},
},
selection_range: Range {
start: Position {
line: 0,
character: 3,
},
end: Position {
line: 0,
character: 10,
},
},
children: None,
}];
let path = std::env::temp_dir().join("test_detail_capture.rs");
let result = convert_document_symbols(&symbols, &path, "");
assert_eq!(result.len(), 1);
assert_eq!(
result[0].detail,
Some("(x: i32) -> bool".to_string()),
"detail should be captured from DocumentSymbol"
);
}
#[test]
fn convert_document_symbols_collapses_empty_detail() {
use lsp_types::{DocumentSymbol, Position, Range, SymbolKind as LspSymbolKind};
let symbols = vec![DocumentSymbol {
name: "my_func".to_string(),
detail: Some("".to_string()),
kind: LspSymbolKind::FUNCTION,
tags: None,
#[allow(deprecated)]
deprecated: None,
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 5,
character: 1,
},
},
selection_range: Range {
start: Position {
line: 0,
character: 3,
},
end: Position {
line: 0,
character: 10,
},
},
children: None,
}];
let path = std::env::temp_dir().join("test_detail_empty.rs");
let result = convert_document_symbols(&symbols, &path, "");
assert_eq!(
result[0].detail, None,
"empty string detail should collapse to None"
);
}
#[test]
fn flat_symbol_information_builds_name_path_from_container() {
use crate::lsp::SymbolInfo;
use lsp_types::{
Location, Position, Range, SymbolInformation, SymbolKind as LspSymbolKind, Uri,
};
let uri: Uri = if cfg!(windows) {
"file:///C:/temp/test.rb".parse().unwrap()
} else {
"file:///tmp/test.rb".parse().unwrap()
};
let infos = [
SymbolInformation {
name: "MyClass".to_string(),
kind: LspSymbolKind::CLASS,
tags: None,
#[allow(deprecated)]
deprecated: None,
location: Location {
uri: uri.clone(),
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 20,
character: 3,
},
},
},
container_name: None,
},
SymbolInformation {
name: "my_method".to_string(),
kind: LspSymbolKind::METHOD,
tags: None,
#[allow(deprecated)]
deprecated: None,
location: Location {
uri: uri.clone(),
range: Range {
start: Position {
line: 5,
character: 2,
},
end: Position {
line: 10,
character: 5,
},
},
},
container_name: Some("MyClass".to_string()),
},
];
let file_path = std::env::temp_dir().join("test.rb");
let result: Vec<SymbolInfo> = infos
.iter()
.map(|si| {
let name_path = match &si.container_name {
Some(container) if !container.is_empty() => {
format!("{}/{}", container, si.name)
}
_ => si.name.clone(),
};
SymbolInfo {
name: si.name.clone(),
name_path,
kind: si.kind.into(),
file: file_path.clone(),
start_line: si.location.range.start.line,
end_line: si.location.range.end.line,
start_col: si.location.range.start.character,
range_start_line: None,
children: vec![],
detail: None,
}
})
.collect();
assert_eq!(result[0].name_path, "MyClass");
assert_eq!(result[1].name_path, "MyClass/my_method");
}
#[test]
fn path_to_uri_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
std::fs::write(&file, "").unwrap();
let uri = path_to_uri(&file).unwrap();
let uri_str = uri.as_str();
assert!(
uri_str.starts_with("file:///"),
"URI should start with file:///: {}",
uri_str
);
let back = uri_to_path(&uri);
assert_eq!(back, file, "roundtrip should preserve the path");
}
#[tokio::test]
async fn drop_kills_child_process() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let dir = tempdir().unwrap();
create_test_cargo_project(dir.path());
let config = LspServerConfig {
command: "rust-analyzer".into(),
args: vec![],
workspace_root: dir.path().to_path_buf(),
init_timeout: None,
mux: false,
env: vec![],
idle_timeout_secs: None,
};
let client = LspClient::start(config).await.unwrap();
let pid = match &client.transport {
LspTransport::Process { child_pid } => child_pid.unwrap(),
_ => panic!("expected Process transport"),
};
assert!(
crate::platform::process_alive(pid),
"child should be alive before drop"
);
drop(client);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
assert!(
!crate::platform::process_alive(pid),
"child should be dead after drop"
);
}
#[tokio::test]
async fn did_change_refreshes_stale_symbol_positions() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let dir = tempdir().unwrap();
create_test_cargo_project(dir.path());
let main_rs = dir.path().join("src/main.rs");
let config = LspServerConfig {
command: "rust-analyzer".into(),
args: vec![],
workspace_root: dir.path().to_path_buf(),
init_timeout: None,
mux: false,
env: vec![],
idle_timeout_secs: None,
};
let client = LspClient::start(config).await.unwrap();
let syms = client.document_symbols(&main_rs, "rust").await.unwrap();
let add_before = syms
.iter()
.find(|s| s.name == "add")
.expect("fn add not found");
let original_line = add_before.start_line;
let original = std::fs::read_to_string(&main_rs).unwrap();
std::fs::write(&main_rs, format!("\n\n\n{}", original)).unwrap();
let syms_stale = client.document_symbols(&main_rs, "rust").await.unwrap();
let add_stale = syms_stale
.iter()
.find(|s| s.name == "add")
.expect("fn add not found");
assert_eq!(
add_stale.start_line, original_line,
"without did_change, LSP should still return the old (stale) line number"
);
client.did_change(&main_rs).await.unwrap();
let syms_fresh = client.document_symbols(&main_rs, "rust").await.unwrap();
let add_fresh = syms_fresh
.iter()
.find(|s| s.name == "add")
.expect("fn add not found");
assert_eq!(
add_fresh.start_line,
original_line + 3,
"after did_change, LSP should return the updated line number (shifted by 3)"
);
client.shutdown().await.unwrap();
}
#[tokio::test]
async fn did_change_opens_file_when_not_previously_open() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let dir = tempdir().unwrap();
create_test_cargo_project(dir.path());
let config = LspServerConfig {
command: "rust-analyzer".into(),
args: vec![],
workspace_root: dir.path().to_path_buf(),
init_timeout: None,
mux: false,
env: vec![],
idle_timeout_secs: None,
};
let client = LspClient::start(config).await.unwrap();
let new_rs = dir.path().join("src/helper.rs");
std::fs::write(&new_rs, "pub fn helper_v1() -> i32 { 1 }\n").unwrap();
client.did_change(&new_rs).await.unwrap();
std::fs::write(
&new_rs,
"pub fn helper_v1() -> i32 { 1 }\npub fn helper_v2() -> i32 { 2 }\n",
)
.unwrap();
client.did_change(&new_rs).await.unwrap();
let syms = client.document_symbols(&new_rs, "rust").await.unwrap();
assert!(
syms.iter().any(|s| s.name == "helper_v2"),
"after two did_change calls (open fallback + update), helper_v2 must be visible"
);
client.shutdown().await.unwrap();
}
#[tokio::test]
async fn call_hierarchy_prepare_returns_item() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let dir = tempdir().unwrap();
create_test_cargo_project(dir.path());
let main_rs = dir.path().join("src/main.rs");
let config = LspServerConfig {
command: "rust-analyzer".into(),
args: vec![],
workspace_root: dir.path().to_path_buf(),
init_timeout: None,
mux: false,
env: vec![],
idle_timeout_secs: None,
};
let client = LspClient::start(config).await.unwrap();
let syms = client.document_symbols(&main_rs, "rust").await.unwrap();
let add_sym = syms.iter().find(|s| s.name == "add");
let Some(add_sym) = add_sym else {
eprintln!("Skipping: fn add not found in symbols (indexing lag)");
client.shutdown().await.unwrap();
return;
};
let item = client
.prepare_call_hierarchy(&main_rs, add_sym.start_line, add_sym.start_col, "rust")
.await
.expect("prepare_call_hierarchy should not error");
match item {
None => {
eprintln!(
"Skipping: prepare_call_hierarchy returned None for fn add \
(line={}, col={}) — indexing not yet complete",
add_sym.start_line, add_sym.start_col
);
}
Some(item) => {
assert_eq!(
item.name, "add",
"expected item name 'add', got '{}'",
item.name
);
}
}
client.shutdown().await.unwrap();
}
#[tokio::test]
async fn call_hierarchy_outgoing_returns_calls() {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let dir = tempdir().unwrap();
create_test_cargo_project(dir.path());
let main_rs = dir.path().join("src/main.rs");
let original = std::fs::read_to_string(&main_rs).unwrap();
std::fs::write(
&main_rs,
format!("{original}\nfn add_twice(x: i32) -> i32 {{ add(x, x) }}\n"),
)
.unwrap();
let config = LspServerConfig {
command: "rust-analyzer".into(),
args: vec![],
workspace_root: dir.path().to_path_buf(),
init_timeout: None,
mux: false,
env: vec![],
idle_timeout_secs: None,
};
let client = LspClient::start(config).await.unwrap();
let syms = client.document_symbols(&main_rs, "rust").await.unwrap();
let add_twice = syms.iter().find(|s| s.name == "add_twice");
let Some(sym) = add_twice else {
eprintln!("Skipping: add_twice not found in symbols (indexing lag)");
client.shutdown().await.unwrap();
return;
};
let item = client
.prepare_call_hierarchy(&main_rs, sym.start_line, sym.start_col, "rust")
.await
.expect("prepare_call_hierarchy should not error");
if let Some(item) = item {
let calls = client
.outgoing_calls(&item, "rust")
.await
.expect("outgoing_calls should not error");
assert!(
!calls.is_empty(),
"expected outgoing calls from add_twice, got none"
);
} else {
eprintln!("Skipping: prepare_call_hierarchy returned None (indexing lag)");
}
client.shutdown().await.unwrap();
}
#[tokio::test]
#[ignore] async fn retry_on_cancelled_succeeds_after_transient_errors() {
let dir = tempdir().unwrap();
let fake_lsp = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/fake_lsp_cancelled.py");
assert!(
fake_lsp.exists(),
"fake LSP script missing: {}",
fake_lsp.display()
);
let config = LspServerConfig {
command: "python3".into(),
args: vec![fake_lsp.to_string_lossy().into_owned(), "2".into()],
workspace_root: dir.path().to_path_buf(),
init_timeout: Some(std::time::Duration::from_secs(5)),
mux: false,
env: vec![],
idle_timeout_secs: None,
};
let client = LspClient::start(config)
.await
.expect("fake LSP should start");
let result = client
.request(
"textDocument/documentSymbol",
serde_json::json!({
"textDocument": { "uri": "file:///fake.kt" }
}),
)
.await;
assert!(
result.is_ok(),
"request should succeed after retries, got: {:?}",
result.err()
);
let symbols = result.unwrap();
assert!(symbols.is_array(), "expected array response");
assert_eq!(
symbols.as_array().unwrap().len(),
1,
"fake server returns exactly one symbol"
);
client.shutdown().await.unwrap();
}
#[tokio::test]
#[ignore] async fn retry_on_cancelled_fails_when_exhausted() {
let dir = tempdir().unwrap();
let fake_lsp = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/fake_lsp_cancelled.py");
let config = LspServerConfig {
command: "python3".into(),
args: vec![fake_lsp.to_string_lossy().into_owned(), "10".into()],
workspace_root: dir.path().to_path_buf(),
init_timeout: Some(std::time::Duration::from_secs(5)),
mux: false,
env: vec![],
idle_timeout_secs: None,
};
let client = LspClient::start(config)
.await
.expect("fake LSP should start");
let result = client
.request(
"textDocument/documentSymbol",
serde_json::json!({
"textDocument": { "uri": "file:///fake.kt" }
}),
)
.await;
assert!(result.is_err(), "should fail after exhausting retries");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("code -32800"),
"error should mention -32800, got: {}",
err_msg
);
client.shutdown().await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn lsp_client_connect_to_nonexistent_socket_returns_error() {
let socket_path = std::env::temp_dir().join("codescout-test-nonexistent.sock");
let _ = std::fs::remove_file(&socket_path);
let result = LspClient::connect(&socket_path, std::env::temp_dir()).await;
let err_msg = match result {
Err(e) => e.to_string(),
Ok(_) => panic!("connecting to nonexistent socket should fail"),
};
assert!(
err_msg.contains("Failed to connect to mux socket"),
"error should mention mux socket, got: {}",
err_msg
);
}
#[test]
fn lsp_server_config_has_idle_timeout_field() {
let cfg = LspServerConfig {
command: "dummy".to_string(),
args: vec![],
workspace_root: std::path::PathBuf::from("/tmp"),
init_timeout: None,
mux: false,
env: vec![],
idle_timeout_secs: Some(42),
};
assert_eq!(cfg.idle_timeout_secs, Some(42));
}
}