use std::collections::{HashMap, HashSet};
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use aether_lspd::{LanguageId, LspClient, get_config_for_language, socket_path};
use futures::future::join_all;
use lsp_types::Uri;
use tokio::sync::RwLock;
pub struct ResolvedSymbol {
pub uri: Uri,
pub line: u32,
pub column: u32,
pub client: Arc<LspClient>,
}
use lsp_types::Diagnostic;
use super::common::{find_symbol_column, path_to_uri, uri_to_path};
use super::error::LspError;
pub struct LspRegistry {
clients: RwLock<HashMap<PathBuf, 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 {
Self {
clients: RwLock::new(HashMap::new()),
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 async fn get_or_spawn(&self, file_path: &Path) -> Option<Arc<LspClient>> {
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,
) -> Option<Arc<LspClient>> {
let client_key = self.client_key(language_id)?;
{
let clients = self.clients.read().await;
if let Some(client) = clients.get(&client_key) {
return Some(Arc::clone(client));
}
}
let mut clients = self.clients.write().await;
if let Some(client) = clients.get(&client_key) {
return Some(Arc::clone(client));
}
match LspClient::connect(&self.root_path, language_id).await {
Ok(client) => {
let client = Arc::new(client);
clients.insert(client_key, Arc::clone(&client));
Some(client)
}
Err(e) => {
tracing::error!(
"Failed to connect to LSP daemon for {:?}: {}",
language_id,
e
);
None
}
}
}
pub async fn get_client_for_language(&self, language_id: LanguageId) -> Option<Arc<LspClient>> {
let client_key = self.client_key(language_id)?;
let clients = self.clients.read().await;
clients.get(&client_key).cloned()
}
pub async fn active_clients(&self) -> Vec<Arc<LspClient>> {
let clients = self.clients.read().await;
clients.values().cloned().collect()
}
pub fn has_config_for(&self, file_path: &Path) -> bool {
let language_id = LanguageId::from_path(file_path);
get_config_for_language(language_id).is_some()
}
pub async fn spawn_project_lsps(&self) {
let languages = self.detect_project_languages();
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 {
if result.is_some() {
tracing::info!(
"Connected to LSP daemon for {:?} based on project detection",
lang
);
}
}
}
fn detect_project_languages(&self) -> Vec<LanguageId> {
let mappings = [
(LanguageId::Rust, HashSet::from(["Cargo.toml"])),
(LanguageId::TypeScript, HashSet::from(["package.json"])),
(LanguageId::Go, HashSet::from(["go.mod"])),
(LanguageId::Cpp, HashSet::from(["CMakeLists.txt"])),
(
LanguageId::Python,
HashSet::from(["pyproject.toml", "setup.py", "requirements.txt"]),
),
];
mappings
.iter()
.filter(|(_, files)| files.iter().any(|f| self.root_path.join(f).exists()))
.map(|(lang, _)| *lang)
.collect()
}
fn client_key(&self, language_id: LanguageId) -> Option<PathBuf> {
get_config_for_language(language_id)?;
Some(socket_path(&self.root_path, language_id))
}
pub async fn resolve_symbol(
&self,
file_path: &str,
symbol: &str,
line: u32,
) -> Result<ResolvedSymbol, LspError> {
let content = tokio::fs::read_to_string(file_path).await?;
let column = find_symbol_column(&content, symbol, line)?;
let uri = path_to_uri(Path::new(file_path)).map_err(LspError::Transport)?;
let client = self.require_client(file_path).await?;
Ok(ResolvedSymbol {
uri,
line: line - 1,
column,
client,
})
}
pub async fn collect_diagnostics(
&self,
file_path: Option<&str>,
) -> HashMap<String, Vec<Diagnostic>> {
if let Some(file_path) = file_path {
return self.collect_file_diagnostics(file_path).await;
}
let mut result: HashMap<String, Vec<Diagnostic>> = HashMap::new();
for client in self.active_clients().await {
if let Ok(params_list) = client.get_diagnostics(None).await {
merge_diagnostics(&mut result, params_list);
}
}
result
}
pub async fn queue_diagnostic_refresh(&self, file_path: &str) {
let resolved_path = self.resolve_path(file_path);
let Some(client) = self.get_or_spawn(&resolved_path).await else {
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) -> HashMap<String, Vec<Diagnostic>> {
let resolved_path = self.resolve_path(file_path);
let Some(client) = self.get_or_spawn(&resolved_path).await else {
return HashMap::new();
};
let Ok(uri) = path_to_uri(&resolved_path) else {
return HashMap::new();
};
let mut result: HashMap<String, Vec<Diagnostic>> = HashMap::new();
if let Ok(params_list) = client.get_diagnostics(Some(uri)).await {
merge_diagnostics(&mut result, params_list);
}
result
}
fn resolve_path(&self, file_path: &str) -> PathBuf {
if Path::new(file_path).is_absolute() {
PathBuf::from(file_path)
} else {
self.root_path.join(file_path)
}
}
pub async fn require_client(&self, file_path: &str) -> Result<Arc<LspClient>, LspError> {
self.get_or_spawn(Path::new(file_path))
.await
.ok_or_else(|| LspError::Transport("No LSP configured for this file type".to_string()))
}
}
fn merge_diagnostics(
result: &mut HashMap<String, Vec<Diagnostic>>,
params_list: Vec<lsp_types::PublishDiagnosticsParams>,
) {
for params in params_list {
let path = uri_to_path(¶ms.uri);
result.entry(path).or_default().extend(params.diagnostics);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_key_uses_shared_socket_identity_for_typescript_family() {
let registry = LspRegistry::new(PathBuf::from("/tmp"));
assert_eq!(
registry.client_key(LanguageId::TypeScript),
registry.client_key(LanguageId::TypeScriptReact)
);
}
#[test]
fn client_key_uses_shared_socket_identity_for_c_family() {
let registry = LspRegistry::new(PathBuf::from("/tmp"));
assert_eq!(
registry.client_key(LanguageId::C),
registry.client_key(LanguageId::Cpp)
);
}
#[test]
fn test_has_config_for() {
let registry = LspRegistry::new(PathBuf::from("/tmp"));
assert!(registry.has_config_for(Path::new("foo.rs")));
assert!(registry.has_config_for(Path::new("bar.ts")));
assert!(registry.has_config_for(Path::new("baz.py")));
assert!(!registry.has_config_for(Path::new("unknown.xyz")));
}
#[tokio::test]
async fn test_no_clients_initially() {
let registry = LspRegistry::new(PathBuf::from("/tmp"));
assert!(registry.active_clients().await.is_empty());
}
}