#![allow(clippy::mutable_key_type)]
use std::collections::HashMap;
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use aether_lspd::{ClientError, LANGUAGES, LanguageId, LspClient, detect_project_languages, socket_path};
use futures::future::join_all;
use lsp_types::{Diagnostic, Uri};
use tokio::sync::RwLock;
pub struct ResolvedSymbol {
pub uri: Uri,
pub line: u32,
pub column: u32,
pub client: Arc<LspClient>,
}
use super::common::{find_document_symbol_line, find_symbol_column, find_symbol_line, path_to_uri};
use super::error::LspError;
use crate::workspace_paths::{resolve_file, resolve_path};
#[doc = include_str!("../docs/lsp_registry.md")]
pub struct LspRegistry {
clients: HashMap<PathBuf, RwLock<Option<Arc<LspClient>>>>,
root_path: PathBuf,
}
impl Debug for LspRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LspRegistry").field("root_path", &self.root_path).finish_non_exhaustive()
}
}
impl LspRegistry {
pub fn new(root_path: PathBuf) -> Self {
let root_path = root_path.canonicalize().unwrap_or(root_path);
let clients = LANGUAGES
.iter()
.filter(|language| language.server.is_some())
.map(|language| (socket_path(&root_path, language.id), RwLock::new(None)))
.collect();
Self { clients, root_path }
}
pub fn new_and_spawn(root_path: PathBuf) -> Arc<Self> {
let registry = Arc::new(Self::new(root_path));
let clone = Arc::clone(®istry);
tokio::spawn(async move { clone.spawn_project_lsps().await });
registry
}
pub fn root_path(&self) -> &Path {
&self.root_path
}
pub fn resolve_file(&self, raw: &str) -> Result<String, LspError> {
resolve_file(&self.root_path, raw)
.map(|path| path.to_string_lossy().to_string())
.map_err(|error| LspError::InvalidPath(error.to_string()))
}
pub async fn get_or_spawn(&self, file_path: &Path) -> Result<Arc<LspClient>, LspError> {
let language_id = LanguageId::from_path(file_path);
self.get_or_spawn_for_language(language_id).await
}
pub async fn get_or_spawn_for_language(&self, language_id: LanguageId) -> Result<Arc<LspClient>, LspError> {
let slot = self.slot(language_id)?;
if let Some(client) = connected(slot).await {
return Ok(client);
}
let mut client = slot.write().await;
if let Some(connected) = client.as_ref().filter(|client| client.is_connected()) {
return Ok(Arc::clone(connected));
}
let connected = LspClient::connect(&self.root_path, language_id)
.await
.map(Arc::new)
.map_err(|error| connection_error(language_id, error))
.inspect_err(
|error| tracing::error!(%error, language = language_id.as_str(), "Failed to connect to LSP daemon"),
)?;
*client = Some(Arc::clone(&connected));
Ok(connected)
}
pub async fn active_clients(&self) -> Vec<Arc<LspClient>> {
let mut active = Vec::new();
for slot in self.clients.values() {
if let Some(client) = connected(slot).await {
active.push(client);
}
}
active
}
pub async fn spawn_project_lsps(&self) {
let languages = detect_project_languages(&self.root_path);
let spawn_futures: Vec<_> =
languages.iter().map(|&lang| async move { (lang, self.get_or_spawn_for_language(lang).await) }).collect();
for (lang, result) in join_all(spawn_futures).await {
match result {
Ok(_) => tracing::info!("Connected to LSP daemon for {:?} based on project detection", lang),
Err(error) => tracing::warn!(%error, language = lang.as_str(), "Failed to start project LSP"),
}
}
}
fn slot(&self, language_id: LanguageId) -> Result<&RwLock<Option<Arc<LspClient>>>, LspError> {
self.clients
.get(&socket_path(&self.root_path, language_id))
.ok_or_else(|| LspError::UnsupportedLanguage(language_id.as_str().to_string()))
}
pub async fn resolve_symbol(
&self,
file_path: &str,
symbol: &str,
line_hint: Option<u32>,
) -> Result<ResolvedSymbol, LspError> {
let content = tokio::fs::read_to_string(file_path).await?;
let uri = path_to_uri(Path::new(file_path))?;
let client = self.get_or_spawn(Path::new(file_path)).await?;
let hinted =
line_hint.and_then(|line| find_symbol_column(&content, symbol, line).ok().map(|column| (line, column)));
let (line, column) = if let Some(position) = hinted {
position
} else {
let document_line = client
.document_symbol(uri.clone())
.await
.ok()
.and_then(|response| find_document_symbol_line(&response, symbol));
let line = document_line
.or_else(|| find_symbol_line(&content, symbol))
.ok_or_else(|| LspError::SymbolNotFound(format!("Symbol '{symbol}' not found in '{file_path}'")))?;
(line, find_symbol_column(&content, symbol, line)?)
};
Ok(ResolvedSymbol { uri, line: line - 1, column, client })
}
pub async fn collect_diagnostics(
&self,
file_path: Option<&str>,
) -> Result<HashMap<Uri, Vec<Diagnostic>>, LspError> {
if let Some(file_path) = file_path {
return self.collect_file_diagnostics(file_path).await;
}
let clients = self.active_clients().await;
if clients.is_empty() {
return Err(LspError::ServerUnavailable(
"No active LSP clients. Open a source file first so its language server can start.".to_string(),
));
}
let mut result: HashMap<Uri, Vec<Diagnostic>> = HashMap::new();
for client in clients {
match client.get_diagnostics(None).await {
Ok(params_list) => merge_diagnostics(&mut result, params_list),
Err(error) => tracing::warn!(%error, "Failed to collect diagnostics from LSP client"),
}
}
Ok(result)
}
pub async fn queue_diagnostic_refresh(&self, file_path: &str) {
let resolved_path = resolve_path(&self.root_path, PathBuf::from(file_path));
let client = match self.get_or_spawn(&resolved_path).await {
Ok(client) => client,
Err(error) => {
tracing::debug!(path = %resolved_path.display(), %error, "Failed to start LSP for diagnostic refresh");
return;
}
};
let Ok(uri) = path_to_uri(&resolved_path) else {
return;
};
if let Err(err) = client.queue_diagnostic_refresh(uri).await {
tracing::debug!(
path = %resolved_path.display(),
%err,
"Failed to queue diagnostic refresh"
);
}
}
async fn collect_file_diagnostics(&self, file_path: &str) -> Result<HashMap<Uri, Vec<Diagnostic>>, LspError> {
let resolved_path = resolve_path(&self.root_path, PathBuf::from(file_path));
let client = self.get_or_spawn(&resolved_path).await?;
let uri = path_to_uri(&resolved_path)?;
let params_list = client.get_diagnostics(Some(uri)).await?;
let mut result: HashMap<Uri, Vec<Diagnostic>> = HashMap::new();
merge_diagnostics(&mut result, params_list);
Ok(result)
}
}
async fn connected(slot: &RwLock<Option<Arc<LspClient>>>) -> Option<Arc<LspClient>> {
slot.read().await.as_ref().filter(|client| client.is_connected()).cloned()
}
fn connection_error(language_id: LanguageId, error: ClientError) -> LspError {
if matches!(&error, ClientError::InitializationFailed(_))
&& let Some(server) = language_id.server()
&& let Some(instructions) = server.installation_instructions
{
return LspError::ServerUnavailable(format!(
"{} could not start: {error}. {instructions}",
server.display_name
));
}
LspError::Client(error)
}
fn merge_diagnostics(
result: &mut HashMap<Uri, Vec<Diagnostic>>,
params_list: Vec<lsp_types::PublishDiagnosticsParams>,
) {
for params in params_list {
result.entry(params.uri).or_default().extend(params.diagnostics);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slot_uses_shared_socket_identity_for_typescript_family() {
let registry = LspRegistry::new(PathBuf::from("/tmp"));
assert!(std::ptr::eq(
registry.slot(LanguageId::TypeScript).unwrap(),
registry.slot(LanguageId::TypeScriptReact).unwrap()
));
}
#[test]
fn slot_uses_shared_socket_identity_for_c_family() {
let registry = LspRegistry::new(PathBuf::from("/tmp"));
assert!(std::ptr::eq(registry.slot(LanguageId::C).unwrap(), registry.slot(LanguageId::Cpp).unwrap()));
}
#[tokio::test]
async fn test_no_clients_initially() {
let registry = LspRegistry::new(PathBuf::from("/tmp"));
assert!(registry.active_clients().await.is_empty());
}
#[test]
fn file_arguments_resolve_against_the_project_root() {
let temp = tempfile::tempdir().expect("temp dir");
let registry = LspRegistry::new(temp.path().to_path_buf());
let expected = registry.root_path().join("src/main.rs");
assert_eq!(registry.resolve_file("src/main.rs").unwrap(), expected.to_string_lossy());
assert_eq!(registry.resolve_file("/elsewhere/lib.rs").unwrap(), "/elsewhere/lib.rs");
assert!(registry.resolve_file(" ").is_err());
}
#[cfg(unix)]
#[test]
fn root_path_resolves_symlinks_to_match_language_server_paths() {
use std::fs::create_dir;
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().expect("temp dir");
let project = temp.path().join("project");
create_dir(&project).expect("create project dir");
let link = temp.path().join("link");
symlink(&project, &link).expect("create symlink");
let registry = LspRegistry::new(link);
assert_eq!(registry.root_path(), project.canonicalize().expect("canonical project dir"));
}
}