use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::sync::{Mutex, Notify};
use tracing::{debug, warn};
use agent_types::lsp::{
LspCallHierarchyItem, LspDiagnostic, LspError, LspIncomingCall, LspLocation, LspOutgoingCall,
LspSymbol, Severity,
};
use crate::client::LspClient;
use crate::host::LspEnv;
use crate::servers::{path_to_uri, resolve_binary, uri_to_path, ServerConfig};
use crate::types::{symbol_kind_name, LspPos};
const MAX_CRASHES: u32 = 3;
const DIAG_TIMEOUT_SECS: u64 = 30;
const DIAG_SETTLE_MS: u64 = 2500;
const STARTUP_TIMEOUT_SECS: u64 = 45;
#[derive(Debug, Clone, PartialEq)]
enum State {
Stopped,
Running,
Failed(String),
}
pub struct LspServerInstance {
config: ServerConfig,
root: PathBuf,
env: Arc<dyn LspEnv>,
state: State,
client: Option<LspClient>,
open_files: HashMap<String, i32>,
diagnostics: Arc<Mutex<HashMap<String, Vec<LspDiagnostic>>>>,
diag_updated: Arc<Notify>,
crash_count: u32,
_diag_task: Option<tokio::task::JoinHandle<()>>,
}
impl LspServerInstance {
pub fn new(config: ServerConfig, root: PathBuf, env: Arc<dyn LspEnv>) -> Self {
Self {
config,
root,
env,
state: State::Stopped,
client: None,
open_files: HashMap::new(),
diagnostics: Arc::new(Mutex::new(HashMap::new())),
diag_updated: Arc::new(Notify::new()),
crash_count: 0,
_diag_task: None,
}
}
pub async fn ensure_started(&mut self) -> Result<(), LspError> {
match &self.state {
State::Running => return Ok(()),
State::Failed(msg) => return Err(LspError::PermanentlyFailed(msg.clone())),
State::Stopped => {}
}
if self.crash_count >= MAX_CRASHES {
let msg = format!(
"server '{}' crashed {} times, giving up",
self.config.id, self.crash_count
);
self.state = State::Failed(msg.clone());
return Err(LspError::PermanentlyFailed(msg));
}
let start = self.start_server().await;
if let Err(e) = start {
self.crash_count += 1;
warn!(
server = self.config.id,
crash_count = self.crash_count,
"LSP server failed to start: {}",
e
);
return Err(e);
}
self.state = State::Running;
debug!(server = self.config.id, root = ?self.root, "LSP server started");
Ok(())
}
async fn start_server(&mut self) -> Result<(), LspError> {
let root_uri = path_to_uri(&self.root);
let binary = resolve_binary(&self.config, self.env.as_ref()).await?;
let binary_str = binary
.to_str()
.ok_or_else(|| LspError::StartupFailed("binary path contains invalid UTF-8".into()))?
.to_string();
let process = tokio::time::timeout(
std::time::Duration::from_secs(STARTUP_TIMEOUT_SECS),
self.env
.spawn_process(&binary_str, self.config.args, &self.root),
)
.await
.map_err(|_| LspError::StartupFailed("process spawn timed out".into()))??;
let client = LspClient::from_process(process);
client
.initialize(&root_uri, self.config.initialization_options.clone())
.await
.map_err(|e| LspError::StartupFailed(e.to_string()))?;
let mut notif_rx = client.subscribe();
let diag_store = Arc::clone(&self.diagnostics);
let diag_updated = Arc::clone(&self.diag_updated);
let task = tokio::spawn(async move {
while let Ok(notif) = notif_rx.recv().await {
if notif.method != "textDocument/publishDiagnostics" {
continue;
}
let uri = match notif.params.get("uri").and_then(|v: &Value| v.as_str()) {
Some(u) => u.to_string(),
None => continue,
};
let raw_diags = match notif
.params
.get("diagnostics")
.and_then(|v: &Value| v.as_array())
{
Some(d) => d.clone(),
None => continue,
};
let diags: Vec<LspDiagnostic> = raw_diags.iter().map(parse_diagnostic).collect();
diag_store.lock().await.insert(uri, diags);
diag_updated.notify_one();
}
});
self.client = Some(client);
self._diag_task = Some(task);
Ok(())
}
pub async fn open_file(&mut self, path: &Path, text: String) -> Result<(), LspError> {
self.ensure_started().await?;
let uri = path_to_uri(path);
if let Some(version) = self.open_files.get_mut(&uri) {
*version += 1;
let v = *version;
self.client().notify(
"textDocument/didChange",
json!({
"textDocument": { "uri": uri, "version": v },
"contentChanges": [{ "text": text }]
}),
);
} else {
self.open_files.insert(uri.clone(), 1);
self.client().notify(
"textDocument/didOpen",
json!({
"textDocument": {
"uri": uri,
"languageId": self.config.language_id,
"version": 1,
"text": text,
}
}),
);
}
Ok(())
}
#[allow(dead_code)]
pub async fn change_file(&mut self, path: &Path, text: String) -> Result<(), LspError> {
self.ensure_started().await?;
let uri = path_to_uri(path);
let version = self.open_files.entry(uri.clone()).or_insert(0);
*version += 1;
let version = *version;
self.client().notify(
"textDocument/didChange",
json!({
"textDocument": { "uri": uri, "version": version },
"contentChanges": [{ "text": text }]
}),
);
Ok(())
}
#[allow(dead_code)]
pub async fn close_file(&mut self, path: &Path) -> Result<(), LspError> {
if self.state != State::Running {
return Ok(());
}
let uri = path_to_uri(path);
self.open_files.remove(&uri);
self.client().notify(
"textDocument/didClose",
json!({ "textDocument": { "uri": uri } }),
);
Ok(())
}
pub async fn diagnostics(&self, path: &Path) -> Result<Vec<LspDiagnostic>, LspError> {
let uri = path_to_uri(path);
{
let store = self.diagnostics.lock().await;
if let Some(diags) = store.get(&uri) {
if !diags.is_empty() {
return Ok(diags.clone());
}
}
}
let timeout_dur = tokio::time::Duration::from_secs(DIAG_TIMEOUT_SECS);
let settle = tokio::time::Duration::from_millis(DIAG_SETTLE_MS);
let diag_store = Arc::clone(&self.diagnostics);
let diag_updated = Arc::clone(&self.diag_updated);
let uri_clone = uri.clone();
let result = tokio::time::timeout(timeout_dur, async move {
loop {
diag_updated.notified().await;
let store = diag_store.lock().await;
if let Some(diags) = store.get(&uri_clone) {
if !diags.is_empty() {
return diags.clone();
}
break;
}
}
loop {
match tokio::time::timeout(settle, diag_updated.notified()).await {
Ok(_) => {
let store = diag_store.lock().await;
if let Some(diags) = store.get(&uri_clone) {
if !diags.is_empty() {
return diags.clone();
}
}
}
Err(_) => {
let store = diag_store.lock().await;
return store.get(&uri_clone).cloned().unwrap_or_default();
}
}
}
})
.await
.unwrap_or_default();
Ok(result)
}
async fn wait_for_any_file_indexed(&self) {
let diag_store = Arc::clone(&self.diagnostics);
let diag_updated = Arc::clone(&self.diag_updated);
let _ = tokio::time::timeout(
tokio::time::Duration::from_secs(DIAG_TIMEOUT_SECS),
async move {
loop {
diag_updated.notified().await;
if !diag_store.lock().await.is_empty() {
return;
}
}
},
)
.await;
}
async fn wait_for_file_ready(&self, path: &Path) {
let uri = path_to_uri(path);
let diag_store = Arc::clone(&self.diagnostics);
let diag_updated = Arc::clone(&self.diag_updated);
let _ = tokio::time::timeout(
tokio::time::Duration::from_secs(DIAG_TIMEOUT_SECS),
async move {
loop {
diag_updated.notified().await;
if diag_store.lock().await.contains_key(&uri) {
return;
}
}
},
)
.await;
}
pub async fn hover(
&self,
path: &Path,
line: u32,
col: u32,
) -> Result<Option<String>, LspError> {
self.wait_for_file_ready(path).await;
let uri = path_to_uri(path);
let pos = LspPos::from_1based(line, col);
let result = self
.client()
.request(
"textDocument/hover",
json!({
"textDocument": { "uri": uri },
"position": { "line": pos.line, "character": pos.character }
}),
)
.await?;
if result.is_null() {
return Ok(None);
}
Ok(extract_hover_text(&result))
}
pub async fn definition(
&self,
path: &Path,
line: u32,
col: u32,
) -> Result<Vec<LspLocation>, LspError> {
self.wait_for_file_ready(path).await;
let uri = path_to_uri(path);
let pos = LspPos::from_1based(line, col);
let result = self
.client()
.request(
"textDocument/definition",
json!({
"textDocument": { "uri": uri },
"position": { "line": pos.line, "character": pos.character }
}),
)
.await?;
Ok(parse_locations(&result))
}
pub async fn references(
&self,
path: &Path,
line: u32,
col: u32,
include_declaration: bool,
) -> Result<Vec<LspLocation>, LspError> {
self.wait_for_file_ready(path).await;
let uri = path_to_uri(path);
let pos = LspPos::from_1based(line, col);
let result = self
.client()
.request(
"textDocument/references",
json!({
"textDocument": { "uri": uri },
"position": { "line": pos.line, "character": pos.character },
"context": { "includeDeclaration": include_declaration }
}),
)
.await?;
Ok(parse_locations(&result))
}
pub async fn document_symbols(&self, path: &Path) -> Result<Vec<LspSymbol>, LspError> {
self.wait_for_file_ready(path).await;
let uri = path_to_uri(path);
let result = self
.client()
.request(
"textDocument/documentSymbol",
json!({ "textDocument": { "uri": uri } }),
)
.await?;
Ok(parse_symbols(&result, &uri))
}
pub async fn workspace_symbols(&self, query: &str) -> Result<Vec<LspSymbol>, LspError> {
self.wait_for_any_file_indexed().await;
let result = self
.client()
.request("workspace/symbol", json!({ "query": query }))
.await?;
Ok(parse_workspace_symbols(&result))
}
pub async fn implementation(
&self,
path: &Path,
line: u32,
col: u32,
) -> Result<Vec<LspLocation>, LspError> {
self.wait_for_file_ready(path).await;
let uri = path_to_uri(path);
let pos = LspPos::from_1based(line, col);
let result = self
.client()
.request(
"textDocument/implementation",
json!({
"textDocument": { "uri": uri },
"position": { "line": pos.line, "character": pos.character }
}),
)
.await?;
Ok(parse_locations(&result))
}
pub async fn prepare_call_hierarchy(
&self,
path: &Path,
line: u32,
col: u32,
) -> Result<Vec<LspCallHierarchyItem>, LspError> {
let raw = self.call_hierarchy_items_raw(path, line, col).await?;
Ok(raw.iter().filter_map(parse_call_hierarchy_item).collect())
}
pub async fn incoming_calls(
&self,
path: &Path,
line: u32,
col: u32,
) -> Result<Vec<LspIncomingCall>, LspError> {
let items = self.call_hierarchy_items_raw(path, line, col).await?;
let Some(first) = items.into_iter().next() else {
return Ok(vec![]);
};
let result = self
.client()
.request("callHierarchy/incomingCalls", json!({ "item": first }))
.await?;
Ok(parse_incoming_calls(&result))
}
pub async fn outgoing_calls(
&self,
path: &Path,
line: u32,
col: u32,
) -> Result<Vec<LspOutgoingCall>, LspError> {
let uri = path_to_uri(path);
let items = self.call_hierarchy_items_raw(path, line, col).await?;
let Some(first) = items.into_iter().next() else {
return Ok(vec![]);
};
let result = self
.client()
.request("callHierarchy/outgoingCalls", json!({ "item": first }))
.await?;
Ok(parse_outgoing_calls(&result, &uri))
}
async fn call_hierarchy_items_raw(
&self,
path: &Path,
line: u32,
col: u32,
) -> Result<Vec<Value>, LspError> {
self.wait_for_file_ready(path).await;
let uri = path_to_uri(path);
let pos = LspPos::from_1based(line, col);
let result = self
.client()
.request(
"textDocument/prepareCallHierarchy",
json!({
"textDocument": { "uri": uri },
"position": { "line": pos.line, "character": pos.character }
}),
)
.await?;
Ok(result.as_array().cloned().unwrap_or_default())
}
fn client(&self) -> &LspClient {
self.client.as_ref().expect("ensure_started was called")
}
}
fn parse_diagnostic(v: &Value) -> LspDiagnostic {
let severity = v
.get("severity")
.and_then(|s| s.as_u64())
.map(|n| Severity::from_lsp_code(n as u32))
.unwrap_or(Severity::Error);
let (line, col) = v
.get("range")
.and_then(|r| r.get("start"))
.map(|s| {
let line = s.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
let col = s.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
(line, col)
})
.unwrap_or((1, 1));
let message = v
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
let source = v
.get("source")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
let code = v.get("code").map(|c| match c {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
_ => String::new(),
});
LspDiagnostic {
severity: severity.as_str().to_string(),
line,
col,
message,
source,
code,
}
}
fn extract_hover_text(v: &Value) -> Option<String> {
let contents = v.get("contents")?;
if let Some(s) = contents.as_str() {
return Some(s.to_string());
}
if let Some(value) = contents.get("value").and_then(|v| v.as_str()) {
return Some(value.to_string());
}
if let Some(arr) = contents.as_array() {
let parts: Vec<&str> = arr
.iter()
.filter_map(|item| {
item.as_str()
.or_else(|| item.get("value").and_then(|v| v.as_str()))
})
.collect();
if !parts.is_empty() {
return Some(parts.join("\n\n"));
}
}
None
}
fn parse_locations(v: &Value) -> Vec<LspLocation> {
let items = match v {
Value::Array(arr) => arr.clone(),
single if single.is_object() => vec![single.clone()],
_ => return vec![],
};
items
.iter()
.filter_map(|item| {
let uri = item
.get("uri")
.or_else(|| item.get("targetUri"))
.and_then(|u| u.as_str())?;
let range = item
.get("range")
.or_else(|| item.get("targetSelectionRange"))
.or_else(|| item.get("targetRange"))?;
let start = range.get("start")?;
let line = start.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
let col = start.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
Some(LspLocation {
file: uri_to_path(uri),
line,
col,
})
})
.collect()
}
fn parse_symbols(v: &Value, default_uri: &str) -> Vec<LspSymbol> {
let items = match v.as_array() {
Some(a) => a,
None => return vec![],
};
let mut result = Vec::new();
for item in items {
collect_symbol(item, None, default_uri, &mut result);
}
result
}
fn collect_symbol(
item: &Value,
container: Option<&str>,
default_uri: &str,
out: &mut Vec<LspSymbol>,
) {
let name = match item.get("name").and_then(|n| n.as_str()) {
Some(n) => n.to_string(),
None => return,
};
let kind = item
.get("kind")
.and_then(|k| k.as_u64())
.map(|k| symbol_kind_name(k as u32).to_string())
.unwrap_or_else(|| "unknown".to_string());
let location = if let Some(loc) = item.get("location") {
let uri = loc
.get("uri")
.and_then(|u| u.as_str())
.unwrap_or(default_uri);
let range = loc.get("range");
make_location(uri, range)
} else {
let range = item.get("selectionRange").or_else(|| item.get("range"));
make_location(default_uri, range)
};
out.push(LspSymbol {
name: name.clone(),
kind,
location,
container: container.map(|s| s.to_string()),
});
if let Some(children) = item.get("children").and_then(|c| c.as_array()) {
for child in children {
collect_symbol(child, Some(&name), default_uri, out);
}
}
}
fn make_location(uri: &str, range: Option<&Value>) -> LspLocation {
let (line, col) = range
.and_then(|r| r.get("start"))
.map(|s| {
let line = s.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
let col = s.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
(line, col)
})
.unwrap_or((1, 1));
LspLocation {
file: uri_to_path(uri),
line,
col,
}
}
fn parse_call_hierarchy_item(v: &Value) -> Option<LspCallHierarchyItem> {
let name = v.get("name")?.as_str()?.to_string();
let kind = v
.get("kind")
.and_then(|k| k.as_u64())
.map(|k| symbol_kind_name(k as u32).to_string())
.unwrap_or_else(|| "unknown".to_string());
let uri = v.get("uri")?.as_str()?;
let range = v.get("selectionRange").or_else(|| v.get("range"))?;
let start = range.get("start")?;
let line = start.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
let col = start.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
Some(LspCallHierarchyItem {
name,
kind,
file: uri_to_path(uri),
line,
col,
})
}
fn parse_incoming_calls(v: &Value) -> Vec<LspIncomingCall> {
let items = match v.as_array() {
Some(a) => a,
None => return vec![],
};
items
.iter()
.filter_map(|item| {
let from = item.get("from")?;
let caller = parse_call_hierarchy_item(from)?;
let caller_uri = from.get("uri")?.as_str()?;
let from_ranges = item
.get("fromRanges")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
let call_sites = from_ranges
.iter()
.filter_map(|range| {
let start = range.get("start")?;
let line = start.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
let col =
start.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
Some(LspLocation {
file: uri_to_path(caller_uri),
line,
col,
})
})
.collect();
Some(LspIncomingCall { caller, call_sites })
})
.collect()
}
fn parse_outgoing_calls(v: &Value, caller_uri: &str) -> Vec<LspOutgoingCall> {
let items = match v.as_array() {
Some(a) => a,
None => return vec![],
};
items
.iter()
.filter_map(|item| {
let to = item.get("to")?;
let callee = parse_call_hierarchy_item(to)?;
let from_ranges = item
.get("fromRanges")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
let call_sites = from_ranges
.iter()
.filter_map(|range| {
let start = range.get("start")?;
let line = start.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
let col =
start.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + 1;
Some(LspLocation {
file: uri_to_path(caller_uri),
line,
col,
})
})
.collect();
Some(LspOutgoingCall { callee, call_sites })
})
.collect()
}
fn parse_workspace_symbols(v: &Value) -> Vec<LspSymbol> {
let items = match v.as_array() {
Some(a) => a,
None => return vec![],
};
items
.iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let kind = item
.get("kind")
.and_then(|k| k.as_u64())
.map(|k| symbol_kind_name(k as u32).to_string())
.unwrap_or_else(|| "unknown".to_string());
let loc = item.get("location")?;
let uri = loc.get("uri")?.as_str()?;
let range = loc.get("range");
let location = make_location(uri, range);
let container = item
.get("containerName")
.and_then(|c| c.as_str())
.map(|s| s.to_string());
Some(LspSymbol {
name,
kind,
location,
container,
})
})
.collect()
}