diff --git a/rust/crates/runtime/src/lsp_client.rs b/rust/crates/runtime/src/lsp_client.rs index aa4e1337..e7e4e121 100644 --- a/rust/crates/runtime/src/lsp_client.rs +++ b/rust/crates/runtime/src/lsp_client.rs @@ -1,7 +1,7 @@ #![allow(clippy::should_implement_trait, clippy::must_use_candidate)] //! LSP (Language Server Protocol) client registry for tool dispatch. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -156,6 +156,7 @@ pub struct LspRegistry { #[derive(Debug, Default)] struct RegistryInner { servers: HashMap, + open_files: HashSet, } impl LspRegistry { @@ -420,6 +421,139 @@ impl LspRegistry { Ok(()) } + /// Notify the LSP server that a file was opened and collect any diagnostics. + /// Best-effort: returns empty vec if no server is available. + pub fn notify_file_open(&self, path: &str, content: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Check if already open + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if inner.open_files.contains(path) { + return Vec::new(); + } + } + + // Lazy-start the server + if self.start_server(&language).is_err() { + return Vec::new(); + } + + // Get the process handle and send didOpen + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + match inner.servers.get(&language).and_then(|e| e.process.clone()) { + Some(p) => p, + None => return Vec::new(), + } + }; + + let mut diagnostics = Vec::new(); + if let Ok(mut process) = process_arc.lock() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + if let Ok(rt) = rt { + let _ = rt.block_on(process.did_open(path, content)); + diagnostics = process.drain_diagnostics(); + } + } + + // Cache diagnostics in registry state + if !diagnostics.is_empty() { + let diag_path = path.to_owned(); + let diags = diagnostics.clone(); + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + // Replace diagnostics for this file (publishDiagnostics is full replacement) + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(diags); + } + } + + // Mark file as open + { + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.open_files.insert(path.to_owned()); + } + + diagnostics + } + + /// Notify the LSP server that a file changed and collect any diagnostics. + /// Best-effort: returns empty vec if no server is available. + pub fn notify_file_change(&self, path: &str, content: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Get the process handle + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + match inner.servers.get(&language).and_then(|e| e.process.clone()) { + Some(p) => p, + None => return Vec::new(), + } + }; + + let mut diagnostics = Vec::new(); + if let Ok(mut process) = process_arc.lock() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + if let Ok(rt) = rt { + let _ = rt.block_on(process.did_change(path, content)); + diagnostics = process.drain_diagnostics(); + } + } + + // Replace cached diagnostics for this file + if !diagnostics.is_empty() { + let diag_path = path.to_owned(); + let diags = diagnostics.clone(); + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(diags); + } + } + + diagnostics + } + + /// Fetch diagnostics for a file by draining pending server notifications + /// and returning cached diagnostics. + pub fn fetch_diagnostics_for_file(&self, path: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Drain pending notifications from the transport + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.get(&language).and_then(|e| e.process.clone()) + }; + + if let Some(process_arc) = process_arc { + if let Ok(mut process) = process_arc.lock() { + let new_diags = process.drain_diagnostics(); + if !new_diags.is_empty() { + let diag_path = path.to_owned(); + let mut inner = + self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(new_diags); + } + } + } + } + + self.get_diagnostics(path) + } + /// Dispatch an LSP action and return a structured result. #[allow(clippy::too_many_lines)] pub fn dispatch( diff --git a/rust/crates/runtime/src/lsp_discovery.rs b/rust/crates/runtime/src/lsp_discovery.rs index 24b28a0c..7c8f7402 100644 --- a/rust/crates/runtime/src/lsp_discovery.rs +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -109,7 +109,7 @@ pub fn command_exists_on_path(command: &str) -> bool { Command::new(command) .arg("--version") .output() - .map_or(false, |_| true) + .is_ok() } /// Discover LSP servers that are actually installed on the current system. diff --git a/rust/crates/runtime/src/lsp_process.rs b/rust/crates/runtime/src/lsp_process.rs index c57c832a..578d43fe 100644 --- a/rust/crates/runtime/src/lsp_process.rs +++ b/rust/crates/runtime/src/lsp_process.rs @@ -1,11 +1,12 @@ //! LSP process manager: spawns language servers and drives the LSP lifecycle. +use std::collections::{HashMap, HashSet}; use std::path::Path; use serde_json::Value as JsonValue; use crate::lsp_client::{ - LspCompletionItem, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, + LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, }; use crate::lsp_transport::{LspTransport, LspTransportError}; @@ -16,6 +17,8 @@ pub struct LspProcess { root_uri: String, capabilities: Option, status: LspServerStatus, + open_files: HashSet, + version_counter: HashMap, } #[allow(clippy::cast_possible_truncation)] @@ -38,6 +41,8 @@ impl LspProcess { root_uri: root_uri.clone(), capabilities: None, status: LspServerStatus::Starting, + open_files: HashSet::new(), + version_counter: HashMap::new(), }; process.initialize(&canonical).await?; @@ -279,6 +284,103 @@ impl LspProcess { } } + /// Notify the server that a file was opened. Sends `textDocument/didOpen`. + /// No-op if the file is already tracked as open. + pub async fn did_open(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> { + if self.open_files.contains(path) { + return Ok(()); + } + + let uri = path_to_uri(path); + let language_id = language_id_for_path(path); + let params = serde_json::json!({ + "textDocument": { + "uri": uri, + "languageId": language_id, + "version": 0, + "text": content + } + }); + + self.transport + .send_notification("textDocument/didOpen", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + self.open_files.insert(path.to_owned()); + self.version_counter.insert(path.to_owned(), 0); + Ok(()) + } + + /// Notify the server that a file's content changed. Sends `textDocument/didChange`. + pub async fn did_change(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> { + let version = self.version_counter.get(path).map_or(1, |v| v + 1); + + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri, "version": version }, + "contentChanges": [{ "text": content }] + }); + + self.transport + .send_notification("textDocument/didChange", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + self.version_counter.insert(path.to_owned(), version); + Ok(()) + } + + /// Drain queued server notifications and extract `publishDiagnostics`. + #[allow(clippy::redundant_closure_for_method_calls)] + pub fn drain_diagnostics(&mut self) -> Vec { + let notifications = self.transport.drain_notifications(); + let mut diagnostics = Vec::new(); + for n in ¬ifications { + if n.method == "textDocument/publishDiagnostics" { + if let Some(params) = &n.params { + if let Some(uri) = params.get("uri").and_then(|v| v.as_str()) { + let path = uri_to_path(uri); + if let Some(diags) = params.get("diagnostics").and_then(|v| v.as_array()) + { + for d in diags { + diagnostics.push(LspDiagnostic { + path: path.clone(), + line: d + .get("range") + .and_then(|r| r.get("start")) + .and_then(|s| s.get("line")) + .and_then(|v| v.as_u64()) + .map_or(0, |v| v as u32), + character: d + .get("range") + .and_then(|r| r.get("start")) + .and_then(|s| s.get("character")) + .and_then(|v| v.as_u64()) + .map_or(0, |v| v as u32), + severity: d + .get("severity") + .and_then(|v| v.as_u64()) + .map_or_else(|| "error".to_owned(), severity_name), + message: d + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(), + source: d + .get("source") + .and_then(|v| v.as_str()) + .map(str::to_owned), + }); + } + } + } + } + } + } + diagnostics + } + #[must_use] pub fn status(&self) -> LspServerStatus { self.status @@ -360,6 +462,39 @@ fn uri_to_path(uri: &str) -> String { uri.strip_prefix("file://").unwrap_or(uri).to_owned() } +fn language_id_for_path(path: &str) -> String { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + match ext { + "rs" => "rust", + "ts" => "typescript", + "tsx" => "typescriptreact", + "js" => "javascript", + "jsx" => "javascriptreact", + "py" => "python", + "go" => "go", + "java" => "java", + "c" | "h" => "c", + "cpp" | "hpp" | "cc" => "cpp", + "rb" => "ruby", + "lua" => "lua", + _ => ext, + } + .to_owned() +} + +fn severity_name(code: u64) -> String { + match code { + 1 => "error".to_owned(), + 2 => "warning".to_owned(), + 3 => "info".to_owned(), + 4 => "hint".to_owned(), + _ => format!("unknown({code})"), + } +} + fn parse_hover(value: &JsonValue) -> Option { let contents = value.get("contents")?; diff --git a/rust/crates/runtime/src/lsp_transport.rs b/rust/crates/runtime/src/lsp_transport.rs index 8ad1f832..015ac145 100644 --- a/rust/crates/runtime/src/lsp_transport.rs +++ b/rust/crates/runtime/src/lsp_transport.rs @@ -89,6 +89,14 @@ impl LspResponse { } } +/// A message received from an LSP server — either a response to a request +/// or a server-initiated notification (e.g. `textDocument/publishDiagnostics`). +#[derive(Debug, Clone)] +pub enum LspServerMessage { + Response(LspResponse), + Notification(LspNotification), +} + #[derive(Debug)] pub enum LspTransportError { Io(io::Error), @@ -138,6 +146,7 @@ pub struct LspTransport { stdout: BufReader, next_id: u64, request_timeout: Duration, + pending_notifications: Vec, } impl LspTransport { @@ -172,6 +181,7 @@ impl LspTransport { stdout: BufReader::new(stdout), next_id: 1, request_timeout, + pending_notifications: Vec::new(), }) } @@ -193,6 +203,7 @@ impl LspTransport { stdout: BufReader::new(stdout), next_id: 1, request_timeout, + pending_notifications: Vec::new(), } } @@ -235,12 +246,27 @@ impl LspTransport { let method_owned = method.to_string(); let timeout_duration = self.request_timeout; - let response = timeout(timeout_duration, self.read_response()) - .await - .map_err(|_| LspTransportError::Timeout { - method: method_owned, - timeout: timeout_duration, - })??; + let response = match timeout(timeout_duration, async { + loop { + match self.read_message().await { + Ok(LspServerMessage::Response(r)) => break Ok(r), + Ok(LspServerMessage::Notification(n)) => { + self.pending_notifications.push(n); + } + Err(e) => break Err(e), + } + } + }) + .await + { + Ok(inner) => inner, + Err(_) => { + return Err(LspTransportError::Timeout { + method: method_owned, + timeout: timeout_duration, + }) + } + }?; if response.jsonrpc != "2.0" { return Err(LspTransportError::InvalidResponse { @@ -266,8 +292,57 @@ impl LspTransport { Ok(response) } + /// Read a single message from the server, returning either a response or + /// a server-initiated notification (e.g. `publishDiagnostics`). + pub async fn read_message(&mut self) -> Result { + let payload = self.read_frame().await?; + let value: JsonValue = serde_json::from_slice(&payload).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: error.to_string(), + } + })?; + + // Responses have an "id" field; notifications have "method" but no "id" + if value.get("id").is_some() { + let response: LspResponse = serde_json::from_value(value).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: format!("failed to parse response: {error}"), + } + })?; + Ok(LspServerMessage::Response(response)) + } else if value.get("method").is_some() { + let notification: LspNotification = serde_json::from_value(value).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: format!("failed to parse notification: {error}"), + } + })?; + Ok(LspServerMessage::Notification(notification)) + } else { + Err(LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: "message has neither 'id' nor 'method'".to_string(), + }) + } + } + + /// Read a response from the server. Interleaved notifications are queued. pub async fn read_response(&mut self) -> Result { - self.read_jsonrpc_message().await + loop { + match self.read_message().await? { + LspServerMessage::Response(r) => return Ok(r), + LspServerMessage::Notification(n) => { + self.pending_notifications.push(n); + } + } + } + } + + /// Drain and return all queued server-initiated notifications. + pub fn drain_notifications(&mut self) -> Vec { + std::mem::take(&mut self.pending_notifications) } pub async fn shutdown(&mut self) -> Result<(), LspTransportError> { @@ -344,16 +419,6 @@ impl LspTransport { Ok(payload) } - - async fn read_jsonrpc_message( - &mut self, - ) -> Result { - let payload = self.read_frame().await?; - serde_json::from_slice(&payload).map_err(|error| LspTransportError::InvalidResponse { - method: "unknown".to_string(), - details: error.to_string(), - }) - } } #[cfg(test)]