diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 79086913..98cc0dd9 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1042,6 +1042,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: None, resume_supported: true, }, + SlashCommandSpec { + name: "lsp", + aliases: &[], + summary: "Show or manage LSP server status", + argument_hint: Some("[start|stop|restart ]"), + resume_supported: true, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -1188,6 +1195,10 @@ pub enum SlashCommand { History { count: Option, }, + Lsp { + action: Option, + target: Option, + }, Unknown(String), Team { action: Option, @@ -1290,7 +1301,11 @@ impl SlashCommand { Self::Tag { .. } => "/tag", Self::OutputStyle { .. } => "/output-style", Self::AddDir { .. } => "/add-dir", +<<<<<<< HEAD Self::Team { .. } => "/team", +======= + Self::Lsp { .. } => "/lsp", +>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) Self::Sandbox => "/sandbox", Self::Mcp { .. } => "/mcp", Self::Export { .. } => "/export", @@ -1506,6 +1521,10 @@ pub fn validate_slash_command_input( "tag" => SlashCommand::Tag { label: remainder }, "output-style" => SlashCommand::OutputStyle { style: remainder }, "add-dir" => SlashCommand::AddDir { path: remainder }, + "lsp" => SlashCommand::Lsp { + action: args.first().map(|s| (*s).to_string()), + target: args.get(1).map(|s| (*s).to_string()), + }, "history" => SlashCommand::History { count: optional_single_arg(command, &args, "[count]")?, }, @@ -5393,7 +5412,11 @@ pub fn handle_slash_command( | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } | SlashCommand::History { .. } +<<<<<<< HEAD | SlashCommand::Team { .. } +======= + | SlashCommand::Lsp { .. } +>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) | SlashCommand::Setup | SlashCommand::Unknown(_) => None, } @@ -6011,8 +6034,12 @@ mod tests { assert!(help.contains("aliases: /skill")); assert!(!help.contains("/login")); assert!(!help.contains("/logout")); +<<<<<<< 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!(resume_supported_slash_commands().len() >= 39); } diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 95057563..65b2dd78 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -125,6 +125,7 @@ pub struct RuntimePluginConfig { max_output_tokens: Option, } +<<<<<<< HEAD /// API timeout and retry configuration. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ApiTimeoutConfig { @@ -144,6 +145,14 @@ impl Default for ApiTimeoutConfig { max_retries: 8, } } +======= +/// Per-language LSP server configuration supplied by the user in settings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LspServerConfig { + pub command: String, + pub args: Vec, + pub enabled: bool, +>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) } /// Structured feature configuration consumed by runtime subsystems. @@ -163,6 +172,7 @@ pub struct RuntimeFeatureConfig { api_timeout: ApiTimeoutConfig, rules_import: RulesImportConfig, provider: RuntimeProviderConfig, + lsp: BTreeMap, } /// Controls which external AI coding framework rules are imported into the system prompt. @@ -496,6 +506,7 @@ impl ConfigLoader { build_runtime_config(merged, loaded_entries, mcp) } +<<<<<<< HEAD /// Like [`load`] but also returns the list of validation warnings collected during /// loading, without emitting them to stderr. Callers that want to surface warnings /// through a structured channel (e.g. the JSON config envelope) should use this. @@ -652,6 +663,24 @@ impl ConfigLoader { load_error.get_or_insert_with(|| error.to_string()); None } +======= + let feature_config = RuntimeFeatureConfig { + hooks: parse_optional_hooks_config(&merged_value)?, + plugins: parse_optional_plugin_config(&merged_value)?, + mcp: McpConfigCollection { + servers: mcp_servers, + }, + oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, + model: parse_optional_model(&merged_value), + aliases: parse_optional_aliases(&merged_value)?, + permission_mode: parse_optional_permission_mode(&merged_value)?, + permission_rules: parse_optional_permission_rules(&merged_value)?, + sandbox: parse_optional_sandbox_config(&merged_value)?, + provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, + trusted_roots: parse_optional_trusted_roots(&merged_value)?, + provider: parse_optional_provider_config(&merged_value)?, + lsp: parse_optional_lsp_config(&merged_value)?, +>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) }; ConfigInspection { @@ -920,6 +949,7 @@ impl RuntimeConfig { &self.feature_config.provider } +<<<<<<< HEAD /// Merge config-level default trusted roots with per-call roots. /// /// Config roots are defaults and are kept first; per-call roots extend the @@ -929,6 +959,11 @@ impl RuntimeConfig { #[must_use] pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec { merge_trusted_roots(self.trusted_roots(), per_call_roots) +======= + #[must_use] + pub fn lsp(&self) -> &BTreeMap { + &self.feature_config.lsp +>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) } } @@ -1012,6 +1047,7 @@ impl RuntimeFeatureConfig { &self.rules_import } +<<<<<<< HEAD /// Merge this config's default trusted roots with per-call roots. #[must_use] pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec { @@ -1027,6 +1063,12 @@ fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Ve } } merged +======= + #[must_use] + pub fn lsp(&self) -> &BTreeMap { + &self.lsp + } +>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) } impl ProviderFallbackConfig { @@ -2247,6 +2289,34 @@ fn expand_config_value(value: &str) -> String { result } +fn parse_optional_lsp_config( + root: &JsonValue, +) -> Result, ConfigError> { + let Some(lsp_value) = root.as_object().and_then(|object| object.get("lsp")) else { + return Ok(BTreeMap::new()); + }; + let lsp_object = expect_object(lsp_value, "merged settings.lsp")?; + let mut result = BTreeMap::new(); + for (language, value) in lsp_object { + let entry = expect_object(value, &format!("merged settings.lsp.{language}"))?; + let command = expect_string(entry, "command", &format!("merged settings.lsp.{language}"))? + .to_string(); + let args = optional_string_array(entry, "args", &format!("merged settings.lsp.{language}"))? + .unwrap_or_default(); + let enabled = optional_bool(entry, "enabled", &format!("merged settings.lsp.{language}"))? + .unwrap_or(true); + result.insert( + language.clone(), + LspServerConfig { + command, + args, + enabled, + }, + ); + } + Ok(result) +} + fn parse_mcp_server_config( server_name: &str, value: &JsonValue, diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index e37a3c46..397f50ef 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -213,12 +213,17 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ expected: FieldType::Object, }, FieldSpec { +<<<<<<< HEAD name: "rulesImport", expected: FieldType::RulesImport, }, FieldSpec { name: "subagentModel", expected: FieldType::String, +======= + name: "lsp", + expected: FieldType::Object, +>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) }, ]; @@ -356,6 +361,21 @@ const PROVIDER_FIELDS: &[FieldSpec] = &[ }, ]; +const LSP_FIELDS: &[FieldSpec] = &[ + FieldSpec { + name: "command", + expected: FieldType::String, + }, + FieldSpec { + name: "args", + expected: FieldType::StringArray, + }, + FieldSpec { + name: "enabled", + expected: FieldType::Bool, + }, +]; + const DEPRECATED_FIELDS: &[DeprecatedField] = &[ DeprecatedField { name: "permissionMode", @@ -556,6 +576,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 } @@ -1015,4 +1060,122 @@ mod tests { r#"/test/settings.json: field "permissionMode" is deprecated (line 3). Use "permissions.defaultMode" instead"# ); } + + #[test] + fn validates_lsp_config_valid() { + // given + let source = r#"{"lsp": {"rust": {"command": "rust-analyzer", "args": [], "enabled": true}, "python": {"command": "pyright-langserver", "args": ["--stdio"], "enabled": false}}}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + // when + let result = validate_config_file(object, source, &test_path()); + + // then + assert!(result.is_ok()); + } + + #[test] + fn validates_lsp_config_unknown_field() { + // given + let source = r#"{"lsp": {"rust": {"command": "rust-analyzer", "port": 8080}}}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + // when + let result = validate_config_file(object, source, &test_path()); + + // then + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "lsp.rust.port"); + assert!(matches!( + result.errors[0].kind, + DiagnosticKind::UnknownKey { .. } + )); + } + + #[test] + fn validates_lsp_config_wrong_type_for_command() { + // given + let source = r#"{"lsp": {"rust": {"command": 123}}}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + // when + let result = validate_config_file(object, source, &test_path()); + + // then + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "lsp.rust.command"); + assert!(matches!( + result.errors[0].kind, + DiagnosticKind::WrongType { + expected: "a string", + got: "a number" + } + )); + } + + #[test] + fn validates_lsp_config_wrong_type_for_args() { + // given + let source = r#"{"lsp": {"rust": {"command": "rust-analyzer", "args": "wrong"}}}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + // when + let result = validate_config_file(object, source, &test_path()); + + // then + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "lsp.rust.args"); + assert!(matches!( + result.errors[0].kind, + DiagnosticKind::WrongType { .. } + )); + } + + #[test] + fn validates_lsp_config_wrong_type_for_enabled() { + // given + let source = r#"{"lsp": {"rust": {"command": "rust-analyzer", "enabled": "yes"}}}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + // when + let result = validate_config_file(object, source, &test_path()); + + // then + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "lsp.rust.enabled"); + assert!(matches!( + result.errors[0].kind, + DiagnosticKind::WrongType { + expected: "a boolean", + got: "a string" + } + )); + } + + #[test] + fn validates_lsp_server_must_be_object() { + // given + let source = r#"{"lsp": {"rust": "not-an-object"}}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + // when + let result = validate_config_file(object, source, &test_path()); + + // then + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].field, "lsp.rust"); + assert!(matches!( + result.errors[0].kind, + DiagnosticKind::WrongType { + expected: "an object", + got: "a string" + } + )); + } } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 674d8925..34129761 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -21,6 +21,9 @@ mod hooks; mod json; mod lane_events; pub mod lsp_client; +pub mod lsp_discovery; +pub mod lsp_process; +pub mod lsp_transport; mod mcp; mod mcp_client; pub mod mcp_lifecycle_hardened; @@ -65,6 +68,7 @@ pub use compact::{ get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, }; pub use config::{ +<<<<<<< HEAD clear_user_provider_settings, default_config_home, save_user_provider_settings, suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError, ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, @@ -74,11 +78,24 @@ pub use config::{ RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, +======= + clear_user_provider_settings, save_user_provider_settings, ConfigEntry, ConfigError, + ConfigLoader, ConfigSource, LspServerConfig, McpConfigCollection, McpManagedProxyServerConfig, + McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, + McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, + ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, + RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, + ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, +>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) }; pub use config_validate::{ check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic, DiagnosticKind, ValidationResult, }; +pub use lsp_discovery::{ + command_exists_on_path, discover_available_servers, find_server_for_file, + known_lsp_servers, LspServerDescriptor, +}; pub use conversation::{ auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError, diff --git a/rust/crates/runtime/src/lsp_client.rs b/rust/crates/runtime/src/lsp_client.rs index 63027139..aa4e1337 100644 --- a/rust/crates/runtime/src/lsp_client.rs +++ b/rust/crates/runtime/src/lsp_client.rs @@ -2,10 +2,14 @@ //! 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")] @@ -106,6 +110,44 @@ pub struct LspServerState { 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>, @@ -113,7 +155,7 @@ pub struct LspRegistry { #[derive(Debug, Default)] struct RegistryInner { - servers: HashMap, + servers: HashMap, } impl LspRegistry { @@ -122,6 +164,8 @@ impl LspRegistry { 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, @@ -129,22 +173,46 @@ impl LspRegistry { 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(), - LspServerState { - language: language.to_owned(), - status, - root_path: root_path.map(str::to_owned), - capabilities, - diagnostics: Vec::new(), - }, + 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).cloned() + inner.servers.get(language).map(|entry| entry.state.clone()) } /// Find the appropriate server for a file path based on extension. @@ -171,10 +239,33 @@ impl LspRegistry { 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().cloned().collect() + inner.servers.values().map(|entry| entry.state.clone()).collect() } /// Add diagnostics to a server. @@ -184,11 +275,11 @@ impl LspRegistry { diagnostics: Vec, ) -> Result<(), String> { let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - let server = inner + let entry = inner .servers .get_mut(language) .ok_or_else(|| format!("LSP server not found for language: {language}"))?; - server.diagnostics.extend(diagnostics); + entry.state.diagnostics.extend(diagnostics); Ok(()) } @@ -198,7 +289,7 @@ impl LspRegistry { inner .servers .values() - .flat_map(|s| &s.diagnostics) + .flat_map(|entry| &entry.state.diagnostics) .filter(|d| d.path == path) .cloned() .collect() @@ -207,18 +298,18 @@ impl LspRegistry { /// Clear diagnostics for a language server. pub fn clear_diagnostics(&self, language: &str) -> Result<(), String> { let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - let server = inner + let entry = inner .servers .get_mut(language) .ok_or_else(|| format!("LSP server not found for language: {language}"))?; - server.diagnostics.clear(); + 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) + inner.servers.remove(language).map(|entry| entry.state) } #[must_use] @@ -232,7 +323,105 @@ impl LspRegistry { 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, @@ -244,7 +433,7 @@ impl LspRegistry { let lsp_action = LspAction::from_str(action).ok_or_else(|| format!("unknown LSP action: {action}"))?; - // For diagnostics, we can check existing cached diagnostics + // For diagnostics, we check existing cached diagnostics if lsp_action == LspAction::Diagnostics { if let Some(path) = path { let diags = self.get_diagnostics(path); @@ -260,7 +449,7 @@ impl LspRegistry { let all_diags: Vec<_> = inner .servers .values() - .flat_map(|s| &s.diagnostics) + .flat_map(|entry| &entry.state.diagnostics) .collect(); return Ok(serde_json::json!({ "action": "diagnostics", @@ -271,28 +460,183 @@ impl LspRegistry { // For other actions, we need a connected server for the given file let path = path.ok_or("path is required for this LSP action")?; - let server = self - .find_server_for_path(path) + let language = Self::language_for_path(path) .ok_or_else(|| format!("no LSP server available for path: {path}"))?; - if server.status != LspServerStatus::Connected { - return Err(format!( - "LSP server for '{}' is not connected (status: {})", - server.language, server.status - )); + // 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}")); + } } - // Return structured placeholder — actual LSP JSON-RPC calls would - // go through the real LSP process here. - Ok(serde_json::json!({ - "action": action, - "path": path, - "line": line, - "character": character, - "language": server.language, - "status": "dispatched", - "message": format!("LSP {} dispatched to {} server", action, server.language) - })) + // 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}")) } } @@ -744,4 +1088,117 @@ mod tests { 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 new file mode 100644 index 00000000..50299a86 --- /dev/null +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -0,0 +1,245 @@ +//! Auto-discovery of installed LSP servers and file-extension mapping. + +use std::path::Path; +use std::process::Command; + +/// Descriptor for a well-known LSP server, including its launch command and +/// the file extensions it handles. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LspServerDescriptor { + pub language: String, + pub command: String, + pub args: Vec, + pub extensions: Vec, +} + +/// Static descriptor used by the [`KNOWN_LSP_SERVERS`] constant. Uses +/// `&'static str` fields so the table can live in read-only memory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct StaticLspServerDescriptor { + language: &'static str, + command: &'static str, + args: &'static [&'static str], + extensions: &'static [&'static str], +} + +impl StaticLspServerDescriptor { + #[allow(clippy::wrong_self_convention)] + fn to_descriptor(&self) -> LspServerDescriptor { + LspServerDescriptor { + language: self.language.to_string(), + command: self.command.to_string(), + args: self.args.iter().map(|s| (*s).to_string()).collect(), + extensions: self.extensions.iter().map(|s| (*s).to_string()).collect(), + } + } +} + +/// Known LSP servers with their default commands, args, and file extensions. +const KNOWN_LSP_SERVERS_TABLE: &[StaticLspServerDescriptor] = &[ + StaticLspServerDescriptor { + language: "rust", + command: "rust-analyzer", + args: &[], + extensions: &["rs"], + }, + StaticLspServerDescriptor { + language: "c/cpp", + command: "clangd", + args: &[], + extensions: &["c", "h", "cpp", "hpp"], + }, + StaticLspServerDescriptor { + language: "python", + command: "pyright-langserver", + args: &["--stdio"], + extensions: &["py"], + }, + StaticLspServerDescriptor { + language: "go", + command: "gopls", + args: &[], + extensions: &["go"], + }, + StaticLspServerDescriptor { + language: "typescript", + command: "typescript-language-server", + args: &["--stdio"], + extensions: &["ts", "tsx", "js", "jsx"], + }, + StaticLspServerDescriptor { + language: "java", + command: "jdtls", + args: &[], + extensions: &["java"], + }, + StaticLspServerDescriptor { + language: "ruby", + command: "solargraph", + args: &["stdio"], + extensions: &["rb"], + }, + StaticLspServerDescriptor { + language: "lua", + command: "lua-language-server", + args: &[], + extensions: &["lua"], + }, +]; + +/// Owned copy of the known LSP server descriptors, useful when callers need +/// to mutate or transfer ownership. +#[must_use] +pub fn known_lsp_servers() -> Vec { + KNOWN_LSP_SERVERS_TABLE + .iter() + .map(StaticLspServerDescriptor::to_descriptor) + .collect() +} + +/// 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. +#[must_use] +pub fn command_exists_on_path(command: &str) -> bool { + Command::new(command) + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +/// 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`. +#[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) + .collect() +} + +/// Find the best-matching LSP server descriptor for a given file path. +/// +/// Matches on the file extension. If multiple servers share the same +/// extension, the first match wins. +#[must_use] +pub fn find_server_for_file<'a>( + path: &Path, + servers: &'a [LspServerDescriptor], +) -> Option<&'a LspServerDescriptor> { + let ext = path.extension().and_then(|e| e.to_str())?; + servers + .iter() + .find(|desc| desc.extensions.iter().any(|e| e == ext)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn known_servers_contains_expected_languages() { + let languages: Vec<&str> = KNOWN_LSP_SERVERS_TABLE + .iter() + .map(|s| s.language) + .collect(); + assert!(languages.contains(&"rust")); + assert!(languages.contains(&"c/cpp")); + assert!(languages.contains(&"python")); + assert!(languages.contains(&"go")); + assert!(languages.contains(&"typescript")); + assert!(languages.contains(&"java")); + assert!(languages.contains(&"ruby")); + assert!(languages.contains(&"lua")); + } + + #[test] + fn find_server_for_rust_file() { + let servers = known_lsp_servers(); + let result = find_server_for_file(PathBuf::from("src/main.rs").as_path(), &servers); + assert!(result.is_some()); + assert_eq!(result.unwrap().language, "rust"); + } + + #[test] + fn find_server_for_python_file() { + let servers = known_lsp_servers(); + let result = find_server_for_file(PathBuf::from("app.py").as_path(), &servers); + assert!(result.is_some()); + assert_eq!(result.unwrap().language, "python"); + } + + #[test] + fn find_server_for_typescript_file() { + let servers = known_lsp_servers(); + let result = find_server_for_file(PathBuf::from("index.tsx").as_path(), &servers); + assert!(result.is_some()); + assert_eq!(result.unwrap().language, "typescript"); + } + + #[test] + fn find_server_for_unknown_extension_returns_none() { + let servers = known_lsp_servers(); + let result = find_server_for_file(PathBuf::from("data.xyz").as_path(), &servers); + assert!(result.is_none()); + } + + #[test] + fn find_server_for_file_without_extension_returns_none() { + let servers = known_lsp_servers(); + let result = find_server_for_file(PathBuf::from("Makefile").as_path(), &servers); + assert!(result.is_none()); + } + + #[test] + fn discover_returns_only_installed_servers() { + let available = discover_available_servers(); + // Every returned server must have a command that actually exists on PATH. + for server in &available { + assert!( + command_exists_on_path(&server.command), + "discover_available_servers returned '{}' but command '{}' is not on PATH", + server.language, + server.command, + ); + } + // If rust-analyzer or clangd are on this system, they should appear. + let languages: Vec<&str> = available.iter().map(|s| s.language.as_str()).collect(); + if command_exists_on_path("rust-analyzer") { + assert!(languages.contains(&"rust"), "rust-analyzer is on PATH but 'rust' not in discovered servers"); + } + if command_exists_on_path("clangd") { + assert!(languages.contains(&"c/cpp"), "clangd is on PATH but 'c/cpp' not in discovered servers"); + } + } + + #[test] + fn find_server_for_rs_file() { + let servers = known_lsp_servers(); + let result = find_server_for_file(Path::new("src/main.rs"), &servers); + assert!(result.is_some()); + assert_eq!(result.unwrap().language, "rust"); + } + + #[test] + fn find_server_for_unknown_extension() { + let servers = known_lsp_servers(); + let result = find_server_for_file(Path::new("README.md"), &servers); + assert!(result.is_none()); + } + + #[test] + fn descriptor_has_correct_args() { + let servers = known_lsp_servers(); + let rust = servers.iter().find(|s| s.language == "rust").expect("rust server should exist"); + assert!(rust.args.is_empty(), "rust-analyzer should have no args"); + + let ts = servers.iter().find(|s| s.language == "typescript").expect("typescript server should exist"); + assert_eq!(ts.args, vec!["--stdio"], "typescript-language-server should have --stdio arg"); + } +} 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 0931b4b0..e0f59382 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -6926,7 +6926,11 @@ fn run_resume_command( | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } +<<<<<<< HEAD | SlashCommand::Team { .. } +======= + | SlashCommand::Lsp { .. } +>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) | SlashCommand::Setup => Err("unsupported resumed slash command".into()), } } @@ -7063,6 +7067,25 @@ fn run_repl( println!("{}", cli.startup_banner()); println!("{}", format_connected_line(&cli.model)); + // Discover and register LSP servers + let lsp_servers = runtime::lsp_discovery::discover_available_servers(); + if !lsp_servers.is_empty() { + let names: Vec = lsp_servers + .iter() + .map(|s| format!("{} ({})", s.language, s.command)) + .collect(); + eprintln!("LSP: {}", names.join(", ")); + for server in &lsp_servers { + tools::global_lsp_registry().register_with_descriptor( + &server.language, + runtime::lsp_client::LspServerStatus::Starting, + None, + vec![], + server.clone(), + ); + } + } + loop { editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); match editor.read_line()? { @@ -7072,6 +7095,7 @@ fn run_repl( continue; } if matches!(trimmed.as_str(), "/exit" | "/quit") { + cli.shutdown_lsp_servers(); cli.persist_session()?; break; } @@ -7114,6 +7138,7 @@ fn run_repl( } input::ReadOutcome::Cancel => {} input::ReadOutcome::Exit => { + cli.shutdown_lsp_servers(); cli.persist_session()?; break; } @@ -8256,6 +8281,10 @@ impl LiveCli { eprintln!("{cmd_name} is not yet implemented in this build."); false } + SlashCommand::Lsp { action, target } => { + self.handle_lsp_command(action.as_deref(), target.as_deref()); + false + } SlashCommand::Unknown(name) => { eprintln!("{}", format_unknown_slash_command(&name)); false @@ -8263,6 +8292,53 @@ impl LiveCli { }) } + fn handle_lsp_command(&self, action: Option<&str>, target: Option<&str>) { + let registry = tools::global_lsp_registry(); + match action { + Some("start") => { + let lang = target.unwrap_or("unknown"); + match registry.start_server(lang) { + Ok(()) => eprintln!("LSP server '{lang}' started."), + Err(e) => eprintln!("Failed to start LSP server '{lang}': {e}"), + } + } + Some("stop") => { + let lang = target.unwrap_or("unknown"); + match registry.stop_server(lang) { + Ok(()) => eprintln!("LSP server '{lang}' stopped."), + Err(e) => eprintln!("Failed to stop LSP server '{lang}': {e}"), + } + } + Some("restart") => { + let lang = target.unwrap_or("unknown"); + let _ = registry.stop_server(lang); + match registry.start_server(lang) { + Ok(()) => eprintln!("LSP server '{lang}' restarted."), + Err(e) => eprintln!("Failed to restart LSP server '{lang}': {e}"), + } + } + _ => { + let servers = registry.list_servers(); + if servers.is_empty() { + eprintln!("No LSP servers registered."); + } else { + for s in &servers { + eprintln!(" {} [{}]", s.language, s.status); + } + } + } + } + } + + fn shutdown_lsp_servers(&self) { + let registry = tools::global_lsp_registry(); + for server in registry.list_servers() { + if server.status == runtime::lsp_client::LspServerStatus::Connected { + let _ = registry.stop_server(&server.language); + } + } + } + fn persist_session(&self) -> Result<(), Box> { self.runtime.session().save_to_path(&self.session.path)?; Ok(()) diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index a72261ed..68d173a2 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -36,7 +36,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; /// Global task registry shared across tool invocations within a session. -fn global_lsp_registry() -> &'static LspRegistry { +pub fn global_lsp_registry() -> &'static LspRegistry { use std::sync::OnceLock; static REGISTRY: OnceLock = OnceLock::new(); REGISTRY.get_or_init(LspRegistry::new)