diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 98cc0dd9..411c0219 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1301,11 +1301,15 @@ impl SlashCommand { Self::Tag { .. } => "/tag", Self::OutputStyle { .. } => "/output-style", Self::AddDir { .. } => "/add-dir", +<<<<<<< HEAD <<<<<<< HEAD Self::Team { .. } => "/team", ======= Self::Lsp { .. } => "/lsp", >>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) +======= + Self::Lsp { .. } => "/lsp", +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) Self::Sandbox => "/sandbox", Self::Mcp { .. } => "/mcp", Self::Export { .. } => "/export", @@ -5412,12 +5416,18 @@ pub fn handle_slash_command( | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } | SlashCommand::History { .. } +<<<<<<< HEAD <<<<<<< HEAD | SlashCommand::Team { .. } ======= | SlashCommand::Lsp { .. } >>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) | SlashCommand::Setup +======= + | SlashCommand::Lsp { .. } + | SlashCommand::Setup + | SlashCommand::Unknown(_) => None, +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) | SlashCommand::Unknown(_) => None, } } @@ -6034,12 +6044,16 @@ mod tests { assert!(help.contains("aliases: /skill")); assert!(!help.contains("/login")); assert!(!help.contains("/logout")); +<<<<<<< HEAD <<<<<<< HEAD assert!(help.contains("/setup")); assert_eq!(slash_command_specs().len(), 140); ======= assert_eq!(slash_command_specs().len(), 141); >>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) +======= + assert_eq!(slash_command_specs().len(), 141); +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) assert!(resume_supported_slash_commands().len() >= 39); } diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 496aaf23..a0690674 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -125,9 +125,12 @@ pub struct RuntimePluginConfig { max_output_tokens: Option, } +<<<<<<< HEAD <<<<<<< HEAD <<<<<<< HEAD ======= +======= +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) /// Per-language LSP server configuration supplied by the user in settings. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LspServerConfig { @@ -136,6 +139,7 @@ pub struct LspServerConfig { pub enabled: bool, } +<<<<<<< HEAD >>>>>>> 1ff5617c (fix: sync all bug fixes to combined branch) /// API timeout and retry configuration. #[derive(Debug, Clone, PartialEq, Eq)] @@ -188,6 +192,8 @@ impl Default for ApiTimeoutConfig { >>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry) } +======= +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) /// Structured feature configuration consumed by runtime subsystems. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeFeatureConfig { @@ -202,6 +208,7 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, trusted_roots: Vec, +<<<<<<< HEAD api_timeout: ApiTimeoutConfig, rules_import: RulesImportConfig, provider: RuntimeProviderConfig, @@ -260,6 +267,13 @@ impl Default for RuntimeFeatureConfig { /// Represents the `provider` section in `~/.claw/settings.json`, used as a /// fallback when environment variables are absent (3-tier resolution: /// env var > .env file > stored config). +======= + provider: RuntimeProviderConfig, + lsp: BTreeMap, +} + +/// Stored provider configuration from the setup wizard. +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeProviderConfig { kind: Option, @@ -736,6 +750,7 @@ impl ConfigLoader { provider: parse_optional_provider_config(&merged_value)?, lsp: parse_optional_lsp_config(&merged_value)?, <<<<<<< HEAD +<<<<<<< HEAD >>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) ======= lsp_auto_start: merged_value @@ -745,6 +760,8 @@ impl ConfigLoader { .unwrap_or(true), api_timeout: parse_optional_api_timeout_config(&merged_value)?, >>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry) +======= +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) }; ConfigInspection { @@ -1004,15 +1021,19 @@ impl RuntimeConfig { } #[must_use] +<<<<<<< HEAD pub fn rules_import(&self) -> &RulesImportConfig { &self.feature_config.rules_import } #[must_use] +======= +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) pub fn provider(&self) -> &RuntimeProviderConfig { &self.feature_config.provider } +<<<<<<< HEAD <<<<<<< HEAD /// Merge config-level default trusted roots with per-call roots. /// @@ -1028,6 +1049,11 @@ impl RuntimeConfig { pub fn lsp(&self) -> &BTreeMap { &self.feature_config.lsp >>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) +======= + #[must_use] + pub fn lsp(&self) -> &BTreeMap { + &self.feature_config.lsp +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) } } @@ -1107,6 +1133,7 @@ impl RuntimeFeatureConfig { } #[must_use] +<<<<<<< HEAD pub fn rules_import(&self) -> &RulesImportConfig { &self.rules_import } @@ -1128,11 +1155,20 @@ fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Ve } merged ======= +======= + pub fn provider(&self) -> &RuntimeProviderConfig { + &self.provider + } + +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) #[must_use] pub fn lsp(&self) -> &BTreeMap { &self.lsp } +<<<<<<< HEAD >>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) +======= +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) } impl ProviderFallbackConfig { @@ -2321,6 +2357,7 @@ fn parse_optional_oauth_config( })) } +<<<<<<< HEAD /// #92: expand `${VAR}` environment variable references and `~/` home directory /// prefix in a config string value. Returns the expanded string. fn expand_config_value(value: &str) -> String { @@ -2358,6 +2395,25 @@ fn expand_config_value(value: &str) -> String { } } result +======= +fn parse_optional_provider_config(root: &JsonValue) -> Result { + let Some(provider_value) = root.as_object().and_then(|object| object.get("provider")) else { + return Ok(RuntimeProviderConfig::default()); + }; + let Some(object) = provider_value.as_object() else { + return Ok(RuntimeProviderConfig::default()); + }; + let kind = optional_string(object, "kind", "provider")?.map(str::to_string); + let api_key = optional_string(object, "apiKey", "provider")?.map(str::to_string); + let base_url = optional_string(object, "baseUrl", "provider")?.map(str::to_string); + let model = optional_string(object, "model", "provider")?.map(str::to_string); + Ok(RuntimeProviderConfig { + kind, + api_key, + base_url, + model, + }) +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) } fn parse_optional_lsp_config( diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 6217b438..cc37a9f2 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -213,6 +213,7 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ expected: FieldType::Object, }, FieldSpec { +<<<<<<< HEAD <<<<<<< HEAD name: "rulesImport", expected: FieldType::RulesImport, @@ -228,6 +229,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ FieldSpec { name: "lspAutoStart", expected: FieldType::Bool, +======= + name: "lsp", + expected: FieldType::Object, +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) }, ]; @@ -605,6 +610,31 @@ pub fn validate_config_file( } } + // Validate lsp map: each value must be an object with LSP_FIELDS. + if let Some(lsp) = object.get("lsp").and_then(JsonValue::as_object) { + for (server_name, server_value) in lsp { + if let Some(server_obj) = server_value.as_object() { + result.merge(validate_object_keys( + server_obj, + LSP_FIELDS, + &format!("lsp.{server_name}"), + source, + &path_display, + )); + } else { + result.errors.push(ConfigDiagnostic { + path: path_display.clone(), + field: format!("lsp.{server_name}"), + line: find_key_line(source, server_name), + kind: DiagnosticKind::WrongType { + expected: "an object", + got: json_type_label(server_value), + }, + }); + } + } + } + result } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 05c336a5..f7b109ea 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -70,6 +70,7 @@ pub use compact::{ }; pub use config::{ <<<<<<< HEAD +<<<<<<< HEAD <<<<<<< HEAD clear_user_provider_settings, default_config_home, save_user_provider_settings, suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError, @@ -81,10 +82,13 @@ pub use config::{ RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, ======= +======= +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) clear_user_provider_settings, save_user_provider_settings, ConfigEntry, ConfigError, ConfigLoader, ConfigSource, LspServerConfig, McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, +<<<<<<< HEAD ApiTimeoutConfig, ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection, ======= ApiTimeoutConfig, clear_user_provider_settings, save_user_provider_settings, ConfigEntry, @@ -92,6 +96,8 @@ pub use config::{ >>>>>>> 1ff5617c (fix: sync all bug fixes to combined branch) McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, +======= +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, diff --git a/rust/crates/runtime/src/lsp_client.rs b/rust/crates/runtime/src/lsp_client.rs new file mode 100644 index 00000000..aa4e1337 --- /dev/null +++ b/rust/crates/runtime/src/lsp_client.rs @@ -0,0 +1,1204 @@ +#![allow(clippy::should_implement_trait, clippy::must_use_candidate)] +//! LSP (Language Server Protocol) client registry for tool dispatch. + +use std::collections::HashMap; +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, +} + +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(()) + } + + /// 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 475f20a6..a717db60 100644 --- a/rust/crates/runtime/src/lsp_discovery.rs +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -100,15 +100,19 @@ pub fn known_lsp_servers() -> Vec { /// Check whether a command exists on the user's PATH by attempting to run it /// with `--version`. Returns `true` if the command could be spawned /// successfully, `false` otherwise. +<<<<<<< HEAD /// /// Some LSP servers (like rust-analyzer via rustup) exit non-zero on --version /// but are still functional. We treat "spawned successfully" as found, regardless /// of the exit code. Only a failure to spawn (command not found) returns false. +======= +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) #[must_use] pub fn command_exists_on_path(command: &str) -> bool { Command::new(command) .arg("--version") .output() +<<<<<<< HEAD .is_ok() } @@ -133,21 +137,30 @@ fn rustup_component_works(component: &str) -> bool { .args(["run", "stable", component, "--version"]) .output() .is_ok_and(|o| o.status.success()) +======= + .map(|output| output.status.success()) + .unwrap_or(false) +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) } /// Discover LSP servers that are actually installed on the current system. /// /// Iterates over the known server table and returns only those whose command +<<<<<<< HEAD /// 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). +======= +/// is found on `PATH`. +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) #[must_use] pub fn discover_available_servers() -> Vec { KNOWN_LSP_SERVERS_TABLE .iter() .filter(|desc| command_exists_on_path(desc.command)) +<<<<<<< HEAD .filter_map(|desc| { let mut server = desc.to_descriptor(); // rustup ships a proxy `rust-analyzer` that exists on PATH but @@ -170,6 +183,9 @@ pub fn discover_available_servers() -> Vec { } Some(server) }) +======= + .map(StaticLspServerDescriptor::to_descriptor) +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) .collect() } diff --git a/rust/crates/runtime/src/lsp_process.rs b/rust/crates/runtime/src/lsp_process.rs new file mode 100644 index 00000000..c57c832a --- /dev/null +++ b/rust/crates/runtime/src/lsp_process.rs @@ -0,0 +1,794 @@ +//! LSP process manager: spawns language servers and drives the LSP lifecycle. + +use std::path::Path; + +use serde_json::Value as JsonValue; + +use crate::lsp_client::{ + LspCompletionItem, 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, +} + +#[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, + }; + + 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()), + } + } + + #[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 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 new file mode 100644 index 00000000..8ad1f832 --- /dev/null +++ b/rust/crates/runtime/src/lsp_transport.rs @@ -0,0 +1,495 @@ +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)) + } + } +} + +#[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, +} + +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, + }) + } + + /// 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, + } + } + + 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 = timeout(timeout_duration, self.read_response()) + .await + .map_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) + } + + pub async fn read_response(&mut self) -> Result { + self.read_jsonrpc_message().await + } + + 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) + } + + async fn read_jsonrpc_message( + &mut self, + ) -> Result { + let payload = self.read_frame().await?; + serde_json::from_slice(&payload).map_err(|error| LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: error.to_string(), + }) + } +} + +#[cfg(test)] +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 8621ead9..9f6cd311 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -6925,12 +6925,18 @@ fn run_resume_command( | SlashCommand::Ide { .. } | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } +<<<<<<< HEAD | SlashCommand::AddDir { .. } <<<<<<< HEAD | SlashCommand::Team { .. } ======= | SlashCommand::Lsp { .. } >>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) +======= + | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), + | SlashCommand::AddDir { .. } + | SlashCommand::Lsp { .. } +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) | SlashCommand::Setup => Err("unsupported resumed slash command".into()), } } @@ -7092,6 +7098,7 @@ fn run_repl( server.clone(), ); } +<<<<<<< HEAD // Auto-start all discovered servers if enabled if cli.lsp_auto_start { let registry = tools::global_lsp_registry(); @@ -7102,6 +7109,8 @@ fn run_repl( } } } +======= +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) } loop { @@ -8386,7 +8395,11 @@ impl LiveCli { }) } +<<<<<<< HEAD fn handle_lsp_command(&mut self, action: Option<&str>, target: Option<&str>) { +======= + fn handle_lsp_command(&self, action: Option<&str>, target: Option<&str>) { +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) let registry = tools::global_lsp_registry(); match action { Some("start") => { @@ -8411,6 +8424,7 @@ impl LiveCli { Err(e) => eprintln!("Failed to restart LSP server '{lang}': {e}"), } } +<<<<<<< HEAD Some("toggle") => { self.lsp_auto_start = !self.lsp_auto_start; let state = if self.lsp_auto_start { "on" } else { "off" }; @@ -8420,6 +8434,10 @@ impl LiveCli { let servers = registry.list_servers(); let auto_state = if self.lsp_auto_start { "on" } else { "off" }; eprintln!("LSP auto-start: {auto_state}"); +======= + _ => { + let servers = registry.list_servers(); +>>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) if servers.is_empty() { eprintln!("No LSP servers registered."); } else {