use anyhow::{Result, anyhow};
use ignore::WalkBuilder;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{info, warn};
use crate::config::Config;
use crate::lsp::LspClient;
use crate::lsp::state::ServerStatus;
use crate::session::EventBroadcaster;
pub struct ClientManager {
config: Config,
roots: Mutex<Vec<PathBuf>>,
active_clients: Mutex<HashMap<String, Arc<Mutex<LspClient>>>>,
broadcaster: EventBroadcaster,
}
impl ClientManager {
#[must_use]
pub fn new(config: Config, roots: Vec<PathBuf>, broadcaster: EventBroadcaster) -> Self {
Self {
config,
roots: Mutex::new(roots),
active_clients: Mutex::new(HashMap::new()),
broadcaster,
}
}
pub async fn spawn_all(&self) {
let roots = self.roots.lock().await.clone();
let configured_keys: HashSet<&str> =
self.config.server.keys().map(String::as_str).collect();
let relevant = 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_client(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 add_root(&self, root: PathBuf) -> Result<()> {
let uri: lsp_types::Uri = format!("file://{}", root.display())
.parse()
.map_err(|e| anyhow!("Invalid root path {}: {e}", root.display()))?;
let folder = lsp_types::WorkspaceFolder {
uri,
name: root.file_name().map_or_else(
|| "workspace".to_string(),
|s| s.to_string_lossy().to_string(),
),
};
self.roots.lock().await.push(root);
let clients = self.active_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(vec![folder.clone()], vec![])
.await
{
warn!(
"Failed to notify {} server about new 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 remove_root(&self, root: &Path) -> Result<()> {
let uri: lsp_types::Uri = format!("file://{}", root.display())
.parse()
.map_err(|e| anyhow!("Invalid root path {}: {e}", root.display()))?;
let folder = lsp_types::WorkspaceFolder {
uri,
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.active_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(vec![], vec![folder.clone()])
.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 = to_add
.iter()
.map(|root| {
let uri: lsp_types::Uri = format!("file://{}", root.display())
.parse()
.map_err(|e| anyhow!("Invalid root path {}: {e}", root.display()))?;
Ok(lsp_types::WorkspaceFolder {
uri,
name: root.file_name().map_or_else(
|| "workspace".to_string(),
|s| s.to_string_lossy().to_string(),
),
})
})
.collect::<Result<Vec<_>>>()?;
let removed_folders = to_remove
.iter()
.map(|root| {
let uri: lsp_types::Uri = format!("file://{}", root.display())
.parse()
.map_err(|e| anyhow!("Invalid root path {}: {e}", root.display()))?;
Ok(lsp_types::WorkspaceFolder {
uri,
name: root.file_name().map_or_else(
|| "workspace".to_string(),
|s| s.to_string_lossy().to_string(),
),
})
})
.collect::<Result<Vec<_>>>()?;
*self.roots.lock().await = new_roots;
let clients = self.active_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_folders.clone(), removed_folders.clone())
.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_client(&self, lang: &str) -> Result<Arc<Mutex<LspClient>>> {
if let Some(client) = self.active_clients.lock().await.get(lang) {
let is_alive = client.lock().await.is_alive();
if is_alive {
return Ok(client.clone());
}
warn!("LSP server for {} died, restarting...", lang);
self.active_clients.lock().await.remove(lang);
}
let mut clients = self.active_clients.lock().await;
let server_config = self
.config
.server
.get(lang)
.ok_or_else(|| anyhow!("No LSP server configured for language '{lang}'"))?;
info!(
"Spawning LSP server for {}: {} {}",
lang,
server_config.command,
server_config.args.join(" ")
);
let args: Vec<&str> = server_config
.args
.iter()
.map(|s: &String| s.as_str())
.collect();
let mut client = LspClient::spawn(
&server_config.command,
&args,
lang,
self.broadcaster.clone(),
)?;
let roots = self.roots.lock().await.clone();
client
.initialize(&roots, server_config.initialization_options.clone())
.await?;
let client_mutex = Arc::new(Mutex::new(client));
clients.insert(lang.to_string(), client_mutex.clone());
drop(clients);
Ok(client_mutex)
}
pub async fn active_clients(&self) -> HashMap<String, Arc<Mutex<LspClient>>> {
self.active_clients.lock().await.clone()
}
pub async fn all_server_status(&self) -> Vec<ServerStatus> {
let clients = self.active_clients.lock().await.clone();
let mut statuses = Vec::new();
for (lang, client_mutex) in clients {
let status = client_mutex.lock().await.status(lang).await;
statuses.push(status);
}
statuses
}
pub async fn shutdown_client(&self, lang: &str) {
let mut clients = self.active_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.active_clients.lock().await;
for (lang, client_mutex) in clients.drain() {
{
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);
}
}
}
}
}
#[must_use]
#[allow(clippy::implicit_hasher, reason = "All callers use the default hasher")]
pub fn detect_workspace_languages(
roots: &[PathBuf],
configured_keys: &HashSet<&str>,
) -> HashSet<String> {
let mut detected = HashSet::new();
for root in roots {
if !root.exists() {
continue;
}
let walker = WalkBuilder::new(root).git_ignore(true).hidden(true).build();
for entry in walker.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
let lang = match name {
"Dockerfile" => Some("dockerfile"),
"Makefile" => Some("makefile"),
"CMakeLists.txt" => Some("cmake"),
_ => None,
};
if let Some(l) = lang {
if configured_keys.contains(l) {
detected.insert(l.to_string());
}
if detected.len() == configured_keys.len() {
return detected;
}
continue;
}
}
if let Some(ext) = path.extension().and_then(|e| e.to_str())
&& let Some(lang) = extension_to_config_key(ext)
&& configured_keys.contains(lang)
{
detected.insert(lang.to_string());
}
if detected.len() == configured_keys.len() {
return detected;
}
}
}
detected
}
fn extension_to_config_key(ext: &str) -> Option<&'static str> {
match ext {
"rs" => Some("rust"),
"py" => Some("python"),
"go" => Some("go"),
"js" | "jsx" => Some("javascript"),
"ts" | "tsx" => Some("typescript"),
"c" => Some("c"),
"cpp" | "cc" | "cxx" | "h" | "hpp" => Some("cpp"),
"cs" => Some("csharp"),
"java" => Some("java"),
"kt" | "kts" => Some("kotlin"),
"swift" => Some("swift"),
"rb" => Some("ruby"),
"php" => Some("php"),
"sh" | "bash" | "zsh" => Some("shellscript"),
"json" => Some("json"),
"yaml" | "yml" => Some("yaml"),
"toml" => Some("toml"),
"md" => Some("markdown"),
"html" => Some("html"),
"css" => Some("css"),
"scss" => Some("scss"),
"lua" => Some("lua"),
"sql" => Some("sql"),
"zig" => Some("zig"),
"mojo" => Some("mojo"),
"dart" => Some("dart"),
"nix" => Some("nix"),
"proto" => Some("proto"),
"graphql" | "gql" => Some("graphql"),
"r" | "R" => Some("r"),
"jl" => Some("julia"),
"scala" | "sc" => Some("scala"),
"hs" => Some("haskell"),
"ex" | "exs" => Some("elixir"),
"erl" | "hrl" => Some("erlang"),
"vim" => Some("vim"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ServerConfig;
use anyhow::Result;
fn test_config() -> Config {
Config {
server: HashMap::new(),
idle_timeout: 300,
}
}
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 mut server = HashMap::new();
server.insert(
"shellscript".to_string(),
ServerConfig {
command: bin.to_string_lossy().to_string(),
args: vec![],
initialization_options: None,
},
);
Config {
server,
idle_timeout: 300,
}
}
fn mockls_workspace_folders_config() -> Config {
let bin = mockls_bin();
let mut server = HashMap::new();
server.insert(
"shellscript".to_string(),
ServerConfig {
command: bin.to_string_lossy().to_string(),
args: vec!["--workspace-folders".to_string()],
initialization_options: None,
},
);
Config {
server,
idle_timeout: 300,
}
}
#[tokio::test]
async fn test_roots_returns_initial_roots() -> Result<()> {
let broadcaster = EventBroadcaster::noop()?;
let manager = ClientManager::new(
test_config(),
vec![PathBuf::from("/tmp/root_a"), PathBuf::from("/tmp/root_b")],
broadcaster,
);
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_add_root_appends() -> Result<()> {
let broadcaster = EventBroadcaster::noop()?;
let manager = ClientManager::new(
test_config(),
vec![PathBuf::from("/tmp/root_a")],
broadcaster,
);
assert_eq!(manager.roots().await.len(), 1);
manager.add_root(PathBuf::from("/tmp/root_b")).await?;
let roots = manager.roots().await;
assert_eq!(roots.len(), 2);
assert_eq!(roots[1], PathBuf::from("/tmp/root_b"));
Ok(())
}
#[tokio::test]
async fn test_roots_empty_initial() -> Result<()> {
let broadcaster = EventBroadcaster::noop()?;
let manager = ClientManager::new(test_config(), vec![], broadcaster);
assert!(manager.roots().await.is_empty());
Ok(())
}
#[tokio::test]
async fn test_remove_root() -> Result<()> {
let broadcaster = EventBroadcaster::noop()?;
let manager = ClientManager::new(
test_config(),
vec![PathBuf::from("/tmp/root_a"), PathBuf::from("/tmp/root_b")],
broadcaster,
);
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 broadcaster = EventBroadcaster::noop()?;
let manager = ClientManager::new(
test_config(),
vec![PathBuf::from("/tmp/root_a"), PathBuf::from("/tmp/root_b")],
broadcaster,
);
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 broadcaster = EventBroadcaster::noop()?;
let manager = ClientManager::new(
test_config(),
vec![PathBuf::from("/tmp/root_a")],
broadcaster,
);
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 broadcaster = EventBroadcaster::noop()?;
let manager = ClientManager::new(mockls_config(), vec![PathBuf::from("/tmp")], broadcaster);
let client = manager.get_client("shellscript").await?;
assert!(client.lock().await.is_alive());
assert!(
!client.lock().await.supports_workspace_folders(),
"mockls (no flags) should NOT support workspace folders"
);
assert!(manager.active_clients().await.contains_key("shellscript"));
manager
.sync_roots(vec![PathBuf::from("/tmp"), PathBuf::from("/var")])
.await?;
assert!(
!manager.active_clients().await.contains_key("shellscript"),
"mockls client should be removed after sync_roots (no workspace folder support)"
);
Ok(())
}
#[tokio::test]
async fn test_sync_roots_notifies_supported_client() -> Result<()> {
let broadcaster = EventBroadcaster::noop()?;
let manager = ClientManager::new(
mockls_workspace_folders_config(),
vec![PathBuf::from("/tmp")],
broadcaster,
);
let client = manager.get_client("shellscript").await?;
assert!(client.lock().await.is_alive());
assert!(
client.lock().await.supports_workspace_folders(),
"mockls --workspace-folders should support workspace folders"
);
assert!(manager.active_clients().await.contains_key("shellscript"));
manager
.sync_roots(vec![PathBuf::from("/tmp"), PathBuf::from("/var")])
.await?;
assert!(
manager.active_clients().await.contains_key("shellscript"),
"mockls client should still be active after sync_roots (workspace folders supported)"
);
Ok(())
}
}