//! LSP (Language Server Protocol) client registry for tool dispatch. //! //! Provides a stateful registry of LSP server connections, supporting //! the LSP tool actions: diagnostics, hover, definition, references, //! completion, symbols, and formatting. use std::collections::HashMap; use std::sync::{Arc, Mutex}; use serde::{Deserialize, Serialize}; /// Supported LSP actions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LspAction { Diagnostics, Hover, Definition, References, Completion, Symbols, Format, } impl LspAction { pub fn from_str(s: &str) -> Option { match s { "diagnostics" => Some(Self::Diagnostics), "hover" => Some(Self::Hover), "definition" | "goto_definition" => Some(Self::Definition), "references" | "find_references" => Some(Self::References), "completion" | "completions" => Some(Self::Completion), "symbols" | "document_symbols" => Some(Self::Symbols), "format" | "formatting" => Some(Self::Format), _ => None, } } } /// A diagnostic entry from an LSP server. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LspDiagnostic { pub path: String, pub line: u32, pub character: u32, pub severity: String, pub message: String, pub source: Option, } /// A location result (definition, references). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LspLocation { pub path: String, pub line: u32, pub character: u32, pub end_line: Option, pub end_character: Option, pub preview: Option, } /// A hover result. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LspHoverResult { pub content: String, pub language: Option, } /// A completion item. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LspCompletionItem { pub label: String, pub kind: Option, pub detail: Option, pub insert_text: Option, } /// A document symbol. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LspSymbol { pub name: String, pub kind: String, pub path: String, pub line: u32, pub character: u32, } /// Connection status. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LspServerStatus { Connected, Disconnected, Starting, Error, } impl std::fmt::Display for LspServerStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Connected => write!(f, "connected"), Self::Disconnected => write!(f, "disconnected"), Self::Starting => write!(f, "starting"), Self::Error => write!(f, "error"), } } } /// Tracked state of an LSP server. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LspServerState { pub language: String, pub status: LspServerStatus, pub root_path: Option, pub capabilities: Vec, pub diagnostics: Vec, } /// Thread-safe LSP server registry. #[derive(Debug, Clone, Default)] pub struct LspRegistry { inner: Arc>, } #[derive(Debug, Default)] struct RegistryInner { servers: HashMap, } impl LspRegistry { #[must_use] pub fn new() -> Self { Self::default() } /// Register an LSP server for a language. pub fn register( &self, language: &str, status: LspServerStatus, root_path: Option<&str>, capabilities: Vec, ) { let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); inner.servers.insert( language.to_owned(), LspServerState { language: language.to_owned(), status, root_path: root_path.map(str::to_owned), capabilities, diagnostics: Vec::new(), }, ); } /// Get server state by language. pub fn get(&self, language: &str) -> Option { let inner = self.inner.lock().expect("lsp registry lock poisoned"); inner.servers.get(language).cloned() } /// Find the appropriate server for a file path based on extension. pub fn find_server_for_path(&self, path: &str) -> Option { let ext = std::path::Path::new(path) .extension() .and_then(|e| e.to_str()) .unwrap_or(""); let language = match ext { "rs" => "rust", "ts" | "tsx" => "typescript", "js" | "jsx" => "javascript", "py" => "python", "go" => "go", "java" => "java", "c" | "h" => "c", "cpp" | "hpp" | "cc" => "cpp", "rb" => "ruby", "lua" => "lua", _ => return None, }; self.get(language) } /// List all registered servers. pub fn list_servers(&self) -> Vec { let inner = self.inner.lock().expect("lsp registry lock poisoned"); inner.servers.values().cloned().collect() } /// Add diagnostics to a server. pub fn add_diagnostics( &self, language: &str, diagnostics: Vec, ) -> Result<(), String> { let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); let server = inner .servers .get_mut(language) .ok_or_else(|| format!("LSP server not found for language: {language}"))?; server.diagnostics.extend(diagnostics); Ok(()) } /// Get diagnostics for a specific file path. pub fn get_diagnostics(&self, path: &str) -> Vec { let inner = self.inner.lock().expect("lsp registry lock poisoned"); inner .servers .values() .flat_map(|s| &s.diagnostics) .filter(|d| d.path == path) .cloned() .collect() } /// Clear diagnostics for a language server. pub fn clear_diagnostics(&self, language: &str) -> Result<(), String> { let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); let server = inner .servers .get_mut(language) .ok_or_else(|| format!("LSP server not found for language: {language}"))?; server.diagnostics.clear(); Ok(()) } /// Disconnect a server. pub fn disconnect(&self, language: &str) -> Option { let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); inner.servers.remove(language) } #[must_use] pub fn len(&self) -> usize { let inner = self.inner.lock().expect("lsp registry lock poisoned"); inner.servers.len() } #[must_use] pub fn is_empty(&self) -> bool { self.len() == 0 } /// Dispatch an LSP action and return a structured result. pub fn dispatch( &self, action: &str, path: Option<&str>, line: Option, character: Option, _query: Option<&str>, ) -> Result { let lsp_action = LspAction::from_str(action).ok_or_else(|| format!("unknown LSP action: {action}"))?; // For diagnostics, we can check existing cached diagnostics if lsp_action == LspAction::Diagnostics { if let Some(path) = path { let diags = self.get_diagnostics(path); return Ok(serde_json::json!({ "action": "diagnostics", "path": path, "diagnostics": diags, "count": diags.len() })); } // All diagnostics across all servers let inner = self.inner.lock().expect("lsp registry lock poisoned"); let all_diags: Vec<_> = inner .servers .values() .flat_map(|s| &s.diagnostics) .collect(); return Ok(serde_json::json!({ "action": "diagnostics", "diagnostics": all_diags, "count": all_diags.len() })); } // For other actions, we need a connected server for the given file let path = path.ok_or("path is required for this LSP action")?; let server = self .find_server_for_path(path) .ok_or_else(|| format!("no LSP server available for path: {path}"))?; if server.status != LspServerStatus::Connected { return Err(format!( "LSP server for '{}' is not connected (status: {})", server.language, server.status )); } // Return structured placeholder — actual LSP JSON-RPC calls would // go through the real LSP process here. Ok(serde_json::json!({ "action": action, "path": path, "line": line, "character": character, "language": server.language, "status": "dispatched", "message": format!("LSP {} dispatched to {} server", action, server.language) })) } } #[cfg(test)] mod tests { use super::*; #[test] fn registers_and_retrieves_server() { let registry = LspRegistry::new(); registry.register( "rust", LspServerStatus::Connected, Some("/workspace"), vec!["hover".into(), "completion".into()], ); let server = registry.get("rust").expect("should exist"); assert_eq!(server.language, "rust"); assert_eq!(server.status, LspServerStatus::Connected); assert_eq!(server.capabilities.len(), 2); } #[test] fn finds_server_by_file_extension() { let registry = LspRegistry::new(); registry.register("rust", LspServerStatus::Connected, None, vec![]); registry.register("typescript", LspServerStatus::Connected, None, vec![]); let rs_server = registry.find_server_for_path("src/main.rs").unwrap(); assert_eq!(rs_server.language, "rust"); let ts_server = registry.find_server_for_path("src/index.ts").unwrap(); assert_eq!(ts_server.language, "typescript"); assert!(registry.find_server_for_path("data.csv").is_none()); } #[test] fn manages_diagnostics() { let registry = LspRegistry::new(); registry.register("rust", LspServerStatus::Connected, None, vec![]); registry .add_diagnostics( "rust", vec![LspDiagnostic { path: "src/main.rs".into(), line: 10, character: 5, severity: "error".into(), message: "mismatched types".into(), source: Some("rust-analyzer".into()), }], ) .unwrap(); let diags = registry.get_diagnostics("src/main.rs"); assert_eq!(diags.len(), 1); assert_eq!(diags[0].message, "mismatched types"); registry.clear_diagnostics("rust").unwrap(); assert!(registry.get_diagnostics("src/main.rs").is_empty()); } #[test] fn dispatches_diagnostics_action() { let registry = LspRegistry::new(); registry.register("rust", LspServerStatus::Connected, None, vec![]); registry .add_diagnostics( "rust", vec![LspDiagnostic { path: "src/lib.rs".into(), line: 1, character: 0, severity: "warning".into(), message: "unused import".into(), source: None, }], ) .unwrap(); let result = registry .dispatch("diagnostics", Some("src/lib.rs"), None, None, None) .unwrap(); assert_eq!(result["count"], 1); } #[test] fn dispatches_hover_action() { let registry = LspRegistry::new(); registry.register("rust", LspServerStatus::Connected, None, vec![]); let result = registry .dispatch("hover", Some("src/main.rs"), Some(10), Some(5), None) .unwrap(); assert_eq!(result["action"], "hover"); assert_eq!(result["language"], "rust"); } #[test] fn rejects_action_on_disconnected_server() { let registry = LspRegistry::new(); registry.register("rust", LspServerStatus::Disconnected, None, vec![]); assert!(registry .dispatch("hover", Some("src/main.rs"), Some(1), Some(0), None) .is_err()); } #[test] fn rejects_unknown_action() { let registry = LspRegistry::new(); assert!(registry .dispatch("unknown_action", Some("file.rs"), None, None, None) .is_err()); } #[test] fn disconnects_server() { let registry = LspRegistry::new(); registry.register("rust", LspServerStatus::Connected, None, vec![]); assert_eq!(registry.len(), 1); let removed = registry.disconnect("rust"); assert!(removed.is_some()); assert!(registry.is_empty()); } }