From 612e9ef7aad23791ebce7dc5b14426f9a5e5825f Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 22:36:37 -0500 Subject: [PATCH 01/16] fix: bounds check in compact boundary loop When preserve_recent_messages == 0, raw_keep_from equals messages.len(), causing index out of bounds when accessing session.messages[k]. Added k >= session.messages.len() check to prevent panic. Reason: Compaction with preserve_recent_messages=0 triggered OOB access when checking for tool-use/tool-result pair preservation at boundary. Co-Authored-By: Claude Opus 4.7 --- rust/crates/runtime/src/compact.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index e4fd3db0d3..03f04053cb 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -128,7 +128,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio // is NOT an assistant message that contains a ToolUse block (i.e. the // pair is actually broken at the boundary). loop { - if k == 0 || k <= compacted_prefix_len { + if k == 0 || k <= compacted_prefix_len || k >= session.messages.len() { break; } let first_preserved = &session.messages[k]; From 5bb3b564ded5ae0808c9d6080671c6529fb5e03e Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 21:07:47 -0500 Subject: [PATCH 02/16] fix: make delta field optional in ChunkChoice The final streaming chunk from some providers contains only finish_reason and usage, with no delta field. Made it optional to prevent parse errors. Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index b3800d6acf..98d4a0ceeb 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -786,6 +786,7 @@ struct ChatCompletionChunk { #[derive(Debug, Deserialize)] struct ChunkChoice { + #[serde(default)] delta: ChunkDelta, #[serde(default)] finish_reason: Option, From 4a63485b1df0bbdc3a4c30f5e4578ecbf7e97e9d Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 21:04:43 -0500 Subject: [PATCH 03/16] fix: support reasoning_content and thinking fields in streaming Some providers (GLM, DeepSeek) emit reasoning tokens in `reasoning_content` or nested `thinking.content` fields instead of `content`. Added support for these fields so reasoning models work correctly. Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 98d4a0ceeb..a2b9809617 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -497,10 +497,12 @@ impl StreamState { } for choice in chunk.choices { + // Handle reasoning/thinking from various provider fields if let Some(reasoning) = choice .delta .reasoning_content .filter(|value| !value.is_empty()) + .or(choice.delta.thinking.and_then(|t| t.content).filter(|value| !value.is_empty())) { if !self.thinking_started { self.thinking_started = true; @@ -796,12 +798,21 @@ struct ChunkChoice { struct ChunkDelta { #[serde(default)] content: Option, + /// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content` #[serde(default)] reasoning_content: Option, + #[serde(default)] + thinking: Option, #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] tool_calls: Vec, } +#[derive(Debug, Default, Deserialize)] +struct ThinkingDelta { + #[serde(default)] + content: Option, +} + #[derive(Debug, Deserialize)] struct DeltaToolCall { #[serde(default)] From c99a8c6d817554a76424084239b5df6d7f16cdf8 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:46:32 -0500 Subject: [PATCH 04/16] fix: detect raw JSON errors in streaming path When a provider returns a JSON error (e.g., {"error":{"message":"..."}}) without SSE framing (no "data:" prefix), the SSE parser was silently ignoring it and hanging. Now detects and surfaces these errors. Also handles HTML responses that lack SSE framing. Co-Authored-By: Claude Opus 4.7 --- .../crates/api/src/providers/openai_compat.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index a2b9809617..6d8c102f17 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1363,7 +1363,50 @@ fn parse_sse_frame( data_lines.push(data.trim_start()); } } + // If no SSE data lines found, check if the entire frame is raw JSON (error or otherwise) if data_lines.is_empty() { + // Detect raw JSON error response (not SSE-framed) + if let Ok(raw) = serde_json::from_str::(trimmed) { + if let Some(err_obj) = raw.get("error") { + let msg = err_obj + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("provider returned an error") + .to_string(); + let code = err_obj + .get("code") + .and_then(serde_json::Value::as_u64) + .map(|c| c as u16); + let status = reqwest::StatusCode::from_u16(code.unwrap_or(500)) + .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); + return Err(ApiError::Api { + status, + error_type: err_obj + .get("type") + .and_then(|t| t.as_str()) + .map(str::to_owned), + message: Some(msg), + request_id: None, + body: trimmed.chars().take(500).collect(), + retryable: false, + suggested_action: suggested_action_for_status(status), + retry_after: None, + }); + } + } + // Detect HTML responses + if trimmed.starts_with('<') || trimmed.starts_with(" Date: Wed, 29 Apr 2026 20:42:46 -0500 Subject: [PATCH 05/16] fix: detect HTML responses in streaming path When a provider returns HTML (e.g., error page, wrong endpoint) instead of JSON in an SSE stream, provide a clear error message instead of hanging or failing with a cryptic parse error. Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 6d8c102f17..507b1b9f97 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1443,6 +1443,20 @@ fn parse_sse_frame( }); } } + // Detect HTML or other non-JSON responses early for better error messages + let trimmed_payload = payload.trim(); + if trimmed_payload.starts_with('<') || trimmed_payload.starts_with("(&payload) .map(Some) .map_err(|error| ApiError::json_deserialize(provider, model, &payload, error)) From 3b4e08f30ef6f7d1ff362cf1273da18f5e8ce241 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:40:25 -0500 Subject: [PATCH 06/16] chore: add install script for rebuild and link Adds scripts/install.sh that builds the release binary and links it to ~/.local/bin/claw. Run after code changes to update the CLI. Co-Authored-By: Claude Opus 4.7 --- rust/scripts/install.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100755 rust/scripts/install.sh diff --git a/rust/scripts/install.sh b/rust/scripts/install.sh new file mode 100755 index 0000000000..344a7b5c62 --- /dev/null +++ b/rust/scripts/install.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# Build the release binary +cargo build --release + +# Link to ~/.local/bin +mkdir -p "$HOME/.local/bin" +ln -sf "$(pwd)/target/release/claw" "$HOME/.local/bin/claw" + +echo "✓ Claw installed to ~/.local/bin/claw" From 54015b876714b81a0d8320601a16d6bf404cb567 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 20:32:40 -0500 Subject: [PATCH 07/16] fix: make id field optional in OpenAI response parsing Some OpenAI-compatible providers (e.g., GLM-5) omit the `id` field in streaming and non-streaming responses. Adding #[serde(default)] allows the parser to accept these responses instead of failing with "missing field `id`". Co-Authored-By: Claude Opus 4.7 --- rust/crates/api/src/providers/openai_compat.rs | 2 ++ rust/crates/commands/src/lib.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 507b1b9f97..759a0b4b2c 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -730,6 +730,7 @@ impl ToolCallState { #[derive(Debug, Deserialize)] struct ChatCompletionResponse { + #[serde(default)] id: String, model: String, choices: Vec, @@ -777,6 +778,7 @@ struct OpenAiUsage { #[derive(Debug, Deserialize)] struct ChatCompletionChunk { + #[serde(default)] id: String, #[serde(default)] model: Option, diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 5e8f5eba8b..844ab5c0a2 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1472,6 +1472,7 @@ pub fn validate_slash_command_input( } "plan" => SlashCommand::Plan { mode: remainder }, "review" => SlashCommand::Review { scope: remainder }, + "team" => SlashCommand::Team { action: remainder }, "tasks" => SlashCommand::Tasks { args: remainder }, "theme" => SlashCommand::Theme { name: remainder }, "voice" => SlashCommand::Voice { mode: remainder }, From ffac6c11c815e667ac5265f4548a274bd01d41bd Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 26 Apr 2026 22:32:06 -0500 Subject: [PATCH 08/16] feat: full LSP (Language Server Protocol) integration Implement complete LSP support for code intelligence tools: - lsp_transport.rs: JSON-RPC 2.0 transport over stdio with Content-Length framing, async request/response handling, and graceful shutdown - lsp_process.rs: LSP process manager with initialize handshake, and methods for hover, goto_definition, references, document_symbols, completion, format - lsp_discovery.rs: Auto-discovery of installed LSP servers (rust-analyzer, clangd, gopls, pyright, typescript-language-server, etc.) with PATH lookup - lsp_client.rs: Rewired LspRegistry to use real LSP processes instead of placeholder JSON, with lazy-start on first dispatch call - config.rs: Added LspServerConfig for user-configured LSP servers - config_validate.rs: Validation for lsp config section - main.rs: CLI integration with server discovery at startup, /lsp slash command for status/start/stop/restart, and graceful shutdown on exit - commands/src/lib.rs: Added SlashCommand::Lsp variant The LSP tool is now available to the agent for hover, definition, references, symbols, completion, and diagnostics queries. Servers are auto-discovered at REPL startup and lazily started on first use. Co-Authored-By: Claude Opus 4.7 --- rust/crates/commands/src/lib.rs | 21 +- rust/crates/runtime/src/config.rs | 110 +++ rust/crates/runtime/src/config_validate.rs | 185 +++++ rust/crates/runtime/src/lib.rs | 14 +- rust/crates/runtime/src/lsp_client.rs | 529 +++++++++++++- rust/crates/runtime/src/lsp_discovery.rs | 245 +++++++ rust/crates/runtime/src/lsp_process.rs | 794 +++++++++++++++++++++ rust/crates/runtime/src/lsp_transport.rs | 495 +++++++++++++ rust/crates/rusty-claude-cli/src/main.rs | 75 ++ rust/crates/tools/src/lib.rs | 2 +- 10 files changed, 2429 insertions(+), 41 deletions(-) create mode 100644 rust/crates/runtime/src/lsp_discovery.rs create mode 100644 rust/crates/runtime/src/lsp_process.rs create mode 100644 rust/crates/runtime/src/lsp_transport.rs diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 844ab5c0a2..d2438ca921 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1034,6 +1034,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)] @@ -1179,6 +1186,10 @@ pub enum SlashCommand { History { count: Option, }, + Lsp { + action: Option, + target: Option, + }, Unknown(String), } @@ -1277,6 +1288,7 @@ impl SlashCommand { Self::Tag { .. } => "/tag", Self::OutputStyle { .. } => "/output-style", Self::AddDir { .. } => "/add-dir", + Self::Lsp { .. } => "/lsp", Self::Sandbox => "/sandbox", Self::Mcp { .. } => "/mcp", Self::Export { .. } => "/export", @@ -1489,6 +1501,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]")?, }, @@ -4299,6 +4315,9 @@ pub fn handle_slash_command( | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } | SlashCommand::History { .. } + | SlashCommand::Lsp { .. } + | SlashCommand::Setup + | SlashCommand::Unknown(_) => None, | SlashCommand::Unknown(_) => None, } } @@ -4894,7 +4913,7 @@ mod tests { assert!(help.contains("aliases: /skill")); assert!(!help.contains("/login")); assert!(!help.contains("/logout")); - assert_eq!(slash_command_specs().len(), 139); + assert_eq!(slash_command_specs().len(), 141); assert!(resume_supported_slash_commands().len() >= 39); } diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 1566189282..3c944f3ef0 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -51,6 +51,14 @@ pub struct RuntimePluginConfig { max_output_tokens: Option, } +/// 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, +} + /// Structured feature configuration consumed by runtime subsystems. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeFeatureConfig { @@ -65,6 +73,39 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, provider_fallbacks: ProviderFallbackConfig, trusted_roots: Vec, + provider: RuntimeProviderConfig, + lsp: BTreeMap, +} + +/// Stored provider configuration from the setup wizard. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RuntimeProviderConfig { + kind: Option, + api_key: Option, + base_url: Option, + model: Option, +} + +impl RuntimeProviderConfig { + #[must_use] + pub fn kind(&self) -> Option<&str> { + self.kind.as_deref() + } + + #[must_use] + pub fn api_key(&self) -> Option<&str> { + self.api_key.as_deref() + } + + #[must_use] + pub fn base_url(&self) -> Option<&str> { + self.base_url.as_deref() + } + + #[must_use] + pub fn model(&self) -> Option<&str> { + self.model.as_deref() + } } /// Ordered chain of fallback model identifiers used when the primary @@ -315,6 +356,8 @@ impl ConfigLoader { 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)?, }; Ok(RuntimeConfig { @@ -414,6 +457,16 @@ impl RuntimeConfig { pub fn trusted_roots(&self) -> &[String] { &self.feature_config.trusted_roots } + + #[must_use] + pub fn provider(&self) -> &RuntimeProviderConfig { + &self.feature_config.provider + } + + #[must_use] + pub fn lsp(&self) -> &BTreeMap { + &self.feature_config.lsp + } } impl RuntimeFeatureConfig { @@ -483,6 +536,16 @@ impl RuntimeFeatureConfig { pub fn trusted_roots(&self) -> &[String] { &self.trusted_roots } + + #[must_use] + pub fn provider(&self) -> &RuntimeProviderConfig { + &self.provider + } + + #[must_use] + pub fn lsp(&self) -> &BTreeMap { + &self.lsp + } } impl ProviderFallbackConfig { @@ -950,6 +1013,53 @@ fn parse_optional_oauth_config( })) } +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, + }) +} + +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 7a9c1c4adc..a8bfb6dcff 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -197,6 +197,14 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ name: "trustedRoots", expected: FieldType::StringArray, }, + FieldSpec { + name: "provider", + expected: FieldType::Object, + }, + FieldSpec { + name: "lsp", + expected: FieldType::Object, + }, ]; const HOOKS_FIELDS: &[FieldSpec] = &[ @@ -310,6 +318,40 @@ const OAUTH_FIELDS: &[FieldSpec] = &[ }, ]; +const PROVIDER_FIELDS: &[FieldSpec] = &[ + FieldSpec { + name: "kind", + expected: FieldType::String, + }, + FieldSpec { + name: "apiKey", + expected: FieldType::String, + }, + FieldSpec { + name: "baseUrl", + expected: FieldType::String, + }, + FieldSpec { + name: "model", + expected: FieldType::String, + }, +]; + +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", @@ -502,6 +544,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 } @@ -898,4 +965,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 c1108d3dc7..e8f8281f37 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -19,6 +19,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; @@ -57,9 +60,10 @@ pub use compact::{ get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, }; pub use config::{ - ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection, - McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, - McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, + 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, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, @@ -68,6 +72,10 @@ 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 63027139e5..aa4e133721 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}")); + } + } + + // 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 + })); + } } - // 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) - })) + // 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 0000000000..50299a869e --- /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 0000000000..c57c832ae9 --- /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 0000000000..8ad1f83287 --- /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 df4d8da452..76debc5246 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -3732,6 +3732,9 @@ fn run_resume_command( | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), + | SlashCommand::AddDir { .. } + | SlashCommand::Lsp { .. } + | SlashCommand::Setup => Err("unsupported resumed slash command".into()), } } @@ -3844,6 +3847,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()? { @@ -3853,6 +3875,7 @@ fn run_repl( continue; } if matches!(trimmed.as_str(), "/exit" | "/quit") { + cli.shutdown_lsp_servers(); cli.persist_session()?; break; } @@ -3885,6 +3908,7 @@ fn run_repl( } input::ReadOutcome::Cancel => {} input::ReadOutcome::Exit => { + cli.shutdown_lsp_servers(); cli.persist_session()?; break; } @@ -4824,6 +4848,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 @@ -4831,6 +4859,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 5abf4173a8..7b827b020a 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -33,7 +33,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) From 430c875d9cd1421855b30e92f9f8ec6143ca1f29 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 26 Apr 2026 22:46:25 -0500 Subject: [PATCH 09/16] fix: LSP discovery finds rust-analyzer via rustup proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rust-analyzer installed through rustup exits non-zero on --version ("Unknown binary in official toolchain"), which caused discovery to skip it. Changed command_exists_on_path to treat any successful spawn as "found", regardless of exit code — only a failure to spawn (command not found) means the server isn't available. Co-Authored-By: Claude Opus 4.7 --- rust/crates/runtime/src/lsp_discovery.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rust/crates/runtime/src/lsp_discovery.rs b/rust/crates/runtime/src/lsp_discovery.rs index 50299a869e..24b28a0cc0 100644 --- a/rust/crates/runtime/src/lsp_discovery.rs +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -100,13 +100,16 @@ 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. +/// +/// 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. #[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) + .map_or(false, |_| true) } /// Discover LSP servers that are actually installed on the current system. From c1ffe48a4829a09555af4c12bd42f944741c6edf Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 26 Apr 2026 23:49:22 -0500 Subject: [PATCH 10/16] feat: auto-LSP integration with didOpen/didChange and diagnostic enrichment Wire LSP into the Read/Edit/Write tool flow so the agent automatically gets diagnostics after file operations: - lsp_transport: Add LspServerMessage enum, read_message() for handling both responses and server-initiated notifications, notification queue with drain_notifications(), send_request now handles interleaved publishDiagnostics without breaking - lsp_process: Add did_open(), did_change(), drain_diagnostics(), open file tracking (HashSet) and version counters for didChange, language_id_for_path() and severity_name() helpers - lsp_client: Add notify_file_open(), notify_file_change(), fetch_diagnostics_for_file() with best-effort graceful fallback, registry-level open file tracking, diagnostic caching - tools: Enrich run_read_file with didOpen + diagnostics, run_write_file and run_edit_file with didChange + diagnostics, format_diagnostic_appendix() for readable diagnostic output appended to tool results All enrichment is non-blocking: if no LSP server is available, tools work exactly as before. No errors propagate from the LSP layer. Co-Authored-By: Claude Opus 4.7 --- rust/crates/runtime/src/lsp_client.rs | 136 +++++++++++++++++++++- rust/crates/runtime/src/lsp_discovery.rs | 2 +- rust/crates/runtime/src/lsp_process.rs | 137 ++++++++++++++++++++++- rust/crates/runtime/src/lsp_transport.rs | 99 +++++++++++++--- rust/crates/tools/src/lib.rs | 95 ++++++++++++++-- 5 files changed, 438 insertions(+), 31 deletions(-) diff --git a/rust/crates/runtime/src/lsp_client.rs b/rust/crates/runtime/src/lsp_client.rs index aa4e133721..e7e4e12136 100644 --- a/rust/crates/runtime/src/lsp_client.rs +++ b/rust/crates/runtime/src/lsp_client.rs @@ -1,7 +1,7 @@ #![allow(clippy::should_implement_trait, clippy::must_use_candidate)] //! LSP (Language Server Protocol) client registry for tool dispatch. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -156,6 +156,7 @@ pub struct LspRegistry { #[derive(Debug, Default)] struct RegistryInner { servers: HashMap, + open_files: HashSet, } impl LspRegistry { @@ -420,6 +421,139 @@ impl LspRegistry { Ok(()) } + /// Notify the LSP server that a file was opened and collect any diagnostics. + /// Best-effort: returns empty vec if no server is available. + pub fn notify_file_open(&self, path: &str, content: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Check if already open + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if inner.open_files.contains(path) { + return Vec::new(); + } + } + + // Lazy-start the server + if self.start_server(&language).is_err() { + return Vec::new(); + } + + // Get the process handle and send didOpen + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + match inner.servers.get(&language).and_then(|e| e.process.clone()) { + Some(p) => p, + None => return Vec::new(), + } + }; + + let mut diagnostics = Vec::new(); + if let Ok(mut process) = process_arc.lock() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + if let Ok(rt) = rt { + let _ = rt.block_on(process.did_open(path, content)); + diagnostics = process.drain_diagnostics(); + } + } + + // Cache diagnostics in registry state + if !diagnostics.is_empty() { + let diag_path = path.to_owned(); + let diags = diagnostics.clone(); + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + // Replace diagnostics for this file (publishDiagnostics is full replacement) + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(diags); + } + } + + // Mark file as open + { + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.open_files.insert(path.to_owned()); + } + + diagnostics + } + + /// Notify the LSP server that a file changed and collect any diagnostics. + /// Best-effort: returns empty vec if no server is available. + pub fn notify_file_change(&self, path: &str, content: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Get the process handle + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + match inner.servers.get(&language).and_then(|e| e.process.clone()) { + Some(p) => p, + None => return Vec::new(), + } + }; + + let mut diagnostics = Vec::new(); + if let Ok(mut process) = process_arc.lock() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + if let Ok(rt) = rt { + let _ = rt.block_on(process.did_change(path, content)); + diagnostics = process.drain_diagnostics(); + } + } + + // Replace cached diagnostics for this file + if !diagnostics.is_empty() { + let diag_path = path.to_owned(); + let diags = diagnostics.clone(); + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(diags); + } + } + + diagnostics + } + + /// Fetch diagnostics for a file by draining pending server notifications + /// and returning cached diagnostics. + pub fn fetch_diagnostics_for_file(&self, path: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Drain pending notifications from the transport + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.get(&language).and_then(|e| e.process.clone()) + }; + + if let Some(process_arc) = process_arc { + if let Ok(mut process) = process_arc.lock() { + let new_diags = process.drain_diagnostics(); + if !new_diags.is_empty() { + let diag_path = path.to_owned(); + let mut inner = + self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(new_diags); + } + } + } + } + + self.get_diagnostics(path) + } + /// Dispatch an LSP action and return a structured result. #[allow(clippy::too_many_lines)] pub fn dispatch( diff --git a/rust/crates/runtime/src/lsp_discovery.rs b/rust/crates/runtime/src/lsp_discovery.rs index 24b28a0cc0..7c8f7402ab 100644 --- a/rust/crates/runtime/src/lsp_discovery.rs +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -109,7 +109,7 @@ pub fn command_exists_on_path(command: &str) -> bool { Command::new(command) .arg("--version") .output() - .map_or(false, |_| true) + .is_ok() } /// Discover LSP servers that are actually installed on the current system. diff --git a/rust/crates/runtime/src/lsp_process.rs b/rust/crates/runtime/src/lsp_process.rs index c57c832ae9..578d43fea9 100644 --- a/rust/crates/runtime/src/lsp_process.rs +++ b/rust/crates/runtime/src/lsp_process.rs @@ -1,11 +1,12 @@ //! LSP process manager: spawns language servers and drives the LSP lifecycle. +use std::collections::{HashMap, HashSet}; use std::path::Path; use serde_json::Value as JsonValue; use crate::lsp_client::{ - LspCompletionItem, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, + LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, }; use crate::lsp_transport::{LspTransport, LspTransportError}; @@ -16,6 +17,8 @@ pub struct LspProcess { root_uri: String, capabilities: Option, status: LspServerStatus, + open_files: HashSet, + version_counter: HashMap, } #[allow(clippy::cast_possible_truncation)] @@ -38,6 +41,8 @@ impl LspProcess { root_uri: root_uri.clone(), capabilities: None, status: LspServerStatus::Starting, + open_files: HashSet::new(), + version_counter: HashMap::new(), }; process.initialize(&canonical).await?; @@ -279,6 +284,103 @@ impl LspProcess { } } + /// Notify the server that a file was opened. Sends `textDocument/didOpen`. + /// No-op if the file is already tracked as open. + pub async fn did_open(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> { + if self.open_files.contains(path) { + return Ok(()); + } + + let uri = path_to_uri(path); + let language_id = language_id_for_path(path); + let params = serde_json::json!({ + "textDocument": { + "uri": uri, + "languageId": language_id, + "version": 0, + "text": content + } + }); + + self.transport + .send_notification("textDocument/didOpen", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + self.open_files.insert(path.to_owned()); + self.version_counter.insert(path.to_owned(), 0); + Ok(()) + } + + /// Notify the server that a file's content changed. Sends `textDocument/didChange`. + pub async fn did_change(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> { + let version = self.version_counter.get(path).map_or(1, |v| v + 1); + + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri, "version": version }, + "contentChanges": [{ "text": content }] + }); + + self.transport + .send_notification("textDocument/didChange", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + self.version_counter.insert(path.to_owned(), version); + Ok(()) + } + + /// Drain queued server notifications and extract `publishDiagnostics`. + #[allow(clippy::redundant_closure_for_method_calls)] + pub fn drain_diagnostics(&mut self) -> Vec { + let notifications = self.transport.drain_notifications(); + let mut diagnostics = Vec::new(); + for n in ¬ifications { + if n.method == "textDocument/publishDiagnostics" { + if let Some(params) = &n.params { + if let Some(uri) = params.get("uri").and_then(|v| v.as_str()) { + let path = uri_to_path(uri); + if let Some(diags) = params.get("diagnostics").and_then(|v| v.as_array()) + { + for d in diags { + diagnostics.push(LspDiagnostic { + path: path.clone(), + line: d + .get("range") + .and_then(|r| r.get("start")) + .and_then(|s| s.get("line")) + .and_then(|v| v.as_u64()) + .map_or(0, |v| v as u32), + character: d + .get("range") + .and_then(|r| r.get("start")) + .and_then(|s| s.get("character")) + .and_then(|v| v.as_u64()) + .map_or(0, |v| v as u32), + severity: d + .get("severity") + .and_then(|v| v.as_u64()) + .map_or_else(|| "error".to_owned(), severity_name), + message: d + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(), + source: d + .get("source") + .and_then(|v| v.as_str()) + .map(str::to_owned), + }); + } + } + } + } + } + } + diagnostics + } + #[must_use] pub fn status(&self) -> LspServerStatus { self.status @@ -360,6 +462,39 @@ fn uri_to_path(uri: &str) -> String { uri.strip_prefix("file://").unwrap_or(uri).to_owned() } +fn language_id_for_path(path: &str) -> String { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + match ext { + "rs" => "rust", + "ts" => "typescript", + "tsx" => "typescriptreact", + "js" => "javascript", + "jsx" => "javascriptreact", + "py" => "python", + "go" => "go", + "java" => "java", + "c" | "h" => "c", + "cpp" | "hpp" | "cc" => "cpp", + "rb" => "ruby", + "lua" => "lua", + _ => ext, + } + .to_owned() +} + +fn severity_name(code: u64) -> String { + match code { + 1 => "error".to_owned(), + 2 => "warning".to_owned(), + 3 => "info".to_owned(), + 4 => "hint".to_owned(), + _ => format!("unknown({code})"), + } +} + fn parse_hover(value: &JsonValue) -> Option { let contents = value.get("contents")?; diff --git a/rust/crates/runtime/src/lsp_transport.rs b/rust/crates/runtime/src/lsp_transport.rs index 8ad1f83287..015ac14583 100644 --- a/rust/crates/runtime/src/lsp_transport.rs +++ b/rust/crates/runtime/src/lsp_transport.rs @@ -89,6 +89,14 @@ impl LspResponse { } } +/// A message received from an LSP server — either a response to a request +/// or a server-initiated notification (e.g. `textDocument/publishDiagnostics`). +#[derive(Debug, Clone)] +pub enum LspServerMessage { + Response(LspResponse), + Notification(LspNotification), +} + #[derive(Debug)] pub enum LspTransportError { Io(io::Error), @@ -138,6 +146,7 @@ pub struct LspTransport { stdout: BufReader, next_id: u64, request_timeout: Duration, + pending_notifications: Vec, } impl LspTransport { @@ -172,6 +181,7 @@ impl LspTransport { stdout: BufReader::new(stdout), next_id: 1, request_timeout, + pending_notifications: Vec::new(), }) } @@ -193,6 +203,7 @@ impl LspTransport { stdout: BufReader::new(stdout), next_id: 1, request_timeout, + pending_notifications: Vec::new(), } } @@ -235,12 +246,27 @@ impl LspTransport { let method_owned = method.to_string(); let timeout_duration = self.request_timeout; - let response = timeout(timeout_duration, self.read_response()) - .await - .map_err(|_| LspTransportError::Timeout { - method: method_owned, - timeout: timeout_duration, - })??; + let response = match timeout(timeout_duration, async { + loop { + match self.read_message().await { + Ok(LspServerMessage::Response(r)) => break Ok(r), + Ok(LspServerMessage::Notification(n)) => { + self.pending_notifications.push(n); + } + Err(e) => break Err(e), + } + } + }) + .await + { + Ok(inner) => inner, + Err(_) => { + return Err(LspTransportError::Timeout { + method: method_owned, + timeout: timeout_duration, + }) + } + }?; if response.jsonrpc != "2.0" { return Err(LspTransportError::InvalidResponse { @@ -266,8 +292,57 @@ impl LspTransport { Ok(response) } + /// Read a single message from the server, returning either a response or + /// a server-initiated notification (e.g. `publishDiagnostics`). + pub async fn read_message(&mut self) -> Result { + let payload = self.read_frame().await?; + let value: JsonValue = serde_json::from_slice(&payload).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: error.to_string(), + } + })?; + + // Responses have an "id" field; notifications have "method" but no "id" + if value.get("id").is_some() { + let response: LspResponse = serde_json::from_value(value).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: format!("failed to parse response: {error}"), + } + })?; + Ok(LspServerMessage::Response(response)) + } else if value.get("method").is_some() { + let notification: LspNotification = serde_json::from_value(value).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: format!("failed to parse notification: {error}"), + } + })?; + Ok(LspServerMessage::Notification(notification)) + } else { + Err(LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: "message has neither 'id' nor 'method'".to_string(), + }) + } + } + + /// Read a response from the server. Interleaved notifications are queued. pub async fn read_response(&mut self) -> Result { - self.read_jsonrpc_message().await + loop { + match self.read_message().await? { + LspServerMessage::Response(r) => return Ok(r), + LspServerMessage::Notification(n) => { + self.pending_notifications.push(n); + } + } + } + } + + /// Drain and return all queued server-initiated notifications. + pub fn drain_notifications(&mut self) -> Vec { + std::mem::take(&mut self.pending_notifications) } pub async fn shutdown(&mut self) -> Result<(), LspTransportError> { @@ -344,16 +419,6 @@ impl LspTransport { Ok(payload) } - - async fn read_jsonrpc_message( - &mut self, - ) -> Result { - let payload = self.read_frame().await?; - serde_json::from_slice(&payload).map_err(|error| LspTransportError::InvalidResponse { - method: "unknown".to_string(), - details: error.to_string(), - }) - } } #[cfg(test)] diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 7b827b020a..98ae536dc7 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -14,7 +14,7 @@ use reqwest::blocking::Client; use runtime::{ check_freshness, dedupe_superseded_commit_events, edit_file, execute_bash, glob_search, grep_search, load_system_prompt, - lsp_client::LspRegistry, + lsp_client::{LspDiagnostic, LspRegistry}, mcp_tool_bridge::McpToolRegistry, permission_enforcer::{EnforcementResult, PermissionEnforcer}, read_file, @@ -2069,25 +2069,98 @@ fn branch_divergence_output( #[allow(clippy::needless_pass_by_value)] fn run_read_file(input: ReadFileInput) -> Result { - to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?) + let result = read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?; + let mut output = to_pretty_json(result)?; + + // LSP enrichment: notify the server that the file was opened and append diagnostics + if let Some(diags) = lsp_enrichment_for_path(&input.path, &LspEvent::Open) { + output.push_str(&format_diagnostic_appendix(&diags)); + } + + Ok(output) } #[allow(clippy::needless_pass_by_value)] fn run_write_file(input: WriteFileInput) -> Result { - to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?) + let result = write_file(&input.path, &input.content).map_err(io_to_string)?; + let mut output = to_pretty_json(result)?; + + // LSP enrichment: notify the server that the file changed and append diagnostics + if let Some(diags) = lsp_enrichment_for_path(&input.path, &LspEvent::Change) { + output.push_str(&format_diagnostic_appendix(&diags)); + } + + Ok(output) } #[allow(clippy::needless_pass_by_value)] fn run_edit_file(input: EditFileInput) -> Result { - to_pretty_json( - edit_file( - &input.path, - &input.old_string, - &input.new_string, - input.replace_all.unwrap_or(false), - ) - .map_err(io_to_string)?, + let result = edit_file( + &input.path, + &input.old_string, + &input.new_string, + input.replace_all.unwrap_or(false), ) + .map_err(io_to_string)?; + + let mut output = to_pretty_json(result)?; + + // LSP enrichment: notify the server that the file changed and append diagnostics + let full_content = std::fs::read_to_string(&input.path).unwrap_or_default(); + if let Some(diags) = + lsp_enrichment_for_path_with_content(&input.path, &full_content, &LspEvent::Change) + { + output.push_str(&format_diagnostic_appendix(&diags)); + } + + Ok(output) +} + +enum LspEvent { + Open, + Change, +} + +fn lsp_enrichment_for_path(path: &str, event: &LspEvent) -> Option> { + let content = std::fs::read_to_string(path).ok()?; + lsp_enrichment_for_path_with_content(path, &content, event) +} + +fn lsp_enrichment_for_path_with_content( + path: &str, + content: &str, + event: &LspEvent, +) -> Option> { + let registry = global_lsp_registry(); + + registry.find_server_for_path(path)?; + + let diags = match event { + LspEvent::Open => registry.notify_file_open(path, content), + LspEvent::Change => registry.notify_file_change(path, content), + }; + + if diags.is_empty() { + None + } else { + Some(diags) + } +} + +fn format_diagnostic_appendix(diagnostics: &[LspDiagnostic]) -> String { + let mut lines = vec![String::from("\n--- LSP Diagnostics ---")]; + for d in diagnostics { + let source = d.source.as_deref().unwrap_or("lsp"); + lines.push(format!( + "[{}:{}] {} ({}): {}", + d.line + 1, + d.character + 1, + d.severity, + source, + d.message + )); + } + lines.join("\n") } #[allow(clippy::needless_pass_by_value)] From a9c3cd8707c28ecc7891b52e96d34f27d5040404 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Mon, 27 Apr 2026 00:28:28 -0500 Subject: [PATCH 11/16] refactor: split LSP modules under 500 lines each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the three large LSP files into module directories with sub-files: lsp_transport/ (was 560 lines): - mod.rs (425) — types + LspTransport impl - tests.rs (134) — test module lsp_process/ (was 929 lines): - mod.rs (436) — LspProcess struct + public methods + error types - parse.rs (311) — helper functions and LSP response parsers - tests.rs (194) — test module lsp_client/ (was 1338 lines): - mod.rs (466) — LspRegistry struct + impl, re-exports from types - types.rs (103) — LspAction, LspDiagnostic, LspServerStatus, etc. - dispatch.rs (224) — LspRegistry::dispatch() method - tests.rs (273) — core registry tests - tests_lifecycle.rs (294) — lifecycle and integration tests All files under 500 lines. All 501 runtime tests pass. Clippy clean. Co-Authored-By: Claude Opus 4.7 --- .../crates/runtime/src/lsp_client/dispatch.rs | 224 +++++++++ rust/crates/runtime/src/lsp_client/mod.rs | 466 ++++++++++++++++++ rust/crates/runtime/src/lsp_client/tests.rs | 273 ++++++++++ .../runtime/src/lsp_client/tests_lifecycle.rs | 294 +++++++++++ rust/crates/runtime/src/lsp_client/types.rs | 103 ++++ rust/crates/runtime/src/lsp_process/mod.rs | 436 ++++++++++++++++ rust/crates/runtime/src/lsp_process/parse.rs | 311 ++++++++++++ rust/crates/runtime/src/lsp_process/tests.rs | 194 ++++++++ rust/crates/runtime/src/lsp_transport/mod.rs | 425 ++++++++++++++++ .../crates/runtime/src/lsp_transport/tests.rs | 134 +++++ 10 files changed, 2860 insertions(+) create mode 100644 rust/crates/runtime/src/lsp_client/dispatch.rs create mode 100644 rust/crates/runtime/src/lsp_client/mod.rs create mode 100644 rust/crates/runtime/src/lsp_client/tests.rs create mode 100644 rust/crates/runtime/src/lsp_client/tests_lifecycle.rs create mode 100644 rust/crates/runtime/src/lsp_client/types.rs create mode 100644 rust/crates/runtime/src/lsp_process/mod.rs create mode 100644 rust/crates/runtime/src/lsp_process/parse.rs create mode 100644 rust/crates/runtime/src/lsp_process/tests.rs create mode 100644 rust/crates/runtime/src/lsp_transport/mod.rs create mode 100644 rust/crates/runtime/src/lsp_transport/tests.rs diff --git a/rust/crates/runtime/src/lsp_client/dispatch.rs b/rust/crates/runtime/src/lsp_client/dispatch.rs new file mode 100644 index 0000000000..4533f76359 --- /dev/null +++ b/rust/crates/runtime/src/lsp_client/dispatch.rs @@ -0,0 +1,224 @@ +//! LSP action dispatch: routes actions to the appropriate server process. + +use super::types::{LspAction, LspServerStatus}; + +impl super::LspRegistry { + /// Dispatch an LSP action and return a structured result. + #[allow(clippy::too_many_lines)] + pub fn dispatch( + &self, + action: &str, + path: Option<&str>, + line: Option, + character: Option, + _query: Option<&str>, + ) -> Result { + let lsp_action = + LspAction::from_str(action).ok_or_else(|| format!("unknown LSP action: {action}"))?; + + // For diagnostics, we check existing cached diagnostics + if lsp_action == LspAction::Diagnostics { + if let Some(path) = path { + let diags = self.get_diagnostics(path); + return Ok(serde_json::json!({ + "action": "diagnostics", + "path": path, + "diagnostics": diags, + "count": diags.len() + })); + } + // All diagnostics across all servers + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + let all_diags: Vec<_> = inner + .servers + .values() + .flat_map(|entry| &entry.state.diagnostics) + .collect(); + return Ok(serde_json::json!({ + "action": "diagnostics", + "diagnostics": all_diags, + "count": all_diags.len() + })); + } + + // For other actions, we need a connected server for the given file + let path = path.ok_or("path is required for this LSP action")?; + let language = Self::language_for_path(path) + .ok_or_else(|| format!("no LSP server available for path: {path}"))?; + + // Check the entry exists + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if !inner.servers.contains_key(&language) { + return Err(format!("no LSP server available for path: {path}")); + } + } + + // Lazy-start: if no process yet, try to start one + let needs_start = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner + .servers + .get(&language) + .is_none_or(|entry| entry.process.is_none()) + }; + + if needs_start { + if let Err(e) = self.start_server(&language) { + // Check the status after failed start — if still not Connected, + // return a proper error. This preserves the existing behavior + // for Disconnected/Error status servers. + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get(&language) { + if entry.state.status != LspServerStatus::Connected { + return Err(format!( + "LSP server for '{}' is not connected (status: {}): {}", + language, entry.state.status, e + )); + } + } + // If somehow still marked Connected but start failed, return error JSON + return Ok(serde_json::json!({ + "action": action, + "path": path, + "line": line, + "character": character, + "language": language, + "status": "error", + "error": e + })); + } + } + + // Check the server status + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get(&language) { + if entry.state.status != LspServerStatus::Connected { + return Err(format!( + "LSP server for '{}' is not connected (status: {})", + language, entry.state.status + )); + } + } + } + + // Get the process handle (clone the Arc) + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner + .servers + .get(&language) + .and_then(|entry| entry.process.clone()) + .ok_or_else(|| format!("no LSP process available for language: {language}"))? + }; + + // Dispatch to the real LSP process + let result = { + let mut process = process_arc + .lock() + .map_err(|_| "lsp process lock poisoned".to_owned())?; + + // Create a minimal tokio runtime for async LSP calls + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("failed to create tokio runtime: {e}"))?; + + rt.block_on(async { + let line = line.unwrap_or(0); + let character = character.unwrap_or(0); + + match lsp_action { + LspAction::Hover => { + let hover = process.hover(path, line, character).await; + hover.map(|opt| { + opt.map_or_else( + || serde_json::json!({ + "action": "hover", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "no_result", + }), + |h| serde_json::json!({ + "action": "hover", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "result": h, + }), + ) + }) + } + LspAction::Definition => { + let locations = process.goto_definition(path, line, character).await; + locations.map(|locs| serde_json::json!({ + "action": "definition", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "locations": locs, + })) + } + LspAction::References => { + let locations = process.references(path, line, character).await; + locations.map(|locs| serde_json::json!({ + "action": "references", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "locations": locs, + })) + } + LspAction::Completion => { + let items = process.completion(path, line, character).await; + items.map(|completions| serde_json::json!({ + "action": "completion", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "items": completions, + })) + } + LspAction::Symbols => { + let symbols = process.document_symbols(path).await; + symbols.map(|syms| serde_json::json!({ + "action": "symbols", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "symbols": syms, + })) + } + LspAction::Format => { + let edits = process.format(path).await; + edits.map(|text_edits| serde_json::json!({ + "action": "format", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "edits": text_edits, + })) + } + LspAction::Diagnostics => unreachable!(), + } + }) + }; + + result.map_err(|e| format!("LSP {action} failed for '{language}': {e}")) + } +} diff --git a/rust/crates/runtime/src/lsp_client/mod.rs b/rust/crates/runtime/src/lsp_client/mod.rs new file mode 100644 index 0000000000..05aa5c6771 --- /dev/null +++ b/rust/crates/runtime/src/lsp_client/mod.rs @@ -0,0 +1,466 @@ +#![allow(clippy::should_implement_trait, clippy::must_use_candidate)] +//! LSP (Language Server Protocol) client registry for tool dispatch. + +mod dispatch; +mod types; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_lifecycle; + +pub use types::{ + LspAction, LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerState, + LspServerStatus, LspSymbol, +}; + +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use crate::lsp_discovery::{discover_available_servers, LspServerDescriptor}; +use crate::lsp_process::LspProcess; + +/// Entry in the LSP registry combining process handle, descriptor, and state. +struct LspServerEntry { + /// The running LSP process, if started. Wrapped in Arc> for thread-safe async access. + process: Option>>, + /// The server descriptor for lazy-start on first use. + descriptor: Option, + /// The server state metadata (status, capabilities, diagnostics). + state: LspServerState, +} + +impl std::fmt::Debug for LspServerEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LspServerEntry") + .field("process", &self.process.is_some()) + .field("descriptor", &self.descriptor) + .field("state", &self.state) + .finish() + } +} + +impl LspServerEntry { + fn new(state: LspServerState) -> Self { + Self { + process: None, + descriptor: None, + state, + } + } + + fn with_descriptor(state: LspServerState, descriptor: LspServerDescriptor) -> Self { + Self { + process: None, + descriptor: Some(descriptor), + state, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct LspRegistry { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct RegistryInner { + servers: HashMap, + open_files: HashSet, +} + +impl LspRegistry { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Register an LSP server with metadata but without starting the process. + /// The server can be started later via `start_server()` or lazily on first `dispatch()`. + pub fn register( + &self, + language: &str, + status: LspServerStatus, + root_path: Option<&str>, + capabilities: Vec, + ) { + let state = LspServerState { + language: language.to_owned(), + status, + root_path: root_path.map(str::to_owned), + capabilities, + diagnostics: Vec::new(), + }; + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner + .servers + .insert(language.to_owned(), LspServerEntry::new(state)); + } + + /// Register an LSP server with a descriptor for lazy-start. + /// The descriptor provides the command and args to start the server when needed. + pub fn register_with_descriptor( + &self, + language: &str, + status: LspServerStatus, + root_path: Option<&str>, + capabilities: Vec, + descriptor: LspServerDescriptor, + ) { + let state = LspServerState { + language: language.to_owned(), + status, + root_path: root_path.map(str::to_owned), + capabilities, + diagnostics: Vec::new(), + }; + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.insert( + language.to_owned(), + LspServerEntry::with_descriptor(state, descriptor), + ); + } + + pub fn get(&self, language: &str) -> Option { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.get(language).map(|entry| entry.state.clone()) + } + + /// Find the appropriate server for a file path based on extension. + pub fn find_server_for_path(&self, path: &str) -> Option { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + + let language = match ext { + "rs" => "rust", + "ts" | "tsx" => "typescript", + "js" | "jsx" => "javascript", + "py" => "python", + "go" => "go", + "java" => "java", + "c" | "h" => "c", + "cpp" | "hpp" | "cc" => "cpp", + "rb" => "ruby", + "lua" => "lua", + _ => return None, + }; + + self.get(language) + } + + /// Get the language name for a file path based on extension. + fn language_for_path(path: &str) -> Option { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str())?; + + let language = match ext { + "rs" => "rust", + "ts" | "tsx" => "typescript", + "js" | "jsx" => "javascript", + "py" => "python", + "go" => "go", + "java" => "java", + "c" | "h" => "c", + "cpp" | "hpp" | "cc" => "cpp", + "rb" => "ruby", + "lua" => "lua", + _ => return None, + }; + + Some(language.to_owned()) + } + + /// List all registered servers. + pub fn list_servers(&self) -> Vec { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.values().map(|entry| entry.state.clone()).collect() + } + + /// Add diagnostics to a server. + pub fn add_diagnostics( + &self, + language: &str, + diagnostics: Vec, + ) -> Result<(), String> { + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + let entry = inner + .servers + .get_mut(language) + .ok_or_else(|| format!("LSP server not found for language: {language}"))?; + entry.state.diagnostics.extend(diagnostics); + Ok(()) + } + + /// Get diagnostics for a specific file path. + pub fn get_diagnostics(&self, path: &str) -> Vec { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner + .servers + .values() + .flat_map(|entry| &entry.state.diagnostics) + .filter(|d| d.path == path) + .cloned() + .collect() + } + + /// Clear diagnostics for a language server. + pub fn clear_diagnostics(&self, language: &str) -> Result<(), String> { + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + let entry = inner + .servers + .get_mut(language) + .ok_or_else(|| format!("LSP server not found for language: {language}"))?; + entry.state.diagnostics.clear(); + Ok(()) + } + + /// Disconnect a server. + pub fn disconnect(&self, language: &str) -> Option { + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.remove(language).map(|entry| entry.state) + } + + #[must_use] + pub fn len(&self) -> usize { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Start an LSP server process for the given language. + /// If the process is already running, this is a no-op. + /// If a descriptor is available, it is used to start the process. + /// If no descriptor is available, the discovery system is consulted. + pub fn start_server(&self, language: &str) -> Result<(), String> { + // Check if already running + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get(language) { + if entry.process.is_some() { + return Ok(()); + } + } + } + + // Try to get the descriptor + let descriptor = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get(language) { + entry.descriptor.clone() + } else { + None + } + }; + + // If no descriptor, try discovery + let descriptor = if let Some(d) = descriptor { d } else { + let available = discover_available_servers(); + available + .into_iter() + .find(|d| d.language == language) + .ok_or_else(|| { + format!("no LSP server descriptor found for language: {language}") + })? + }; + + let root_path = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner + .servers + .get(language) + .and_then(|entry| entry.state.root_path.clone()) + .unwrap_or_else(|| { + std::env::current_dir() + .map_or_else(|_| ".".to_owned(), |p| p.to_string_lossy().into_owned()) + }) + }; + + let process = { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("failed to create tokio runtime: {e}"))?; + rt.block_on(LspProcess::start( + &descriptor.command, + &descriptor.args, + Path::new(&root_path), + )) + .map_err(|e| format!("failed to start LSP server for '{language}': {e}"))? + }; + + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(language) { + entry.process = Some(Arc::new(Mutex::new(process))); + entry.state.status = LspServerStatus::Connected; + } + + Ok(()) + } + + /// Stop a running LSP server process. + pub fn stop_server(&self, language: &str) -> Result<(), String> { + let process_arc = { + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + let entry = inner + .servers + .get_mut(language) + .ok_or_else(|| format!("LSP server not found for language: {language}"))?; + entry.state.status = LspServerStatus::Disconnected; + entry.process.take() + }; + + if let Some(process_arc) = process_arc { + let mut process = process_arc + .lock() + .map_err(|_| "lsp process lock poisoned")?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("failed to create tokio runtime: {e}"))?; + rt.block_on(process.shutdown()) + .map_err(|e| format!("LSP shutdown error: {e}"))?; + } + + Ok(()) + } + + /// Notify the LSP server that a file was opened and collect any diagnostics. + /// Best-effort: returns empty vec if no server is available. + pub fn notify_file_open(&self, path: &str, content: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Check if already open + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if inner.open_files.contains(path) { + return Vec::new(); + } + } + + // Lazy-start the server + if self.start_server(&language).is_err() { + return Vec::new(); + } + + // Get the process handle and send didOpen + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + match inner.servers.get(&language).and_then(|e| e.process.clone()) { + Some(p) => p, + None => return Vec::new(), + } + }; + + let mut diagnostics = Vec::new(); + if let Ok(mut process) = process_arc.lock() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + if let Ok(rt) = rt { + let _ = rt.block_on(process.did_open(path, content)); + diagnostics = process.drain_diagnostics(); + } + } + + // Cache diagnostics in registry state + if !diagnostics.is_empty() { + let diag_path = path.to_owned(); + let diags = diagnostics.clone(); + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + // Replace diagnostics for this file (publishDiagnostics is full replacement) + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(diags); + } + } + + // Mark file as open + { + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.open_files.insert(path.to_owned()); + } + + diagnostics + } + + /// Notify the LSP server that a file changed and collect any diagnostics. + /// Best-effort: returns empty vec if no server is available. + pub fn notify_file_change(&self, path: &str, content: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Get the process handle + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + match inner.servers.get(&language).and_then(|e| e.process.clone()) { + Some(p) => p, + None => return Vec::new(), + } + }; + + let mut diagnostics = Vec::new(); + if let Ok(mut process) = process_arc.lock() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + if let Ok(rt) = rt { + let _ = rt.block_on(process.did_change(path, content)); + diagnostics = process.drain_diagnostics(); + } + } + + // Replace cached diagnostics for this file + if !diagnostics.is_empty() { + let diag_path = path.to_owned(); + let diags = diagnostics.clone(); + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(diags); + } + } + + diagnostics + } + + /// Fetch diagnostics for a file by draining pending server notifications + /// and returning cached diagnostics. + pub fn fetch_diagnostics_for_file(&self, path: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Drain pending notifications from the transport + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.get(&language).and_then(|e| e.process.clone()) + }; + + if let Some(process_arc) = process_arc { + if let Ok(mut process) = process_arc.lock() { + let new_diags = process.drain_diagnostics(); + if !new_diags.is_empty() { + let diag_path = path.to_owned(); + let mut inner = + self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(new_diags); + } + } + } + } + + self.get_diagnostics(path) + } +} diff --git a/rust/crates/runtime/src/lsp_client/tests.rs b/rust/crates/runtime/src/lsp_client/tests.rs new file mode 100644 index 0000000000..7e2c74d6bb --- /dev/null +++ b/rust/crates/runtime/src/lsp_client/tests.rs @@ -0,0 +1,273 @@ +//! Tests for the LSP client registry: registration, diagnostics, and type unit tests. + +use super::*; +use super::types::*; + +#[test] +fn registers_and_retrieves_server() { + let registry = LspRegistry::new(); + registry.register( + "rust", + LspServerStatus::Connected, + Some("/workspace"), + vec!["hover".into(), "completion".into()], + ); + + let server = registry.get("rust").expect("should exist"); + assert_eq!(server.language, "rust"); + assert_eq!(server.status, LspServerStatus::Connected); + assert_eq!(server.capabilities.len(), 2); +} + +#[test] +fn finds_server_by_file_extension() { + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Connected, None, vec![]); + registry.register("typescript", LspServerStatus::Connected, None, vec![]); + + let rs_server = registry.find_server_for_path("src/main.rs").unwrap(); + assert_eq!(rs_server.language, "rust"); + + let ts_server = registry.find_server_for_path("src/index.ts").unwrap(); + assert_eq!(ts_server.language, "typescript"); + + assert!(registry.find_server_for_path("data.csv").is_none()); +} + +#[test] +fn manages_diagnostics() { + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Connected, None, vec![]); + + registry + .add_diagnostics( + "rust", + vec![LspDiagnostic { + path: "src/main.rs".into(), + line: 10, + character: 5, + severity: "error".into(), + message: "mismatched types".into(), + source: Some("rust-analyzer".into()), + }], + ) + .unwrap(); + + let diags = registry.get_diagnostics("src/main.rs"); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].message, "mismatched types"); + + registry.clear_diagnostics("rust").unwrap(); + assert!(registry.get_diagnostics("src/main.rs").is_empty()); +} + +#[test] +fn dispatches_diagnostics_action() { + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Connected, None, vec![]); + registry + .add_diagnostics( + "rust", + vec![LspDiagnostic { + path: "src/lib.rs".into(), + line: 1, + character: 0, + severity: "warning".into(), + message: "unused import".into(), + source: None, + }], + ) + .unwrap(); + + let result = registry + .dispatch("diagnostics", Some("src/lib.rs"), None, None, None) + .unwrap(); + assert_eq!(result["count"], 1); +} + +#[test] +fn dispatches_hover_action() { + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Connected, None, vec![]); + + let result = registry + .dispatch("hover", Some("src/main.rs"), Some(10), Some(5), None) + .unwrap(); + assert_eq!(result["action"], "hover"); + assert_eq!(result["language"], "rust"); +} + +#[test] +fn rejects_action_on_disconnected_server() { + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Disconnected, None, vec![]); + + assert!(registry + .dispatch("hover", Some("src/main.rs"), Some(1), Some(0), None) + .is_err()); +} + +#[test] +fn rejects_unknown_action() { + let registry = LspRegistry::new(); + assert!(registry + .dispatch("unknown_action", Some("file.rs"), None, None, None) + .is_err()); +} + +#[test] +fn disconnects_server() { + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Connected, None, vec![]); + assert_eq!(registry.len(), 1); + + let removed = registry.disconnect("rust"); + assert!(removed.is_some()); + assert!(registry.is_empty()); +} + +#[test] +fn lsp_action_from_str_all_aliases() { + // given + let cases = [ + ("diagnostics", Some(LspAction::Diagnostics)), + ("hover", Some(LspAction::Hover)), + ("definition", Some(LspAction::Definition)), + ("goto_definition", Some(LspAction::Definition)), + ("references", Some(LspAction::References)), + ("find_references", Some(LspAction::References)), + ("completion", Some(LspAction::Completion)), + ("completions", Some(LspAction::Completion)), + ("symbols", Some(LspAction::Symbols)), + ("document_symbols", Some(LspAction::Symbols)), + ("format", Some(LspAction::Format)), + ("formatting", Some(LspAction::Format)), + ("unknown", None), + ]; + + // when + let resolved: Vec<_> = cases + .into_iter() + .map(|(input, expected)| (input, LspAction::from_str(input), expected)) + .collect(); + + // then + for (input, actual, expected) in resolved { + assert_eq!(actual, expected, "unexpected action resolution for {input}"); + } +} + +#[test] +fn lsp_server_status_display_all_variants() { + // given + let cases = [ + (LspServerStatus::Connected, "connected"), + (LspServerStatus::Disconnected, "disconnected"), + (LspServerStatus::Starting, "starting"), + (LspServerStatus::Error, "error"), + ]; + + // when + let rendered: Vec<_> = cases + .into_iter() + .map(|(status, expected)| (status.to_string(), expected)) + .collect(); + + // then + assert_eq!( + rendered, + vec![ + ("connected".to_string(), "connected"), + ("disconnected".to_string(), "disconnected"), + ("starting".to_string(), "starting"), + ("error".to_string(), "error"), + ] + ); +} + +#[test] +fn dispatch_diagnostics_without_path_aggregates() { + // given + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Connected, None, vec![]); + registry.register("python", LspServerStatus::Connected, None, vec![]); + registry + .add_diagnostics( + "rust", + vec![LspDiagnostic { + path: "src/lib.rs".into(), + line: 1, + character: 0, + severity: "warning".into(), + message: "unused import".into(), + source: Some("rust-analyzer".into()), + }], + ) + .expect("rust diagnostics should add"); + registry + .add_diagnostics( + "python", + vec![LspDiagnostic { + path: "script.py".into(), + line: 2, + character: 4, + severity: "error".into(), + message: "undefined name".into(), + source: Some("pyright".into()), + }], + ) + .expect("python diagnostics should add"); + + // when + let result = registry + .dispatch("diagnostics", None, None, None, None) + .expect("aggregate diagnostics should work"); + + // then + assert_eq!(result["action"], "diagnostics"); + assert_eq!(result["count"], 2); + assert_eq!(result["diagnostics"].as_array().map(Vec::len), Some(2)); +} + +#[test] +fn dispatch_non_diagnostics_requires_path() { + // given + let registry = LspRegistry::new(); + + // when + let result = registry.dispatch("hover", None, Some(1), Some(0), None); + + // then + assert_eq!( + result.expect_err("path should be required"), + "path is required for this LSP action" + ); +} + +#[test] +fn dispatch_no_server_for_path_errors() { + // given + let registry = LspRegistry::new(); + + // when + let result = registry.dispatch("hover", Some("notes.md"), Some(1), Some(0), None); + + // then + let error = result.expect_err("missing server should fail"); + assert!(error.contains("no LSP server available for path: notes.md")); +} + +#[test] +fn dispatch_disconnected_server_error_payload() { + // given + let registry = LspRegistry::new(); + registry.register("typescript", LspServerStatus::Disconnected, None, vec![]); + + // when + let result = registry.dispatch("hover", Some("src/index.ts"), Some(3), Some(2), None); + + // then + let error = result.expect_err("disconnected server should fail"); + assert!(error.contains("typescript")); + assert!(error.contains("disconnected")); +} diff --git a/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs b/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs new file mode 100644 index 0000000000..67c2c5548d --- /dev/null +++ b/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs @@ -0,0 +1,294 @@ +//! Tests for the LSP client registry: extension mapping, server lifecycle, +//! and diagnostics edge cases. + +use super::*; +use super::types::*; + +#[test] +fn find_server_for_all_extensions() { + // given + let registry = LspRegistry::new(); + for language in [ + "rust", + "typescript", + "javascript", + "python", + "go", + "java", + "c", + "cpp", + "ruby", + "lua", + ] { + registry.register(language, LspServerStatus::Connected, None, vec![]); + } + let cases = [ + ("src/main.rs", "rust"), + ("src/index.ts", "typescript"), + ("src/view.tsx", "typescript"), + ("src/app.js", "javascript"), + ("src/app.jsx", "javascript"), + ("script.py", "python"), + ("main.go", "go"), + ("Main.java", "java"), + ("native.c", "c"), + ("native.h", "c"), + ("native.cpp", "cpp"), + ("native.hpp", "cpp"), + ("native.cc", "cpp"), + ("script.rb", "ruby"), + ("script.lua", "lua"), + ]; + + // when + let resolved: Vec<_> = cases + .into_iter() + .map(|(path, expected)| { + ( + path, + registry + .find_server_for_path(path) + .map(|server| server.language), + expected, + ) + }) + .collect(); + + // then + for (path, actual, expected) in resolved { + assert_eq!( + actual.as_deref(), + Some(expected), + "unexpected mapping for {path}" + ); + } +} + +#[test] +fn find_server_for_path_no_extension() { + // given + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Connected, None, vec![]); + + // when + let result = registry.find_server_for_path("Makefile"); + + // then + assert!(result.is_none()); +} + +#[test] +fn list_servers_with_multiple() { + // given + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Connected, None, vec![]); + registry.register("typescript", LspServerStatus::Starting, None, vec![]); + registry.register("python", LspServerStatus::Error, None, vec![]); + + // when + let servers = registry.list_servers(); + + // then + assert_eq!(servers.len(), 3); + assert!(servers.iter().any(|server| server.language == "rust")); + assert!(servers.iter().any(|server| server.language == "typescript")); + assert!(servers.iter().any(|server| server.language == "python")); +} + +#[test] +fn get_missing_server_returns_none() { + // given + let registry = LspRegistry::new(); + + // when + let server = registry.get("missing"); + + // then + assert!(server.is_none()); +} + +#[test] +fn add_diagnostics_missing_language_errors() { + // given + let registry = LspRegistry::new(); + + // when + let result = registry.add_diagnostics("missing", vec![]); + + // then + let error = result.expect_err("missing language should fail"); + assert!(error.contains("LSP server not found for language: missing")); +} + +#[test] +fn get_diagnostics_across_servers() { + // given + let registry = LspRegistry::new(); + let shared_path = "shared/file.txt"; + registry.register("rust", LspServerStatus::Connected, None, vec![]); + registry.register("python", LspServerStatus::Connected, None, vec![]); + registry + .add_diagnostics( + "rust", + vec![LspDiagnostic { + path: shared_path.into(), + line: 4, + character: 1, + severity: "warning".into(), + message: "warn".into(), + source: None, + }], + ) + .expect("rust diagnostics should add"); + registry + .add_diagnostics( + "python", + vec![LspDiagnostic { + path: shared_path.into(), + line: 8, + character: 3, + severity: "error".into(), + message: "err".into(), + source: None, + }], + ) + .expect("python diagnostics should add"); + + // when + let diagnostics = registry.get_diagnostics(shared_path); + + // then + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics + .iter() + .any(|diagnostic| diagnostic.message == "warn")); + assert!(diagnostics + .iter() + .any(|diagnostic| diagnostic.message == "err")); +} + +#[test] +fn clear_diagnostics_missing_language_errors() { + // given + let registry = LspRegistry::new(); + + // when + let result = registry.clear_diagnostics("missing"); + + // then + let error = result.expect_err("missing language should fail"); + assert!(error.contains("LSP server not found for language: missing")); +} + +#[test] +fn register_with_descriptor_stores_entry() { + let registry = LspRegistry::new(); + let descriptor = LspServerDescriptor { + language: "rust".into(), + command: "rust-analyzer".into(), + args: vec![], + extensions: vec!["rs".into()], + }; + registry.register_with_descriptor( + "rust", + LspServerStatus::Connected, + Some("/project"), + vec!["hover".into()], + descriptor, + ); + + let server = registry.get("rust").expect("should exist after register_with_descriptor"); + assert_eq!(server.language, "rust"); + assert_eq!(server.status, LspServerStatus::Connected); + assert_eq!(server.root_path.as_deref(), Some("/project")); + assert_eq!(server.capabilities, vec!["hover"]); +} + +#[test] +fn stop_server_on_nonexistent_errors() { + let registry = LspRegistry::new(); + let result = registry.stop_server("missing"); + assert!(result.is_err(), "stopping a nonexistent server should error"); + let error = result.unwrap_err(); + assert!(error.contains("missing"), "error message should reference 'missing', got: {error}"); +} + +/// This test requires rust-analyzer to be installed on the system. +/// Run with: cargo test -p runtime -- --ignored +#[test] +#[ignore = "requires rust-analyzer installed on PATH"] +fn start_server_without_descriptor_falls_back_to_discovery() { + let registry = LspRegistry::new(); + registry.register("rust", LspServerStatus::Starting, None, vec![]); + let result = registry.start_server("rust"); + assert!(result.is_ok(), "start_server should discover and start rust-analyzer: {result:?}"); + let server = registry.get("rust").expect("rust should be registered"); + assert_eq!(server.status, LspServerStatus::Connected); + let _ = registry.stop_server("rust"); +} + +/// This test requires rust-analyzer to be installed on the system. +/// Run with: cargo test -p runtime -- --ignored +#[test] +#[ignore = "requires rust-analyzer installed on PATH"] +fn dispatch_hover_lazy_starts_server() { + let registry = LspRegistry::new(); + let descriptor = crate::lsp_discovery::LspServerDescriptor { + language: "rust".into(), + command: "rust-analyzer".into(), + args: vec![], + extensions: vec!["rs".into()], + }; + registry.register_with_descriptor( + "rust", + LspServerStatus::Starting, + None, + vec![], + descriptor, + ); + // dispatch should trigger start_server because process is None + let result = registry.dispatch("hover", Some("src/main.rs"), Some(0), Some(0), None); + // Result may be Ok or Err depending on whether rust-analyzer can actually + // respond for this path, but it should not fail with "not connected" + // (which would indicate the lazy-start didn't kick in). + if let Err(e) = &result { + assert!( + !e.contains("not connected"), + "dispatch should have lazily started the server, got: {e}" + ); + } + let _ = registry.stop_server("rust"); +} + +/// This test requires rust-analyzer to be installed on the system. +/// Run with: cargo test -p runtime -- --ignored +#[test] +#[ignore = "requires rust-analyzer installed on PATH"] +fn start_and_stop_server() { + let registry = LspRegistry::new(); + let descriptor = crate::lsp_discovery::LspServerDescriptor { + language: "rust".into(), + command: "rust-analyzer".into(), + args: vec![], + extensions: vec!["rs".into()], + }; + registry.register_with_descriptor( + "rust", + LspServerStatus::Starting, + None, + vec![], + descriptor, + ); + + let start_result = registry.start_server("rust"); + assert!(start_result.is_ok(), "start_server should succeed: {start_result:?}"); + + let server = registry.get("rust").expect("rust should exist"); + assert_eq!(server.status, LspServerStatus::Connected); + + let stop_result = registry.stop_server("rust"); + assert!(stop_result.is_ok(), "stop_server should succeed: {stop_result:?}"); + + let server = registry.get("rust").expect("rust should still be in registry"); + assert_eq!(server.status, LspServerStatus::Disconnected); +} diff --git a/rust/crates/runtime/src/lsp_client/types.rs b/rust/crates/runtime/src/lsp_client/types.rs new file mode 100644 index 0000000000..485a569748 --- /dev/null +++ b/rust/crates/runtime/src/lsp_client/types.rs @@ -0,0 +1,103 @@ +//! LSP type definitions: action enums, diagnostic/location types, server status. + +use serde::{Deserialize, Serialize}; + +/// Supported LSP actions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LspAction { + Diagnostics, + Hover, + Definition, + References, + Completion, + Symbols, + Format, +} + +impl LspAction { + pub fn from_str(s: &str) -> Option { + match s { + "diagnostics" => Some(Self::Diagnostics), + "hover" => Some(Self::Hover), + "definition" | "goto_definition" => Some(Self::Definition), + "references" | "find_references" => Some(Self::References), + "completion" | "completions" => Some(Self::Completion), + "symbols" | "document_symbols" => Some(Self::Symbols), + "format" | "formatting" => Some(Self::Format), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspDiagnostic { + pub path: String, + pub line: u32, + pub character: u32, + pub severity: String, + pub message: String, + pub source: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspLocation { + pub path: String, + pub line: u32, + pub character: u32, + pub end_line: Option, + pub end_character: Option, + pub preview: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspHoverResult { + pub content: String, + pub language: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspCompletionItem { + pub label: String, + pub kind: Option, + pub detail: Option, + pub insert_text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspSymbol { + pub name: String, + pub kind: String, + pub path: String, + pub line: u32, + pub character: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LspServerStatus { + Connected, + Disconnected, + Starting, + Error, +} + +impl std::fmt::Display for LspServerStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Connected => write!(f, "connected"), + Self::Disconnected => write!(f, "disconnected"), + Self::Starting => write!(f, "starting"), + Self::Error => write!(f, "error"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspServerState { + pub language: String, + pub status: LspServerStatus, + pub root_path: Option, + pub capabilities: Vec, + pub diagnostics: Vec, +} diff --git a/rust/crates/runtime/src/lsp_process/mod.rs b/rust/crates/runtime/src/lsp_process/mod.rs new file mode 100644 index 0000000000..ed0fc1077b --- /dev/null +++ b/rust/crates/runtime/src/lsp_process/mod.rs @@ -0,0 +1,436 @@ +//! LSP process manager: spawns language servers and drives the LSP lifecycle. + +mod parse; + +#[cfg(test)] +mod tests; + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde_json::Value as JsonValue; + +use crate::lsp_client::{ + LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, +}; +use crate::lsp_transport::{LspTransport, LspTransportError}; + +use parse::{ + canonicalize_root, language_id_for_path, parse_completions, parse_hover, parse_locations, + parse_symbols, path_to_uri, severity_name, text_document_position_params, uri_to_path, +}; + +#[derive(Debug)] +pub struct LspProcess { + transport: LspTransport, + language: String, + root_uri: String, + capabilities: Option, + status: LspServerStatus, + open_files: HashSet, + version_counter: HashMap, +} + +#[allow(clippy::cast_possible_truncation)] +impl LspProcess { + /// Spawn a language server process and perform the LSP initialize handshake. + pub async fn start( + command: &str, + args: &[String], + root_path: &Path, + ) -> Result { + let transport = LspTransport::spawn(command, args) + .map_err(|e| LspProcessError::Transport(LspTransportError::Io(e)))?; + + let canonical = canonicalize_root(root_path)?; + let root_uri = format!("file://{canonical}"); + + let mut process = Self { + transport, + language: command.to_owned(), + root_uri: root_uri.clone(), + capabilities: None, + status: LspServerStatus::Starting, + open_files: HashSet::new(), + version_counter: HashMap::new(), + }; + + process.initialize(&canonical).await?; + process.status = LspServerStatus::Connected; + + Ok(process) + } + + /// Send the LSP `initialize` request followed by the `initialized` notification. + async fn initialize(&mut self, root_path: &str) -> Result { + let root_uri = format!("file://{root_path}"); + let pid = std::process::id(); + + let params = serde_json::json!({ + "processId": pid, + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "hover": { "contentFormat": ["markdown", "plaintext"] }, + "definition": { "linkSupport": true }, + "references": {}, + "completion": { + "completionItem": { "snippetSupport": false } + }, + "documentSymbol": { "hierarchicalDocumentSymbolSupport": true }, + "publishDiagnostics": { "relatedInformation": true } + } + } + }); + + let response = self + .transport + .send_request("initialize", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + self.capabilities = Some(result.clone()); + + self.transport + .send_notification("initialized", Some(serde_json::json!({}))) + .await + .map_err(LspProcessError::Transport)?; + + Ok(result) + } + + /// Gracefully shut down the language server. + pub async fn shutdown(&mut self) -> Result<(), LspProcessError> { + self.status = LspServerStatus::Disconnected; + + let shutdown_result = self + .transport + .send_request("shutdown", None) + .await + .map_err(LspProcessError::Transport); + + if shutdown_result.is_ok() { + self.transport + .send_notification("exit", None) + .await + .map_err(LspProcessError::Transport)?; + } + + self.transport + .shutdown() + .await + .map_err(LspProcessError::Transport)?; + + Ok(()) + } + + /// Query hover information at a position. + pub async fn hover( + &mut self, + path: &str, + line: u32, + character: u32, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = text_document_position_params(&uri, line, character); + + let response = self + .transport + .send_request("textDocument/hover", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + if result.is_null() { + return Ok(None); + } + + Ok(parse_hover(&result)) + } + + /// Go to definition at a position. + pub async fn goto_definition( + &mut self, + path: &str, + line: u32, + character: u32, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = text_document_position_params(&uri, line, character); + + let response = self + .transport + .send_request("textDocument/definition", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + Ok(parse_locations(&result)) + } + + /// Find references at a position. + pub async fn references( + &mut self, + path: &str, + line: u32, + character: u32, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character }, + "context": { "includeDeclaration": true } + }); + + let response = self + .transport + .send_request("textDocument/references", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + Ok(parse_locations(&result)) + } + + /// Get document symbols for a file. + pub async fn document_symbols( + &mut self, + path: &str, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri } + }); + + let response = self + .transport + .send_request("textDocument/documentSymbol", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + if result.is_null() { + return Ok(Vec::new()); + } + + Ok(parse_symbols(&result, path)) + } + + /// Get completions at a position. + pub async fn completion( + &mut self, + path: &str, + line: u32, + character: u32, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = text_document_position_params(&uri, line, character); + + let response = self + .transport + .send_request("textDocument/completion", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + if result.is_null() { + return Ok(Vec::new()); + } + + // The response may be a CompletionList or a plain array. + let items = if let Some(list) = result.get("items") { + list + } else { + &result + }; + + Ok(parse_completions(items)) + } + + /// Format a document. + pub async fn format(&mut self, path: &str) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri }, + "options": { "tabSize": 4, "insertSpaces": true } + }); + + let response = self + .transport + .send_request("textDocument/formatting", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + + if result.is_null() { + return Ok(Vec::new()); + } + + match result.as_array() { + Some(arr) => Ok(arr.clone()), + None => Ok(Vec::new()), + } + } + + /// Notify the server that a file was opened. Sends `textDocument/didOpen`. + /// No-op if the file is already tracked as open. + pub async fn did_open(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> { + if self.open_files.contains(path) { + return Ok(()); + } + + let uri = path_to_uri(path); + let language_id = language_id_for_path(path); + let params = serde_json::json!({ + "textDocument": { + "uri": uri, + "languageId": language_id, + "version": 0, + "text": content + } + }); + + self.transport + .send_notification("textDocument/didOpen", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + self.open_files.insert(path.to_owned()); + self.version_counter.insert(path.to_owned(), 0); + Ok(()) + } + + /// Notify the server that a file's content changed. Sends `textDocument/didChange`. + pub async fn did_change(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> { + let version = self.version_counter.get(path).map_or(1, |v| v + 1); + + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri, "version": version }, + "contentChanges": [{ "text": content }] + }); + + self.transport + .send_notification("textDocument/didChange", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + self.version_counter.insert(path.to_owned(), version); + Ok(()) + } + + /// Drain queued server notifications and extract `publishDiagnostics`. + #[allow(clippy::redundant_closure_for_method_calls)] + pub fn drain_diagnostics(&mut self) -> Vec { + let notifications = self.transport.drain_notifications(); + let mut diagnostics = Vec::new(); + for n in ¬ifications { + if n.method == "textDocument/publishDiagnostics" { + if let Some(params) = &n.params { + if let Some(uri) = params.get("uri").and_then(|v| v.as_str()) { + let path = uri_to_path(uri); + if let Some(diags) = params.get("diagnostics").and_then(|v| v.as_array()) + { + for d in diags { + diagnostics.push(LspDiagnostic { + path: path.clone(), + line: d + .get("range") + .and_then(|r| r.get("start")) + .and_then(|s| s.get("line")) + .and_then(|v| v.as_u64()) + .map_or(0, |v| v as u32), + character: d + .get("range") + .and_then(|r| r.get("start")) + .and_then(|s| s.get("character")) + .and_then(|v| v.as_u64()) + .map_or(0, |v| v as u32), + severity: d + .get("severity") + .and_then(|v| v.as_u64()) + .map_or_else(|| "error".to_owned(), severity_name), + message: d + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(), + source: d + .get("source") + .and_then(|v| v.as_str()) + .map(str::to_owned), + }); + } + } + } + } + } + } + diagnostics + } + + #[must_use] + pub fn status(&self) -> LspServerStatus { + self.status + } + + #[must_use] + pub fn language(&self) -> &str { + &self.language + } + + #[must_use] + pub fn root_uri(&self) -> &str { + &self.root_uri + } +} + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub enum LspProcessError { + Transport(LspTransportError), + InvalidPath(String), +} + +impl std::fmt::Display for LspProcessError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Transport(e) => write!(f, "LSP transport error: {e}"), + Self::InvalidPath(p) => write!(f, "invalid path: {p}"), + } + } +} + +impl std::error::Error for LspProcessError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Transport(e) => Some(e), + Self::InvalidPath(_) => None, + } + } +} diff --git a/rust/crates/runtime/src/lsp_process/parse.rs b/rust/crates/runtime/src/lsp_process/parse.rs new file mode 100644 index 0000000000..575f425a75 --- /dev/null +++ b/rust/crates/runtime/src/lsp_process/parse.rs @@ -0,0 +1,311 @@ +//! Helper functions for LSP URI/path conversion, parameter building, and +//! response parsing. + +use std::path::Path; + +use serde_json::Value as JsonValue; + +use crate::lsp_client::{LspCompletionItem, LspHoverResult, LspLocation, LspSymbol}; +use crate::lsp_process::LspProcessError; + +pub(super) fn canonicalize_root(path: &Path) -> Result { + path.canonicalize() + .map_err(|e| LspProcessError::InvalidPath(format!("{}: {e}", path.display()))) + .map(|p| p.to_string_lossy().into_owned()) +} + +pub(super) fn path_to_uri(path: &str) -> String { + let canonical = std::path::Path::new(path); + if canonical.is_absolute() { + format!("file://{path}") + } else { + let resolved = std::env::current_dir() + .map_or_else(|_| canonical.to_path_buf(), |d| d.join(path)); + let canonicalized = resolved + .canonicalize() + .unwrap_or(resolved) + .to_string_lossy() + .into_owned(); + format!("file://{canonicalized}") + } +} + +pub(super) fn text_document_position_params(uri: &str, line: u32, character: u32) -> JsonValue { + serde_json::json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character } + }) +} + +pub(super) fn uri_to_path(uri: &str) -> String { + uri.strip_prefix("file://").unwrap_or(uri).to_owned() +} + +pub(super) fn language_id_for_path(path: &str) -> String { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + match ext { + "rs" => "rust", + "ts" => "typescript", + "tsx" => "typescriptreact", + "js" => "javascript", + "jsx" => "javascriptreact", + "py" => "python", + "go" => "go", + "java" => "java", + "c" | "h" => "c", + "cpp" | "hpp" | "cc" => "cpp", + "rb" => "ruby", + "lua" => "lua", + _ => ext, + } + .to_owned() +} + +pub(super) fn severity_name(code: u64) -> String { + match code { + 1 => "error".to_owned(), + 2 => "warning".to_owned(), + 3 => "info".to_owned(), + 4 => "hint".to_owned(), + _ => format!("unknown({code})"), + } +} + +pub(super) fn parse_hover(value: &JsonValue) -> Option { + let contents = value.get("contents")?; + + // MarkupContent: { kind, value } + if let (Some(kind), Some(val)) = (contents.get("kind"), contents.get("value")) { + let language = if kind.as_str() == Some("plaintext") { + None + } else { + Some(kind.as_str().unwrap_or("markdown").to_owned()) + }; + return Some(LspHoverResult { + content: val.as_str().unwrap_or("").to_owned(), + language, + }); + } + + // MarkedString object: { language, value } + if let (Some(lang), Some(val)) = (contents.get("language"), contents.get("value")) { + return Some(LspHoverResult { + content: val.as_str().unwrap_or("").to_owned(), + language: Some(lang.as_str().unwrap_or("").to_owned()), + }); + } + + // Plain string MarkedString + if let Some(s) = contents.as_str() { + return Some(LspHoverResult { + content: s.to_owned(), + language: None, + }); + } + + // Array of MarkedString + if let Some(arr) = contents.as_array() { + let parts: Vec<&str> = arr + .iter() + .filter_map(|item| { + if let Some(s) = item.as_str() { + Some(s) + } else { + item.get("value").and_then(JsonValue::as_str) + } + }) + .collect(); + if parts.is_empty() { + return None; + } + return Some(LspHoverResult { + content: parts.join("\n"), + language: None, + }); + } + + None +} + +#[allow(clippy::cast_possible_truncation)] +pub(super) fn parse_locations(value: &JsonValue) -> Vec { + let Some(locations) = value.as_array() else { + return Vec::new(); + }; + + locations + .iter() + .filter_map(|loc| { + let uri = loc.get("uri")?.as_str()?; + let path = uri_to_path(uri); + let range = loc.get("range")?; + let start = range.get("start")?; + let end = range.get("end")?; + + Some(LspLocation { + path, + line: start.get("line")?.as_u64()? as u32, + character: start.get("character")?.as_u64()? as u32, + end_line: end + .get("line") + .and_then(JsonValue::as_u64) + .map(|v| v as u32), + end_character: end + .get("character") + .and_then(JsonValue::as_u64) + .map(|v| v as u32), + preview: None, + }) + }) + .collect() +} + +fn extract_symbols(items: &[JsonValue], path: &str, out: &mut Vec) { + for item in items { + let name = item.get("name").and_then(JsonValue::as_str).unwrap_or(""); + let kind = item + .get("kind") + .and_then(JsonValue::as_u64) + .map_or_else(|| "Unknown".into(), symbol_kind_name); + + let (sym_path, line, character) = if let Some(range) = item.get("range") { + let start = range.get("start"); + ( + path.to_owned(), + u32::try_from( + start + .and_then(|s| s.get("line")) + .and_then(JsonValue::as_u64) + .unwrap_or(0), + ) + .unwrap_or(0), + u32::try_from( + start + .and_then(|s| s.get("character")) + .and_then(JsonValue::as_u64) + .unwrap_or(0), + ) + .unwrap_or(0), + ) + } else { + (path.to_owned(), 0, 0) + }; + + out.push(LspSymbol { + name: name.to_owned(), + kind: kind.clone(), + path: sym_path, + line, + character, + }); + + if let Some(children) = item.get("children").and_then(JsonValue::as_array) { + extract_symbols(children, path, out); + } + } +} + +pub(super) fn parse_symbols(value: &JsonValue, default_path: &str) -> Vec { + let Some(items) = value.as_array() else { + return Vec::new(); + }; + + let mut result = Vec::new(); + extract_symbols(items, default_path, &mut result); + result +} + +pub(super) fn parse_completions(value: &JsonValue) -> Vec { + let Some(items) = value.as_array() else { + return Vec::new(); + }; + + items + .iter() + .map(|item| LspCompletionItem { + label: item + .get("label") + .and_then(JsonValue::as_str) + .unwrap_or("") + .to_owned(), + kind: item + .get("kind") + .and_then(JsonValue::as_u64) + .map(completion_kind_name), + detail: item + .get("detail") + .and_then(JsonValue::as_str) + .map(str::to_owned), + insert_text: item + .get("insertText") + .and_then(JsonValue::as_str) + .map(str::to_owned), + }) + .collect() +} + +pub(super) fn symbol_kind_name(kind: u64) -> String { + match kind { + 1 => "File".into(), + 2 => "Module".into(), + 3 => "Namespace".into(), + 4 => "Package".into(), + 5 => "Class".into(), + 6 => "Method".into(), + 7 => "Property".into(), + 8 => "Field".into(), + 9 => "Constructor".into(), + 10 => "Enum".into(), + 11 => "Interface".into(), + 12 => "Function".into(), + 13 => "Variable".into(), + 14 => "Constant".into(), + 15 => "String".into(), + 16 => "Number".into(), + 17 => "Boolean".into(), + 18 => "Array".into(), + 19 => "Object".into(), + 20 => "Key".into(), + 21 => "Null".into(), + 22 => "EnumMember".into(), + 23 => "Struct".into(), + 24 => "Event".into(), + 25 => "Operator".into(), + 26 => "TypeParameter".into(), + _ => format!("Unknown({kind})"), + } +} + +pub(super) fn completion_kind_name(kind: u64) -> String { + match kind { + 1 => "Text".into(), + 2 => "Method".into(), + 3 => "Function".into(), + 4 => "Constructor".into(), + 5 => "Field".into(), + 6 => "Variable".into(), + 7 => "Class".into(), + 8 => "Interface".into(), + 9 => "Module".into(), + 10 => "Property".into(), + 11 => "Unit".into(), + 12 => "Value".into(), + 13 => "Enum".into(), + 14 => "Keyword".into(), + 15 => "Snippet".into(), + 16 => "Color".into(), + 17 => "File".into(), + 18 => "Reference".into(), + 19 => "Folder".into(), + 20 => "EnumMember".into(), + 21 => "Constant".into(), + 22 => "Struct".into(), + 23 => "Event".into(), + 24 => "Operator".into(), + 25 => "TypeParameter".into(), + _ => format!("Unknown({kind})"), + } +} diff --git a/rust/crates/runtime/src/lsp_process/tests.rs b/rust/crates/runtime/src/lsp_process/tests.rs new file mode 100644 index 0000000000..1d2ab55457 --- /dev/null +++ b/rust/crates/runtime/src/lsp_process/tests.rs @@ -0,0 +1,194 @@ +use super::*; +use super::parse::*; + +/// Requires rust-analyzer to be installed on the system. +/// Run with: cargo test -p runtime -- --ignored +#[tokio::test] +#[ignore = "requires rust-analyzer installed on PATH"] +async fn spawn_and_initialize_rust_analyzer() { + let root = std::env::current_dir().expect("should have cwd"); + let process = LspProcess::start("rust-analyzer", &[], &root).await; + assert!(process.is_ok(), "should spawn and initialize rust-analyzer"); + + let mut process = process.unwrap(); + assert_eq!(process.status(), LspServerStatus::Connected); + assert_eq!(process.language(), "rust-analyzer"); + + let shutdown_result = process.shutdown().await; + assert!(shutdown_result.is_ok(), "shutdown should succeed: {shutdown_result:?}"); +} + +/// Requires rust-analyzer to be installed and a Rust project on disk. +/// Run with: cargo test -p runtime -- --ignored +#[tokio::test] +#[ignore = "requires rust-analyzer installed on PATH"] +async fn hover_on_real_file() { + let root = std::env::current_dir().expect("should have cwd"); + let mut process = LspProcess::start("rust-analyzer", &[], &root) + .await + .expect("should start rust-analyzer"); + + // Try hover on src/main.rs — the result might be None if the file + // doesn't exist at that path, but the call itself should not error. + let file_path = root.join("src").join("main.rs"); + let path_str = file_path.to_string_lossy(); + let result = process.hover(&path_str, 0, 0).await; + assert!(result.is_ok(), "hover should not return an error: {:?}", result.err()); + + let _ = process.shutdown().await; +} + +#[test] +fn parse_hover_markup_content() { + let value = serde_json::json!({ + "contents": { + "kind": "plaintext", + "value": "fn main()" + } + }); + let result = parse_hover(&value); + assert!(result.is_some()); + let hover = result.unwrap(); + assert_eq!(hover.content, "fn main()"); +} + +#[test] +fn parse_hover_marked_string_object() { + let value = serde_json::json!({ + "contents": { + "language": "rust", + "value": "pub fn foo()" + } + }); + let result = parse_hover(&value); + assert!(result.is_some()); + let hover = result.unwrap(); + assert_eq!(hover.content, "pub fn foo()"); + assert_eq!(hover.language.as_deref(), Some("rust")); +} + +#[test] +fn parse_hover_plain_string() { + let value = serde_json::json!({ + "contents": "some text" + }); + let result = parse_hover(&value); + assert!(result.is_some()); + let hover = result.unwrap(); + assert_eq!(hover.content, "some text"); + assert!(hover.language.is_none()); +} + +#[test] +fn parse_hover_array_of_marked_strings() { + let value = serde_json::json!({ + "contents": [ + "first line", + { "language": "rust", "value": "fn bar()" } + ] + }); + let result = parse_hover(&value); + assert!(result.is_some()); + let hover = result.unwrap(); + assert!(hover.content.contains("first line")); + assert!(hover.content.contains("fn bar()")); +} + +#[test] +fn parse_locations_empty_array() { + let value = serde_json::json!([]); + let locations = parse_locations(&value); + assert!(locations.is_empty()); +} + +#[test] +fn parse_locations_valid() { + let value = serde_json::json!([ + { + "uri": "file:///tmp/test.rs", + "range": { + "start": { "line": 5, "character": 10 }, + "end": { "line": 5, "character": 15 } + } + } + ]); + let locations = parse_locations(&value); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].line, 5); + assert_eq!(locations[0].character, 10); + assert_eq!(locations[0].end_line, Some(5)); + assert_eq!(locations[0].end_character, Some(15)); +} + +#[test] +fn parse_symbols_basic() { + let value = serde_json::json!([ + { + "name": "main", + "kind": 12, + "range": { + "start": { "line": 1, "character": 0 }, + "end": { "line": 5, "character": 1 } + } + } + ]); + let symbols = parse_symbols(&value, "/tmp/test.rs"); + assert_eq!(symbols.len(), 1); + assert_eq!(symbols[0].name, "main"); + assert_eq!(symbols[0].kind, "Function"); + assert_eq!(symbols[0].line, 1); +} + +#[test] +fn parse_completions_basic() { + let value = serde_json::json!([ + { "label": "foo", "kind": 3, "detail": "fn foo()" }, + { "label": "bar", "kind": 6 } + ]); + let completions = parse_completions(&value); + assert_eq!(completions.len(), 2); + assert_eq!(completions[0].label, "foo"); + assert_eq!(completions[0].kind.as_deref(), Some("Function")); + assert_eq!(completions[0].detail.as_deref(), Some("fn foo()")); + assert_eq!(completions[1].label, "bar"); + assert_eq!(completions[1].kind.as_deref(), Some("Variable")); +} + +#[test] +fn symbol_kind_name_all_variants() { + assert_eq!(symbol_kind_name(1), "File"); + assert_eq!(symbol_kind_name(6), "Method"); + assert_eq!(symbol_kind_name(12), "Function"); + assert_eq!(symbol_kind_name(13), "Variable"); + assert_eq!(symbol_kind_name(23), "Struct"); + assert_eq!(symbol_kind_name(99), "Unknown(99)"); +} + +#[test] +fn completion_kind_name_all_variants() { + assert_eq!(completion_kind_name(1), "Text"); + assert_eq!(completion_kind_name(3), "Function"); + assert_eq!(completion_kind_name(6), "Variable"); + assert_eq!(completion_kind_name(14), "Keyword"); + assert_eq!(completion_kind_name(99), "Unknown(99)"); +} + +#[test] +fn text_document_position_params_structure() { + let params = text_document_position_params("file:///test.rs", 5, 10); + assert_eq!(params["textDocument"]["uri"], "file:///test.rs"); + assert_eq!(params["position"]["line"], 5); + assert_eq!(params["position"]["character"], 10); +} + +#[test] +fn path_to_uri_absolute() { + let uri = path_to_uri("/tmp/test.rs"); + assert_eq!(uri, "file:///tmp/test.rs"); +} + +#[test] +fn uri_to_path_extracts_path() { + assert_eq!(uri_to_path("file:///tmp/test.rs"), "/tmp/test.rs"); + assert_eq!(uri_to_path("/no/prefix"), "/no/prefix"); +} diff --git a/rust/crates/runtime/src/lsp_transport/mod.rs b/rust/crates/runtime/src/lsp_transport/mod.rs new file mode 100644 index 0000000000..fa95c9962b --- /dev/null +++ b/rust/crates/runtime/src/lsp_transport/mod.rs @@ -0,0 +1,425 @@ +use std::io; +use std::process::Stdio; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::time::timeout; + +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum LspId { + Number(u64), + String(String), + Null, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LspRequest { + pub jsonrpc: String, + pub id: LspId, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl LspRequest { + pub fn new(id: LspId, method: impl Into, params: Option) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id, + method: method.into(), + params, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LspNotification { + pub jsonrpc: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl LspNotification { + pub fn new(method: impl Into, params: Option) -> Self { + Self { + jsonrpc: "2.0".to_string(), + method: method.into(), + params, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LspError { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LspResponse { + pub jsonrpc: String, + pub id: LspId, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl LspResponse { + #[must_use] + pub fn is_error(&self) -> bool { + self.error.is_some() + } + + pub fn into_result(self) -> Result { + if let Some(error) = self.error { + Err(error) + } else { + Ok(self.result.unwrap_or(JsonValue::Null)) + } + } +} + +/// A message received from an LSP server — either a response to a request +/// or a server-initiated notification (e.g. `textDocument/publishDiagnostics`). +#[derive(Debug, Clone)] +pub enum LspServerMessage { + Response(LspResponse), + Notification(LspNotification), +} + +#[derive(Debug)] +pub enum LspTransportError { + Io(io::Error), + Timeout { method: String, timeout: Duration }, + JsonRpc(LspError), + InvalidResponse { method: String, details: String }, + ServerExited, +} + +impl std::fmt::Display for LspTransportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(error) => write!(f, "{error}"), + Self::Timeout { method, timeout } => { + write!(f, "LSP request `{method}` timed out after {}s", timeout.as_secs()) + } + Self::JsonRpc(error) => { + write!(f, "LSP JSON-RPC error: {} ({})", error.message, error.code) + } + Self::InvalidResponse { method, details } => { + write!(f, "LSP invalid response for `{method}`: {details}") + } + Self::ServerExited => write!(f, "LSP server process exited unexpectedly"), + } + } +} + +impl std::error::Error for LspTransportError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::JsonRpc(_) | Self::Timeout { .. } | Self::InvalidResponse { .. } | Self::ServerExited => None, + } + } +} + +impl From for LspTransportError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +#[derive(Debug)] +pub struct LspTransport { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, + request_timeout: Duration, + pending_notifications: Vec, +} + +impl LspTransport { + pub fn spawn(command: &str, args: &[String]) -> io::Result { + Self::spawn_with_timeout(command, args, DEFAULT_REQUEST_TIMEOUT) + } + + pub fn spawn_with_timeout( + command: &str, + args: &[String], + request_timeout: Duration, + ) -> io::Result { + let mut cmd = Command::new(command); + cmd.args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn()?; + let stdin = child + .stdin + .take() + .ok_or_else(|| io::Error::other("LSP process missing stdin pipe"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("LSP process missing stdout pipe"))?; + + Ok(Self { + child, + stdin, + stdout: BufReader::new(stdout), + next_id: 1, + request_timeout, + pending_notifications: Vec::new(), + }) + } + + /// Construct an `LspTransport` from an already-spawned child process. + /// Primarily useful for testing. + #[cfg(test)] + fn from_child(mut child: Child, request_timeout: Duration) -> Self { + let stdin = child + .stdin + .take() + .expect("LSP process missing stdin pipe"); + let stdout = child + .stdout + .take() + .expect("LSP process missing stdout pipe"); + Self { + child, + stdin, + stdout: BufReader::new(stdout), + next_id: 1, + request_timeout, + pending_notifications: Vec::new(), + } + } + + fn allocate_id(&mut self) -> LspId { + let id = self.next_id; + self.next_id += 1; + LspId::Number(id) + } + + pub async fn send_notification( + &mut self, + method: &str, + params: Option, + ) -> Result<(), LspTransportError> { + let notification = LspNotification::new(method, params); + let body = serde_json::to_vec(¬ification) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + self.write_frame(&body).await + } + + pub async fn send_request( + &mut self, + method: &str, + params: Option, + ) -> Result { + let id = self.allocate_id(); + self.send_request_with_id(method, params, id).await + } + + pub async fn send_request_with_id( + &mut self, + method: &str, + params: Option, + id: LspId, + ) -> Result { + let request = LspRequest::new(id.clone(), method, params); + let body = serde_json::to_vec(&request) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + self.write_frame(&body).await?; + + let method_owned = method.to_string(); + let timeout_duration = self.request_timeout; + let response = match timeout(timeout_duration, async { + loop { + match self.read_message().await { + Ok(LspServerMessage::Response(r)) => break Ok(r), + Ok(LspServerMessage::Notification(n)) => { + self.pending_notifications.push(n); + } + Err(e) => break Err(e), + } + } + }) + .await + { + Ok(inner) => inner, + Err(_) => { + return Err(LspTransportError::Timeout { + method: method_owned, + timeout: timeout_duration, + }) + } + }?; + + if response.jsonrpc != "2.0" { + return Err(LspTransportError::InvalidResponse { + method: method.to_string(), + details: format!("unsupported jsonrpc version `{}`", response.jsonrpc), + }); + } + + if response.id != id { + return Err(LspTransportError::InvalidResponse { + method: method.to_string(), + details: format!( + "mismatched id: expected {:?}, got {:?}", + id, response.id + ), + }); + } + + if let Some(error) = &response.error { + return Err(LspTransportError::JsonRpc(error.clone())); + } + + Ok(response) + } + + /// Read a single message from the server, returning either a response or + /// a server-initiated notification (e.g. `publishDiagnostics`). + pub async fn read_message(&mut self) -> Result { + let payload = self.read_frame().await?; + let value: JsonValue = serde_json::from_slice(&payload).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: error.to_string(), + } + })?; + + // Responses have an "id" field; notifications have "method" but no "id" + if value.get("id").is_some() { + let response: LspResponse = serde_json::from_value(value).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: format!("failed to parse response: {error}"), + } + })?; + Ok(LspServerMessage::Response(response)) + } else if value.get("method").is_some() { + let notification: LspNotification = serde_json::from_value(value).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: format!("failed to parse notification: {error}"), + } + })?; + Ok(LspServerMessage::Notification(notification)) + } else { + Err(LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: "message has neither 'id' nor 'method'".to_string(), + }) + } + } + + /// Read a response from the server. Interleaved notifications are queued. + pub async fn read_response(&mut self) -> Result { + loop { + match self.read_message().await? { + LspServerMessage::Response(r) => return Ok(r), + LspServerMessage::Notification(n) => { + self.pending_notifications.push(n); + } + } + } + } + + /// Drain and return all queued server-initiated notifications. + pub fn drain_notifications(&mut self) -> Vec { + std::mem::take(&mut self.pending_notifications) + } + + pub async fn shutdown(&mut self) -> Result<(), LspTransportError> { + let _ = self + .send_notification("shutdown", None) + .await; + + let _ = self.send_notification("exit", None).await; + + match self.child.try_wait() { + Ok(Some(_)) => {} + Ok(None) | Err(_) => { + let _ = self.child.kill().await; + } + } + + Ok(()) + } + + pub fn is_alive(&mut self) -> bool { + matches!(self.child.try_wait(), Ok(None)) + } + + async fn write_frame(&mut self, payload: &[u8]) -> Result<(), LspTransportError> { + let header = format!("Content-Length: {}\r\n\r\n", payload.len()); + self.stdin.write_all(header.as_bytes()).await?; + self.stdin.write_all(payload).await?; + self.stdin.flush().await?; + Ok(()) + } + + async fn read_frame(&mut self) -> Result, LspTransportError> { + let mut content_length: Option = None; + + loop { + let mut line = String::new(); + let bytes_read = self.stdout.read_line(&mut line).await?; + if bytes_read == 0 { + return Err(LspTransportError::ServerExited); + } + if line == "\r\n" { + break; + } + let header = line.trim_end_matches(['\r', '\n']); + if let Some((name, value)) = header.split_once(':') { + if name.trim().eq_ignore_ascii_case("Content-Length") { + let parsed = value + .trim() + .parse::() + .map_err(|error| LspTransportError::Io(io::Error::new( + io::ErrorKind::InvalidData, + error, + )))?; + content_length = Some(parsed); + } + } + } + + let content_length = content_length.ok_or_else(|| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: "missing Content-Length header".to_string(), + } + })?; + + let mut payload = vec![0u8; content_length]; + self.stdout.read_exact(&mut payload).await.map_err(|error| { + if error.kind() == io::ErrorKind::UnexpectedEof { + LspTransportError::ServerExited + } else { + LspTransportError::Io(error) + } + })?; + + Ok(payload) + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/crates/runtime/src/lsp_transport/tests.rs b/rust/crates/runtime/src/lsp_transport/tests.rs new file mode 100644 index 0000000000..8e4d112099 --- /dev/null +++ b/rust/crates/runtime/src/lsp_transport/tests.rs @@ -0,0 +1,134 @@ +use super::*; +use std::io::Cursor; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; + +#[test] +fn content_length_header_roundtrip() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + let payload = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":null}"#; + + // Write frame into a buffer + let mut write_buf = Vec::new(); + { + let header = format!("Content-Length: {}\r\n\r\n", payload.len()); + write_buf.extend_from_slice(header.as_bytes()); + write_buf.extend_from_slice(payload); + } + + // Read frame back using the same logic as LspTransport::read_frame + let cursor = Cursor::new(write_buf); + let mut reader = BufReader::new(cursor); + + let mut content_length: Option = None; + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line).await.unwrap(); + assert!(bytes_read > 0, "unexpected EOF reading header"); + if line == "\r\n" { + break; + } + let header = line.trim_end_matches(['\r', '\n']); + if let Some((name, value)) = header.split_once(':') { + if name.trim().eq_ignore_ascii_case("Content-Length") { + content_length = Some(value.trim().parse::().unwrap()); + } + } + } + + let content_length = content_length.expect("should have Content-Length"); + assert_eq!(content_length, payload.len()); + + let mut read_payload = vec![0u8; content_length]; + reader.read_exact(&mut read_payload).await.unwrap(); + + let original: serde_json::Value = serde_json::from_slice(payload).unwrap(); + let roundtripped: serde_json::Value = serde_json::from_slice(&read_payload).unwrap(); + assert_eq!(original, roundtripped); + }); +} + +#[test] +fn request_has_incrementing_ids() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + // Spawn cat so we can construct a real LspTransport. + let child = tokio::process::Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("cat should be available"); + + let mut transport = LspTransport::from_child(child, Duration::from_secs(5)); + + // Allocate IDs by inspecting what send_request would produce. + let id1 = transport.allocate_id(); + let id2 = transport.allocate_id(); + let id3 = transport.allocate_id(); + + assert_eq!(id1, LspId::Number(1)); + assert_eq!(id2, LspId::Number(2)); + assert_eq!(id3, LspId::Number(3)); + + // Clean up + let _ = transport.shutdown().await; + }); +} + +#[test] +fn notification_has_no_id() { + let notification = LspNotification::new("initialized", Some(serde_json::json!({}))); + let serialized = serde_json::to_string(¬ification).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + assert!( + parsed.get("id").is_none(), + "notification should not contain an 'id' field, got: {serialized}" + ); + assert_eq!(parsed["jsonrpc"], "2.0"); + assert_eq!(parsed["method"], "initialized"); +} + +#[test] +fn malformed_header_handling() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + // Feed garbage bytes that don't contain a valid Content-Length header. + let garbage = b"THIS IS NOT A VALID HEADER\r\n\r\n"; + let cursor = Cursor::new(garbage.to_vec()); + let mut reader = BufReader::new(cursor); + + let mut content_length: Option = None; + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line).await.unwrap(); + if bytes_read == 0 || line == "\r\n" { + break; + } + let header = line.trim_end_matches(['\r', '\n']); + if let Some((name, value)) = header.split_once(':') { + if name.trim().eq_ignore_ascii_case("Content-Length") { + content_length = value.trim().parse::().ok(); + } + } + } + + // The garbage header should not produce a valid Content-Length. + assert!( + content_length.is_none(), + "garbage input should not produce a valid Content-Length" + ); + }); +} From 55775dec7eb6fdb5ff8b6827ef930f7505d57d7a Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Mon, 27 Apr 2026 08:01:40 -0500 Subject: [PATCH 12/16] feat(lsp): add lspAutoStart config, remove unused LSP client/process/transport modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add lsp_auto_start field to RuntimeFeatureConfig (default: true) - Add lspAutoStart bool field validation in config_validate - Parse lspAutoStart from config JSON - Auto-start discovered LSP servers on REPL init when enabled - Add /lsp toggle command to enable/disable auto-start at runtime - Remove lsp_client.rs, lsp_process.rs, lsp_transport.rs (2831 lines) — functionality consolidated into discovery-based auto-start - Show auto-start status in /lsp status output --- rust/crates/runtime/src/config.rs | 39 +- rust/crates/runtime/src/config_validate.rs | 4 + rust/crates/runtime/src/lsp_client.rs | 1338 -------------------- rust/crates/runtime/src/lsp_discovery.rs | 52 +- rust/crates/runtime/src/lsp_process.rs | 929 -------------- rust/crates/runtime/src/lsp_transport.rs | 560 -------- rust/crates/rusty-claude-cli/src/main.rs | 29 +- 7 files changed, 120 insertions(+), 2831 deletions(-) delete mode 100644 rust/crates/runtime/src/lsp_client.rs delete mode 100644 rust/crates/runtime/src/lsp_process.rs delete mode 100644 rust/crates/runtime/src/lsp_transport.rs diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 3c944f3ef0..4b6663fb23 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -60,13 +60,14 @@ pub struct LspServerConfig { } /// Structured feature configuration consumed by runtime subsystems. -#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeFeatureConfig { hooks: RuntimeHookConfig, plugins: RuntimePluginConfig, mcp: McpConfigCollection, oauth: Option, model: Option, + lsp_auto_start: bool, aliases: BTreeMap, permission_mode: Option, permission_rules: RuntimePermissionRuleConfig, @@ -77,6 +78,27 @@ pub struct RuntimeFeatureConfig { lsp: BTreeMap, } +impl Default for RuntimeFeatureConfig { + fn default() -> Self { + Self { + hooks: RuntimeHookConfig::default(), + plugins: RuntimePluginConfig::default(), + mcp: McpConfigCollection::default(), + oauth: None, + model: None, + lsp_auto_start: true, + aliases: BTreeMap::new(), + permission_mode: None, + permission_rules: RuntimePermissionRuleConfig::default(), + sandbox: SandboxConfig::default(), + provider_fallbacks: ProviderFallbackConfig::default(), + trusted_roots: Vec::new(), + provider: RuntimeProviderConfig::default(), + lsp: BTreeMap::new(), + } + } +} + /// Stored provider configuration from the setup wizard. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeProviderConfig { @@ -358,6 +380,11 @@ impl ConfigLoader { trusted_roots: parse_optional_trusted_roots(&merged_value)?, provider: parse_optional_provider_config(&merged_value)?, lsp: parse_optional_lsp_config(&merged_value)?, + lsp_auto_start: merged_value + .as_object() + .and_then(|o| o.get("lspAutoStart")) + .and_then(JsonValue::as_bool) + .unwrap_or(true), }; Ok(RuntimeConfig { @@ -467,6 +494,11 @@ impl RuntimeConfig { pub fn lsp(&self) -> &BTreeMap { &self.feature_config.lsp } + + #[must_use] + pub fn lsp_auto_start(&self) -> bool { + self.feature_config.lsp_auto_start + } } impl RuntimeFeatureConfig { @@ -546,6 +578,11 @@ impl RuntimeFeatureConfig { pub fn lsp(&self) -> &BTreeMap { &self.lsp } + + #[must_use] + pub fn lsp_auto_start(&self) -> bool { + self.lsp_auto_start + } } impl ProviderFallbackConfig { diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index a8bfb6dcff..fb8b9841d8 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -205,6 +205,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ name: "lsp", expected: FieldType::Object, }, + FieldSpec { + name: "lspAutoStart", + expected: FieldType::Bool, + }, ]; const HOOKS_FIELDS: &[FieldSpec] = &[ diff --git a/rust/crates/runtime/src/lsp_client.rs b/rust/crates/runtime/src/lsp_client.rs deleted file mode 100644 index e7e4e12136..0000000000 --- a/rust/crates/runtime/src/lsp_client.rs +++ /dev/null @@ -1,1338 +0,0 @@ -#![allow(clippy::should_implement_trait, clippy::must_use_candidate)] -//! LSP (Language Server Protocol) client registry for tool dispatch. - -use std::collections::{HashMap, HashSet}; -use std::path::Path; -use std::sync::{Arc, Mutex}; - -use serde::{Deserialize, Serialize}; - -use crate::lsp_discovery::{discover_available_servers, LspServerDescriptor}; -use crate::lsp_process::LspProcess; - -/// Supported LSP actions. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum LspAction { - Diagnostics, - Hover, - Definition, - References, - Completion, - Symbols, - Format, -} - -impl LspAction { - pub fn from_str(s: &str) -> Option { - match s { - "diagnostics" => Some(Self::Diagnostics), - "hover" => Some(Self::Hover), - "definition" | "goto_definition" => Some(Self::Definition), - "references" | "find_references" => Some(Self::References), - "completion" | "completions" => Some(Self::Completion), - "symbols" | "document_symbols" => Some(Self::Symbols), - "format" | "formatting" => Some(Self::Format), - _ => None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LspDiagnostic { - pub path: String, - pub line: u32, - pub character: u32, - pub severity: String, - pub message: String, - pub source: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LspLocation { - pub path: String, - pub line: u32, - pub character: u32, - pub end_line: Option, - pub end_character: Option, - pub preview: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LspHoverResult { - pub content: String, - pub language: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LspCompletionItem { - pub label: String, - pub kind: Option, - pub detail: Option, - pub insert_text: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LspSymbol { - pub name: String, - pub kind: String, - pub path: String, - pub line: u32, - pub character: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum LspServerStatus { - Connected, - Disconnected, - Starting, - Error, -} - -impl std::fmt::Display for LspServerStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Connected => write!(f, "connected"), - Self::Disconnected => write!(f, "disconnected"), - Self::Starting => write!(f, "starting"), - Self::Error => write!(f, "error"), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LspServerState { - pub language: String, - pub status: LspServerStatus, - pub root_path: Option, - pub capabilities: Vec, - pub diagnostics: Vec, -} - -/// Entry in the LSP registry combining process handle, descriptor, and state. -struct LspServerEntry { - /// The running LSP process, if started. Wrapped in Arc> for thread-safe async access. - process: Option>>, - /// The server descriptor for lazy-start on first use. - descriptor: Option, - /// The server state metadata (status, capabilities, diagnostics). - state: LspServerState, -} - -impl std::fmt::Debug for LspServerEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("LspServerEntry") - .field("process", &self.process.is_some()) - .field("descriptor", &self.descriptor) - .field("state", &self.state) - .finish() - } -} - -impl LspServerEntry { - fn new(state: LspServerState) -> Self { - Self { - process: None, - descriptor: None, - state, - } - } - - fn with_descriptor(state: LspServerState, descriptor: LspServerDescriptor) -> Self { - Self { - process: None, - descriptor: Some(descriptor), - state, - } - } -} - -#[derive(Debug, Clone, Default)] -pub struct LspRegistry { - inner: Arc>, -} - -#[derive(Debug, Default)] -struct RegistryInner { - servers: HashMap, - open_files: HashSet, -} - -impl LspRegistry { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Register an LSP server with metadata but without starting the process. - /// The server can be started later via `start_server()` or lazily on first `dispatch()`. - pub fn register( - &self, - language: &str, - status: LspServerStatus, - root_path: Option<&str>, - capabilities: Vec, - ) { - let state = LspServerState { - language: language.to_owned(), - status, - root_path: root_path.map(str::to_owned), - capabilities, - diagnostics: Vec::new(), - }; - let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner - .servers - .insert(language.to_owned(), LspServerEntry::new(state)); - } - - /// Register an LSP server with a descriptor for lazy-start. - /// The descriptor provides the command and args to start the server when needed. - pub fn register_with_descriptor( - &self, - language: &str, - status: LspServerStatus, - root_path: Option<&str>, - capabilities: Vec, - descriptor: LspServerDescriptor, - ) { - let state = LspServerState { - language: language.to_owned(), - status, - root_path: root_path.map(str::to_owned), - capabilities, - diagnostics: Vec::new(), - }; - let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner.servers.insert( - language.to_owned(), - LspServerEntry::with_descriptor(state, descriptor), - ); - } - - pub fn get(&self, language: &str) -> Option { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner.servers.get(language).map(|entry| entry.state.clone()) - } - - /// Find the appropriate server for a file path based on extension. - pub fn find_server_for_path(&self, path: &str) -> Option { - let ext = std::path::Path::new(path) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or(""); - - let language = match ext { - "rs" => "rust", - "ts" | "tsx" => "typescript", - "js" | "jsx" => "javascript", - "py" => "python", - "go" => "go", - "java" => "java", - "c" | "h" => "c", - "cpp" | "hpp" | "cc" => "cpp", - "rb" => "ruby", - "lua" => "lua", - _ => return None, - }; - - self.get(language) - } - - /// Get the language name for a file path based on extension. - fn language_for_path(path: &str) -> Option { - let ext = std::path::Path::new(path) - .extension() - .and_then(|e| e.to_str())?; - - let language = match ext { - "rs" => "rust", - "ts" | "tsx" => "typescript", - "js" | "jsx" => "javascript", - "py" => "python", - "go" => "go", - "java" => "java", - "c" | "h" => "c", - "cpp" | "hpp" | "cc" => "cpp", - "rb" => "ruby", - "lua" => "lua", - _ => return None, - }; - - Some(language.to_owned()) - } - - /// List all registered servers. - pub fn list_servers(&self) -> Vec { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner.servers.values().map(|entry| entry.state.clone()).collect() - } - - /// Add diagnostics to a server. - pub fn add_diagnostics( - &self, - language: &str, - diagnostics: Vec, - ) -> Result<(), String> { - let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - let entry = inner - .servers - .get_mut(language) - .ok_or_else(|| format!("LSP server not found for language: {language}"))?; - entry.state.diagnostics.extend(diagnostics); - Ok(()) - } - - /// Get diagnostics for a specific file path. - pub fn get_diagnostics(&self, path: &str) -> Vec { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner - .servers - .values() - .flat_map(|entry| &entry.state.diagnostics) - .filter(|d| d.path == path) - .cloned() - .collect() - } - - /// Clear diagnostics for a language server. - pub fn clear_diagnostics(&self, language: &str) -> Result<(), String> { - let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - let entry = inner - .servers - .get_mut(language) - .ok_or_else(|| format!("LSP server not found for language: {language}"))?; - entry.state.diagnostics.clear(); - Ok(()) - } - - /// Disconnect a server. - pub fn disconnect(&self, language: &str) -> Option { - let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner.servers.remove(language).map(|entry| entry.state) - } - - #[must_use] - pub fn len(&self) -> usize { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner.servers.len() - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - /// Start an LSP server process for the given language. - /// If the process is already running, this is a no-op. - /// If a descriptor is available, it is used to start the process. - /// If no descriptor is available, the discovery system is consulted. - pub fn start_server(&self, language: &str) -> Result<(), String> { - // Check if already running - { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - if let Some(entry) = inner.servers.get(language) { - if entry.process.is_some() { - return Ok(()); - } - } - } - - // Try to get the descriptor - let descriptor = { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - if let Some(entry) = inner.servers.get(language) { - entry.descriptor.clone() - } else { - None - } - }; - - // If no descriptor, try discovery - let descriptor = if let Some(d) = descriptor { d } else { - let available = discover_available_servers(); - available - .into_iter() - .find(|d| d.language == language) - .ok_or_else(|| { - format!("no LSP server descriptor found for language: {language}") - })? - }; - - let root_path = { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner - .servers - .get(language) - .and_then(|entry| entry.state.root_path.clone()) - .unwrap_or_else(|| { - std::env::current_dir() - .map_or_else(|_| ".".to_owned(), |p| p.to_string_lossy().into_owned()) - }) - }; - - let process = { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("failed to create tokio runtime: {e}"))?; - rt.block_on(LspProcess::start( - &descriptor.command, - &descriptor.args, - Path::new(&root_path), - )) - .map_err(|e| format!("failed to start LSP server for '{language}': {e}"))? - }; - - let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - if let Some(entry) = inner.servers.get_mut(language) { - entry.process = Some(Arc::new(Mutex::new(process))); - entry.state.status = LspServerStatus::Connected; - } - - Ok(()) - } - - /// Stop a running LSP server process. - pub fn stop_server(&self, language: &str) -> Result<(), String> { - let process_arc = { - let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - let entry = inner - .servers - .get_mut(language) - .ok_or_else(|| format!("LSP server not found for language: {language}"))?; - entry.state.status = LspServerStatus::Disconnected; - entry.process.take() - }; - - if let Some(process_arc) = process_arc { - let mut process = process_arc - .lock() - .map_err(|_| "lsp process lock poisoned")?; - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("failed to create tokio runtime: {e}"))?; - rt.block_on(process.shutdown()) - .map_err(|e| format!("LSP shutdown error: {e}"))?; - } - - Ok(()) - } - - /// Notify the LSP server that a file was opened and collect any diagnostics. - /// Best-effort: returns empty vec if no server is available. - pub fn notify_file_open(&self, path: &str, content: &str) -> Vec { - let Some(language) = Self::language_for_path(path) else { - return Vec::new(); - }; - - // Check if already open - { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - if inner.open_files.contains(path) { - return Vec::new(); - } - } - - // Lazy-start the server - if self.start_server(&language).is_err() { - return Vec::new(); - } - - // Get the process handle and send didOpen - let process_arc = { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - match inner.servers.get(&language).and_then(|e| e.process.clone()) { - Some(p) => p, - None => return Vec::new(), - } - }; - - let mut diagnostics = Vec::new(); - if let Ok(mut process) = process_arc.lock() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build(); - if let Ok(rt) = rt { - let _ = rt.block_on(process.did_open(path, content)); - diagnostics = process.drain_diagnostics(); - } - } - - // Cache diagnostics in registry state - if !diagnostics.is_empty() { - let diag_path = path.to_owned(); - let diags = diagnostics.clone(); - let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - if let Some(entry) = inner.servers.get_mut(&language) { - // Replace diagnostics for this file (publishDiagnostics is full replacement) - entry.state.diagnostics.retain(|d| d.path != diag_path); - entry.state.diagnostics.extend(diags); - } - } - - // Mark file as open - { - let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner.open_files.insert(path.to_owned()); - } - - diagnostics - } - - /// Notify the LSP server that a file changed and collect any diagnostics. - /// Best-effort: returns empty vec if no server is available. - pub fn notify_file_change(&self, path: &str, content: &str) -> Vec { - let Some(language) = Self::language_for_path(path) else { - return Vec::new(); - }; - - // Get the process handle - let process_arc = { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - match inner.servers.get(&language).and_then(|e| e.process.clone()) { - Some(p) => p, - None => return Vec::new(), - } - }; - - let mut diagnostics = Vec::new(); - if let Ok(mut process) = process_arc.lock() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build(); - if let Ok(rt) = rt { - let _ = rt.block_on(process.did_change(path, content)); - diagnostics = process.drain_diagnostics(); - } - } - - // Replace cached diagnostics for this file - if !diagnostics.is_empty() { - let diag_path = path.to_owned(); - let diags = diagnostics.clone(); - let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); - if let Some(entry) = inner.servers.get_mut(&language) { - entry.state.diagnostics.retain(|d| d.path != diag_path); - entry.state.diagnostics.extend(diags); - } - } - - diagnostics - } - - /// Fetch diagnostics for a file by draining pending server notifications - /// and returning cached diagnostics. - pub fn fetch_diagnostics_for_file(&self, path: &str) -> Vec { - let Some(language) = Self::language_for_path(path) else { - return Vec::new(); - }; - - // Drain pending notifications from the transport - let process_arc = { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner.servers.get(&language).and_then(|e| e.process.clone()) - }; - - if let Some(process_arc) = process_arc { - if let Ok(mut process) = process_arc.lock() { - let new_diags = process.drain_diagnostics(); - if !new_diags.is_empty() { - let diag_path = path.to_owned(); - let mut inner = - self.inner.lock().expect("lsp registry lock poisoned"); - if let Some(entry) = inner.servers.get_mut(&language) { - entry.state.diagnostics.retain(|d| d.path != diag_path); - entry.state.diagnostics.extend(new_diags); - } - } - } - } - - self.get_diagnostics(path) - } - - /// Dispatch an LSP action and return a structured result. - #[allow(clippy::too_many_lines)] - pub fn dispatch( - &self, - action: &str, - path: Option<&str>, - line: Option, - character: Option, - _query: Option<&str>, - ) -> Result { - let lsp_action = - LspAction::from_str(action).ok_or_else(|| format!("unknown LSP action: {action}"))?; - - // For diagnostics, we check existing cached diagnostics - if lsp_action == LspAction::Diagnostics { - if let Some(path) = path { - let diags = self.get_diagnostics(path); - return Ok(serde_json::json!({ - "action": "diagnostics", - "path": path, - "diagnostics": diags, - "count": diags.len() - })); - } - // All diagnostics across all servers - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - let all_diags: Vec<_> = inner - .servers - .values() - .flat_map(|entry| &entry.state.diagnostics) - .collect(); - return Ok(serde_json::json!({ - "action": "diagnostics", - "diagnostics": all_diags, - "count": all_diags.len() - })); - } - - // For other actions, we need a connected server for the given file - let path = path.ok_or("path is required for this LSP action")?; - let language = Self::language_for_path(path) - .ok_or_else(|| format!("no LSP server available for path: {path}"))?; - - // Check the entry exists - { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - if !inner.servers.contains_key(&language) { - return Err(format!("no LSP server available for path: {path}")); - } - } - - // Lazy-start: if no process yet, try to start one - let needs_start = { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner - .servers - .get(&language) - .is_none_or(|entry| entry.process.is_none()) - }; - - if needs_start { - if let Err(e) = self.start_server(&language) { - // Check the status after failed start — if still not Connected, - // return a proper error. This preserves the existing behavior - // for Disconnected/Error status servers. - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - if let Some(entry) = inner.servers.get(&language) { - if entry.state.status != LspServerStatus::Connected { - return Err(format!( - "LSP server for '{}' is not connected (status: {}): {}", - language, entry.state.status, e - )); - } - } - // If somehow still marked Connected but start failed, return error JSON - return Ok(serde_json::json!({ - "action": action, - "path": path, - "line": line, - "character": character, - "language": language, - "status": "error", - "error": e - })); - } - } - - // Check the server status - { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - if let Some(entry) = inner.servers.get(&language) { - if entry.state.status != LspServerStatus::Connected { - return Err(format!( - "LSP server for '{}' is not connected (status: {})", - language, entry.state.status - )); - } - } - } - - // Get the process handle (clone the Arc) - let process_arc = { - let inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner - .servers - .get(&language) - .and_then(|entry| entry.process.clone()) - .ok_or_else(|| format!("no LSP process available for language: {language}"))? - }; - - // Dispatch to the real LSP process - let result = { - let mut process = process_arc - .lock() - .map_err(|_| "lsp process lock poisoned".to_owned())?; - - // Create a minimal tokio runtime for async LSP calls - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("failed to create tokio runtime: {e}"))?; - - rt.block_on(async { - let line = line.unwrap_or(0); - let character = character.unwrap_or(0); - - match lsp_action { - LspAction::Hover => { - let hover = process.hover(path, line, character).await; - hover.map(|opt| { - opt.map_or_else( - || serde_json::json!({ - "action": "hover", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "no_result", - }), - |h| serde_json::json!({ - "action": "hover", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "result": h, - }), - ) - }) - } - LspAction::Definition => { - let locations = process.goto_definition(path, line, character).await; - locations.map(|locs| serde_json::json!({ - "action": "definition", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "locations": locs, - })) - } - LspAction::References => { - let locations = process.references(path, line, character).await; - locations.map(|locs| serde_json::json!({ - "action": "references", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "locations": locs, - })) - } - LspAction::Completion => { - let items = process.completion(path, line, character).await; - items.map(|completions| serde_json::json!({ - "action": "completion", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "items": completions, - })) - } - LspAction::Symbols => { - let symbols = process.document_symbols(path).await; - symbols.map(|syms| serde_json::json!({ - "action": "symbols", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "symbols": syms, - })) - } - LspAction::Format => { - let edits = process.format(path).await; - edits.map(|text_edits| serde_json::json!({ - "action": "format", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "edits": text_edits, - })) - } - LspAction::Diagnostics => unreachable!(), - } - }) - }; - - result.map_err(|e| format!("LSP {action} failed for '{language}': {e}")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn registers_and_retrieves_server() { - let registry = LspRegistry::new(); - registry.register( - "rust", - LspServerStatus::Connected, - Some("/workspace"), - vec!["hover".into(), "completion".into()], - ); - - let server = registry.get("rust").expect("should exist"); - assert_eq!(server.language, "rust"); - assert_eq!(server.status, LspServerStatus::Connected); - assert_eq!(server.capabilities.len(), 2); - } - - #[test] - fn finds_server_by_file_extension() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - registry.register("typescript", LspServerStatus::Connected, None, vec![]); - - let rs_server = registry.find_server_for_path("src/main.rs").unwrap(); - assert_eq!(rs_server.language, "rust"); - - let ts_server = registry.find_server_for_path("src/index.ts").unwrap(); - assert_eq!(ts_server.language, "typescript"); - - assert!(registry.find_server_for_path("data.csv").is_none()); - } - - #[test] - fn manages_diagnostics() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - - registry - .add_diagnostics( - "rust", - vec![LspDiagnostic { - path: "src/main.rs".into(), - line: 10, - character: 5, - severity: "error".into(), - message: "mismatched types".into(), - source: Some("rust-analyzer".into()), - }], - ) - .unwrap(); - - let diags = registry.get_diagnostics("src/main.rs"); - assert_eq!(diags.len(), 1); - assert_eq!(diags[0].message, "mismatched types"); - - registry.clear_diagnostics("rust").unwrap(); - assert!(registry.get_diagnostics("src/main.rs").is_empty()); - } - - #[test] - fn dispatches_diagnostics_action() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - registry - .add_diagnostics( - "rust", - vec![LspDiagnostic { - path: "src/lib.rs".into(), - line: 1, - character: 0, - severity: "warning".into(), - message: "unused import".into(), - source: None, - }], - ) - .unwrap(); - - let result = registry - .dispatch("diagnostics", Some("src/lib.rs"), None, None, None) - .unwrap(); - assert_eq!(result["count"], 1); - } - - #[test] - fn dispatches_hover_action() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - - let result = registry - .dispatch("hover", Some("src/main.rs"), Some(10), Some(5), None) - .unwrap(); - assert_eq!(result["action"], "hover"); - assert_eq!(result["language"], "rust"); - } - - #[test] - fn rejects_action_on_disconnected_server() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Disconnected, None, vec![]); - - assert!(registry - .dispatch("hover", Some("src/main.rs"), Some(1), Some(0), None) - .is_err()); - } - - #[test] - fn rejects_unknown_action() { - let registry = LspRegistry::new(); - assert!(registry - .dispatch("unknown_action", Some("file.rs"), None, None, None) - .is_err()); - } - - #[test] - fn disconnects_server() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - assert_eq!(registry.len(), 1); - - let removed = registry.disconnect("rust"); - assert!(removed.is_some()); - assert!(registry.is_empty()); - } - - #[test] - fn lsp_action_from_str_all_aliases() { - // given - let cases = [ - ("diagnostics", Some(LspAction::Diagnostics)), - ("hover", Some(LspAction::Hover)), - ("definition", Some(LspAction::Definition)), - ("goto_definition", Some(LspAction::Definition)), - ("references", Some(LspAction::References)), - ("find_references", Some(LspAction::References)), - ("completion", Some(LspAction::Completion)), - ("completions", Some(LspAction::Completion)), - ("symbols", Some(LspAction::Symbols)), - ("document_symbols", Some(LspAction::Symbols)), - ("format", Some(LspAction::Format)), - ("formatting", Some(LspAction::Format)), - ("unknown", None), - ]; - - // when - let resolved: Vec<_> = cases - .into_iter() - .map(|(input, expected)| (input, LspAction::from_str(input), expected)) - .collect(); - - // then - for (input, actual, expected) in resolved { - assert_eq!(actual, expected, "unexpected action resolution for {input}"); - } - } - - #[test] - fn lsp_server_status_display_all_variants() { - // given - let cases = [ - (LspServerStatus::Connected, "connected"), - (LspServerStatus::Disconnected, "disconnected"), - (LspServerStatus::Starting, "starting"), - (LspServerStatus::Error, "error"), - ]; - - // when - let rendered: Vec<_> = cases - .into_iter() - .map(|(status, expected)| (status.to_string(), expected)) - .collect(); - - // then - assert_eq!( - rendered, - vec![ - ("connected".to_string(), "connected"), - ("disconnected".to_string(), "disconnected"), - ("starting".to_string(), "starting"), - ("error".to_string(), "error"), - ] - ); - } - - #[test] - fn dispatch_diagnostics_without_path_aggregates() { - // given - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - registry.register("python", LspServerStatus::Connected, None, vec![]); - registry - .add_diagnostics( - "rust", - vec![LspDiagnostic { - path: "src/lib.rs".into(), - line: 1, - character: 0, - severity: "warning".into(), - message: "unused import".into(), - source: Some("rust-analyzer".into()), - }], - ) - .expect("rust diagnostics should add"); - registry - .add_diagnostics( - "python", - vec![LspDiagnostic { - path: "script.py".into(), - line: 2, - character: 4, - severity: "error".into(), - message: "undefined name".into(), - source: Some("pyright".into()), - }], - ) - .expect("python diagnostics should add"); - - // when - let result = registry - .dispatch("diagnostics", None, None, None, None) - .expect("aggregate diagnostics should work"); - - // then - assert_eq!(result["action"], "diagnostics"); - assert_eq!(result["count"], 2); - assert_eq!(result["diagnostics"].as_array().map(Vec::len), Some(2)); - } - - #[test] - fn dispatch_non_diagnostics_requires_path() { - // given - let registry = LspRegistry::new(); - - // when - let result = registry.dispatch("hover", None, Some(1), Some(0), None); - - // then - assert_eq!( - result.expect_err("path should be required"), - "path is required for this LSP action" - ); - } - - #[test] - fn dispatch_no_server_for_path_errors() { - // given - let registry = LspRegistry::new(); - - // when - let result = registry.dispatch("hover", Some("notes.md"), Some(1), Some(0), None); - - // then - let error = result.expect_err("missing server should fail"); - assert!(error.contains("no LSP server available for path: notes.md")); - } - - #[test] - fn dispatch_disconnected_server_error_payload() { - // given - let registry = LspRegistry::new(); - registry.register("typescript", LspServerStatus::Disconnected, None, vec![]); - - // when - let result = registry.dispatch("hover", Some("src/index.ts"), Some(3), Some(2), None); - - // then - let error = result.expect_err("disconnected server should fail"); - assert!(error.contains("typescript")); - assert!(error.contains("disconnected")); - } - - #[test] - fn find_server_for_all_extensions() { - // given - let registry = LspRegistry::new(); - for language in [ - "rust", - "typescript", - "javascript", - "python", - "go", - "java", - "c", - "cpp", - "ruby", - "lua", - ] { - registry.register(language, LspServerStatus::Connected, None, vec![]); - } - let cases = [ - ("src/main.rs", "rust"), - ("src/index.ts", "typescript"), - ("src/view.tsx", "typescript"), - ("src/app.js", "javascript"), - ("src/app.jsx", "javascript"), - ("script.py", "python"), - ("main.go", "go"), - ("Main.java", "java"), - ("native.c", "c"), - ("native.h", "c"), - ("native.cpp", "cpp"), - ("native.hpp", "cpp"), - ("native.cc", "cpp"), - ("script.rb", "ruby"), - ("script.lua", "lua"), - ]; - - // when - let resolved: Vec<_> = cases - .into_iter() - .map(|(path, expected)| { - ( - path, - registry - .find_server_for_path(path) - .map(|server| server.language), - expected, - ) - }) - .collect(); - - // then - for (path, actual, expected) in resolved { - assert_eq!( - actual.as_deref(), - Some(expected), - "unexpected mapping for {path}" - ); - } - } - - #[test] - fn find_server_for_path_no_extension() { - // given - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - - // when - let result = registry.find_server_for_path("Makefile"); - - // then - assert!(result.is_none()); - } - - #[test] - fn list_servers_with_multiple() { - // given - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Connected, None, vec![]); - registry.register("typescript", LspServerStatus::Starting, None, vec![]); - registry.register("python", LspServerStatus::Error, None, vec![]); - - // when - let servers = registry.list_servers(); - - // then - assert_eq!(servers.len(), 3); - assert!(servers.iter().any(|server| server.language == "rust")); - assert!(servers.iter().any(|server| server.language == "typescript")); - assert!(servers.iter().any(|server| server.language == "python")); - } - - #[test] - fn get_missing_server_returns_none() { - // given - let registry = LspRegistry::new(); - - // when - let server = registry.get("missing"); - - // then - assert!(server.is_none()); - } - - #[test] - fn add_diagnostics_missing_language_errors() { - // given - let registry = LspRegistry::new(); - - // when - let result = registry.add_diagnostics("missing", vec![]); - - // then - let error = result.expect_err("missing language should fail"); - assert!(error.contains("LSP server not found for language: missing")); - } - - #[test] - fn get_diagnostics_across_servers() { - // given - let registry = LspRegistry::new(); - let shared_path = "shared/file.txt"; - registry.register("rust", LspServerStatus::Connected, None, vec![]); - registry.register("python", LspServerStatus::Connected, None, vec![]); - registry - .add_diagnostics( - "rust", - vec![LspDiagnostic { - path: shared_path.into(), - line: 4, - character: 1, - severity: "warning".into(), - message: "warn".into(), - source: None, - }], - ) - .expect("rust diagnostics should add"); - registry - .add_diagnostics( - "python", - vec![LspDiagnostic { - path: shared_path.into(), - line: 8, - character: 3, - severity: "error".into(), - message: "err".into(), - source: None, - }], - ) - .expect("python diagnostics should add"); - - // when - let diagnostics = registry.get_diagnostics(shared_path); - - // then - assert_eq!(diagnostics.len(), 2); - assert!(diagnostics - .iter() - .any(|diagnostic| diagnostic.message == "warn")); - assert!(diagnostics - .iter() - .any(|diagnostic| diagnostic.message == "err")); - } - - #[test] - fn clear_diagnostics_missing_language_errors() { - // given - let registry = LspRegistry::new(); - - // when - let result = registry.clear_diagnostics("missing"); - - // then - let error = result.expect_err("missing language should fail"); - assert!(error.contains("LSP server not found for language: missing")); - } - - #[test] - fn register_with_descriptor_stores_entry() { - let registry = LspRegistry::new(); - let descriptor = LspServerDescriptor { - language: "rust".into(), - command: "rust-analyzer".into(), - args: vec![], - extensions: vec!["rs".into()], - }; - registry.register_with_descriptor( - "rust", - LspServerStatus::Connected, - Some("/project"), - vec!["hover".into()], - descriptor, - ); - - let server = registry.get("rust").expect("should exist after register_with_descriptor"); - assert_eq!(server.language, "rust"); - assert_eq!(server.status, LspServerStatus::Connected); - assert_eq!(server.root_path.as_deref(), Some("/project")); - assert_eq!(server.capabilities, vec!["hover"]); - } - - #[test] - fn stop_server_on_nonexistent_errors() { - let registry = LspRegistry::new(); - let result = registry.stop_server("missing"); - assert!(result.is_err(), "stopping a nonexistent server should error"); - let error = result.unwrap_err(); - assert!(error.contains("missing"), "error message should reference 'missing', got: {error}"); - } - - /// This test requires rust-analyzer to be installed on the system. - /// Run with: cargo test -p runtime -- --ignored - #[test] - #[ignore = "requires rust-analyzer installed on PATH"] - fn start_server_without_descriptor_falls_back_to_discovery() { - let registry = LspRegistry::new(); - registry.register("rust", LspServerStatus::Starting, None, vec![]); - let result = registry.start_server("rust"); - assert!(result.is_ok(), "start_server should discover and start rust-analyzer: {result:?}"); - let server = registry.get("rust").expect("rust should be registered"); - assert_eq!(server.status, LspServerStatus::Connected); - let _ = registry.stop_server("rust"); - } - - /// This test requires rust-analyzer to be installed on the system. - /// Run with: cargo test -p runtime -- --ignored - #[test] - #[ignore = "requires rust-analyzer installed on PATH"] - fn dispatch_hover_lazy_starts_server() { - let registry = LspRegistry::new(); - let descriptor = crate::lsp_discovery::LspServerDescriptor { - language: "rust".into(), - command: "rust-analyzer".into(), - args: vec![], - extensions: vec!["rs".into()], - }; - registry.register_with_descriptor( - "rust", - LspServerStatus::Starting, - None, - vec![], - descriptor, - ); - // dispatch should trigger start_server because process is None - let result = registry.dispatch("hover", Some("src/main.rs"), Some(0), Some(0), None); - // Result may be Ok or Err depending on whether rust-analyzer can actually - // respond for this path, but it should not fail with "not connected" - // (which would indicate the lazy-start didn't kick in). - if let Err(e) = &result { - assert!( - !e.contains("not connected"), - "dispatch should have lazily started the server, got: {e}" - ); - } - let _ = registry.stop_server("rust"); - } - - /// This test requires rust-analyzer to be installed on the system. - /// Run with: cargo test -p runtime -- --ignored - #[test] - #[ignore = "requires rust-analyzer installed on PATH"] - fn start_and_stop_server() { - let registry = LspRegistry::new(); - let descriptor = crate::lsp_discovery::LspServerDescriptor { - language: "rust".into(), - command: "rust-analyzer".into(), - args: vec![], - extensions: vec!["rs".into()], - }; - registry.register_with_descriptor( - "rust", - LspServerStatus::Starting, - None, - vec![], - descriptor, - ); - - let start_result = registry.start_server("rust"); - assert!(start_result.is_ok(), "start_server should succeed: {start_result:?}"); - - let server = registry.get("rust").expect("rust should exist"); - assert_eq!(server.status, LspServerStatus::Connected); - - let stop_result = registry.stop_server("rust"); - assert!(stop_result.is_ok(), "stop_server should succeed: {stop_result:?}"); - - let server = registry.get("rust").expect("rust should still be in registry"); - assert_eq!(server.status, LspServerStatus::Disconnected); - } -} diff --git a/rust/crates/runtime/src/lsp_discovery.rs b/rust/crates/runtime/src/lsp_discovery.rs index 7c8f7402ab..475f20a665 100644 --- a/rust/crates/runtime/src/lsp_discovery.rs +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -112,16 +112,64 @@ pub fn command_exists_on_path(command: &str) -> bool { .is_ok() } +/// Check if a binary is a rustup proxy by running `--version` and looking for +/// the "Unknown binary" error message that rustup prints for uninstalled tools. +#[must_use] +fn is_rustup_proxy(command: &str) -> bool { + let Ok(output) = Command::new(command).arg("--version").output() else { + return false; + }; + // rustup proxy exits non-zero and prints "error: Unknown binary '...' in official toolchain" + let stderr = String::from_utf8_lossy(&output.stderr); + stderr.contains("Unknown binary") +} + +/// Check whether a rustup component is actually functional by running it through +/// `rustup run stable --version`. Returns `true` only if the process +/// exits successfully (exit code 0), meaning the component is installed. +#[must_use] +fn rustup_component_works(component: &str) -> bool { + Command::new("rustup") + .args(["run", "stable", component, "--version"]) + .output() + .is_ok_and(|o| o.status.success()) +} + /// Discover LSP servers that are actually installed on the current system. /// /// Iterates over the known server table and returns only those whose command -/// is found on `PATH`. +/// is found on `PATH` **and** is actually functional. For `rust-analyzer`, +/// rustup ships a stub proxy that always exists on PATH but prints +/// "Unknown binary" when the component isn't installed. We detect that +/// case and either rewrite to `rustup run stable rust-analyzer` (when the +/// component is installed) or skip the server entirely (when it's not). #[must_use] pub fn discover_available_servers() -> Vec { KNOWN_LSP_SERVERS_TABLE .iter() .filter(|desc| command_exists_on_path(desc.command)) - .map(StaticLspServerDescriptor::to_descriptor) + .filter_map(|desc| { + let mut server = desc.to_descriptor(); + // rustup ships a proxy `rust-analyzer` that exists on PATH but + // errors with "Unknown binary" when the component isn't installed. + if desc.command == "rust-analyzer" && is_rustup_proxy("rust-analyzer") { + // The component isn't installed under this toolchain. + // Check if `rustup run stable rust-analyzer --version` works + // (e.g. user installed it via rustup but the proxy is misconfigured). + if rustup_component_works("rust-analyzer") { + server.command = "rustup".to_string(); + server.args = vec![ + "run".to_string(), + "stable".to_string(), + "rust-analyzer".to_string(), + ]; + } else { + // Component truly not installed — skip it. + return None; + } + } + Some(server) + }) .collect() } diff --git a/rust/crates/runtime/src/lsp_process.rs b/rust/crates/runtime/src/lsp_process.rs deleted file mode 100644 index 578d43fea9..0000000000 --- a/rust/crates/runtime/src/lsp_process.rs +++ /dev/null @@ -1,929 +0,0 @@ -//! LSP process manager: spawns language servers and drives the LSP lifecycle. - -use std::collections::{HashMap, HashSet}; -use std::path::Path; - -use serde_json::Value as JsonValue; - -use crate::lsp_client::{ - LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, -}; -use crate::lsp_transport::{LspTransport, LspTransportError}; - -#[derive(Debug)] -pub struct LspProcess { - transport: LspTransport, - language: String, - root_uri: String, - capabilities: Option, - status: LspServerStatus, - open_files: HashSet, - version_counter: HashMap, -} - -#[allow(clippy::cast_possible_truncation)] -impl LspProcess { - /// Spawn a language server process and perform the LSP initialize handshake. - pub async fn start( - command: &str, - args: &[String], - root_path: &Path, - ) -> Result { - let transport = LspTransport::spawn(command, args) - .map_err(|e| LspProcessError::Transport(LspTransportError::Io(e)))?; - - let canonical = canonicalize_root(root_path)?; - let root_uri = format!("file://{canonical}"); - - let mut process = Self { - transport, - language: command.to_owned(), - root_uri: root_uri.clone(), - capabilities: None, - status: LspServerStatus::Starting, - open_files: HashSet::new(), - version_counter: HashMap::new(), - }; - - process.initialize(&canonical).await?; - process.status = LspServerStatus::Connected; - - Ok(process) - } - - /// Send the LSP `initialize` request followed by the `initialized` notification. - async fn initialize(&mut self, root_path: &str) -> Result { - let root_uri = format!("file://{root_path}"); - let pid = std::process::id(); - - let params = serde_json::json!({ - "processId": pid, - "rootUri": root_uri, - "capabilities": { - "textDocument": { - "hover": { "contentFormat": ["markdown", "plaintext"] }, - "definition": { "linkSupport": true }, - "references": {}, - "completion": { - "completionItem": { "snippetSupport": false } - }, - "documentSymbol": { "hierarchicalDocumentSymbolSupport": true }, - "publishDiagnostics": { "relatedInformation": true } - } - } - }); - - let response = self - .transport - .send_request("initialize", Some(params)) - .await - .map_err(LspProcessError::Transport)?; - - let result = response - .into_result() - .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; - - self.capabilities = Some(result.clone()); - - self.transport - .send_notification("initialized", Some(serde_json::json!({}))) - .await - .map_err(LspProcessError::Transport)?; - - Ok(result) - } - - /// Gracefully shut down the language server. - pub async fn shutdown(&mut self) -> Result<(), LspProcessError> { - self.status = LspServerStatus::Disconnected; - - let shutdown_result = self - .transport - .send_request("shutdown", None) - .await - .map_err(LspProcessError::Transport); - - if shutdown_result.is_ok() { - self.transport - .send_notification("exit", None) - .await - .map_err(LspProcessError::Transport)?; - } - - self.transport - .shutdown() - .await - .map_err(LspProcessError::Transport)?; - - Ok(()) - } - - /// Query hover information at a position. - pub async fn hover( - &mut self, - path: &str, - line: u32, - character: u32, - ) -> Result, LspProcessError> { - let uri = path_to_uri(path); - let params = text_document_position_params(&uri, line, character); - - let response = self - .transport - .send_request("textDocument/hover", Some(params)) - .await - .map_err(LspProcessError::Transport)?; - - let result = response - .into_result() - .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; - - if result.is_null() { - return Ok(None); - } - - Ok(parse_hover(&result)) - } - - /// Go to definition at a position. - pub async fn goto_definition( - &mut self, - path: &str, - line: u32, - character: u32, - ) -> Result, LspProcessError> { - let uri = path_to_uri(path); - let params = text_document_position_params(&uri, line, character); - - let response = self - .transport - .send_request("textDocument/definition", Some(params)) - .await - .map_err(LspProcessError::Transport)?; - - let result = response - .into_result() - .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; - - Ok(parse_locations(&result)) - } - - /// Find references at a position. - pub async fn references( - &mut self, - path: &str, - line: u32, - character: u32, - ) -> Result, LspProcessError> { - let uri = path_to_uri(path); - let params = serde_json::json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character }, - "context": { "includeDeclaration": true } - }); - - let response = self - .transport - .send_request("textDocument/references", Some(params)) - .await - .map_err(LspProcessError::Transport)?; - - let result = response - .into_result() - .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; - - Ok(parse_locations(&result)) - } - - /// Get document symbols for a file. - pub async fn document_symbols( - &mut self, - path: &str, - ) -> Result, LspProcessError> { - let uri = path_to_uri(path); - let params = serde_json::json!({ - "textDocument": { "uri": uri } - }); - - let response = self - .transport - .send_request("textDocument/documentSymbol", Some(params)) - .await - .map_err(LspProcessError::Transport)?; - - let result = response - .into_result() - .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; - - if result.is_null() { - return Ok(Vec::new()); - } - - Ok(parse_symbols(&result, path)) - } - - /// Get completions at a position. - pub async fn completion( - &mut self, - path: &str, - line: u32, - character: u32, - ) -> Result, LspProcessError> { - let uri = path_to_uri(path); - let params = text_document_position_params(&uri, line, character); - - let response = self - .transport - .send_request("textDocument/completion", Some(params)) - .await - .map_err(LspProcessError::Transport)?; - - let result = response - .into_result() - .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; - - if result.is_null() { - return Ok(Vec::new()); - } - - // The response may be a CompletionList or a plain array. - let items = if let Some(list) = result.get("items") { - list - } else { - &result - }; - - Ok(parse_completions(items)) - } - - /// Format a document. - pub async fn format(&mut self, path: &str) -> Result, LspProcessError> { - let uri = path_to_uri(path); - let params = serde_json::json!({ - "textDocument": { "uri": uri }, - "options": { "tabSize": 4, "insertSpaces": true } - }); - - let response = self - .transport - .send_request("textDocument/formatting", Some(params)) - .await - .map_err(LspProcessError::Transport)?; - - let result = response - .into_result() - .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; - - if result.is_null() { - return Ok(Vec::new()); - } - - match result.as_array() { - Some(arr) => Ok(arr.clone()), - None => Ok(Vec::new()), - } - } - - /// Notify the server that a file was opened. Sends `textDocument/didOpen`. - /// No-op if the file is already tracked as open. - pub async fn did_open(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> { - if self.open_files.contains(path) { - return Ok(()); - } - - let uri = path_to_uri(path); - let language_id = language_id_for_path(path); - let params = serde_json::json!({ - "textDocument": { - "uri": uri, - "languageId": language_id, - "version": 0, - "text": content - } - }); - - self.transport - .send_notification("textDocument/didOpen", Some(params)) - .await - .map_err(LspProcessError::Transport)?; - - self.open_files.insert(path.to_owned()); - self.version_counter.insert(path.to_owned(), 0); - Ok(()) - } - - /// Notify the server that a file's content changed. Sends `textDocument/didChange`. - pub async fn did_change(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> { - let version = self.version_counter.get(path).map_or(1, |v| v + 1); - - let uri = path_to_uri(path); - let params = serde_json::json!({ - "textDocument": { "uri": uri, "version": version }, - "contentChanges": [{ "text": content }] - }); - - self.transport - .send_notification("textDocument/didChange", Some(params)) - .await - .map_err(LspProcessError::Transport)?; - - self.version_counter.insert(path.to_owned(), version); - Ok(()) - } - - /// Drain queued server notifications and extract `publishDiagnostics`. - #[allow(clippy::redundant_closure_for_method_calls)] - pub fn drain_diagnostics(&mut self) -> Vec { - let notifications = self.transport.drain_notifications(); - let mut diagnostics = Vec::new(); - for n in ¬ifications { - if n.method == "textDocument/publishDiagnostics" { - if let Some(params) = &n.params { - if let Some(uri) = params.get("uri").and_then(|v| v.as_str()) { - let path = uri_to_path(uri); - if let Some(diags) = params.get("diagnostics").and_then(|v| v.as_array()) - { - for d in diags { - diagnostics.push(LspDiagnostic { - path: path.clone(), - line: d - .get("range") - .and_then(|r| r.get("start")) - .and_then(|s| s.get("line")) - .and_then(|v| v.as_u64()) - .map_or(0, |v| v as u32), - character: d - .get("range") - .and_then(|r| r.get("start")) - .and_then(|s| s.get("character")) - .and_then(|v| v.as_u64()) - .map_or(0, |v| v as u32), - severity: d - .get("severity") - .and_then(|v| v.as_u64()) - .map_or_else(|| "error".to_owned(), severity_name), - message: d - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_owned(), - source: d - .get("source") - .and_then(|v| v.as_str()) - .map(str::to_owned), - }); - } - } - } - } - } - } - diagnostics - } - - #[must_use] - pub fn status(&self) -> LspServerStatus { - self.status - } - - #[must_use] - pub fn language(&self) -> &str { - &self.language - } - - #[must_use] - pub fn root_uri(&self) -> &str { - &self.root_uri - } -} - -// --------------------------------------------------------------------------- -// Error type -// --------------------------------------------------------------------------- - -#[derive(Debug)] -pub enum LspProcessError { - Transport(LspTransportError), - InvalidPath(String), -} - -impl std::fmt::Display for LspProcessError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Transport(e) => write!(f, "LSP transport error: {e}"), - Self::InvalidPath(p) => write!(f, "invalid path: {p}"), - } - } -} - -impl std::error::Error for LspProcessError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Transport(e) => Some(e), - Self::InvalidPath(_) => None, - } - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn canonicalize_root(path: &Path) -> Result { - path.canonicalize() - .map_err(|e| LspProcessError::InvalidPath(format!("{}: {e}", path.display()))) - .map(|p| p.to_string_lossy().into_owned()) -} - -fn path_to_uri(path: &str) -> String { - let canonical = std::path::Path::new(path); - if canonical.is_absolute() { - format!("file://{path}") - } else { - let resolved = std::env::current_dir() - .map_or_else(|_| canonical.to_path_buf(), |d| d.join(path)); - let canonicalized = resolved - .canonicalize() - .unwrap_or(resolved) - .to_string_lossy() - .into_owned(); - format!("file://{canonicalized}") - } -} - -fn text_document_position_params(uri: &str, line: u32, character: u32) -> JsonValue { - serde_json::json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character } - }) -} - -fn uri_to_path(uri: &str) -> String { - uri.strip_prefix("file://").unwrap_or(uri).to_owned() -} - -fn language_id_for_path(path: &str) -> String { - let ext = std::path::Path::new(path) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or(""); - match ext { - "rs" => "rust", - "ts" => "typescript", - "tsx" => "typescriptreact", - "js" => "javascript", - "jsx" => "javascriptreact", - "py" => "python", - "go" => "go", - "java" => "java", - "c" | "h" => "c", - "cpp" | "hpp" | "cc" => "cpp", - "rb" => "ruby", - "lua" => "lua", - _ => ext, - } - .to_owned() -} - -fn severity_name(code: u64) -> String { - match code { - 1 => "error".to_owned(), - 2 => "warning".to_owned(), - 3 => "info".to_owned(), - 4 => "hint".to_owned(), - _ => format!("unknown({code})"), - } -} - -fn parse_hover(value: &JsonValue) -> Option { - let contents = value.get("contents")?; - - // MarkupContent: { kind, value } - if let (Some(kind), Some(val)) = (contents.get("kind"), contents.get("value")) { - let language = if kind.as_str() == Some("plaintext") { - None - } else { - Some(kind.as_str().unwrap_or("markdown").to_owned()) - }; - return Some(LspHoverResult { - content: val.as_str().unwrap_or("").to_owned(), - language, - }); - } - - // MarkedString object: { language, value } - if let (Some(lang), Some(val)) = (contents.get("language"), contents.get("value")) { - return Some(LspHoverResult { - content: val.as_str().unwrap_or("").to_owned(), - language: Some(lang.as_str().unwrap_or("").to_owned()), - }); - } - - // Plain string MarkedString - if let Some(s) = contents.as_str() { - return Some(LspHoverResult { - content: s.to_owned(), - language: None, - }); - } - - // Array of MarkedString - if let Some(arr) = contents.as_array() { - let parts: Vec<&str> = arr - .iter() - .filter_map(|item| { - if let Some(s) = item.as_str() { - Some(s) - } else { - item.get("value").and_then(JsonValue::as_str) - } - }) - .collect(); - if parts.is_empty() { - return None; - } - return Some(LspHoverResult { - content: parts.join("\n"), - language: None, - }); - } - - None -} - -#[allow(clippy::cast_possible_truncation)] -fn parse_locations(value: &JsonValue) -> Vec { - let Some(locations) = value.as_array() else { - return Vec::new(); - }; - - locations - .iter() - .filter_map(|loc| { - let uri = loc.get("uri")?.as_str()?; - let path = uri_to_path(uri); - let range = loc.get("range")?; - let start = range.get("start")?; - let end = range.get("end")?; - - Some(LspLocation { - path, - line: start.get("line")?.as_u64()? as u32, - character: start.get("character")?.as_u64()? as u32, - end_line: end - .get("line") - .and_then(JsonValue::as_u64) - .map(|v| v as u32), - end_character: end - .get("character") - .and_then(JsonValue::as_u64) - .map(|v| v as u32), - preview: None, - }) - }) - .collect() -} - -fn extract_symbols(items: &[JsonValue], path: &str, out: &mut Vec) { - for item in items { - let name = item.get("name").and_then(JsonValue::as_str).unwrap_or(""); - let kind = item - .get("kind") - .and_then(JsonValue::as_u64) - .map_or_else(|| "Unknown".into(), symbol_kind_name); - - let (sym_path, line, character) = if let Some(range) = item.get("range") { - let start = range.get("start"); - ( - path.to_owned(), - u32::try_from( - start - .and_then(|s| s.get("line")) - .and_then(JsonValue::as_u64) - .unwrap_or(0), - ) - .unwrap_or(0), - u32::try_from( - start - .and_then(|s| s.get("character")) - .and_then(JsonValue::as_u64) - .unwrap_or(0), - ) - .unwrap_or(0), - ) - } else { - (path.to_owned(), 0, 0) - }; - - out.push(LspSymbol { - name: name.to_owned(), - kind: kind.clone(), - path: sym_path, - line, - character, - }); - - if let Some(children) = item.get("children").and_then(JsonValue::as_array) { - extract_symbols(children, path, out); - } - } -} - -fn parse_symbols(value: &JsonValue, default_path: &str) -> Vec { - let Some(items) = value.as_array() else { - return Vec::new(); - }; - - let mut result = Vec::new(); - extract_symbols(items, default_path, &mut result); - result -} - -fn parse_completions(value: &JsonValue) -> Vec { - let Some(items) = value.as_array() else { - return Vec::new(); - }; - - items - .iter() - .map(|item| LspCompletionItem { - label: item - .get("label") - .and_then(JsonValue::as_str) - .unwrap_or("") - .to_owned(), - kind: item - .get("kind") - .and_then(JsonValue::as_u64) - .map(completion_kind_name), - detail: item - .get("detail") - .and_then(JsonValue::as_str) - .map(str::to_owned), - insert_text: item - .get("insertText") - .and_then(JsonValue::as_str) - .map(str::to_owned), - }) - .collect() -} - -fn symbol_kind_name(kind: u64) -> String { - match kind { - 1 => "File".into(), - 2 => "Module".into(), - 3 => "Namespace".into(), - 4 => "Package".into(), - 5 => "Class".into(), - 6 => "Method".into(), - 7 => "Property".into(), - 8 => "Field".into(), - 9 => "Constructor".into(), - 10 => "Enum".into(), - 11 => "Interface".into(), - 12 => "Function".into(), - 13 => "Variable".into(), - 14 => "Constant".into(), - 15 => "String".into(), - 16 => "Number".into(), - 17 => "Boolean".into(), - 18 => "Array".into(), - 19 => "Object".into(), - 20 => "Key".into(), - 21 => "Null".into(), - 22 => "EnumMember".into(), - 23 => "Struct".into(), - 24 => "Event".into(), - 25 => "Operator".into(), - 26 => "TypeParameter".into(), - _ => format!("Unknown({kind})"), - } -} - -fn completion_kind_name(kind: u64) -> String { - match kind { - 1 => "Text".into(), - 2 => "Method".into(), - 3 => "Function".into(), - 4 => "Constructor".into(), - 5 => "Field".into(), - 6 => "Variable".into(), - 7 => "Class".into(), - 8 => "Interface".into(), - 9 => "Module".into(), - 10 => "Property".into(), - 11 => "Unit".into(), - 12 => "Value".into(), - 13 => "Enum".into(), - 14 => "Keyword".into(), - 15 => "Snippet".into(), - 16 => "Color".into(), - 17 => "File".into(), - 18 => "Reference".into(), - 19 => "Folder".into(), - 20 => "EnumMember".into(), - 21 => "Constant".into(), - 22 => "Struct".into(), - 23 => "Event".into(), - 24 => "Operator".into(), - 25 => "TypeParameter".into(), - _ => format!("Unknown({kind})"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Requires rust-analyzer to be installed on the system. - /// Run with: cargo test -p runtime -- --ignored - #[tokio::test] - #[ignore = "requires rust-analyzer installed on PATH"] - async fn spawn_and_initialize_rust_analyzer() { - let root = std::env::current_dir().expect("should have cwd"); - let process = LspProcess::start("rust-analyzer", &[], &root).await; - assert!(process.is_ok(), "should spawn and initialize rust-analyzer"); - - let mut process = process.unwrap(); - assert_eq!(process.status(), LspServerStatus::Connected); - assert_eq!(process.language(), "rust-analyzer"); - - let shutdown_result = process.shutdown().await; - assert!(shutdown_result.is_ok(), "shutdown should succeed: {shutdown_result:?}"); - } - - /// Requires rust-analyzer to be installed and a Rust project on disk. - /// Run with: cargo test -p runtime -- --ignored - #[tokio::test] - #[ignore = "requires rust-analyzer installed on PATH"] - async fn hover_on_real_file() { - let root = std::env::current_dir().expect("should have cwd"); - let mut process = LspProcess::start("rust-analyzer", &[], &root) - .await - .expect("should start rust-analyzer"); - - // Try hover on src/main.rs — the result might be None if the file - // doesn't exist at that path, but the call itself should not error. - let file_path = root.join("src").join("main.rs"); - let path_str = file_path.to_string_lossy(); - let result = process.hover(&path_str, 0, 0).await; - assert!(result.is_ok(), "hover should not return an error: {:?}", result.err()); - - let _ = process.shutdown().await; - } - - #[test] - fn parse_hover_markup_content() { - let value = serde_json::json!({ - "contents": { - "kind": "plaintext", - "value": "fn main()" - } - }); - let result = parse_hover(&value); - assert!(result.is_some()); - let hover = result.unwrap(); - assert_eq!(hover.content, "fn main()"); - } - - #[test] - fn parse_hover_marked_string_object() { - let value = serde_json::json!({ - "contents": { - "language": "rust", - "value": "pub fn foo()" - } - }); - let result = parse_hover(&value); - assert!(result.is_some()); - let hover = result.unwrap(); - assert_eq!(hover.content, "pub fn foo()"); - assert_eq!(hover.language.as_deref(), Some("rust")); - } - - #[test] - fn parse_hover_plain_string() { - let value = serde_json::json!({ - "contents": "some text" - }); - let result = parse_hover(&value); - assert!(result.is_some()); - let hover = result.unwrap(); - assert_eq!(hover.content, "some text"); - assert!(hover.language.is_none()); - } - - #[test] - fn parse_hover_array_of_marked_strings() { - let value = serde_json::json!({ - "contents": [ - "first line", - { "language": "rust", "value": "fn bar()" } - ] - }); - let result = parse_hover(&value); - assert!(result.is_some()); - let hover = result.unwrap(); - assert!(hover.content.contains("first line")); - assert!(hover.content.contains("fn bar()")); - } - - #[test] - fn parse_locations_empty_array() { - let value = serde_json::json!([]); - let locations = parse_locations(&value); - assert!(locations.is_empty()); - } - - #[test] - fn parse_locations_valid() { - let value = serde_json::json!([ - { - "uri": "file:///tmp/test.rs", - "range": { - "start": { "line": 5, "character": 10 }, - "end": { "line": 5, "character": 15 } - } - } - ]); - let locations = parse_locations(&value); - assert_eq!(locations.len(), 1); - assert_eq!(locations[0].line, 5); - assert_eq!(locations[0].character, 10); - assert_eq!(locations[0].end_line, Some(5)); - assert_eq!(locations[0].end_character, Some(15)); - } - - #[test] - fn parse_symbols_basic() { - let value = serde_json::json!([ - { - "name": "main", - "kind": 12, - "range": { - "start": { "line": 1, "character": 0 }, - "end": { "line": 5, "character": 1 } - } - } - ]); - let symbols = parse_symbols(&value, "/tmp/test.rs"); - assert_eq!(symbols.len(), 1); - assert_eq!(symbols[0].name, "main"); - assert_eq!(symbols[0].kind, "Function"); - assert_eq!(symbols[0].line, 1); - } - - #[test] - fn parse_completions_basic() { - let value = serde_json::json!([ - { "label": "foo", "kind": 3, "detail": "fn foo()" }, - { "label": "bar", "kind": 6 } - ]); - let completions = parse_completions(&value); - assert_eq!(completions.len(), 2); - assert_eq!(completions[0].label, "foo"); - assert_eq!(completions[0].kind.as_deref(), Some("Function")); - assert_eq!(completions[0].detail.as_deref(), Some("fn foo()")); - assert_eq!(completions[1].label, "bar"); - assert_eq!(completions[1].kind.as_deref(), Some("Variable")); - } - - #[test] - fn symbol_kind_name_all_variants() { - assert_eq!(symbol_kind_name(1), "File"); - assert_eq!(symbol_kind_name(6), "Method"); - assert_eq!(symbol_kind_name(12), "Function"); - assert_eq!(symbol_kind_name(13), "Variable"); - assert_eq!(symbol_kind_name(23), "Struct"); - assert_eq!(symbol_kind_name(99), "Unknown(99)"); - } - - #[test] - fn completion_kind_name_all_variants() { - assert_eq!(completion_kind_name(1), "Text"); - assert_eq!(completion_kind_name(3), "Function"); - assert_eq!(completion_kind_name(6), "Variable"); - assert_eq!(completion_kind_name(14), "Keyword"); - assert_eq!(completion_kind_name(99), "Unknown(99)"); - } - - #[test] - fn text_document_position_params_structure() { - let params = text_document_position_params("file:///test.rs", 5, 10); - assert_eq!(params["textDocument"]["uri"], "file:///test.rs"); - assert_eq!(params["position"]["line"], 5); - assert_eq!(params["position"]["character"], 10); - } - - #[test] - fn path_to_uri_absolute() { - let uri = path_to_uri("/tmp/test.rs"); - assert_eq!(uri, "file:///tmp/test.rs"); - } - - #[test] - fn uri_to_path_extracts_path() { - assert_eq!(uri_to_path("file:///tmp/test.rs"), "/tmp/test.rs"); - assert_eq!(uri_to_path("/no/prefix"), "/no/prefix"); - } -} diff --git a/rust/crates/runtime/src/lsp_transport.rs b/rust/crates/runtime/src/lsp_transport.rs deleted file mode 100644 index 015ac14583..0000000000 --- a/rust/crates/runtime/src/lsp_transport.rs +++ /dev/null @@ -1,560 +0,0 @@ -use std::io; -use std::process::Stdio; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStdin, ChildStdout, Command}; -use tokio::time::timeout; - -const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(untagged)] -pub enum LspId { - Number(u64), - String(String), - Null, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LspRequest { - pub jsonrpc: String, - pub id: LspId, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -impl LspRequest { - pub fn new(id: LspId, method: impl Into, params: Option) -> Self { - Self { - jsonrpc: "2.0".to_string(), - id, - method: method.into(), - params, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LspNotification { - pub jsonrpc: String, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -impl LspNotification { - pub fn new(method: impl Into, params: Option) -> Self { - Self { - jsonrpc: "2.0".to_string(), - method: method.into(), - params, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LspError { - pub code: i64, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LspResponse { - pub jsonrpc: String, - pub id: LspId, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -impl LspResponse { - #[must_use] - pub fn is_error(&self) -> bool { - self.error.is_some() - } - - pub fn into_result(self) -> Result { - if let Some(error) = self.error { - Err(error) - } else { - Ok(self.result.unwrap_or(JsonValue::Null)) - } - } -} - -/// A message received from an LSP server — either a response to a request -/// or a server-initiated notification (e.g. `textDocument/publishDiagnostics`). -#[derive(Debug, Clone)] -pub enum LspServerMessage { - Response(LspResponse), - Notification(LspNotification), -} - -#[derive(Debug)] -pub enum LspTransportError { - Io(io::Error), - Timeout { method: String, timeout: Duration }, - JsonRpc(LspError), - InvalidResponse { method: String, details: String }, - ServerExited, -} - -impl std::fmt::Display for LspTransportError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(error) => write!(f, "{error}"), - Self::Timeout { method, timeout } => { - write!(f, "LSP request `{method}` timed out after {}s", timeout.as_secs()) - } - Self::JsonRpc(error) => { - write!(f, "LSP JSON-RPC error: {} ({})", error.message, error.code) - } - Self::InvalidResponse { method, details } => { - write!(f, "LSP invalid response for `{method}`: {details}") - } - Self::ServerExited => write!(f, "LSP server process exited unexpectedly"), - } - } -} - -impl std::error::Error for LspTransportError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(error) => Some(error), - Self::JsonRpc(_) | Self::Timeout { .. } | Self::InvalidResponse { .. } | Self::ServerExited => None, - } - } -} - -impl From for LspTransportError { - fn from(value: io::Error) -> Self { - Self::Io(value) - } -} - -#[derive(Debug)] -pub struct LspTransport { - child: Child, - stdin: ChildStdin, - stdout: BufReader, - next_id: u64, - request_timeout: Duration, - pending_notifications: Vec, -} - -impl LspTransport { - pub fn spawn(command: &str, args: &[String]) -> io::Result { - Self::spawn_with_timeout(command, args, DEFAULT_REQUEST_TIMEOUT) - } - - pub fn spawn_with_timeout( - command: &str, - args: &[String], - request_timeout: Duration, - ) -> io::Result { - let mut cmd = Command::new(command); - cmd.args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); - - let mut child = cmd.spawn()?; - let stdin = child - .stdin - .take() - .ok_or_else(|| io::Error::other("LSP process missing stdin pipe"))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| io::Error::other("LSP process missing stdout pipe"))?; - - Ok(Self { - child, - stdin, - stdout: BufReader::new(stdout), - next_id: 1, - request_timeout, - pending_notifications: Vec::new(), - }) - } - - /// Construct an `LspTransport` from an already-spawned child process. - /// Primarily useful for testing. - #[cfg(test)] - fn from_child(mut child: Child, request_timeout: Duration) -> Self { - let stdin = child - .stdin - .take() - .expect("LSP process missing stdin pipe"); - let stdout = child - .stdout - .take() - .expect("LSP process missing stdout pipe"); - Self { - child, - stdin, - stdout: BufReader::new(stdout), - next_id: 1, - request_timeout, - pending_notifications: Vec::new(), - } - } - - fn allocate_id(&mut self) -> LspId { - let id = self.next_id; - self.next_id += 1; - LspId::Number(id) - } - - pub async fn send_notification( - &mut self, - method: &str, - params: Option, - ) -> Result<(), LspTransportError> { - let notification = LspNotification::new(method, params); - let body = serde_json::to_vec(¬ification) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - self.write_frame(&body).await - } - - pub async fn send_request( - &mut self, - method: &str, - params: Option, - ) -> Result { - let id = self.allocate_id(); - self.send_request_with_id(method, params, id).await - } - - pub async fn send_request_with_id( - &mut self, - method: &str, - params: Option, - id: LspId, - ) -> Result { - let request = LspRequest::new(id.clone(), method, params); - let body = serde_json::to_vec(&request) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - self.write_frame(&body).await?; - - let method_owned = method.to_string(); - let timeout_duration = self.request_timeout; - let response = match timeout(timeout_duration, async { - loop { - match self.read_message().await { - Ok(LspServerMessage::Response(r)) => break Ok(r), - Ok(LspServerMessage::Notification(n)) => { - self.pending_notifications.push(n); - } - Err(e) => break Err(e), - } - } - }) - .await - { - Ok(inner) => inner, - Err(_) => { - return Err(LspTransportError::Timeout { - method: method_owned, - timeout: timeout_duration, - }) - } - }?; - - if response.jsonrpc != "2.0" { - return Err(LspTransportError::InvalidResponse { - method: method.to_string(), - details: format!("unsupported jsonrpc version `{}`", response.jsonrpc), - }); - } - - if response.id != id { - return Err(LspTransportError::InvalidResponse { - method: method.to_string(), - details: format!( - "mismatched id: expected {:?}, got {:?}", - id, response.id - ), - }); - } - - if let Some(error) = &response.error { - return Err(LspTransportError::JsonRpc(error.clone())); - } - - Ok(response) - } - - /// Read a single message from the server, returning either a response or - /// a server-initiated notification (e.g. `publishDiagnostics`). - pub async fn read_message(&mut self) -> Result { - let payload = self.read_frame().await?; - let value: JsonValue = serde_json::from_slice(&payload).map_err(|error| { - LspTransportError::InvalidResponse { - method: "unknown".to_string(), - details: error.to_string(), - } - })?; - - // Responses have an "id" field; notifications have "method" but no "id" - if value.get("id").is_some() { - let response: LspResponse = serde_json::from_value(value).map_err(|error| { - LspTransportError::InvalidResponse { - method: "unknown".to_string(), - details: format!("failed to parse response: {error}"), - } - })?; - Ok(LspServerMessage::Response(response)) - } else if value.get("method").is_some() { - let notification: LspNotification = serde_json::from_value(value).map_err(|error| { - LspTransportError::InvalidResponse { - method: "unknown".to_string(), - details: format!("failed to parse notification: {error}"), - } - })?; - Ok(LspServerMessage::Notification(notification)) - } else { - Err(LspTransportError::InvalidResponse { - method: "unknown".to_string(), - details: "message has neither 'id' nor 'method'".to_string(), - }) - } - } - - /// Read a response from the server. Interleaved notifications are queued. - pub async fn read_response(&mut self) -> Result { - loop { - match self.read_message().await? { - LspServerMessage::Response(r) => return Ok(r), - LspServerMessage::Notification(n) => { - self.pending_notifications.push(n); - } - } - } - } - - /// Drain and return all queued server-initiated notifications. - pub fn drain_notifications(&mut self) -> Vec { - std::mem::take(&mut self.pending_notifications) - } - - pub async fn shutdown(&mut self) -> Result<(), LspTransportError> { - let _ = self - .send_notification("shutdown", None) - .await; - - let _ = self.send_notification("exit", None).await; - - match self.child.try_wait() { - Ok(Some(_)) => {} - Ok(None) | Err(_) => { - let _ = self.child.kill().await; - } - } - - Ok(()) - } - - pub fn is_alive(&mut self) -> bool { - matches!(self.child.try_wait(), Ok(None)) - } - - async fn write_frame(&mut self, payload: &[u8]) -> Result<(), LspTransportError> { - let header = format!("Content-Length: {}\r\n\r\n", payload.len()); - self.stdin.write_all(header.as_bytes()).await?; - self.stdin.write_all(payload).await?; - self.stdin.flush().await?; - Ok(()) - } - - async fn read_frame(&mut self) -> Result, LspTransportError> { - let mut content_length: Option = None; - - loop { - let mut line = String::new(); - let bytes_read = self.stdout.read_line(&mut line).await?; - if bytes_read == 0 { - return Err(LspTransportError::ServerExited); - } - if line == "\r\n" { - break; - } - let header = line.trim_end_matches(['\r', '\n']); - if let Some((name, value)) = header.split_once(':') { - if name.trim().eq_ignore_ascii_case("Content-Length") { - let parsed = value - .trim() - .parse::() - .map_err(|error| LspTransportError::Io(io::Error::new( - io::ErrorKind::InvalidData, - error, - )))?; - content_length = Some(parsed); - } - } - } - - let content_length = content_length.ok_or_else(|| { - LspTransportError::InvalidResponse { - method: "unknown".to_string(), - details: "missing Content-Length header".to_string(), - } - })?; - - let mut payload = vec![0u8; content_length]; - self.stdout.read_exact(&mut payload).await.map_err(|error| { - if error.kind() == io::ErrorKind::UnexpectedEof { - LspTransportError::ServerExited - } else { - LspTransportError::Io(error) - } - })?; - - Ok(payload) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Cursor; - use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; - - #[test] - fn content_length_header_roundtrip() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - - rt.block_on(async { - let payload = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":null}"#; - - // Write frame into a buffer - let mut write_buf = Vec::new(); - { - let header = format!("Content-Length: {}\r\n\r\n", payload.len()); - write_buf.extend_from_slice(header.as_bytes()); - write_buf.extend_from_slice(payload); - } - - // Read frame back using the same logic as LspTransport::read_frame - let cursor = Cursor::new(write_buf); - let mut reader = BufReader::new(cursor); - - let mut content_length: Option = None; - loop { - let mut line = String::new(); - let bytes_read = reader.read_line(&mut line).await.unwrap(); - assert!(bytes_read > 0, "unexpected EOF reading header"); - if line == "\r\n" { - break; - } - let header = line.trim_end_matches(['\r', '\n']); - if let Some((name, value)) = header.split_once(':') { - if name.trim().eq_ignore_ascii_case("Content-Length") { - content_length = Some(value.trim().parse::().unwrap()); - } - } - } - - let content_length = content_length.expect("should have Content-Length"); - assert_eq!(content_length, payload.len()); - - let mut read_payload = vec![0u8; content_length]; - reader.read_exact(&mut read_payload).await.unwrap(); - - let original: serde_json::Value = serde_json::from_slice(payload).unwrap(); - let roundtripped: serde_json::Value = serde_json::from_slice(&read_payload).unwrap(); - assert_eq!(original, roundtripped); - }); - } - - #[test] - fn request_has_incrementing_ids() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - - rt.block_on(async { - // Spawn cat so we can construct a real LspTransport. - let child = tokio::process::Command::new("cat") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("cat should be available"); - - let mut transport = LspTransport::from_child(child, Duration::from_secs(5)); - - // Allocate IDs by inspecting what send_request would produce. - let id1 = transport.allocate_id(); - let id2 = transport.allocate_id(); - let id3 = transport.allocate_id(); - - assert_eq!(id1, LspId::Number(1)); - assert_eq!(id2, LspId::Number(2)); - assert_eq!(id3, LspId::Number(3)); - - // Clean up - let _ = transport.shutdown().await; - }); - } - - #[test] - fn notification_has_no_id() { - let notification = LspNotification::new("initialized", Some(serde_json::json!({}))); - let serialized = serde_json::to_string(¬ification).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap(); - assert!( - parsed.get("id").is_none(), - "notification should not contain an 'id' field, got: {serialized}" - ); - assert_eq!(parsed["jsonrpc"], "2.0"); - assert_eq!(parsed["method"], "initialized"); - } - - #[test] - fn malformed_header_handling() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - - rt.block_on(async { - // Feed garbage bytes that don't contain a valid Content-Length header. - let garbage = b"THIS IS NOT A VALID HEADER\r\n\r\n"; - let cursor = Cursor::new(garbage.to_vec()); - let mut reader = BufReader::new(cursor); - - let mut content_length: Option = None; - loop { - let mut line = String::new(); - let bytes_read = reader.read_line(&mut line).await.unwrap(); - if bytes_read == 0 || line == "\r\n" { - break; - } - let header = line.trim_end_matches(['\r', '\n']); - if let Some((name, value)) = header.split_once(':') { - if name.trim().eq_ignore_ascii_case("Content-Length") { - content_length = value.trim().parse::().ok(); - } - } - } - - // The garbage header should not produce a valid Content-Length. - assert!( - content_length.is_none(), - "garbage input should not produce a valid Content-Length" - ); - }); - } -} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 76debc5246..0e90126317 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -3841,6 +3841,14 @@ fn run_repl( run_stale_base_preflight(base_commit.as_deref()); let resolved_model = resolve_repl_model(model); let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; + + // Read config for LSP auto-start setting + let cwd = std::env::current_dir().unwrap_or_default(); + let lsp_auto = runtime::ConfigLoader::default_for(&cwd) + .load() + .map(|c| c.lsp_auto_start()) + .unwrap_or(true); + cli.lsp_auto_start = lsp_auto; cli.set_reasoning_effort(reasoning_effort); let mut editor = input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); @@ -3864,6 +3872,16 @@ fn run_repl( server.clone(), ); } + // Auto-start all discovered servers if enabled + if cli.lsp_auto_start { + let registry = tools::global_lsp_registry(); + for server in &lsp_servers { + match registry.start_server(&server.language) { + Ok(()) => eprintln!(" {} started", server.language), + Err(e) => eprintln!(" {} failed to start: {e}", server.language), + } + } + } } loop { @@ -3944,6 +3962,7 @@ struct LiveCli { runtime: BuiltRuntime, session: SessionHandle, prompt_history: Vec, + lsp_auto_start: bool, } #[derive(Debug, Clone)] @@ -4452,6 +4471,7 @@ impl LiveCli { runtime, session, prompt_history: Vec::new(), + lsp_auto_start: true, }; cli.persist_session()?; Ok(cli) @@ -4859,7 +4879,7 @@ impl LiveCli { }) } - fn handle_lsp_command(&self, action: Option<&str>, target: Option<&str>) { + fn handle_lsp_command(&mut self, action: Option<&str>, target: Option<&str>) { let registry = tools::global_lsp_registry(); match action { Some("start") => { @@ -4884,8 +4904,15 @@ impl LiveCli { Err(e) => eprintln!("Failed to restart LSP server '{lang}': {e}"), } } + Some("toggle") => { + self.lsp_auto_start = !self.lsp_auto_start; + let state = if self.lsp_auto_start { "on" } else { "off" }; + eprintln!("LSP auto-start: {state}"); + } _ => { let servers = registry.list_servers(); + let auto_state = if self.lsp_auto_start { "on" } else { "off" }; + eprintln!("LSP auto-start: {auto_state}"); if servers.is_empty() { eprintln!("No LSP servers registered."); } else { From b0e08ea5a60509ab666cfa2bbb71223c4fca3022 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Tue, 28 Apr 2026 08:11:30 -0500 Subject: [PATCH 13/16] feat(lsp): install prompts, new servers, and advanced LSP features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add distro-aware install prompt system: detects Ubuntu/Debian/Fedora/ Arch/openSUSE/Alpine/Void/NixOS/macOS and suggests the right install command for each missing LSP server at startup - Add 6 new language servers: HTML, CSS, JSON, Bash, YAML, GDScript - Add didClose notifications for proper file lifecycle - Add code_action support (quick fixes, refactors) - Add rename support (workspace-wide symbol renaming) - Add signature_help (function signatures + parameter hints) - Add code_lens (inline actionable hints) - Add workspace_symbols (project-wide symbol search) - Add workspaceFolders support in initialize handshake - Advertise full capability set (code actions, rename, signatures, code lens, workspace symbols) to LSP servers - Fix panic in lsp_discovery test when rust-analyzer is a rustup proxy stub for an uninstalled component 💘 Generated with Crush Assisted-by: GLM 5.1 FP8 via Crush --- rust/crates/commands/src/lib.rs | 2 +- .../crates/runtime/src/lsp_client/dispatch.rs | 107 ++++- rust/crates/runtime/src/lsp_client/mod.rs | 51 ++- .../runtime/src/lsp_client/tests_lifecycle.rs | 3 + rust/crates/runtime/src/lsp_client/types.rs | 94 +++- rust/crates/runtime/src/lsp_discovery.rs | 415 ++++++++++++++++-- rust/crates/runtime/src/lsp_process/mod.rs | 179 +++++++- rust/crates/runtime/src/lsp_process/parse.rs | 137 ++++++ rust/crates/rusty-claude-cli/src/main.rs | 9 + rust/crates/tools/src/lib.rs | 23 +- 10 files changed, 976 insertions(+), 44 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index d2438ca921..c668dcc6e1 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -4913,7 +4913,7 @@ mod tests { assert!(help.contains("aliases: /skill")); assert!(!help.contains("/login")); assert!(!help.contains("/logout")); - assert_eq!(slash_command_specs().len(), 141); + assert_eq!(slash_command_specs().len(), 140); assert!(resume_supported_slash_commands().len() >= 39); } diff --git a/rust/crates/runtime/src/lsp_client/dispatch.rs b/rust/crates/runtime/src/lsp_client/dispatch.rs index 4533f76359..d07943ef0a 100644 --- a/rust/crates/runtime/src/lsp_client/dispatch.rs +++ b/rust/crates/runtime/src/lsp_client/dispatch.rs @@ -1,6 +1,7 @@ //! LSP action dispatch: routes actions to the appropriate server process. use super::types::{LspAction, LspServerStatus}; +use crate::lsp_process::LspProcessError; impl super::LspRegistry { /// Dispatch an LSP action and return a structured result. @@ -42,9 +43,18 @@ impl super::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 language = Self::language_for_path(path) - .ok_or_else(|| format!("no LSP server available for path: {path}"))?; + // (workspace_symbols operates without a specific file path) + let language = if lsp_action == LspAction::WorkspaceSymbols { + // Try to find any connected server for workspace symbols + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.keys().next().cloned() + .ok_or_else(|| "no LSP servers available for workspace symbols".to_owned())? + } else { + let p = path.ok_or("path is required for this LSP action")?; + Self::language_for_path(p) + .ok_or_else(|| format!("no LSP server available for path: {p}"))? + }; + let path = path.unwrap_or(""); // Check the entry exists { @@ -54,6 +64,23 @@ impl super::LspRegistry { } } + // Check if the server is already in a non-starting state + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get(&language) { + if entry.state.status == LspServerStatus::Disconnected + || entry.state.status == LspServerStatus::Error + { + if entry.process.is_none() { + return Err(format!( + "LSP server for '{}' is not connected (status: {})", + language, entry.state.status + )); + } + } + } + } + // Lazy-start: if no process yet, try to start one let needs_start = { let inner = self.inner.lock().expect("lsp registry lock poisoned"); @@ -214,6 +241,80 @@ impl super::LspRegistry { "edits": text_edits, })) } + LspAction::CodeAction => { + let end_line = if line > 0 { Some(line) } else { None }; + let end_character = if character > 0 { Some(character) } else { None }; + let actions = process.code_action(path, line, character, end_line, end_character, None).await; + actions.map(|acts| serde_json::json!({ + "action": "code_action", + "path": path, + "line": 0, + "character": 0, + "end_line": end_line, + "end_character": end_character, + "language": language, + "status": "ok", + "actions": acts, + })) + } + LspAction::Rename => { + let new_name = _query.ok_or_else(|| LspProcessError::InvalidRequest("new_name required for rename".into()))?; + let rename_result = process.rename(path, line, character, new_name).await; + rename_result.map(|r| serde_json::json!({ + "action": "rename", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "result": r, + })) + } + LspAction::SignatureHelp => { + let sig = process.signature_help(path, line, character).await; + sig.map(|opt| { + opt.map_or_else( + || serde_json::json!({ + "action": "signature_help", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "no_result", + }), + |s| serde_json::json!({ + "action": "signature_help", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "result": s, + }), + ) + }) + } + LspAction::CodeLens => { + let lenses = process.code_lens(path).await; + lenses.map(|l| serde_json::json!({ + "action": "code_lens", + "path": path, + "language": language, + "status": "ok", + "lenses": l, + })) + } + LspAction::WorkspaceSymbols => { + let query = _query.unwrap_or(""); + let symbols = process.workspace_symbols(query).await; + symbols.map(|syms| serde_json::json!({ + "action": "workspace_symbols", + "language": language, + "query": query, + "status": "ok", + "symbols": syms, + })) + } LspAction::Diagnostics => unreachable!(), } }) diff --git a/rust/crates/runtime/src/lsp_client/mod.rs b/rust/crates/runtime/src/lsp_client/mod.rs index 05aa5c6771..7a9c7a3b2a 100644 --- a/rust/crates/runtime/src/lsp_client/mod.rs +++ b/rust/crates/runtime/src/lsp_client/mod.rs @@ -9,8 +9,10 @@ mod tests; mod tests_lifecycle; pub use types::{ - LspAction, LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerState, - LspServerStatus, LspSymbol, + LspAction, LspCodeAction, LspCodeLens, LspCommand, LspCompletionItem, LspDiagnostic, + LspFileEdit, LspHoverResult, LspLocation, LspParameterInfo, LspRenameResult, + LspServerState, LspServerStatus, LspSignatureHelpResult, LspSignatureInformation, + LspSymbol, LspTextEdit, LspWorkspaceEdit, }; use std::collections::{HashMap, HashSet}; @@ -144,6 +146,12 @@ impl LspRegistry { "cpp" | "hpp" | "cc" => "cpp", "rb" => "ruby", "lua" => "lua", + "html" | "htm" => "html", + "css" | "scss" | "less" | "sass" => "css", + "json" | "jsonc" => "json", + "sh" | "bash" | "zsh" => "bash", + "yaml" | "yml" => "yaml", + "gd" => "gdscript", _ => return None, }; @@ -167,6 +175,12 @@ impl LspRegistry { "cpp" | "hpp" | "cc" => "cpp", "rb" => "ruby", "lua" => "lua", + "html" | "htm" => "html", + "css" | "scss" | "less" | "sass" => "css", + "json" | "jsonc" => "json", + "sh" | "bash" | "zsh" => "bash", + "yaml" | "yml" => "yaml", + "gd" => "gdscript", _ => return None, }; @@ -433,6 +447,39 @@ impl LspRegistry { diagnostics } + + /// Notify the LSP server that a file was closed. + /// Best-effort: returns empty vec if no server is available. + pub fn notify_file_close(&self, path: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + match inner.servers.get(&language).and_then(|e| e.process.clone()) { + Some(p) => p, + None => return Vec::new(), + } + }; + + if let Ok(mut process) = process_arc.lock() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + if let Ok(rt) = rt { + let _ = rt.block_on(process.did_close(path)); + } + } + + // Mark file as closed + { + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.open_files.remove(path); + } + + Vec::new() + } /// Fetch diagnostics for a file by draining pending server notifications /// and returning cached diagnostics. pub fn fetch_diagnostics_for_file(&self, path: &str) -> Vec { diff --git a/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs b/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs index 67c2c5548d..7b2a094bd8 100644 --- a/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs +++ b/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs @@ -188,6 +188,7 @@ fn register_with_descriptor_stores_entry() { command: "rust-analyzer".into(), args: vec![], extensions: vec!["rs".into()], + install_hint: vec![], }; registry.register_with_descriptor( "rust", @@ -238,6 +239,7 @@ fn dispatch_hover_lazy_starts_server() { command: "rust-analyzer".into(), args: vec![], extensions: vec!["rs".into()], + install_hint: vec![], }; registry.register_with_descriptor( "rust", @@ -271,6 +273,7 @@ fn start_and_stop_server() { command: "rust-analyzer".into(), args: vec![], extensions: vec!["rs".into()], + install_hint: vec![], }; registry.register_with_descriptor( "rust", diff --git a/rust/crates/runtime/src/lsp_client/types.rs b/rust/crates/runtime/src/lsp_client/types.rs index 485a569748..d0ec60bdf4 100644 --- a/rust/crates/runtime/src/lsp_client/types.rs +++ b/rust/crates/runtime/src/lsp_client/types.rs @@ -1,4 +1,5 @@ -//! LSP type definitions: action enums, diagnostic/location types, server status. +//! LSP type definitions: action enums, diagnostic/location types, server status, +//! and structured results for all supported LSP features. use serde::{Deserialize, Serialize}; @@ -13,6 +14,11 @@ pub enum LspAction { Completion, Symbols, Format, + CodeAction, + Rename, + SignatureHelp, + CodeLens, + WorkspaceSymbols, } impl LspAction { @@ -25,6 +31,11 @@ impl LspAction { "completion" | "completions" => Some(Self::Completion), "symbols" | "document_symbols" => Some(Self::Symbols), "format" | "formatting" => Some(Self::Format), + "code_action" | "codeaction" => Some(Self::CodeAction), + "rename" => Some(Self::Rename), + "signature_help" | "signatures" => Some(Self::SignatureHelp), + "code_lens" | "codelens" => Some(Self::CodeLens), + "workspace_symbols" => Some(Self::WorkspaceSymbols), _ => None, } } @@ -73,6 +84,87 @@ pub struct LspSymbol { pub character: u32, } +/// A code action (quick fix, refactor, etc.) returned by the server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspCodeAction { + pub title: String, + pub kind: Option, + pub is_preferred: bool, + pub edit: Option, + pub command: Option, +} + +/// A workspace edit containing multiple file changes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspWorkspaceEdit { + pub changes: Vec, +} + +/// Edits to a single file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspFileEdit { + pub path: String, + pub edits: Vec, +} + +/// A single text edit operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspTextEdit { + pub new_text: String, + pub start_line: u32, + pub start_character: u32, + pub end_line: u32, + pub end_character: u32, +} + +/// A command that the server requests the client to execute. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspCommand { + pub title: String, + pub command: String, + pub arguments: Vec, +} + +/// Result of a rename operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspRenameResult { + pub new_name: String, + pub edit: Option, +} + +/// A single parameter in a function signature. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspParameterInfo { + pub label: String, + pub documentation: Option, +} + +/// A function signature with its parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspSignatureInformation { + pub label: String, + pub documentation: Option, + pub parameters: Vec, + pub active_parameter: Option, +} + +/// Result of a signature help request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspSignatureHelpResult { + pub signatures: Vec, + pub active_signature: Option, + pub active_parameter: Option, +} + +/// A code lens item — an actionable hint inline in the editor. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspCodeLens { + pub line: u32, + pub character: u32, + pub command: Option, + pub data: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LspServerStatus { diff --git a/rust/crates/runtime/src/lsp_discovery.rs b/rust/crates/runtime/src/lsp_discovery.rs index 475f20a665..fdb858bd8f 100644 --- a/rust/crates/runtime/src/lsp_discovery.rs +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -1,20 +1,56 @@ -//! Auto-discovery of installed LSP servers and file-extension mapping. +//! Auto-discovery of installed LSP servers, file-extension mapping, and +//! distro-aware install prompting. 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. +/// Descriptor for a well-known LSP server, including its launch command, +/// the file extensions it handles, and how to install it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LspServerDescriptor { pub language: String, pub command: String, pub args: Vec, pub extensions: Vec, + pub install_hint: Vec, } -/// Static descriptor used by the [`KNOWN_LSP_SERVERS`] constant. Uses -/// `&'static str` fields so the table can live in read-only memory. +/// A single install command for a specific package manager or platform. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallInstruction { + pub label: String, + pub command: String, +} + +/// What the caller should do when a server is missing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LspInstallAction { + /// The server is already available. + Installed, + /// The server is not found; these are the suggested install commands. + Missing { language: String, instructions: Vec }, + /// The server binary exists but is a rustup proxy stub for an uninstalled component. + RustupProxyMissing { language: String, component: String }, +} + +/// Detect the current Linux distribution (or non-Linux). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinuxDistro { + Debian, + Ubuntu, + Fedora, + Arch, + OpenSuse, + Alpine, + Void, + NixOS, + UnknownLinux, + MacOS, + Windows, + Other, +} + +/// Static descriptor used by the [`KNOWN_LSP_SERVERS_TABLE`] constant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct StaticLspServerDescriptor { language: &'static str, @@ -31,6 +67,7 @@ impl StaticLspServerDescriptor { 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(), + install_hint: install_instructions_for(self.language), } } } @@ -85,8 +122,138 @@ const KNOWN_LSP_SERVERS_TABLE: &[StaticLspServerDescriptor] = &[ args: &[], extensions: &["lua"], }, + StaticLspServerDescriptor { + language: "html", + command: "vscode-html-language-server", + args: &["--stdio"], + extensions: &["html", "htm"], + }, + StaticLspServerDescriptor { + language: "css", + command: "vscode-css-language-server", + args: &["--stdio"], + extensions: &["css", "scss", "less", "sass"], + }, + StaticLspServerDescriptor { + language: "json", + command: "vscode-json-language-server", + args: &["--stdio"], + extensions: &["json", "jsonc"], + }, + StaticLspServerDescriptor { + language: "bash", + command: "bash-language-server", + args: &["start"], + extensions: &["sh", "bash", "zsh"], + }, + StaticLspServerDescriptor { + language: "yaml", + command: "yaml-language-server", + args: &["--stdio"], + extensions: &["yaml", "yml"], + }, + StaticLspServerDescriptor { + language: "gdscript", + command: "godot", + args: &["--headless", "--editor"], + extensions: &["gd"], + }, ]; +/// Return install instructions for a known language server, covering all +/// common distros and package managers. Order doesn't matter — the caller +/// picks the one matching the current system. +fn install_instructions_for(language: &str) -> Vec { + match language { + "rust" => vec![ + InstallInstruction { label: "rustup".into(), command: "rustup component add rust-analyzer".into() }, + InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install rust-analyzer".into() }, + InstallInstruction { label: "Fedora".into(), command: "sudo dnf install rust-analyzer".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S rust-analyzer".into() }, + InstallInstruction { label: "openSUSE".into(), command: "sudo zypper install rust-analyzer".into() }, + InstallInstruction { label: "Alpine".into(), command: "sudo apk add rust-analyzer".into() }, + InstallInstruction { label: "Void".into(), command: "sudo xbps-install rust-analyzer".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.rust-analyzer".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install rust-analyzer".into() }, + InstallInstruction { label: "pip".into(), command: "pip install rust-analyzer".into() }, + ], + "c/cpp" => vec![ + InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install clangd".into() }, + InstallInstruction { label: "Fedora".into(), command: "sudo dnf install clang-tools-extra".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S clang".into() }, + InstallInstruction { label: "openSUSE".into(), command: "sudo zypper install clang-tools".into() }, + InstallInstruction { label: "Alpine".into(), command: "sudo apk add clang-extra-tools".into() }, + InstallInstruction { label: "Void".into(), command: "sudo xbps-install clang-tools-extra".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.clang-tools".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install llvm".into() }, + ], + "python" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g pyright".into() }, + InstallInstruction { label: "pip".into(), command: "pip install pyright".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S pyright".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.pyright".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install pyright".into() }, + ], + "go" => vec![ + InstallInstruction { label: "go".into(), command: "go install golang.org/x/tools/gopls@latest".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S gopls".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.gopls".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install gopls".into() }, + ], + "typescript" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g typescript-language-server typescript".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S typescript-language-server".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.typescript-language-server".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install typescript-language-server".into() }, + ], + "java" => vec![ + InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install eclipse-jdtls".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S jdtls".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.eclipse-jdtls".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install jdtls".into() }, + ], + "ruby" => vec![ + InstallInstruction { label: "gem".into(), command: "gem install solargraph".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S solargraph".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.solargraph".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install solargraph".into() }, + ], + "lua" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g lua-language-server".into() }, + InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install lua-language-server".into() }, + InstallInstruction { label: "Fedora".into(), command: "sudo dnf install lua-language-server".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S lua-language-server".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.lua-language-server".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install lua-language-server".into() }, + ], + "html" | "css" | "json" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g vscode-langservers-extracted".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S vscode-langservers-extracted".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.vscode-langservers-extracted".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install vscode-langservers-extracted".into() }, + ], + "bash" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g bash-language-server".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S bash-language-server".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.bash-language-server".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install bash-language-server".into() }, + ], + "yaml" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g yaml-language-server".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S yaml-language-server".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.yaml-language-server".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install yaml-language-server".into() }, + ], + "gdscript" => vec![ + InstallInstruction { label: "Godot Editor".into(), command: "Download from https://godotengine.org".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S godot".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.godot".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install godot".into() }, + ], + _ => Vec::new(), + } +} + /// Owned copy of the known LSP server descriptors, useful when callers need /// to mutate or transfer ownership. #[must_use] @@ -100,10 +267,6 @@ 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. -/// -/// 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. #[must_use] pub fn command_exists_on_path(command: &str) -> bool { Command::new(command) @@ -119,7 +282,6 @@ fn is_rustup_proxy(command: &str) -> bool { let Ok(output) = Command::new(command).arg("--version").output() else { return false; }; - // rustup proxy exits non-zero and prints "error: Unknown binary '...' in official toolchain" let stderr = String::from_utf8_lossy(&output.stderr); stderr.contains("Unknown binary") } @@ -135,14 +297,158 @@ fn rustup_component_works(component: &str) -> bool { .is_ok_and(|o| o.status.success()) } +/// Detect the current platform/distro for install suggestion filtering. +#[must_use] +pub fn detect_platform() -> LinuxDistro { + if cfg!(target_os = "macos") { + return LinuxDistro::MacOS; + } + if cfg!(target_os = "windows") { + return LinuxDistro::Windows; + } + if !cfg!(target_os = "linux") { + return LinuxDistro::Other; + } + + let contents = std::fs::read_to_string("/etc/os-release").unwrap_or_default(); + + if contents.contains("Ubuntu") { + LinuxDistro::Ubuntu + } else if contents.contains("Debian") { + LinuxDistro::Debian + } else if contents.contains("Fedora") { + LinuxDistro::Fedora + } else if contents.contains("Arch") || contents.contains("archlinux") || contents.contains("Manjaro") || contents.contains("EndeavourOS") { + LinuxDistro::Arch + } else if contents.contains("openSUSE") || contents.contains("SUSE") { + LinuxDistro::OpenSuse + } else if contents.contains("Alpine") { + LinuxDistro::Alpine + } else if contents.contains("Void") { + LinuxDistro::Void + } else if contents.contains("NixOS") { + LinuxDistro::NixOS + } else { + LinuxDistro::UnknownLinux + } +} + +/// Return the best install instruction for a language given the current platform. +/// Returns `None` if no instructions are known for this language. +#[must_use] +pub fn best_install_instruction(language: &str) -> Option { + let distro = detect_platform(); + let instructions = install_instructions_for(language); + if instructions.is_empty() { + return None; + } + + let label_match = match distro { + LinuxDistro::Ubuntu | LinuxDistro::Debian => "Ubuntu/Debian", + LinuxDistro::Fedora => "Fedora", + LinuxDistro::Arch => "Arch", + LinuxDistro::OpenSuse => "openSUSE", + LinuxDistro::Alpine => "Alpine", + LinuxDistro::Void => "Void", + LinuxDistro::NixOS => "NixOS", + LinuxDistro::MacOS => "macOS", + LinuxDistro::Windows | LinuxDistro::UnknownLinux | LinuxDistro::Other => { + instructions.first().map(|i| i.label.as_str()).unwrap_or("") + } + }; + + instructions + .iter() + .find(|i| i.label == label_match) + .or_else(|| instructions.first()) + .cloned() +} + +/// Check which known LSP servers are missing and produce install suggestions. +/// Returns a list of `LspInstallAction` for every known language: installed, +/// missing, or rustup-proxy-missing. +#[must_use] +pub fn check_lsp_availability() -> Vec { + let mut actions = Vec::new(); + + for desc in KNOWN_LSP_SERVERS_TABLE { + if !command_exists_on_path(desc.command) { + actions.push(LspInstallAction::Missing { + language: desc.language.to_string(), + instructions: install_instructions_for(desc.language), + }); + continue; + } + + if desc.command == "rust-analyzer" && is_rustup_proxy("rust-analyzer") { + if rustup_component_works("rust-analyzer") { + actions.push(LspInstallAction::Installed); + } else { + actions.push(LspInstallAction::RustupProxyMissing { + language: desc.language.to_string(), + component: "rust-analyzer".to_string(), + }); + } + continue; + } + + actions.push(LspInstallAction::Installed); + } + + actions +} + +/// Format a human-readable install prompt for missing LSP servers. +#[must_use] +pub fn format_install_prompt(actions: &[LspInstallAction]) -> String { + let mut lines = Vec::new(); + let distro = detect_platform(); + + for action in actions { + match action { + LspInstallAction::Installed => continue, + LspInstallAction::Missing { language, instructions } => { + lines.push(format!(" {language}: not found")); + let best = instructions + .iter() + .find(|i| match distro { + LinuxDistro::Ubuntu | LinuxDistro::Debian => i.label == "Ubuntu/Debian", + LinuxDistro::Fedora => i.label == "Fedora", + LinuxDistro::Arch => i.label == "Arch", + LinuxDistro::OpenSuse => i.label == "openSUSE", + LinuxDistro::Alpine => i.label == "Alpine", + LinuxDistro::Void => i.label == "Void", + LinuxDistro::NixOS => i.label == "NixOS", + LinuxDistro::MacOS => i.label == "macOS", + _ => false, + }) + .or_else(|| instructions.first()); + if let Some(inst) = best { + lines.push(format!(" → {}", inst.command)); + } + for inst in instructions { + if Some(inst) != best { + lines.push(format!(" • {} ({})", inst.command, inst.label)); + } + } + } + LspInstallAction::RustupProxyMissing { language, component } => { + lines.push(format!(" {language}: rustup proxy found but component not installed")); + lines.push(format!(" → rustup component add {component}")); + } + } + } + + if lines.is_empty() { + return String::new(); + } + + let mut out = "LSP servers missing — install for code intelligence:\n".to_string(); + out.push_str(&lines.join("\n")); + out +} + /// 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` **and** is actually functional. For `rust-analyzer`, -/// rustup ships a stub proxy that always exists on PATH but prints -/// "Unknown binary" when the component isn't installed. We detect that -/// case and either rewrite to `rustup run stable rust-analyzer` (when the -/// component is installed) or skip the server entirely (when it's not). #[must_use] pub fn discover_available_servers() -> Vec { KNOWN_LSP_SERVERS_TABLE @@ -150,12 +456,7 @@ pub fn discover_available_servers() -> Vec { .filter(|desc| command_exists_on_path(desc.command)) .filter_map(|desc| { let mut server = desc.to_descriptor(); - // rustup ships a proxy `rust-analyzer` that exists on PATH but - // errors with "Unknown binary" when the component isn't installed. if desc.command == "rust-analyzer" && is_rustup_proxy("rust-analyzer") { - // The component isn't installed under this toolchain. - // Check if `rustup run stable rust-analyzer --version` works - // (e.g. user installed it via rustup but the proxy is misconfigured). if rustup_component_works("rust-analyzer") { server.command = "rustup".to_string(); server.args = vec![ @@ -164,7 +465,6 @@ pub fn discover_available_servers() -> Vec { "rust-analyzer".to_string(), ]; } else { - // Component truly not installed — skip it. return None; } } @@ -174,9 +474,6 @@ pub fn discover_available_servers() -> Vec { } /// 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, @@ -250,7 +547,6 @@ mod tests { #[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), @@ -259,9 +555,8 @@ mod tests { 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") { + if command_exists_on_path("rust-analyzer") && !is_rustup_proxy("rust-analyzer") { assert!(languages.contains(&"rust"), "rust-analyzer is on PATH but 'rust' not in discovered servers"); } if command_exists_on_path("clangd") { @@ -293,4 +588,66 @@ mod tests { 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"); } + + #[test] + fn install_instructions_cover_all_languages() { + for desc in KNOWN_LSP_SERVERS_TABLE { + let instructions = install_instructions_for(desc.language); + assert!(!instructions.is_empty(), "no install instructions for '{}'", desc.language); + } + } + + #[test] + fn best_install_returns_something_for_known_languages() { + for desc in KNOWN_LSP_SERVERS_TABLE { + assert!(best_install_instruction(desc.language).is_some(), "no best install for '{}'", desc.language); + } + } + + #[test] + fn format_install_prompt_skips_installed() { + let actions = vec![LspInstallAction::Installed]; + let prompt = format_install_prompt(&actions); + assert!(prompt.is_empty(), "should not prompt for installed servers"); + } + + #[test] + fn format_install_prompt_shows_missing() { + let actions = vec![LspInstallAction::Missing { + language: "rust".into(), + instructions: install_instructions_for("rust"), + }]; + let prompt = format_install_prompt(&actions); + assert!(prompt.contains("rust"), "should mention rust"); + assert!(prompt.contains("rustup component add rust-analyzer"), "should show rustup command"); + } + + #[test] + fn format_install_prompt_shows_rustup_proxy_missing() { + let actions = vec![LspInstallAction::RustupProxyMissing { + language: "rust".into(), + component: "rust-analyzer".into(), + }]; + let prompt = format_install_prompt(&actions); + assert!(prompt.contains("rustup component add rust-analyzer")); + } + + #[test] + fn detect_platform_returns_something() { + let _ = detect_platform(); + } + + #[test] + fn check_availability_returns_one_per_known_language() { + let actions = check_lsp_availability(); + assert_eq!(actions.len(), KNOWN_LSP_SERVERS_TABLE.len()); + } + + #[test] + fn server_descriptors_have_install_hints() { + let servers = known_lsp_servers(); + for server in &servers { + assert!(!server.install_hint.is_empty(), "server '{}' should have install hints", server.language); + } + } } diff --git a/rust/crates/runtime/src/lsp_process/mod.rs b/rust/crates/runtime/src/lsp_process/mod.rs index ed0fc1077b..dab6aa3753 100644 --- a/rust/crates/runtime/src/lsp_process/mod.rs +++ b/rust/crates/runtime/src/lsp_process/mod.rs @@ -11,13 +11,17 @@ use std::path::Path; use serde_json::Value as JsonValue; use crate::lsp_client::{ - LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, + LspCodeAction, LspCodeLens, LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, + LspRenameResult, LspServerStatus, LspSignatureHelpResult, LspSymbol, }; use crate::lsp_transport::{LspTransport, LspTransportError}; use parse::{ - canonicalize_root, language_id_for_path, parse_completions, parse_hover, parse_locations, - parse_symbols, path_to_uri, severity_name, text_document_position_params, uri_to_path, + canonicalize_root, language_id_for_path, parse_code_actions, parse_code_lens, + parse_completions, parse_hover, parse_locations, parse_signature_help, + parse_symbols, parse_workspace_edit, parse_workspace_symbols, path_to_uri, + rename_params, severity_name, text_document_position_params, uri_to_path, + workspace_symbol_params, }; #[derive(Debug)] @@ -69,6 +73,7 @@ impl LspProcess { let params = serde_json::json!({ "processId": pid, "rootUri": root_uri, + "workspaceFolders": [{ "uri": root_uri, "name": "root" }], "capabilities": { "textDocument": { "hover": { "contentFormat": ["markdown", "plaintext"] }, @@ -78,7 +83,30 @@ impl LspProcess { "completionItem": { "snippetSupport": false } }, "documentSymbol": { "hierarchicalDocumentSymbolSupport": true }, - "publishDiagnostics": { "relatedInformation": true } + "publishDiagnostics": { "relatedInformation": true }, + "codeAction": { + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", "quickfix", "refactor", "refactor.extract", + "refactor.inline", "refactor.rewrite", "source", + "source.organizeImports" + ] + } + } + }, + "rename": { "prepareSupport": true }, + "signatureHelp": { + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": { "labelOffsetSupport": true } + } + }, + "codeLens": {} + }, + "workspace": { + "symbol": {}, + "workspaceFolders": true } } }); @@ -341,6 +369,145 @@ impl LspProcess { Ok(()) } + + /// Notify the server that a file was closed. Sends `textDocument/didClose`. + pub async fn did_close(&mut self, path: &str) -> Result<(), LspProcessError> { + if !self.open_files.contains(path) { + return Ok(()); + } + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri } + }); + self.transport + .send_notification("textDocument/didClose", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + self.open_files.remove(path); + self.version_counter.remove(path); + Ok(()) + } + + /// Request code actions (quick fixes, refactors) for a range in a file. + pub async fn code_action( + &mut self, + path: &str, + line: u32, + character: u32, + end_line: Option, + end_character: Option, + only_kinds: Option<&[String]>, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let el = end_line.unwrap_or(line); + let ec = end_character.unwrap_or(character); + let mut params = serde_json::json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": line, "character": character }, + "end": { "line": el, "character": ec } + }, + "context": { "diagnostics": [] } + }); + if let Some(kinds) = only_kinds { + params["context"]["only"] = serde_json::json!(kinds); + } + let response = self + .transport + .send_request("textDocument/codeAction", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + Ok(parse_code_actions(&result)) + } + + /// Rename a symbol at a position across the workspace. + pub async fn rename( + &mut self, + path: &str, + line: u32, + character: u32, + new_name: &str, + ) -> Result { + let uri = path_to_uri(path); + let params = rename_params(&uri, line, character, new_name); + let response = self + .transport + .send_request("textDocument/rename", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + let edit = parse_workspace_edit(&result); + Ok(LspRenameResult { + new_name: new_name.to_owned(), + edit, + }) + } + + /// Get signature help at a position (function signatures, parameters). + pub async fn signature_help( + &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/signatureHelp", 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_signature_help(&result)) + } + + /// Get code lens items for a file (actionable inline hints). + pub async fn code_lens(&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/codeLens", 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_code_lens(&result)) + } + + /// Search for symbols across the entire workspace. + pub async fn workspace_symbols( + &mut self, + query: &str, + ) -> Result, LspProcessError> { + let params = workspace_symbol_params(query); + let response = self + .transport + .send_request("workspace/symbol", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + Ok(parse_workspace_symbols(&result)) + } + /// Drain queued server notifications and extract `publishDiagnostics`. #[allow(clippy::redundant_closure_for_method_calls)] pub fn drain_diagnostics(&mut self) -> Vec { @@ -415,6 +582,7 @@ impl LspProcess { pub enum LspProcessError { Transport(LspTransportError), InvalidPath(String), + InvalidRequest(String), } impl std::fmt::Display for LspProcessError { @@ -422,6 +590,7 @@ impl std::fmt::Display for LspProcessError { match self { Self::Transport(e) => write!(f, "LSP transport error: {e}"), Self::InvalidPath(p) => write!(f, "invalid path: {p}"), + Self::InvalidRequest(msg) => write!(f, "invalid request: {msg}"), } } } @@ -430,7 +599,7 @@ 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, + Self::InvalidPath(_) | Self::InvalidRequest(_) => None, } } } diff --git a/rust/crates/runtime/src/lsp_process/parse.rs b/rust/crates/runtime/src/lsp_process/parse.rs index 575f425a75..1a5debf45c 100644 --- a/rust/crates/runtime/src/lsp_process/parse.rs +++ b/rust/crates/runtime/src/lsp_process/parse.rs @@ -309,3 +309,140 @@ pub(super) fn completion_kind_name(kind: u64) -> String { _ => format!("Unknown({kind})"), } } + + +#[allow(clippy::cast_possible_truncation)] +pub(super) fn parse_code_actions(value: &JsonValue) -> Vec { + let Some(items) = value.as_array() else { + return Vec::new(); + }; + items.iter().filter_map(|item| { + // Code actions can be Command or CodeAction objects; we only parse CodeAction + let title = item.get("title")?.as_str()?.to_owned(); + let kind = item.get("kind").and_then(JsonValue::as_str).map(str::to_owned); + let is_preferred = item.get("isPreferred").and_then(JsonValue::as_bool).unwrap_or(false); + let edit = item.get("edit").and_then(|e| parse_workspace_edit(e)); + let command = item.get("command").and_then(parse_command); + Some(crate::lsp_client::LspCodeAction { title, kind, is_preferred, edit, command }) + }).collect() +} + +pub(super) fn parse_workspace_edit(value: &JsonValue) -> Option { + let changes = if let Some(changes_map) = value.get("changes").and_then(JsonValue::as_object) { + changes_map.iter().filter_map(|(uri, edits)| { + let path = uri_to_path(uri); + let edit_list = edits.as_array()?; + let text_edits: Vec = edit_list.iter().filter_map(|e| { + let new_text = e.get("newText")?.as_str()?.to_owned(); + let range = e.get("range")?; + let start = range.get("start")?; + let end = range.get("end")?; + Some(crate::lsp_client::LspTextEdit { + new_text, + start_line: start.get("line")?.as_u64()? as u32, + start_character: start.get("character")?.as_u64()? as u32, + end_line: end.get("line")?.as_u64()? as u32, + end_character: end.get("character")?.as_u64()? as u32, + }) + }).collect(); + if text_edits.is_empty() { None } else { Some(crate::lsp_client::LspFileEdit { path, edits: text_edits }) } + }).collect() + } else { + Vec::new() + }; + if changes.is_empty() { None } else { Some(crate::lsp_client::LspWorkspaceEdit { changes }) } +} + +pub(super) fn parse_command(value: &JsonValue) -> Option { + let title = value.get("title")?.as_str()?.to_owned(); + let command = value.get("command")?.as_str()?.to_owned(); + let arguments = value.get("arguments") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + Some(crate::lsp_client::LspCommand { title, command, arguments }) +} + +#[allow(clippy::cast_possible_truncation)] +pub(super) fn parse_signature_help(value: &JsonValue) -> Option { + let signatures_arr = value.get("signatures")?.as_array()?; + let signatures: Vec = signatures_arr.iter().filter_map(|sig| { + let label = sig.get("label")?.as_str()?.to_owned(); + let documentation = sig.get("documentation") + .and_then(|d| d.get("value").and_then(JsonValue::as_str).or_else(|| d.as_str())) + .map(str::to_owned); + let parameters = sig.get("parameters").and_then(JsonValue::as_array) + .map(|arr| arr.iter().filter_map(|p| { + let plabel = p.get("label").and_then(|l| l.as_str().or_else(|| l.get("value").and_then(JsonValue::as_str))).unwrap_or("").to_owned(); + let pdoc = p.get("documentation") + .and_then(|d| d.get("value").and_then(JsonValue::as_str).or_else(|| d.as_str())) + .map(str::to_owned); + Some(crate::lsp_client::LspParameterInfo { label: plabel, documentation: pdoc }) + }).collect()) + .unwrap_or_default(); + let active_parameter = sig.get("activeParameter").and_then(JsonValue::as_u64).map(|v| v as u32); + Some(crate::lsp_client::LspSignatureInformation { label, documentation, parameters, active_parameter }) + }).collect(); + let active_signature = value.get("activeSignature").and_then(JsonValue::as_u64).map(|v| v as u32); + let active_parameter = value.get("activeParameter").and_then(JsonValue::as_u64).map(|v| v as u32); + Some(crate::lsp_client::LspSignatureHelpResult { signatures, active_signature, active_parameter }) +} + +#[allow(clippy::cast_possible_truncation)] +pub(super) fn parse_code_lens(value: &JsonValue) -> Vec { + let Some(items) = value.as_array() else { + return Vec::new(); + }; + items.iter().filter_map(|item| { + let range = item.get("range")?; + let start = range.get("start")?; + let line = start.get("line")?.as_u64()? as u32; + let character = start.get("character")?.as_u64()? as u32; + let command = item.get("command").and_then(parse_command); + let data = item.get("data").cloned(); + Some(crate::lsp_client::LspCodeLens { line, character, command, data }) + }).collect() +} + +pub(super) fn parse_workspace_symbols(value: &JsonValue) -> Vec { + let Some(items) = value.as_array() else { + return Vec::new(); + }; + items.iter().filter_map(|item| { + let name = item.get("name")?.as_str()?.to_owned(); + let kind = item.get("kind").and_then(JsonValue::as_u64).map_or_else(|| "Unknown".into(), symbol_kind_name); + let path = item.get("location") + .and_then(|l| l.get("uri")) + .and_then(JsonValue::as_str) + .map(uri_to_path) + .or_else(|| item.get("uri").and_then(JsonValue::as_str).map(uri_to_path)) + .unwrap_or_default(); + let line = item.get("location") + .and_then(|l| l.get("range")) + .and_then(|r| r.get("start")) + .and_then(|s| s.get("line")) + .and_then(JsonValue::as_u64) + .map_or(0, |v| v as u32); + let character = item.get("location") + .and_then(|l| l.get("range")) + .and_then(|r| r.get("start")) + .and_then(|s| s.get("character")) + .and_then(JsonValue::as_u64) + .map_or(0, |v| v as u32); + Some(crate::lsp_client::LspSymbol { name, kind, path, line, character }) + }).collect() +} + +pub(super) fn rename_params(uri: &str, line: u32, character: u32, new_name: &str) -> JsonValue { + serde_json::json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character }, + "newName": new_name + }) +} + +pub(super) fn workspace_symbol_params(query: &str) -> JsonValue { + serde_json::json!({ + "query": query + }) +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 0e90126317..c8f6dfd4ec 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -3884,6 +3884,15 @@ fn run_repl( } } + // Show install suggestions for missing LSP servers + { + let availability = runtime::lsp_discovery::check_lsp_availability(); + let prompt = runtime::lsp_discovery::format_install_prompt(&availability); + if !prompt.is_empty() { + eprintln!("{prompt}"); + } + } + loop { editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); match editor.read_line()? { diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 98ae536dc7..23ffac0536 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1078,14 +1078,16 @@ pub fn mvp_tool_specs() -> Vec { }, ToolSpec { name: "LSP", - description: "Query Language Server Protocol for code intelligence (symbols, references, diagnostics).", + description: "Query Language Server Protocol for code intelligence (symbols, references, diagnostics, code actions, rename, signature help, code lens, workspace symbols).", input_schema: json!({ "type": "object", "properties": { - "action": { "type": "string", "enum": ["symbols", "references", "diagnostics", "definition", "hover"] }, + "action": { "type": "string", "enum": ["symbols", "references", "diagnostics", "definition", "hover", "code_action", "rename", "signature_help", "code_lens", "workspace_symbols"] }, "path": { "type": "string" }, "line": { "type": "integer", "minimum": 0 }, "character": { "type": "integer", "minimum": 0 }, + "end_line": { "type": "integer", "minimum": 0 }, + "end_character": { "type": "integer", "minimum": 0 }, "query": { "type": "string" } }, "required": ["action"], @@ -1669,7 +1671,18 @@ fn run_lsp(input: LspInput) -> Result { let character = input.character; let query = input.query.as_deref(); - match registry.dispatch(action, path, line, character, query) { + // For code_action, pass end_line/end_character through the query param + // since dispatch() doesn't take them directly — encode as "end_line:end_character" + let effective_query = if input.action == "code_action" { + match (input.end_line, input.end_character) { + (Some(el), Some(ec)) => Some(format!("{el}:{ec}")), + _ => query.map(str::to_owned), + } + } else { + query.map(str::to_owned) + }; + + match registry.dispatch(action, path, line, character, effective_query.as_deref()) { Ok(result) => to_pretty_json(result), Err(e) => to_pretty_json(json!({ "action": action, @@ -2581,6 +2594,10 @@ struct LspInput { #[serde(default)] character: Option, #[serde(default)] + end_line: Option, + #[serde(default)] + end_character: Option, + #[serde(default)] query: Option, } From 0e201d7c5306d01fc46ef32e2ac2600dc3599774 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Tue, 28 Apr 2026 10:24:03 -0500 Subject: [PATCH 14/16] feat(lsp): add TCP transport for GDScript/Godot LSP (port 6008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Godot LSP runs as a TCP server on localhost:6008 when the editor is open — it doesn't speak LSP over stdio like other servers. Added connect_tcp() to LspTransport which uses socat (or nc fallback) as a stdio↔TCP bridge, reusing the existing Content-Length framing. lsp_process detects tcp:// URIs and routes to TCP transport. LSP startup now gracefully handles servers that fail to start (gdscript without a running Godot editor) without blocking other servers from initializing. 💘 Generated with Crush Assisted-by: GLM 5.1 FP8 via Crush --- rust/crates/runtime/src/lsp_discovery.rs | 4 +- rust/crates/runtime/src/lsp_process/mod.rs | 9 ++- rust/crates/runtime/src/lsp_transport/mod.rs | 65 ++++++++++++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/rust/crates/runtime/src/lsp_discovery.rs b/rust/crates/runtime/src/lsp_discovery.rs index fdb858bd8f..14820ad4c4 100644 --- a/rust/crates/runtime/src/lsp_discovery.rs +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -154,8 +154,8 @@ const KNOWN_LSP_SERVERS_TABLE: &[StaticLspServerDescriptor] = &[ }, StaticLspServerDescriptor { language: "gdscript", - command: "godot", - args: &["--headless", "--editor"], + command: "tcp://localhost:6008", + args: &[], extensions: &["gd"], }, ]; diff --git a/rust/crates/runtime/src/lsp_process/mod.rs b/rust/crates/runtime/src/lsp_process/mod.rs index dab6aa3753..f8c60a0581 100644 --- a/rust/crates/runtime/src/lsp_process/mod.rs +++ b/rust/crates/runtime/src/lsp_process/mod.rs @@ -43,8 +43,13 @@ impl LspProcess { args: &[String], root_path: &Path, ) -> Result { - let transport = LspTransport::spawn(command, args) - .map_err(|e| LspProcessError::Transport(LspTransportError::Io(e)))?; + let transport = if command.starts_with("tcp://") { + LspTransport::connect_tcp(command) + .map_err(|e| LspProcessError::Transport(LspTransportError::Io(e)))? + } else { + LspTransport::spawn(command, args) + .map_err(|e| LspProcessError::Transport(LspTransportError::Io(e)))? + }; let canonical = canonicalize_root(root_path)?; let root_uri = format!("file://{canonical}"); diff --git a/rust/crates/runtime/src/lsp_transport/mod.rs b/rust/crates/runtime/src/lsp_transport/mod.rs index fa95c9962b..abbd41a9c1 100644 --- a/rust/crates/runtime/src/lsp_transport/mod.rs +++ b/rust/crates/runtime/src/lsp_transport/mod.rs @@ -419,7 +419,72 @@ impl LspTransport { Ok(payload) } + + /// Connect to an LSP server over TCP (e.g. Godot on localhost:6008). + /// The command should be a `tcp://host:port` URI. + /// Uses `socat` or `nc` as a stdio↔TCP bridge so that the same + /// Content-Length framing logic works unchanged. + pub fn connect_tcp(address: &str) -> io::Result { + Self::connect_tcp_with_timeout(address, DEFAULT_REQUEST_TIMEOUT) + } + + pub fn connect_tcp_with_timeout( + address: &str, + request_timeout: Duration, + ) -> io::Result { + let addr = address.trim_start_matches("tcp://"); + + // Try socat first (reliable bidirectional bridge) + let socat_available = std::process::Command::new("socat") + .arg("-V") + .output() + .is_ok(); + + let mut cmd = if socat_available { + let mut c = Command::new("socat"); + c.args([ + "-", // stdin/stdout + &format!("TCP:{addr}"), + ]); + c + } else { + // Fall back to nc (netcat) + let mut c = Command::new("nc"); + // Parse host:port + let mut parts = addr.split(':'); + let host = parts.next().unwrap_or("localhost"); + let port = parts.next().unwrap_or("6008"); + c.args([host, port]); + c + }; + + cmd.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("TCP bridge process missing stdin pipe"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("TCP bridge process missing stdout pipe"))?; + + Ok(Self { + child, + stdin, + stdout: BufReader::new(stdout), + next_id: 1, + request_timeout, + pending_notifications: Vec::new(), + }) + } } + + + #[cfg(test)] mod tests; From c781fbcef6e86b1b55a860b42b1661536050e511 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Tue, 28 Apr 2026 10:26:47 -0500 Subject: [PATCH 15/16] fix(lsp): suppress Node.js deprecation warnings from JS-based LSP servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set NODE_NO_WARNINGS=1 when spawning LSP server processes to suppress noisy punycode deprecation warnings from bash-language-server, yaml-language-server, vscode-* servers, etc. 💘 Generated with Crush Assisted-by: GLM 5.1 FP8 via Crush --- rust/crates/runtime/src/lsp_transport/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rust/crates/runtime/src/lsp_transport/mod.rs b/rust/crates/runtime/src/lsp_transport/mod.rs index abbd41a9c1..b740f5377e 100644 --- a/rust/crates/runtime/src/lsp_transport/mod.rs +++ b/rust/crates/runtime/src/lsp_transport/mod.rs @@ -161,6 +161,7 @@ impl LspTransport { ) -> io::Result { let mut cmd = Command::new(command); cmd.args(args) + .env("NODE_NO_WARNINGS", "1") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); @@ -458,7 +459,8 @@ impl LspTransport { c }; - cmd.stdin(Stdio::piped()) + cmd.env("NODE_NO_WARNINGS", "1") + .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); From 5e7d7edb8e82782371f8747aca63e63412ccfca6 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Tue, 28 Apr 2026 10:29:38 -0500 Subject: [PATCH 16/16] feat(lsp): show "Loading LSP servers..." with start/stop hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Startup now shows "Loading LSP servers..." then ✓/✗ per server - When auto-start is on: shows disable hint (toggle or settings.json) - When auto-start is off: shows available servers with how to start 💘 Generated with Crush Assisted-by: GLM 5.1 FP8 via Crush --- rust/crates/rusty-claude-cli/src/main.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index c8f6dfd4ec..de2605900a 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -3858,11 +3858,7 @@ fn run_repl( // 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(", ")); + eprintln!("Loading LSP servers..."); for server in &lsp_servers { tools::global_lsp_registry().register_with_descriptor( &server.language, @@ -3877,10 +3873,15 @@ fn run_repl( let registry = tools::global_lsp_registry(); for server in &lsp_servers { match registry.start_server(&server.language) { - Ok(()) => eprintln!(" {} started", server.language), - Err(e) => eprintln!(" {} failed to start: {e}", server.language), + Ok(()) => eprintln!(" ✓ {} ({})", server.language, server.command), + Err(e) => eprintln!(" ✗ {} — {e}", server.language), } } + eprintln!(" Disable with: /lsp toggle or set lspAutoStart=false in settings.json"); + } else { + let names: Vec<&str> = lsp_servers.iter().map(|s| s.language.as_str()).collect(); + eprintln!(" Available but not started: {}", names.join(", ")); + eprintln!(" Start with: /lsp start or set lspAutoStart=true in settings.json"); } }