use anyhow::{Result, anyhow};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tracing::{info, warn};
use crate::bridge::filesystem_manager::FilesystemManager;
use crate::bridge::{DocumentManager, DocumentNotification};
use crate::config::Config;
use crate::lsp::LspClient;
use crate::lsp::state::ServerStatus;
use crate::session::MessageLog;
pub struct LspClientManager {
config: Config,
roots: Mutex<Vec<PathBuf>>,
clients: Mutex<HashMap<String, Arc<Mutex<LspClient>>>>,
message_log: Arc<MessageLog>,
fs: Arc<FilesystemManager>,
doc_manager: Mutex<DocumentManager>,
}
impl LspClientManager {
#[must_use]
pub fn new(
config: Config,
roots: Vec<PathBuf>,
message_log: Arc<MessageLog>,
fs: Arc<FilesystemManager>,
session_id: String,
) -> Self {
Self {
config,
roots: Mutex::new(roots),
clients: Mutex::new(HashMap::new()),
message_log,
fs,
doc_manager: Mutex::new(DocumentManager::new(session_id)),
}
}
pub const fn config(&self) -> &Config {
&self.config
}
pub const fn doc_manager(&self) -> &Mutex<DocumentManager> {
&self.doc_manager
}
pub async fn spawn_all(&self) {
let roots = self.roots.lock().await.clone();
let configured_keys: HashSet<&str> =
self.config.language.keys().map(String::as_str).collect();
let relevant = self.fs.detect_workspace_languages(&roots, &configured_keys);
if relevant.is_empty() {
info!("No configured languages detected in workspace");
return;
}
let mut sorted: Vec<&str> = relevant.iter().map(String::as_str).collect();
sorted.sort_unstable();
info!("Detected languages in workspace: {}", sorted.join(", "));
for lang in &relevant {
if let Err(e) = self.get_or_spawn(lang).await {
warn!("Failed to spawn LSP server for {lang}: {e}");
}
}
}
pub async fn roots(&self) -> Vec<PathBuf> {
self.roots.lock().await.clone()
}
pub async fn remove_root(&self, root: &Path) -> Result<()> {
let uri = format!("file://{}", root.display());
let name = root.file_name().map_or_else(
|| "workspace".to_string(),
|s| s.to_string_lossy().to_string(),
);
self.roots.lock().await.retain(|r| r != root);
let clients = self.clients.lock().await.clone();
let mut to_restart = Vec::new();
for (lang, client_mutex) in &clients {
let client = client_mutex.lock().await;
if !client.is_alive() {
continue;
}
if client.supports_workspace_folders() {
if let Err(e) = client
.did_change_workspace_folders(&[], &[(&uri, &name)])
.await
{
warn!(
"Failed to notify {} server about removed workspace folder: {}",
lang, e
);
}
} else {
to_restart.push(lang.clone());
}
}
for lang in &to_restart {
info!(
"{} server does not support workspace folder changes, restarting",
lang
);
self.shutdown_client(lang).await;
}
Ok(())
}
pub async fn sync_roots(&self, new_roots: Vec<PathBuf>) -> Result<()> {
let current_roots = self.roots.lock().await.clone();
let to_add: Vec<&PathBuf> = new_roots
.iter()
.filter(|r| !current_roots.contains(r))
.collect();
let to_remove: Vec<&PathBuf> = current_roots
.iter()
.filter(|r| !new_roots.contains(r))
.collect();
if to_add.is_empty() && to_remove.is_empty() {
return Ok(());
}
info!(
"Syncing roots: {} added, {} removed",
to_add.len(),
to_remove.len()
);
let added_folders: Vec<(String, String)> = to_add
.iter()
.map(|root| {
(
format!("file://{}", root.display()),
root.file_name().map_or_else(
|| "workspace".to_string(),
|s| s.to_string_lossy().to_string(),
),
)
})
.collect();
let removed_folders: Vec<(String, String)> = to_remove
.iter()
.map(|root| {
(
format!("file://{}", root.display()),
root.file_name().map_or_else(
|| "workspace".to_string(),
|s| s.to_string_lossy().to_string(),
),
)
})
.collect();
*self.roots.lock().await = new_roots;
let added_refs: Vec<(&str, &str)> = added_folders
.iter()
.map(|(u, n)| (u.as_str(), n.as_str()))
.collect();
let removed_refs: Vec<(&str, &str)> = removed_folders
.iter()
.map(|(u, n)| (u.as_str(), n.as_str()))
.collect();
let clients = self.clients.lock().await.clone();
let mut to_restart = Vec::new();
for (lang, client_mutex) in &clients {
let client = client_mutex.lock().await;
if !client.is_alive() {
continue;
}
if client.supports_workspace_folders() {
if let Err(e) = client
.did_change_workspace_folders(&added_refs, &removed_refs)
.await
{
warn!(
"Failed to notify {} server about workspace folder changes: {}",
lang, e
);
}
} else {
to_restart.push(lang.clone());
}
}
for lang in &to_restart {
info!(
"{} server does not support workspace folder changes, restarting",
lang
);
self.shutdown_client(lang).await;
}
Ok(())
}
pub async fn get_or_spawn(&self, lang: &str) -> Result<Arc<Mutex<LspClient>>> {
let (canonical, lang_config) = self
.config
.resolve_language(lang)
.ok_or_else(|| anyhow!("No LSP server configured for language '{lang}'"))?;
if let Some(client) = self.clients.lock().await.get(canonical) {
if client.lock().await.is_alive() {
return Ok(client.clone());
}
anyhow::bail!("LSP server for '{canonical}' is dead");
}
let mut clients = self.clients.lock().await;
if let Some(client) = clients.get(canonical) {
if client.lock().await.is_alive() {
return Ok(client.clone());
}
anyhow::bail!("LSP server for '{canonical}' is dead");
}
let server_name = lang_config
.servers
.first()
.ok_or_else(|| anyhow!("No servers configured for language '{canonical}'"))?;
let server_def = self
.config
.server
.get(server_name)
.ok_or_else(|| anyhow!("Server '{server_name}' not found in [server.*] config"))?;
info!(
"Spawning LSP server for {}: {} {}",
canonical,
server_def.command,
server_def.args.join(" ")
);
let args: Vec<&str> = server_def
.args
.iter()
.map(|s: &String| s.as_str())
.collect();
let mut client = LspClient::spawn(
&server_def.command,
&args,
canonical,
self.message_log.clone(),
server_def.settings.clone(),
)?;
let roots = self.roots.lock().await.clone();
client
.initialize(&roots, server_def.initialization_options.clone())
.await?;
let client_mutex = Arc::new(Mutex::new(client));
clients.insert(canonical.to_string(), client_mutex.clone());
drop(clients);
Ok(client_mutex)
}
pub async fn get_client(&self, path: &Path) -> Result<Arc<Mutex<LspClient>>> {
if let Some(lang_id) = self.fs.language_id(path)
&& let Ok(client) = self.get_or_spawn(lang_id).await
{
return Ok(client);
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.ok_or_else(|| anyhow!("No LSP server configured for {}", path.display()))?;
self.get_or_spawn(ext).await
}
#[allow(
clippy::significant_drop_tightening,
reason = "Client lock held across notification send"
)]
pub async fn ensure_document_open(
&self,
path: &Path,
parent_id: Option<i64>,
) -> Result<(String, Arc<Mutex<LspClient>>)> {
let client_mutex = self.get_client(path).await?;
let mut doc_manager = self.doc_manager.lock().await;
let mut client = client_mutex.lock().await;
client.set_parent_id(parent_id);
if !client.is_alive() {
client.set_parent_id(None);
return Err(anyhow!(
"[{}] server is no longer running",
client.language()
));
}
let uri = doc_manager.uri_for_path(path)?;
if let Some(notification) = doc_manager.ensure_open(path).await? {
match notification {
DocumentNotification::Open {
language_id,
version,
text,
..
} => {
client.did_open(&uri, &language_id, version, &text).await?;
}
DocumentNotification::Change { version, text, .. } => {
client.did_change(&uri, version, &text).await?;
}
}
}
drop(doc_manager);
drop(client);
Ok((uri, client_mutex))
}
pub async fn ensure_clients_for_paths(&self, paths: &[PathBuf]) {
let configured_keys: HashSet<&str> =
self.config.language.keys().map(String::as_str).collect();
let mut to_spawn: HashSet<String> = HashSet::new();
{
let active = self.clients.lock().await;
for path in paths {
let key = self.fs.language_id(path).map(str::to_string).or_else(|| {
path.extension()
.and_then(|e| e.to_str())
.map(str::to_string)
});
if let Some(key) = key
&& configured_keys.contains(key.as_str())
&& !active.contains_key(&key)
{
to_spawn.insert(key);
}
}
}
if to_spawn.is_empty() {
return;
}
let mut sorted: Vec<&str> = to_spawn.iter().map(String::as_str).collect();
sorted.sort_unstable();
info!("Mid-session server spawn for: {}", sorted.join(", "));
for lang in &to_spawn {
if let Err(e) = self.get_or_spawn(lang).await {
warn!("Failed to spawn LSP server for {lang}: {e}");
}
}
}
pub async fn clients(&self) -> HashMap<String, Arc<Mutex<LspClient>>> {
self.clients.lock().await.clone()
}
pub async fn all_server_status(&self) -> Vec<ServerStatus> {
let clients = self.clients.lock().await.clone();
let mut statuses = Vec::new();
for (lang, client_mutex) in clients {
let status = client_mutex.lock().await.status(lang);
statuses.push(status);
}
statuses
}
pub async fn shutdown_client(&self, lang: &str) {
let mut clients = self.clients.lock().await;
if let Some(client_mutex) = clients.remove(lang) {
info!("Shutting down idle LSP server for {}", lang);
let mut client = client_mutex.lock().await;
if client.is_alive()
&& let Err(e) = client.shutdown().await
{
warn!("Failed to shutdown LSP server for {}: {}", lang, e);
}
}
}
pub async fn shutdown_all(&self) {
let mut clients = self.clients.lock().await;
for (lang, client_mutex) in clients.drain() {
let mut client = client_mutex.lock().await;
if client.is_alive() {
let result = tokio::time::timeout(Duration::from_secs(5), client.shutdown()).await;
drop(client);
match result {
Ok(Err(e)) => {
warn!("Failed to shutdown LSP server for {}: {}", lang, e);
}
Err(_) => {
warn!(
"LSP server for {} did not respond to shutdown within 5s, killing",
lang
);
}
Ok(Ok(())) => {}
}
}
}
}
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
use crate::config::{IconConfig, LanguageConfig, ServerDef};
use crate::session::MessageLog;
use anyhow::Result;
const MOCK_LANG_A: &str = "yX4Za";
fn test_message_log() -> Arc<MessageLog> {
Arc::new(MessageLog::noop())
}
fn test_fs() -> Arc<FilesystemManager> {
Arc::new(FilesystemManager::new())
}
fn test_config() -> Config {
Config {
language: HashMap::new(),
server: HashMap::new(),
idle_timeout: 300,
log_retention_days: 7,
icons: IconConfig::default(),
tui: crate::config::TuiConfig::default(),
}
}
fn mockls_bin() -> PathBuf {
let test_exe = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
.and_then(|p| p.parent().map(Path::to_path_buf))
.map(|p| p.join("mockls"));
test_exe.unwrap_or_else(|| PathBuf::from("mockls"))
}
fn mockls_config() -> Config {
let bin = mockls_bin();
let server_name = format!("mockls-{MOCK_LANG_A}");
let mut server = HashMap::new();
server.insert(
server_name.clone(),
ServerDef {
command: bin.to_string_lossy().to_string(),
args: vec![MOCK_LANG_A.to_string()],
initialization_options: None,
settings: None,
},
);
let mut language = HashMap::new();
language.insert(
MOCK_LANG_A.to_string(),
LanguageConfig {
servers: vec![server_name],
min_severity: None,
inherit: None,
},
);
Config {
language,
server,
idle_timeout: 300,
log_retention_days: 7,
icons: IconConfig::default(),
tui: crate::config::TuiConfig::default(),
}
}
fn mockls_workspace_folders_config() -> Config {
let bin = mockls_bin();
let server_name = format!("mockls-{MOCK_LANG_A}-wf");
let mut server = HashMap::new();
server.insert(
server_name.clone(),
ServerDef {
command: bin.to_string_lossy().to_string(),
args: vec![MOCK_LANG_A.to_string(), "--workspace-folders".to_string()],
initialization_options: None,
settings: None,
},
);
let mut language = HashMap::new();
language.insert(
MOCK_LANG_A.to_string(),
LanguageConfig {
servers: vec![server_name],
min_severity: None,
inherit: None,
},
);
Config {
language,
server,
idle_timeout: 300,
log_retention_days: 7,
icons: IconConfig::default(),
tui: crate::config::TuiConfig::default(),
}
}
#[tokio::test]
async fn test_roots_returns_initial_roots() -> Result<()> {
let manager = LspClientManager::new(
test_config(),
vec![PathBuf::from("/tmp/root_a"), PathBuf::from("/tmp/root_b")],
test_message_log(),
test_fs(),
String::new(),
);
let roots = manager.roots().await;
assert_eq!(roots.len(), 2);
assert_eq!(roots[0], PathBuf::from("/tmp/root_a"));
assert_eq!(roots[1], PathBuf::from("/tmp/root_b"));
Ok(())
}
#[tokio::test]
async fn test_roots_empty_initial() -> Result<()> {
let manager = LspClientManager::new(
test_config(),
vec![],
test_message_log(),
test_fs(),
String::new(),
);
assert!(manager.roots().await.is_empty());
Ok(())
}
#[tokio::test]
async fn test_remove_root() -> Result<()> {
let manager = LspClientManager::new(
test_config(),
vec![PathBuf::from("/tmp/root_a"), PathBuf::from("/tmp/root_b")],
test_message_log(),
test_fs(),
String::new(),
);
assert_eq!(manager.roots().await.len(), 2);
manager.remove_root(Path::new("/tmp/root_a")).await?;
let roots = manager.roots().await;
assert_eq!(roots.len(), 1);
assert_eq!(roots[0], PathBuf::from("/tmp/root_b"));
Ok(())
}
#[tokio::test]
async fn test_sync_roots_adds_and_removes() -> Result<()> {
let manager = LspClientManager::new(
test_config(),
vec![PathBuf::from("/tmp/root_a"), PathBuf::from("/tmp/root_b")],
test_message_log(),
test_fs(),
String::new(),
);
manager
.sync_roots(vec![
PathBuf::from("/tmp/root_b"),
PathBuf::from("/tmp/root_c"),
])
.await?;
let roots = manager.roots().await;
assert_eq!(roots.len(), 2);
assert_eq!(roots[0], PathBuf::from("/tmp/root_b"));
assert_eq!(roots[1], PathBuf::from("/tmp/root_c"));
Ok(())
}
#[tokio::test]
async fn test_sync_roots_no_change() -> Result<()> {
let manager = LspClientManager::new(
test_config(),
vec![PathBuf::from("/tmp/root_a")],
test_message_log(),
test_fs(),
String::new(),
);
manager
.sync_roots(vec![PathBuf::from("/tmp/root_a")])
.await?;
let roots = manager.roots().await;
assert_eq!(roots.len(), 1);
assert_eq!(roots[0], PathBuf::from("/tmp/root_a"));
Ok(())
}
#[tokio::test]
async fn test_sync_roots_shuts_down_unsupported_client() -> Result<()> {
let manager = LspClientManager::new(
mockls_config(),
vec![PathBuf::from("/tmp")],
test_message_log(),
test_fs(),
String::new(),
);
let client = manager.get_or_spawn(MOCK_LANG_A).await?;
assert!(client.lock().await.is_alive());
assert!(
!client.lock().await.supports_workspace_folders(),
"mockls (no flags) should NOT support workspace folders"
);
assert!(manager.clients().await.contains_key(MOCK_LANG_A));
manager
.sync_roots(vec![PathBuf::from("/tmp"), PathBuf::from("/var")])
.await?;
assert!(
!manager.clients().await.contains_key(MOCK_LANG_A),
"mockls client should be removed after sync_roots (no workspace folder support)"
);
Ok(())
}
#[tokio::test]
async fn test_configuration_returns_settings() -> Result<()> {
let bin = mockls_bin();
let server_name = format!("mockls-{MOCK_LANG_A}-cfg");
let mut server = HashMap::new();
server.insert(
server_name.clone(),
ServerDef {
command: bin.to_string_lossy().to_string(),
args: vec![
MOCK_LANG_A.to_string(),
"--send-configuration-request".to_string(),
],
initialization_options: None,
settings: Some(serde_json::json!({"mockls": {"key": "value"}})),
},
);
let mut language = HashMap::new();
language.insert(
MOCK_LANG_A.to_string(),
LanguageConfig {
servers: vec![server_name],
min_severity: None,
inherit: None,
},
);
let config = Config {
language,
server,
idle_timeout: 300,
log_retention_days: 7,
icons: IconConfig::default(),
tui: crate::config::TuiConfig::default(),
};
let manager = LspClientManager::new(
config,
vec![PathBuf::from("/tmp")],
test_message_log(),
test_fs(),
String::new(),
);
let client = manager.get_or_spawn(MOCK_LANG_A).await?;
assert!(client.lock().await.is_alive());
Ok(())
}
#[tokio::test]
async fn test_sync_roots_notifies_supported_client() -> Result<()> {
let manager = LspClientManager::new(
mockls_workspace_folders_config(),
vec![PathBuf::from("/tmp")],
test_message_log(),
test_fs(),
String::new(),
);
let client = manager.get_or_spawn(MOCK_LANG_A).await?;
assert!(client.lock().await.is_alive());
assert!(
client.lock().await.supports_workspace_folders(),
"mockls --workspace-folders should support workspace folders"
);
assert!(manager.clients().await.contains_key(MOCK_LANG_A));
manager
.sync_roots(vec![PathBuf::from("/tmp"), PathBuf::from("/var")])
.await?;
assert!(
manager.clients().await.contains_key(MOCK_LANG_A),
"mockls client should still be active after sync_roots (workspace folders supported)"
);
Ok(())
}
#[tokio::test]
async fn test_ensure_clients_for_paths_spawns_new_language() -> Result<()> {
let manager = LspClientManager::new(
mockls_config(),
vec![PathBuf::from("/tmp")],
test_message_log(),
test_fs(),
String::new(),
);
assert!(manager.clients().await.is_empty());
let paths = vec![PathBuf::from(format!("/tmp/test.{MOCK_LANG_A}"))];
manager.ensure_clients_for_paths(&paths).await;
assert!(
manager.clients().await.contains_key(MOCK_LANG_A),
"ensure_clients_for_paths should spawn the mock language server"
);
Ok(())
}
#[tokio::test]
async fn test_ensure_clients_for_paths_skips_existing() -> Result<()> {
let manager = LspClientManager::new(
mockls_config(),
vec![PathBuf::from("/tmp")],
test_message_log(),
test_fs(),
String::new(),
);
let _ = manager.get_or_spawn(MOCK_LANG_A).await?;
assert_eq!(manager.clients().await.len(), 1);
let paths = vec![PathBuf::from(format!("/tmp/test.{MOCK_LANG_A}"))];
manager.ensure_clients_for_paths(&paths).await;
assert_eq!(
manager.clients().await.len(),
1,
"should not create a duplicate client"
);
Ok(())
}
#[tokio::test]
async fn test_ensure_clients_for_paths_ignores_unconfigured() -> Result<()> {
let manager = LspClientManager::new(
mockls_config(),
vec![PathBuf::from("/tmp")],
test_message_log(),
test_fs(),
String::new(),
);
let paths = vec![PathBuf::from("/tmp/test.xyz")];
manager.ensure_clients_for_paths(&paths).await;
assert!(
manager.clients().await.is_empty(),
"unconfigured languages should not trigger a spawn"
);
Ok(())
}
#[tokio::test]
async fn test_get_client_resolves_language_from_path() -> Result<()> {
let manager = LspClientManager::new(
mockls_config(),
vec![PathBuf::from("/tmp")],
test_message_log(),
test_fs(),
String::new(),
);
let path = PathBuf::from(format!("/tmp/test.{MOCK_LANG_A}"));
let client = manager.get_client(&path).await?;
assert!(client.lock().await.is_alive());
Ok(())
}
#[tokio::test]
async fn test_get_client_unknown_language_errors() {
let manager = LspClientManager::new(
mockls_config(),
vec![PathBuf::from("/tmp")],
test_message_log(),
test_fs(),
String::new(),
);
let result = manager.get_client(Path::new("/tmp/test.xyz")).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_ensure_document_open_sends_did_open() -> Result<()> {
let manager = LspClientManager::new(
mockls_config(),
vec![PathBuf::from("/tmp")],
test_message_log(),
test_fs(),
String::new(),
);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(format!("test.{MOCK_LANG_A}"));
std::fs::write(&path, "content").expect("write");
let (uri, client_mutex) = manager.ensure_document_open(&path, None).await?;
assert!(uri.starts_with("file://"));
assert!(client_mutex.lock().await.is_alive());
Ok(())
}
}