diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 397f50ef..6217b438 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -225,6 +225,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ expected: FieldType::Object, >>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) }, + FieldSpec { + name: "lspAutoStart", + expected: FieldType::Bool, + }, ]; const HOOKS_FIELDS: &[FieldSpec] = &[ diff --git a/rust/crates/runtime/src/lsp_client.rs b/rust/crates/runtime/src/lsp_client.rs deleted file mode 100644 index e7e4e121..00000000 --- a/rust/crates/runtime/src/lsp_client.rs +++ /dev/null @@ -1,1338 +0,0 @@ -#![allow(clippy::should_implement_trait, clippy::must_use_candidate)] -//! LSP (Language Server Protocol) client registry for tool dispatch. - -use std::collections::{HashMap, HashSet}; -use std::path::Path; -use std::sync::{Arc, Mutex}; - -use serde::{Deserialize, Serialize}; - -use crate::lsp_discovery::{discover_available_servers, LspServerDescriptor}; -use crate::lsp_process::LspProcess; - -/// 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, -} - -/// 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) - } - - /// 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}")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn registers_and_retrieves_server() { - let registry = LspRegistry::new(); - registry.register( - "rust", - LspServerStatus::Connected, - Some("/workspace"), - vec!["hover".into(), "completion".into()], - ); - - let server = registry.get("rust").expect("should exist"); - assert_eq!(server.language, "rust"); - assert_eq!(server.status, LspServerStatus::Connected); - assert_eq!(server.capabilities.len(), 2); - } - - #[test] - fn finds_server_by_file_extension() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - registry.register("typescript", LspServerStatus::Connected, None, vec![]); - - let rs_server = registry.find_server_for_path("src/main.rs").unwrap(); - assert_eq!(rs_server.language, "rust"); - - let ts_server = registry.find_server_for_path("src/index.ts").unwrap(); - assert_eq!(ts_server.language, "typescript"); - - assert!(registry.find_server_for_path("data.csv").is_none()); - } - - #[test] - fn manages_diagnostics() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - - registry - .add_diagnostics( - "rust", - vec![LspDiagnostic { - path: "src/main.rs".into(), - line: 10, - character: 5, - severity: "error".into(), - message: "mismatched types".into(), - source: Some("rust-analyzer".into()), - }], - ) - .unwrap(); - - let diags = registry.get_diagnostics("src/main.rs"); - assert_eq!(diags.len(), 1); - assert_eq!(diags[0].message, "mismatched types"); - - registry.clear_diagnostics("rust").unwrap(); - assert!(registry.get_diagnostics("src/main.rs").is_empty()); - } - - #[test] - fn dispatches_diagnostics_action() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - registry - .add_diagnostics( - "rust", - vec![LspDiagnostic { - path: "src/lib.rs".into(), - line: 1, - character: 0, - severity: "warning".into(), - message: "unused import".into(), - source: None, - }], - ) - .unwrap(); - - let result = registry - .dispatch("diagnostics", Some("src/lib.rs"), None, None, None) - .unwrap(); - assert_eq!(result["count"], 1); - } - - #[test] - fn dispatches_hover_action() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - - let result = registry - .dispatch("hover", Some("src/main.rs"), Some(10), Some(5), None) - .unwrap(); - assert_eq!(result["action"], "hover"); - assert_eq!(result["language"], "rust"); - } - - #[test] - fn rejects_action_on_disconnected_server() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Disconnected, None, vec![]); - - assert!(registry - .dispatch("hover", Some("src/main.rs"), Some(1), Some(0), None) - .is_err()); - } - - #[test] - fn rejects_unknown_action() { - let registry = LspRegistry::new(); - assert!(registry - .dispatch("unknown_action", Some("file.rs"), None, None, None) - .is_err()); - } - - #[test] - fn disconnects_server() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - assert_eq!(registry.len(), 1); - - let removed = registry.disconnect("rust"); - assert!(removed.is_some()); - assert!(registry.is_empty()); - } - - #[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")); - } - - #[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_discovery.rs b/rust/crates/runtime/src/lsp_discovery.rs index 7c8f7402..475f20a6 100644 --- a/rust/crates/runtime/src/lsp_discovery.rs +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -112,16 +112,64 @@ pub fn command_exists_on_path(command: &str) -> bool { .is_ok() } +/// Check if a binary is a rustup proxy by running `--version` and looking for +/// the "Unknown binary" error message that rustup prints for uninstalled tools. +#[must_use] +fn is_rustup_proxy(command: &str) -> bool { + let Ok(output) = Command::new(command).arg("--version").output() else { + return false; + }; + // rustup proxy exits non-zero and prints "error: Unknown binary '...' in official toolchain" + let stderr = String::from_utf8_lossy(&output.stderr); + stderr.contains("Unknown binary") +} + +/// Check whether a rustup component is actually functional by running it through +/// `rustup run stable --version`. Returns `true` only if the process +/// exits successfully (exit code 0), meaning the component is installed. +#[must_use] +fn rustup_component_works(component: &str) -> bool { + Command::new("rustup") + .args(["run", "stable", component, "--version"]) + .output() + .is_ok_and(|o| o.status.success()) +} + /// Discover LSP servers that are actually installed on the current system. /// /// Iterates over the known server table and returns only those whose command -/// is found on `PATH`. +/// is found on `PATH` **and** is actually functional. For `rust-analyzer`, +/// rustup ships a stub proxy that always exists on PATH but prints +/// "Unknown binary" when the component isn't installed. We detect that +/// case and either rewrite to `rustup run stable rust-analyzer` (when the +/// component is installed) or skip the server entirely (when it's not). #[must_use] pub fn discover_available_servers() -> Vec { KNOWN_LSP_SERVERS_TABLE .iter() .filter(|desc| command_exists_on_path(desc.command)) - .map(StaticLspServerDescriptor::to_descriptor) + .filter_map(|desc| { + let mut server = desc.to_descriptor(); + // rustup ships a proxy `rust-analyzer` that exists on PATH but + // errors with "Unknown binary" when the component isn't installed. + if desc.command == "rust-analyzer" && is_rustup_proxy("rust-analyzer") { + // The component isn't installed under this toolchain. + // Check if `rustup run stable rust-analyzer --version` works + // (e.g. user installed it via rustup but the proxy is misconfigured). + if rustup_component_works("rust-analyzer") { + server.command = "rustup".to_string(); + server.args = vec![ + "run".to_string(), + "stable".to_string(), + "rust-analyzer".to_string(), + ]; + } else { + // Component truly not installed — skip it. + return None; + } + } + Some(server) + }) .collect() } diff --git a/rust/crates/runtime/src/lsp_process.rs b/rust/crates/runtime/src/lsp_process.rs deleted file mode 100644 index 578d43fe..00000000 --- a/rust/crates/runtime/src/lsp_process.rs +++ /dev/null @@ -1,929 +0,0 @@ -//! 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, LspDiagnostic, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, -}; -use crate::lsp_transport::{LspTransport, LspTransportError}; - -#[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, - } - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -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()) -} - -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}") - } -} - -fn text_document_position_params(uri: &str, line: u32, character: u32) -> JsonValue { - serde_json::json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character } - }) -} - -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")?; - - // 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)] -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); - } - } -} - -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 -} - -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() -} - -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})"), - } -} - -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})"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// 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.rs b/rust/crates/runtime/src/lsp_transport.rs deleted file mode 100644 index 015ac145..00000000 --- a/rust/crates/runtime/src/lsp_transport.rs +++ /dev/null @@ -1,560 +0,0 @@ -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 { - 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" - ); - }); - } -} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index e0f59382..82d1270f 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -7061,6 +7061,14 @@ fn run_repl( run_stale_base_preflight(base_commit.as_deref()); let resolved_model = resolve_repl_model(model)?; let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; + + // Read config for LSP auto-start setting + let cwd = std::env::current_dir().unwrap_or_default(); + let lsp_auto = runtime::ConfigLoader::default_for(&cwd) + .load() + .map(|c| c.lsp_auto_start()) + .unwrap_or(true); + cli.lsp_auto_start = lsp_auto; cli.set_reasoning_effort(reasoning_effort); let mut editor = input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); @@ -7084,6 +7092,16 @@ fn run_repl( server.clone(), ); } + // Auto-start all discovered servers if enabled + if cli.lsp_auto_start { + let registry = tools::global_lsp_registry(); + for server in &lsp_servers { + match registry.start_server(&server.language) { + Ok(()) => eprintln!(" {} started", server.language), + Err(e) => eprintln!(" {} failed to start: {e}", server.language), + } + } + } } loop { @@ -7175,6 +7193,7 @@ struct LiveCli { runtime: BuiltRuntime, session: SessionHandle, prompt_history: Vec, + lsp_auto_start: bool, } #[derive(Debug, Clone)] @@ -7689,6 +7708,7 @@ impl LiveCli { runtime, session, prompt_history: Vec::new(), + lsp_auto_start: true, }; cli.persist_session()?; Ok(cli) @@ -8292,7 +8312,7 @@ impl LiveCli { }) } - fn handle_lsp_command(&self, action: Option<&str>, target: Option<&str>) { + fn handle_lsp_command(&mut self, action: Option<&str>, target: Option<&str>) { let registry = tools::global_lsp_registry(); match action { Some("start") => { @@ -8317,8 +8337,15 @@ impl LiveCli { Err(e) => eprintln!("Failed to restart LSP server '{lang}': {e}"), } } + Some("toggle") => { + self.lsp_auto_start = !self.lsp_auto_start; + let state = if self.lsp_auto_start { "on" } else { "off" }; + eprintln!("LSP auto-start: {state}"); + } _ => { let servers = registry.list_servers(); + let auto_state = if self.lsp_auto_start { "on" } else { "off" }; + eprintln!("LSP auto-start: {auto_state}"); if servers.is_empty() { eprintln!("No LSP servers registered."); } else {