From d610657cf0d0d674b4d7a4701facf0c90075940e Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Mon, 27 Apr 2026 00:28:28 -0500 Subject: [PATCH] refactor: split LSP modules under 500 lines each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the three large LSP files into module directories with sub-files: lsp_transport/ (was 560 lines): - mod.rs (425) — types + LspTransport impl - tests.rs (134) — test module lsp_process/ (was 929 lines): - mod.rs (436) — LspProcess struct + public methods + error types - parse.rs (311) — helper functions and LSP response parsers - tests.rs (194) — test module lsp_client/ (was 1338 lines): - mod.rs (466) — LspRegistry struct + impl, re-exports from types - types.rs (103) — LspAction, LspDiagnostic, LspServerStatus, etc. - dispatch.rs (224) — LspRegistry::dispatch() method - tests.rs (273) — core registry tests - tests_lifecycle.rs (294) — lifecycle and integration tests All files under 500 lines. All 501 runtime tests pass. Clippy clean. Co-Authored-By: Claude Opus 4.7 --- .../crates/runtime/src/lsp_client/dispatch.rs | 224 +++++++++ rust/crates/runtime/src/lsp_client/mod.rs | 466 ++++++++++++++++++ rust/crates/runtime/src/lsp_client/tests.rs | 273 ++++++++++ .../runtime/src/lsp_client/tests_lifecycle.rs | 294 +++++++++++ rust/crates/runtime/src/lsp_client/types.rs | 103 ++++ rust/crates/runtime/src/lsp_process/mod.rs | 436 ++++++++++++++++ rust/crates/runtime/src/lsp_process/parse.rs | 311 ++++++++++++ rust/crates/runtime/src/lsp_process/tests.rs | 194 ++++++++ rust/crates/runtime/src/lsp_transport/mod.rs | 425 ++++++++++++++++ .../crates/runtime/src/lsp_transport/tests.rs | 134 +++++ 10 files changed, 2860 insertions(+) create mode 100644 rust/crates/runtime/src/lsp_client/dispatch.rs create mode 100644 rust/crates/runtime/src/lsp_client/mod.rs create mode 100644 rust/crates/runtime/src/lsp_client/tests.rs create mode 100644 rust/crates/runtime/src/lsp_client/tests_lifecycle.rs create mode 100644 rust/crates/runtime/src/lsp_client/types.rs create mode 100644 rust/crates/runtime/src/lsp_process/mod.rs create mode 100644 rust/crates/runtime/src/lsp_process/parse.rs create mode 100644 rust/crates/runtime/src/lsp_process/tests.rs create mode 100644 rust/crates/runtime/src/lsp_transport/mod.rs create mode 100644 rust/crates/runtime/src/lsp_transport/tests.rs diff --git a/rust/crates/runtime/src/lsp_client/dispatch.rs b/rust/crates/runtime/src/lsp_client/dispatch.rs new file mode 100644 index 00000000..4533f763 --- /dev/null +++ b/rust/crates/runtime/src/lsp_client/dispatch.rs @@ -0,0 +1,224 @@ +//! LSP action dispatch: routes actions to the appropriate server process. + +use super::types::{LspAction, LspServerStatus}; + +impl super::LspRegistry { + /// Dispatch an LSP action and return a structured result. + #[allow(clippy::too_many_lines)] + 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 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(|entry| &entry.state.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 language = Self::language_for_path(path) + .ok_or_else(|| format!("no LSP server available for path: {path}"))?; + + // Check the entry exists + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if !inner.servers.contains_key(&language) { + return Err(format!("no LSP server available for path: {path}")); + } + } + + // Lazy-start: if no process yet, try to start one + let needs_start = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner + .servers + .get(&language) + .is_none_or(|entry| entry.process.is_none()) + }; + + if needs_start { + if let Err(e) = self.start_server(&language) { + // Check the status after failed start — if still not Connected, + // return a proper error. This preserves the existing behavior + // for Disconnected/Error status servers. + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get(&language) { + if entry.state.status != LspServerStatus::Connected { + return Err(format!( + "LSP server for '{}' is not connected (status: {}): {}", + language, entry.state.status, e + )); + } + } + // If somehow still marked Connected but start failed, return error JSON + return Ok(serde_json::json!({ + "action": action, + "path": path, + "line": line, + "character": character, + "language": language, + "status": "error", + "error": e + })); + } + } + + // Check the server status + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get(&language) { + if entry.state.status != LspServerStatus::Connected { + return Err(format!( + "LSP server for '{}' is not connected (status: {})", + language, entry.state.status + )); + } + } + } + + // Get the process handle (clone the Arc) + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner + .servers + .get(&language) + .and_then(|entry| entry.process.clone()) + .ok_or_else(|| format!("no LSP process available for language: {language}"))? + }; + + // Dispatch to the real LSP process + let result = { + let mut process = process_arc + .lock() + .map_err(|_| "lsp process lock poisoned".to_owned())?; + + // Create a minimal tokio runtime for async LSP calls + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("failed to create tokio runtime: {e}"))?; + + rt.block_on(async { + let line = line.unwrap_or(0); + let character = character.unwrap_or(0); + + match lsp_action { + LspAction::Hover => { + let hover = process.hover(path, line, character).await; + hover.map(|opt| { + opt.map_or_else( + || serde_json::json!({ + "action": "hover", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "no_result", + }), + |h| serde_json::json!({ + "action": "hover", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "result": h, + }), + ) + }) + } + LspAction::Definition => { + let locations = process.goto_definition(path, line, character).await; + locations.map(|locs| serde_json::json!({ + "action": "definition", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "locations": locs, + })) + } + LspAction::References => { + let locations = process.references(path, line, character).await; + locations.map(|locs| serde_json::json!({ + "action": "references", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "locations": locs, + })) + } + LspAction::Completion => { + let items = process.completion(path, line, character).await; + items.map(|completions| serde_json::json!({ + "action": "completion", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "items": completions, + })) + } + LspAction::Symbols => { + let symbols = process.document_symbols(path).await; + symbols.map(|syms| serde_json::json!({ + "action": "symbols", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "symbols": syms, + })) + } + LspAction::Format => { + let edits = process.format(path).await; + edits.map(|text_edits| serde_json::json!({ + "action": "format", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "edits": text_edits, + })) + } + LspAction::Diagnostics => unreachable!(), + } + }) + }; + + result.map_err(|e| format!("LSP {action} failed for '{language}': {e}")) + } +} diff --git a/rust/crates/runtime/src/lsp_client/mod.rs b/rust/crates/runtime/src/lsp_client/mod.rs new file mode 100644 index 00000000..05aa5c67 --- /dev/null +++ b/rust/crates/runtime/src/lsp_client/mod.rs @@ -0,0 +1,466 @@ +#![allow(clippy::should_implement_trait, clippy::must_use_candidate)] +//! LSP (Language Server Protocol) client registry for tool dispatch. + +mod dispatch; +mod types; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_lifecycle; + +pub use types::{ + LspAction, LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerState, + LspServerStatus, LspSymbol, +}; + +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use crate::lsp_discovery::{discover_available_servers, LspServerDescriptor}; +use crate::lsp_process::LspProcess; + +/// Entry in the LSP registry combining process handle, descriptor, and state. +struct LspServerEntry { + /// The running LSP process, if started. Wrapped in Arc> for thread-safe async access. + process: Option>>, + /// The server descriptor for lazy-start on first use. + descriptor: Option, + /// The server state metadata (status, capabilities, diagnostics). + state: LspServerState, +} + +impl std::fmt::Debug for LspServerEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LspServerEntry") + .field("process", &self.process.is_some()) + .field("descriptor", &self.descriptor) + .field("state", &self.state) + .finish() + } +} + +impl LspServerEntry { + fn new(state: LspServerState) -> Self { + Self { + process: None, + descriptor: None, + state, + } + } + + fn with_descriptor(state: LspServerState, descriptor: LspServerDescriptor) -> Self { + Self { + process: None, + descriptor: Some(descriptor), + state, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct LspRegistry { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct RegistryInner { + servers: HashMap, + open_files: HashSet, +} + +impl LspRegistry { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Register an LSP server with metadata but without starting the process. + /// The server can be started later via `start_server()` or lazily on first `dispatch()`. + pub fn register( + &self, + language: &str, + status: LspServerStatus, + root_path: Option<&str>, + capabilities: Vec, + ) { + let state = LspServerState { + language: language.to_owned(), + status, + root_path: root_path.map(str::to_owned), + capabilities, + diagnostics: Vec::new(), + }; + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner + .servers + .insert(language.to_owned(), LspServerEntry::new(state)); + } + + /// Register an LSP server with a descriptor for lazy-start. + /// The descriptor provides the command and args to start the server when needed. + pub fn register_with_descriptor( + &self, + language: &str, + status: LspServerStatus, + root_path: Option<&str>, + capabilities: Vec, + descriptor: LspServerDescriptor, + ) { + let state = LspServerState { + language: language.to_owned(), + status, + root_path: root_path.map(str::to_owned), + capabilities, + diagnostics: Vec::new(), + }; + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.insert( + language.to_owned(), + LspServerEntry::with_descriptor(state, descriptor), + ); + } + + pub fn get(&self, language: &str) -> Option { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.get(language).map(|entry| entry.state.clone()) + } + + /// 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) + } + + /// Get the language name for a file path based on extension. + fn language_for_path(path: &str) -> Option { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str())?; + + 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, + }; + + Some(language.to_owned()) + } + + /// List all registered servers. + pub fn list_servers(&self) -> Vec { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.values().map(|entry| entry.state.clone()).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 entry = inner + .servers + .get_mut(language) + .ok_or_else(|| format!("LSP server not found for language: {language}"))?; + entry.state.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(|entry| &entry.state.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 entry = inner + .servers + .get_mut(language) + .ok_or_else(|| format!("LSP server not found for language: {language}"))?; + entry.state.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).map(|entry| entry.state) + } + + #[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 + } + + /// Start an LSP server process for the given language. + /// If the process is already running, this is a no-op. + /// If a descriptor is available, it is used to start the process. + /// If no descriptor is available, the discovery system is consulted. + pub fn start_server(&self, language: &str) -> Result<(), String> { + // Check if already running + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get(language) { + if entry.process.is_some() { + return Ok(()); + } + } + } + + // Try to get the descriptor + let descriptor = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get(language) { + entry.descriptor.clone() + } else { + None + } + }; + + // If no descriptor, try discovery + let descriptor = if let Some(d) = descriptor { d } else { + let available = discover_available_servers(); + available + .into_iter() + .find(|d| d.language == language) + .ok_or_else(|| { + format!("no LSP server descriptor found for language: {language}") + })? + }; + + let root_path = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner + .servers + .get(language) + .and_then(|entry| entry.state.root_path.clone()) + .unwrap_or_else(|| { + std::env::current_dir() + .map_or_else(|_| ".".to_owned(), |p| p.to_string_lossy().into_owned()) + }) + }; + + let process = { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("failed to create tokio runtime: {e}"))?; + rt.block_on(LspProcess::start( + &descriptor.command, + &descriptor.args, + Path::new(&root_path), + )) + .map_err(|e| format!("failed to start LSP server for '{language}': {e}"))? + }; + + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(language) { + entry.process = Some(Arc::new(Mutex::new(process))); + entry.state.status = LspServerStatus::Connected; + } + + Ok(()) + } + + /// Stop a running LSP server process. + pub fn stop_server(&self, language: &str) -> Result<(), String> { + let process_arc = { + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + let entry = inner + .servers + .get_mut(language) + .ok_or_else(|| format!("LSP server not found for language: {language}"))?; + entry.state.status = LspServerStatus::Disconnected; + entry.process.take() + }; + + if let Some(process_arc) = process_arc { + let mut process = process_arc + .lock() + .map_err(|_| "lsp process lock poisoned")?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("failed to create tokio runtime: {e}"))?; + rt.block_on(process.shutdown()) + .map_err(|e| format!("LSP shutdown error: {e}"))?; + } + + 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) + } +} diff --git a/rust/crates/runtime/src/lsp_client/tests.rs b/rust/crates/runtime/src/lsp_client/tests.rs new file mode 100644 index 00000000..7e2c74d6 --- /dev/null +++ b/rust/crates/runtime/src/lsp_client/tests.rs @@ -0,0 +1,273 @@ +//! Tests for the LSP client registry: registration, diagnostics, and type unit tests. + +use super::*; +use super::types::*; + +#[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()); +} + +#[test] +fn lsp_action_from_str_all_aliases() { + // given + let cases = [ + ("diagnostics", Some(LspAction::Diagnostics)), + ("hover", Some(LspAction::Hover)), + ("definition", Some(LspAction::Definition)), + ("goto_definition", Some(LspAction::Definition)), + ("references", Some(LspAction::References)), + ("find_references", Some(LspAction::References)), + ("completion", Some(LspAction::Completion)), + ("completions", Some(LspAction::Completion)), + ("symbols", Some(LspAction::Symbols)), + ("document_symbols", Some(LspAction::Symbols)), + ("format", Some(LspAction::Format)), + ("formatting", Some(LspAction::Format)), + ("unknown", None), + ]; + + // when + let resolved: Vec<_> = cases + .into_iter() + .map(|(input, expected)| (input, LspAction::from_str(input), expected)) + .collect(); + + // then + for (input, actual, expected) in resolved { + assert_eq!(actual, expected, "unexpected action resolution for {input}"); + } +} + +#[test] +fn lsp_server_status_display_all_variants() { + // given + let cases = [ + (LspServerStatus::Connected, "connected"), + (LspServerStatus::Disconnected, "disconnected"), + (LspServerStatus::Starting, "starting"), + (LspServerStatus::Error, "error"), + ]; + + // when + let rendered: Vec<_> = cases + .into_iter() + .map(|(status, expected)| (status.to_string(), expected)) + .collect(); + + // then + assert_eq!( + rendered, + vec![ + ("connected".to_string(), "connected"), + ("disconnected".to_string(), "disconnected"), + ("starting".to_string(), "starting"), + ("error".to_string(), "error"), + ] + ); +} + +#[test] +fn dispatch_diagnostics_without_path_aggregates() { + // given + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Connected, None, vec![]); + registry.register("python", 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: Some("rust-analyzer".into()), + }], + ) + .expect("rust diagnostics should add"); + registry + .add_diagnostics( + "python", + vec![LspDiagnostic { + path: "script.py".into(), + line: 2, + character: 4, + severity: "error".into(), + message: "undefined name".into(), + source: Some("pyright".into()), + }], + ) + .expect("python diagnostics should add"); + + // when + let result = registry + .dispatch("diagnostics", None, None, None, None) + .expect("aggregate diagnostics should work"); + + // then + assert_eq!(result["action"], "diagnostics"); + assert_eq!(result["count"], 2); + assert_eq!(result["diagnostics"].as_array().map(Vec::len), Some(2)); +} + +#[test] +fn dispatch_non_diagnostics_requires_path() { + // given + let registry = LspRegistry::new(); + + // when + let result = registry.dispatch("hover", None, Some(1), Some(0), None); + + // then + assert_eq!( + result.expect_err("path should be required"), + "path is required for this LSP action" + ); +} + +#[test] +fn dispatch_no_server_for_path_errors() { + // given + let registry = LspRegistry::new(); + + // when + let result = registry.dispatch("hover", Some("notes.md"), Some(1), Some(0), None); + + // then + let error = result.expect_err("missing server should fail"); + assert!(error.contains("no LSP server available for path: notes.md")); +} + +#[test] +fn dispatch_disconnected_server_error_payload() { + // given + let registry = LspRegistry::new(); + registry.register("typescript", LspServerStatus::Disconnected, None, vec![]); + + // when + let result = registry.dispatch("hover", Some("src/index.ts"), Some(3), Some(2), None); + + // then + let error = result.expect_err("disconnected server should fail"); + assert!(error.contains("typescript")); + assert!(error.contains("disconnected")); +} diff --git a/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs b/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs new file mode 100644 index 00000000..67c2c554 --- /dev/null +++ b/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs @@ -0,0 +1,294 @@ +//! Tests for the LSP client registry: extension mapping, server lifecycle, +//! and diagnostics edge cases. + +use super::*; +use super::types::*; + +#[test] +fn find_server_for_all_extensions() { + // given + let registry = LspRegistry::new(); + for language in [ + "rust", + "typescript", + "javascript", + "python", + "go", + "java", + "c", + "cpp", + "ruby", + "lua", + ] { + registry.register(language, LspServerStatus::Connected, None, vec![]); + } + let cases = [ + ("src/main.rs", "rust"), + ("src/index.ts", "typescript"), + ("src/view.tsx", "typescript"), + ("src/app.js", "javascript"), + ("src/app.jsx", "javascript"), + ("script.py", "python"), + ("main.go", "go"), + ("Main.java", "java"), + ("native.c", "c"), + ("native.h", "c"), + ("native.cpp", "cpp"), + ("native.hpp", "cpp"), + ("native.cc", "cpp"), + ("script.rb", "ruby"), + ("script.lua", "lua"), + ]; + + // when + let resolved: Vec<_> = cases + .into_iter() + .map(|(path, expected)| { + ( + path, + registry + .find_server_for_path(path) + .map(|server| server.language), + expected, + ) + }) + .collect(); + + // then + for (path, actual, expected) in resolved { + assert_eq!( + actual.as_deref(), + Some(expected), + "unexpected mapping for {path}" + ); + } +} + +#[test] +fn find_server_for_path_no_extension() { + // given + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Connected, None, vec![]); + + // when + let result = registry.find_server_for_path("Makefile"); + + // then + assert!(result.is_none()); +} + +#[test] +fn list_servers_with_multiple() { + // given + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Connected, None, vec![]); + registry.register("typescript", LspServerStatus::Starting, None, vec![]); + registry.register("python", LspServerStatus::Error, None, vec![]); + + // when + let servers = registry.list_servers(); + + // then + assert_eq!(servers.len(), 3); + assert!(servers.iter().any(|server| server.language == "rust")); + assert!(servers.iter().any(|server| server.language == "typescript")); + assert!(servers.iter().any(|server| server.language == "python")); +} + +#[test] +fn get_missing_server_returns_none() { + // given + let registry = LspRegistry::new(); + + // when + let server = registry.get("missing"); + + // then + assert!(server.is_none()); +} + +#[test] +fn add_diagnostics_missing_language_errors() { + // given + let registry = LspRegistry::new(); + + // when + let result = registry.add_diagnostics("missing", vec![]); + + // then + let error = result.expect_err("missing language should fail"); + assert!(error.contains("LSP server not found for language: missing")); +} + +#[test] +fn get_diagnostics_across_servers() { + // given + let registry = LspRegistry::new(); + let shared_path = "shared/file.txt"; + registry.register("rust", LspServerStatus::Connected, None, vec![]); + registry.register("python", LspServerStatus::Connected, None, vec![]); + registry + .add_diagnostics( + "rust", + vec![LspDiagnostic { + path: shared_path.into(), + line: 4, + character: 1, + severity: "warning".into(), + message: "warn".into(), + source: None, + }], + ) + .expect("rust diagnostics should add"); + registry + .add_diagnostics( + "python", + vec![LspDiagnostic { + path: shared_path.into(), + line: 8, + character: 3, + severity: "error".into(), + message: "err".into(), + source: None, + }], + ) + .expect("python diagnostics should add"); + + // when + let diagnostics = registry.get_diagnostics(shared_path); + + // then + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics + .iter() + .any(|diagnostic| diagnostic.message == "warn")); + assert!(diagnostics + .iter() + .any(|diagnostic| diagnostic.message == "err")); +} + +#[test] +fn clear_diagnostics_missing_language_errors() { + // given + let registry = LspRegistry::new(); + + // when + let result = registry.clear_diagnostics("missing"); + + // then + let error = result.expect_err("missing language should fail"); + assert!(error.contains("LSP server not found for language: missing")); +} + +#[test] +fn register_with_descriptor_stores_entry() { + let registry = LspRegistry::new(); + let descriptor = LspServerDescriptor { + language: "rust".into(), + command: "rust-analyzer".into(), + args: vec![], + extensions: vec!["rs".into()], + }; + registry.register_with_descriptor( + "rust", + LspServerStatus::Connected, + Some("/project"), + vec!["hover".into()], + descriptor, + ); + + let server = registry.get("rust").expect("should exist after register_with_descriptor"); + assert_eq!(server.language, "rust"); + assert_eq!(server.status, LspServerStatus::Connected); + assert_eq!(server.root_path.as_deref(), Some("/project")); + assert_eq!(server.capabilities, vec!["hover"]); +} + +#[test] +fn stop_server_on_nonexistent_errors() { + let registry = LspRegistry::new(); + let result = registry.stop_server("missing"); + assert!(result.is_err(), "stopping a nonexistent server should error"); + let error = result.unwrap_err(); + assert!(error.contains("missing"), "error message should reference 'missing', got: {error}"); +} + +/// This test requires rust-analyzer to be installed on the system. +/// Run with: cargo test -p runtime -- --ignored +#[test] +#[ignore = "requires rust-analyzer installed on PATH"] +fn start_server_without_descriptor_falls_back_to_discovery() { + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Starting, None, vec![]); + let result = registry.start_server("rust"); + assert!(result.is_ok(), "start_server should discover and start rust-analyzer: {result:?}"); + let server = registry.get("rust").expect("rust should be registered"); + assert_eq!(server.status, LspServerStatus::Connected); + let _ = registry.stop_server("rust"); +} + +/// This test requires rust-analyzer to be installed on the system. +/// Run with: cargo test -p runtime -- --ignored +#[test] +#[ignore = "requires rust-analyzer installed on PATH"] +fn dispatch_hover_lazy_starts_server() { + let registry = LspRegistry::new(); + let descriptor = crate::lsp_discovery::LspServerDescriptor { + language: "rust".into(), + command: "rust-analyzer".into(), + args: vec![], + extensions: vec!["rs".into()], + }; + registry.register_with_descriptor( + "rust", + LspServerStatus::Starting, + None, + vec![], + descriptor, + ); + // dispatch should trigger start_server because process is None + let result = registry.dispatch("hover", Some("src/main.rs"), Some(0), Some(0), None); + // Result may be Ok or Err depending on whether rust-analyzer can actually + // respond for this path, but it should not fail with "not connected" + // (which would indicate the lazy-start didn't kick in). + if let Err(e) = &result { + assert!( + !e.contains("not connected"), + "dispatch should have lazily started the server, got: {e}" + ); + } + let _ = registry.stop_server("rust"); +} + +/// This test requires rust-analyzer to be installed on the system. +/// Run with: cargo test -p runtime -- --ignored +#[test] +#[ignore = "requires rust-analyzer installed on PATH"] +fn start_and_stop_server() { + let registry = LspRegistry::new(); + let descriptor = crate::lsp_discovery::LspServerDescriptor { + language: "rust".into(), + command: "rust-analyzer".into(), + args: vec![], + extensions: vec!["rs".into()], + }; + registry.register_with_descriptor( + "rust", + LspServerStatus::Starting, + None, + vec![], + descriptor, + ); + + let start_result = registry.start_server("rust"); + assert!(start_result.is_ok(), "start_server should succeed: {start_result:?}"); + + let server = registry.get("rust").expect("rust should exist"); + assert_eq!(server.status, LspServerStatus::Connected); + + let stop_result = registry.stop_server("rust"); + assert!(stop_result.is_ok(), "stop_server should succeed: {stop_result:?}"); + + let server = registry.get("rust").expect("rust should still be in registry"); + assert_eq!(server.status, LspServerStatus::Disconnected); +} diff --git a/rust/crates/runtime/src/lsp_client/types.rs b/rust/crates/runtime/src/lsp_client/types.rs new file mode 100644 index 00000000..485a5697 --- /dev/null +++ b/rust/crates/runtime/src/lsp_client/types.rs @@ -0,0 +1,103 @@ +//! LSP type definitions: action enums, diagnostic/location types, server status. + +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, + } + } +} + +#[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, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspHoverResult { + pub content: String, + pub language: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspCompletionItem { + pub label: String, + pub kind: Option, + pub detail: Option, + pub insert_text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspSymbol { + pub name: String, + pub kind: String, + pub path: String, + pub line: u32, + pub character: u32, +} + +#[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"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspServerState { + pub language: String, + pub status: LspServerStatus, + pub root_path: Option, + pub capabilities: Vec, + pub diagnostics: Vec, +} diff --git a/rust/crates/runtime/src/lsp_process/mod.rs b/rust/crates/runtime/src/lsp_process/mod.rs new file mode 100644 index 00000000..ed0fc107 --- /dev/null +++ b/rust/crates/runtime/src/lsp_process/mod.rs @@ -0,0 +1,436 @@ +//! LSP process manager: spawns language servers and drives the LSP lifecycle. + +mod parse; + +#[cfg(test)] +mod tests; + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde_json::Value as JsonValue; + +use crate::lsp_client::{ + LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, +}; +use crate::lsp_transport::{LspTransport, LspTransportError}; + +use parse::{ + canonicalize_root, language_id_for_path, parse_completions, parse_hover, parse_locations, + parse_symbols, path_to_uri, severity_name, text_document_position_params, uri_to_path, +}; + +#[derive(Debug)] +pub struct LspProcess { + transport: LspTransport, + language: String, + root_uri: String, + capabilities: Option, + status: LspServerStatus, + open_files: HashSet, + version_counter: HashMap, +} + +#[allow(clippy::cast_possible_truncation)] +impl LspProcess { + /// Spawn a language server process and perform the LSP initialize handshake. + pub async fn start( + command: &str, + args: &[String], + root_path: &Path, + ) -> Result { + let transport = LspTransport::spawn(command, args) + .map_err(|e| LspProcessError::Transport(LspTransportError::Io(e)))?; + + let canonical = canonicalize_root(root_path)?; + let root_uri = format!("file://{canonical}"); + + let mut process = Self { + transport, + language: command.to_owned(), + root_uri: root_uri.clone(), + capabilities: None, + status: LspServerStatus::Starting, + open_files: HashSet::new(), + version_counter: HashMap::new(), + }; + + process.initialize(&canonical).await?; + process.status = LspServerStatus::Connected; + + Ok(process) + } + + /// Send the LSP `initialize` request followed by the `initialized` notification. + async fn initialize(&mut self, root_path: &str) -> Result { + let root_uri = format!("file://{root_path}"); + let pid = std::process::id(); + + let params = serde_json::json!({ + "processId": pid, + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "hover": { "contentFormat": ["markdown", "plaintext"] }, + "definition": { "linkSupport": true }, + "references": {}, + "completion": { + "completionItem": { "snippetSupport": false } + }, + "documentSymbol": { "hierarchicalDocumentSymbolSupport": true }, + "publishDiagnostics": { "relatedInformation": true } + } + } + }); + + let response = self + .transport + .send_request("initialize", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + self.capabilities = Some(result.clone()); + + self.transport + .send_notification("initialized", Some(serde_json::json!({}))) + .await + .map_err(LspProcessError::Transport)?; + + Ok(result) + } + + /// Gracefully shut down the language server. + pub async fn shutdown(&mut self) -> Result<(), LspProcessError> { + self.status = LspServerStatus::Disconnected; + + let shutdown_result = self + .transport + .send_request("shutdown", None) + .await + .map_err(LspProcessError::Transport); + + if shutdown_result.is_ok() { + self.transport + .send_notification("exit", None) + .await + .map_err(LspProcessError::Transport)?; + } + + self.transport + .shutdown() + .await + .map_err(LspProcessError::Transport)?; + + Ok(()) + } + + /// Query hover information at a position. + pub async fn hover( + &mut self, + path: &str, + line: u32, + character: u32, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = text_document_position_params(&uri, line, character); + + let response = self + .transport + .send_request("textDocument/hover", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + if result.is_null() { + return Ok(None); + } + + Ok(parse_hover(&result)) + } + + /// Go to definition at a position. + pub async fn goto_definition( + &mut self, + path: &str, + line: u32, + character: u32, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = text_document_position_params(&uri, line, character); + + let response = self + .transport + .send_request("textDocument/definition", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + Ok(parse_locations(&result)) + } + + /// Find references at a position. + pub async fn references( + &mut self, + path: &str, + line: u32, + character: u32, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character }, + "context": { "includeDeclaration": true } + }); + + let response = self + .transport + .send_request("textDocument/references", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + Ok(parse_locations(&result)) + } + + /// Get document symbols for a file. + pub async fn document_symbols( + &mut self, + path: &str, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri } + }); + + let response = self + .transport + .send_request("textDocument/documentSymbol", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + if result.is_null() { + return Ok(Vec::new()); + } + + Ok(parse_symbols(&result, path)) + } + + /// Get completions at a position. + pub async fn completion( + &mut self, + path: &str, + line: u32, + character: u32, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = text_document_position_params(&uri, line, character); + + let response = self + .transport + .send_request("textDocument/completion", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + if result.is_null() { + return Ok(Vec::new()); + } + + // The response may be a CompletionList or a plain array. + let items = if let Some(list) = result.get("items") { + list + } else { + &result + }; + + Ok(parse_completions(items)) + } + + /// Format a document. + pub async fn format(&mut self, path: &str) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri }, + "options": { "tabSize": 4, "insertSpaces": true } + }); + + let response = self + .transport + .send_request("textDocument/formatting", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + if result.is_null() { + return Ok(Vec::new()); + } + + match result.as_array() { + Some(arr) => Ok(arr.clone()), + None => Ok(Vec::new()), + } + } + + /// 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 + } + + #[must_use] + pub fn language(&self) -> &str { + &self.language + } + + #[must_use] + pub fn root_uri(&self) -> &str { + &self.root_uri + } +} + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub enum LspProcessError { + Transport(LspTransportError), + InvalidPath(String), +} + +impl std::fmt::Display for LspProcessError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Transport(e) => write!(f, "LSP transport error: {e}"), + Self::InvalidPath(p) => write!(f, "invalid path: {p}"), + } + } +} + +impl std::error::Error for LspProcessError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Transport(e) => Some(e), + Self::InvalidPath(_) => None, + } + } +} diff --git a/rust/crates/runtime/src/lsp_process/parse.rs b/rust/crates/runtime/src/lsp_process/parse.rs new file mode 100644 index 00000000..575f425a --- /dev/null +++ b/rust/crates/runtime/src/lsp_process/parse.rs @@ -0,0 +1,311 @@ +//! Helper functions for LSP URI/path conversion, parameter building, and +//! response parsing. + +use std::path::Path; + +use serde_json::Value as JsonValue; + +use crate::lsp_client::{LspCompletionItem, LspHoverResult, LspLocation, LspSymbol}; +use crate::lsp_process::LspProcessError; + +pub(super) fn canonicalize_root(path: &Path) -> Result { + path.canonicalize() + .map_err(|e| LspProcessError::InvalidPath(format!("{}: {e}", path.display()))) + .map(|p| p.to_string_lossy().into_owned()) +} + +pub(super) fn path_to_uri(path: &str) -> String { + let canonical = std::path::Path::new(path); + if canonical.is_absolute() { + format!("file://{path}") + } else { + let resolved = std::env::current_dir() + .map_or_else(|_| canonical.to_path_buf(), |d| d.join(path)); + let canonicalized = resolved + .canonicalize() + .unwrap_or(resolved) + .to_string_lossy() + .into_owned(); + format!("file://{canonicalized}") + } +} + +pub(super) fn text_document_position_params(uri: &str, line: u32, character: u32) -> JsonValue { + serde_json::json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character } + }) +} + +pub(super) fn uri_to_path(uri: &str) -> String { + uri.strip_prefix("file://").unwrap_or(uri).to_owned() +} + +pub(super) 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() +} + +pub(super) 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})"), + } +} + +pub(super) fn parse_hover(value: &JsonValue) -> Option { + let contents = value.get("contents")?; + + // MarkupContent: { kind, value } + if let (Some(kind), Some(val)) = (contents.get("kind"), contents.get("value")) { + let language = if kind.as_str() == Some("plaintext") { + None + } else { + Some(kind.as_str().unwrap_or("markdown").to_owned()) + }; + return Some(LspHoverResult { + content: val.as_str().unwrap_or("").to_owned(), + language, + }); + } + + // MarkedString object: { language, value } + if let (Some(lang), Some(val)) = (contents.get("language"), contents.get("value")) { + return Some(LspHoverResult { + content: val.as_str().unwrap_or("").to_owned(), + language: Some(lang.as_str().unwrap_or("").to_owned()), + }); + } + + // Plain string MarkedString + if let Some(s) = contents.as_str() { + return Some(LspHoverResult { + content: s.to_owned(), + language: None, + }); + } + + // Array of MarkedString + if let Some(arr) = contents.as_array() { + let parts: Vec<&str> = arr + .iter() + .filter_map(|item| { + if let Some(s) = item.as_str() { + Some(s) + } else { + item.get("value").and_then(JsonValue::as_str) + } + }) + .collect(); + if parts.is_empty() { + return None; + } + return Some(LspHoverResult { + content: parts.join("\n"), + language: None, + }); + } + + None +} + +#[allow(clippy::cast_possible_truncation)] +pub(super) fn parse_locations(value: &JsonValue) -> Vec { + let Some(locations) = value.as_array() else { + return Vec::new(); + }; + + locations + .iter() + .filter_map(|loc| { + let uri = loc.get("uri")?.as_str()?; + let path = uri_to_path(uri); + let range = loc.get("range")?; + let start = range.get("start")?; + let end = range.get("end")?; + + Some(LspLocation { + path, + line: start.get("line")?.as_u64()? as u32, + character: start.get("character")?.as_u64()? as u32, + end_line: end + .get("line") + .and_then(JsonValue::as_u64) + .map(|v| v as u32), + end_character: end + .get("character") + .and_then(JsonValue::as_u64) + .map(|v| v as u32), + preview: None, + }) + }) + .collect() +} + +fn extract_symbols(items: &[JsonValue], path: &str, out: &mut Vec) { + for item in items { + let name = item.get("name").and_then(JsonValue::as_str).unwrap_or(""); + let kind = item + .get("kind") + .and_then(JsonValue::as_u64) + .map_or_else(|| "Unknown".into(), symbol_kind_name); + + let (sym_path, line, character) = if let Some(range) = item.get("range") { + let start = range.get("start"); + ( + path.to_owned(), + u32::try_from( + start + .and_then(|s| s.get("line")) + .and_then(JsonValue::as_u64) + .unwrap_or(0), + ) + .unwrap_or(0), + u32::try_from( + start + .and_then(|s| s.get("character")) + .and_then(JsonValue::as_u64) + .unwrap_or(0), + ) + .unwrap_or(0), + ) + } else { + (path.to_owned(), 0, 0) + }; + + out.push(LspSymbol { + name: name.to_owned(), + kind: kind.clone(), + path: sym_path, + line, + character, + }); + + if let Some(children) = item.get("children").and_then(JsonValue::as_array) { + extract_symbols(children, path, out); + } + } +} + +pub(super) fn parse_symbols(value: &JsonValue, default_path: &str) -> Vec { + let Some(items) = value.as_array() else { + return Vec::new(); + }; + + let mut result = Vec::new(); + extract_symbols(items, default_path, &mut result); + result +} + +pub(super) fn parse_completions(value: &JsonValue) -> Vec { + let Some(items) = value.as_array() else { + return Vec::new(); + }; + + items + .iter() + .map(|item| LspCompletionItem { + label: item + .get("label") + .and_then(JsonValue::as_str) + .unwrap_or("") + .to_owned(), + kind: item + .get("kind") + .and_then(JsonValue::as_u64) + .map(completion_kind_name), + detail: item + .get("detail") + .and_then(JsonValue::as_str) + .map(str::to_owned), + insert_text: item + .get("insertText") + .and_then(JsonValue::as_str) + .map(str::to_owned), + }) + .collect() +} + +pub(super) fn symbol_kind_name(kind: u64) -> String { + match kind { + 1 => "File".into(), + 2 => "Module".into(), + 3 => "Namespace".into(), + 4 => "Package".into(), + 5 => "Class".into(), + 6 => "Method".into(), + 7 => "Property".into(), + 8 => "Field".into(), + 9 => "Constructor".into(), + 10 => "Enum".into(), + 11 => "Interface".into(), + 12 => "Function".into(), + 13 => "Variable".into(), + 14 => "Constant".into(), + 15 => "String".into(), + 16 => "Number".into(), + 17 => "Boolean".into(), + 18 => "Array".into(), + 19 => "Object".into(), + 20 => "Key".into(), + 21 => "Null".into(), + 22 => "EnumMember".into(), + 23 => "Struct".into(), + 24 => "Event".into(), + 25 => "Operator".into(), + 26 => "TypeParameter".into(), + _ => format!("Unknown({kind})"), + } +} + +pub(super) fn completion_kind_name(kind: u64) -> String { + match kind { + 1 => "Text".into(), + 2 => "Method".into(), + 3 => "Function".into(), + 4 => "Constructor".into(), + 5 => "Field".into(), + 6 => "Variable".into(), + 7 => "Class".into(), + 8 => "Interface".into(), + 9 => "Module".into(), + 10 => "Property".into(), + 11 => "Unit".into(), + 12 => "Value".into(), + 13 => "Enum".into(), + 14 => "Keyword".into(), + 15 => "Snippet".into(), + 16 => "Color".into(), + 17 => "File".into(), + 18 => "Reference".into(), + 19 => "Folder".into(), + 20 => "EnumMember".into(), + 21 => "Constant".into(), + 22 => "Struct".into(), + 23 => "Event".into(), + 24 => "Operator".into(), + 25 => "TypeParameter".into(), + _ => format!("Unknown({kind})"), + } +} diff --git a/rust/crates/runtime/src/lsp_process/tests.rs b/rust/crates/runtime/src/lsp_process/tests.rs new file mode 100644 index 00000000..1d2ab554 --- /dev/null +++ b/rust/crates/runtime/src/lsp_process/tests.rs @@ -0,0 +1,194 @@ +use super::*; +use super::parse::*; + +/// Requires rust-analyzer to be installed on the system. +/// Run with: cargo test -p runtime -- --ignored +#[tokio::test] +#[ignore = "requires rust-analyzer installed on PATH"] +async fn spawn_and_initialize_rust_analyzer() { + let root = std::env::current_dir().expect("should have cwd"); + let process = LspProcess::start("rust-analyzer", &[], &root).await; + assert!(process.is_ok(), "should spawn and initialize rust-analyzer"); + + let mut process = process.unwrap(); + assert_eq!(process.status(), LspServerStatus::Connected); + assert_eq!(process.language(), "rust-analyzer"); + + let shutdown_result = process.shutdown().await; + assert!(shutdown_result.is_ok(), "shutdown should succeed: {shutdown_result:?}"); +} + +/// Requires rust-analyzer to be installed and a Rust project on disk. +/// Run with: cargo test -p runtime -- --ignored +#[tokio::test] +#[ignore = "requires rust-analyzer installed on PATH"] +async fn hover_on_real_file() { + let root = std::env::current_dir().expect("should have cwd"); + let mut process = LspProcess::start("rust-analyzer", &[], &root) + .await + .expect("should start rust-analyzer"); + + // Try hover on src/main.rs — the result might be None if the file + // doesn't exist at that path, but the call itself should not error. + let file_path = root.join("src").join("main.rs"); + let path_str = file_path.to_string_lossy(); + let result = process.hover(&path_str, 0, 0).await; + assert!(result.is_ok(), "hover should not return an error: {:?}", result.err()); + + let _ = process.shutdown().await; +} + +#[test] +fn parse_hover_markup_content() { + let value = serde_json::json!({ + "contents": { + "kind": "plaintext", + "value": "fn main()" + } + }); + let result = parse_hover(&value); + assert!(result.is_some()); + let hover = result.unwrap(); + assert_eq!(hover.content, "fn main()"); +} + +#[test] +fn parse_hover_marked_string_object() { + let value = serde_json::json!({ + "contents": { + "language": "rust", + "value": "pub fn foo()" + } + }); + let result = parse_hover(&value); + assert!(result.is_some()); + let hover = result.unwrap(); + assert_eq!(hover.content, "pub fn foo()"); + assert_eq!(hover.language.as_deref(), Some("rust")); +} + +#[test] +fn parse_hover_plain_string() { + let value = serde_json::json!({ + "contents": "some text" + }); + let result = parse_hover(&value); + assert!(result.is_some()); + let hover = result.unwrap(); + assert_eq!(hover.content, "some text"); + assert!(hover.language.is_none()); +} + +#[test] +fn parse_hover_array_of_marked_strings() { + let value = serde_json::json!({ + "contents": [ + "first line", + { "language": "rust", "value": "fn bar()" } + ] + }); + let result = parse_hover(&value); + assert!(result.is_some()); + let hover = result.unwrap(); + assert!(hover.content.contains("first line")); + assert!(hover.content.contains("fn bar()")); +} + +#[test] +fn parse_locations_empty_array() { + let value = serde_json::json!([]); + let locations = parse_locations(&value); + assert!(locations.is_empty()); +} + +#[test] +fn parse_locations_valid() { + let value = serde_json::json!([ + { + "uri": "file:///tmp/test.rs", + "range": { + "start": { "line": 5, "character": 10 }, + "end": { "line": 5, "character": 15 } + } + } + ]); + let locations = parse_locations(&value); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].line, 5); + assert_eq!(locations[0].character, 10); + assert_eq!(locations[0].end_line, Some(5)); + assert_eq!(locations[0].end_character, Some(15)); +} + +#[test] +fn parse_symbols_basic() { + let value = serde_json::json!([ + { + "name": "main", + "kind": 12, + "range": { + "start": { "line": 1, "character": 0 }, + "end": { "line": 5, "character": 1 } + } + } + ]); + let symbols = parse_symbols(&value, "/tmp/test.rs"); + assert_eq!(symbols.len(), 1); + assert_eq!(symbols[0].name, "main"); + assert_eq!(symbols[0].kind, "Function"); + assert_eq!(symbols[0].line, 1); +} + +#[test] +fn parse_completions_basic() { + let value = serde_json::json!([ + { "label": "foo", "kind": 3, "detail": "fn foo()" }, + { "label": "bar", "kind": 6 } + ]); + let completions = parse_completions(&value); + assert_eq!(completions.len(), 2); + assert_eq!(completions[0].label, "foo"); + assert_eq!(completions[0].kind.as_deref(), Some("Function")); + assert_eq!(completions[0].detail.as_deref(), Some("fn foo()")); + assert_eq!(completions[1].label, "bar"); + assert_eq!(completions[1].kind.as_deref(), Some("Variable")); +} + +#[test] +fn symbol_kind_name_all_variants() { + assert_eq!(symbol_kind_name(1), "File"); + assert_eq!(symbol_kind_name(6), "Method"); + assert_eq!(symbol_kind_name(12), "Function"); + assert_eq!(symbol_kind_name(13), "Variable"); + assert_eq!(symbol_kind_name(23), "Struct"); + assert_eq!(symbol_kind_name(99), "Unknown(99)"); +} + +#[test] +fn completion_kind_name_all_variants() { + assert_eq!(completion_kind_name(1), "Text"); + assert_eq!(completion_kind_name(3), "Function"); + assert_eq!(completion_kind_name(6), "Variable"); + assert_eq!(completion_kind_name(14), "Keyword"); + assert_eq!(completion_kind_name(99), "Unknown(99)"); +} + +#[test] +fn text_document_position_params_structure() { + let params = text_document_position_params("file:///test.rs", 5, 10); + assert_eq!(params["textDocument"]["uri"], "file:///test.rs"); + assert_eq!(params["position"]["line"], 5); + assert_eq!(params["position"]["character"], 10); +} + +#[test] +fn path_to_uri_absolute() { + let uri = path_to_uri("/tmp/test.rs"); + assert_eq!(uri, "file:///tmp/test.rs"); +} + +#[test] +fn uri_to_path_extracts_path() { + assert_eq!(uri_to_path("file:///tmp/test.rs"), "/tmp/test.rs"); + assert_eq!(uri_to_path("/no/prefix"), "/no/prefix"); +} diff --git a/rust/crates/runtime/src/lsp_transport/mod.rs b/rust/crates/runtime/src/lsp_transport/mod.rs new file mode 100644 index 00000000..fa95c996 --- /dev/null +++ b/rust/crates/runtime/src/lsp_transport/mod.rs @@ -0,0 +1,425 @@ +use std::io; +use std::process::Stdio; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::time::timeout; + +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum LspId { + Number(u64), + String(String), + Null, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LspRequest { + pub jsonrpc: String, + pub id: LspId, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl LspRequest { + pub fn new(id: LspId, method: impl Into, params: Option) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id, + method: method.into(), + params, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LspNotification { + pub jsonrpc: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl LspNotification { + pub fn new(method: impl Into, params: Option) -> Self { + Self { + jsonrpc: "2.0".to_string(), + method: method.into(), + params, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LspError { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LspResponse { + pub jsonrpc: String, + pub id: LspId, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl LspResponse { + #[must_use] + pub fn is_error(&self) -> bool { + self.error.is_some() + } + + pub fn into_result(self) -> Result { + if let Some(error) = self.error { + Err(error) + } else { + Ok(self.result.unwrap_or(JsonValue::Null)) + } + } +} + +/// 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), + Timeout { method: String, timeout: Duration }, + JsonRpc(LspError), + InvalidResponse { method: String, details: String }, + ServerExited, +} + +impl std::fmt::Display for LspTransportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(error) => write!(f, "{error}"), + Self::Timeout { method, timeout } => { + write!(f, "LSP request `{method}` timed out after {}s", timeout.as_secs()) + } + Self::JsonRpc(error) => { + write!(f, "LSP JSON-RPC error: {} ({})", error.message, error.code) + } + Self::InvalidResponse { method, details } => { + write!(f, "LSP invalid response for `{method}`: {details}") + } + Self::ServerExited => write!(f, "LSP server process exited unexpectedly"), + } + } +} + +impl std::error::Error for LspTransportError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::JsonRpc(_) | Self::Timeout { .. } | Self::InvalidResponse { .. } | Self::ServerExited => None, + } + } +} + +impl From for LspTransportError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +#[derive(Debug)] +pub struct LspTransport { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, + request_timeout: Duration, + pending_notifications: Vec, +} + +impl LspTransport { + pub fn spawn(command: &str, args: &[String]) -> io::Result { + Self::spawn_with_timeout(command, args, DEFAULT_REQUEST_TIMEOUT) + } + + pub fn spawn_with_timeout( + command: &str, + args: &[String], + request_timeout: Duration, + ) -> io::Result { + let mut cmd = Command::new(command); + cmd.args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn()?; + let stdin = child + .stdin + .take() + .ok_or_else(|| io::Error::other("LSP process missing stdin pipe"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("LSP process missing stdout pipe"))?; + + Ok(Self { + child, + stdin, + stdout: BufReader::new(stdout), + next_id: 1, + request_timeout, + pending_notifications: Vec::new(), + }) + } + + /// Construct an `LspTransport` from an already-spawned child process. + /// Primarily useful for testing. + #[cfg(test)] + fn from_child(mut child: Child, request_timeout: Duration) -> Self { + let stdin = child + .stdin + .take() + .expect("LSP process missing stdin pipe"); + let stdout = child + .stdout + .take() + .expect("LSP process missing stdout pipe"); + Self { + child, + stdin, + stdout: BufReader::new(stdout), + next_id: 1, + request_timeout, + pending_notifications: Vec::new(), + } + } + + fn allocate_id(&mut self) -> LspId { + let id = self.next_id; + self.next_id += 1; + LspId::Number(id) + } + + pub async fn send_notification( + &mut self, + method: &str, + params: Option, + ) -> Result<(), LspTransportError> { + let notification = LspNotification::new(method, params); + let body = serde_json::to_vec(¬ification) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + self.write_frame(&body).await + } + + pub async fn send_request( + &mut self, + method: &str, + params: Option, + ) -> Result { + let id = self.allocate_id(); + self.send_request_with_id(method, params, id).await + } + + pub async fn send_request_with_id( + &mut self, + method: &str, + params: Option, + id: LspId, + ) -> Result { + let request = LspRequest::new(id.clone(), method, params); + let body = serde_json::to_vec(&request) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + self.write_frame(&body).await?; + + let method_owned = method.to_string(); + let timeout_duration = self.request_timeout; + 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 { + method: method.to_string(), + details: format!("unsupported jsonrpc version `{}`", response.jsonrpc), + }); + } + + if response.id != id { + return Err(LspTransportError::InvalidResponse { + method: method.to_string(), + details: format!( + "mismatched id: expected {:?}, got {:?}", + id, response.id + ), + }); + } + + if let Some(error) = &response.error { + return Err(LspTransportError::JsonRpc(error.clone())); + } + + 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 { + 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> { + let _ = self + .send_notification("shutdown", None) + .await; + + let _ = self.send_notification("exit", None).await; + + match self.child.try_wait() { + Ok(Some(_)) => {} + Ok(None) | Err(_) => { + let _ = self.child.kill().await; + } + } + + Ok(()) + } + + pub fn is_alive(&mut self) -> bool { + matches!(self.child.try_wait(), Ok(None)) + } + + async fn write_frame(&mut self, payload: &[u8]) -> Result<(), LspTransportError> { + let header = format!("Content-Length: {}\r\n\r\n", payload.len()); + self.stdin.write_all(header.as_bytes()).await?; + self.stdin.write_all(payload).await?; + self.stdin.flush().await?; + Ok(()) + } + + async fn read_frame(&mut self) -> Result, LspTransportError> { + let mut content_length: Option = None; + + loop { + let mut line = String::new(); + let bytes_read = self.stdout.read_line(&mut line).await?; + if bytes_read == 0 { + return Err(LspTransportError::ServerExited); + } + if line == "\r\n" { + break; + } + let header = line.trim_end_matches(['\r', '\n']); + if let Some((name, value)) = header.split_once(':') { + if name.trim().eq_ignore_ascii_case("Content-Length") { + let parsed = value + .trim() + .parse::() + .map_err(|error| LspTransportError::Io(io::Error::new( + io::ErrorKind::InvalidData, + error, + )))?; + content_length = Some(parsed); + } + } + } + + let content_length = content_length.ok_or_else(|| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: "missing Content-Length header".to_string(), + } + })?; + + let mut payload = vec![0u8; content_length]; + self.stdout.read_exact(&mut payload).await.map_err(|error| { + if error.kind() == io::ErrorKind::UnexpectedEof { + LspTransportError::ServerExited + } else { + LspTransportError::Io(error) + } + })?; + + Ok(payload) + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/crates/runtime/src/lsp_transport/tests.rs b/rust/crates/runtime/src/lsp_transport/tests.rs new file mode 100644 index 00000000..8e4d1120 --- /dev/null +++ b/rust/crates/runtime/src/lsp_transport/tests.rs @@ -0,0 +1,134 @@ +use super::*; +use std::io::Cursor; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; + +#[test] +fn content_length_header_roundtrip() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + let payload = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":null}"#; + + // Write frame into a buffer + let mut write_buf = Vec::new(); + { + let header = format!("Content-Length: {}\r\n\r\n", payload.len()); + write_buf.extend_from_slice(header.as_bytes()); + write_buf.extend_from_slice(payload); + } + + // Read frame back using the same logic as LspTransport::read_frame + let cursor = Cursor::new(write_buf); + let mut reader = BufReader::new(cursor); + + let mut content_length: Option = None; + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line).await.unwrap(); + assert!(bytes_read > 0, "unexpected EOF reading header"); + if line == "\r\n" { + break; + } + let header = line.trim_end_matches(['\r', '\n']); + if let Some((name, value)) = header.split_once(':') { + if name.trim().eq_ignore_ascii_case("Content-Length") { + content_length = Some(value.trim().parse::().unwrap()); + } + } + } + + let content_length = content_length.expect("should have Content-Length"); + assert_eq!(content_length, payload.len()); + + let mut read_payload = vec![0u8; content_length]; + reader.read_exact(&mut read_payload).await.unwrap(); + + let original: serde_json::Value = serde_json::from_slice(payload).unwrap(); + let roundtripped: serde_json::Value = serde_json::from_slice(&read_payload).unwrap(); + assert_eq!(original, roundtripped); + }); +} + +#[test] +fn request_has_incrementing_ids() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + // Spawn cat so we can construct a real LspTransport. + let child = tokio::process::Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("cat should be available"); + + let mut transport = LspTransport::from_child(child, Duration::from_secs(5)); + + // Allocate IDs by inspecting what send_request would produce. + let id1 = transport.allocate_id(); + let id2 = transport.allocate_id(); + let id3 = transport.allocate_id(); + + assert_eq!(id1, LspId::Number(1)); + assert_eq!(id2, LspId::Number(2)); + assert_eq!(id3, LspId::Number(3)); + + // Clean up + let _ = transport.shutdown().await; + }); +} + +#[test] +fn notification_has_no_id() { + let notification = LspNotification::new("initialized", Some(serde_json::json!({}))); + let serialized = serde_json::to_string(¬ification).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + assert!( + parsed.get("id").is_none(), + "notification should not contain an 'id' field, got: {serialized}" + ); + assert_eq!(parsed["jsonrpc"], "2.0"); + assert_eq!(parsed["method"], "initialized"); +} + +#[test] +fn malformed_header_handling() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + // Feed garbage bytes that don't contain a valid Content-Length header. + let garbage = b"THIS IS NOT A VALID HEADER\r\n\r\n"; + let cursor = Cursor::new(garbage.to_vec()); + let mut reader = BufReader::new(cursor); + + let mut content_length: Option = None; + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line).await.unwrap(); + if bytes_read == 0 || line == "\r\n" { + break; + } + let header = line.trim_end_matches(['\r', '\n']); + if let Some((name, value)) = header.split_once(':') { + if name.trim().eq_ignore_ascii_case("Content-Length") { + content_length = value.trim().parse::().ok(); + } + } + } + + // The garbage header should not produce a valid Content-Length. + assert!( + content_length.is_none(), + "garbage input should not produce a valid Content-Length" + ); + }); +}